Compare commits
15 Commits
c22050005c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a576e572d9 | ||
|
|
db2bf1994e | ||
|
|
fbcea9e77b | ||
|
|
f5af0c8711 | ||
|
|
4709e2a0ab | ||
|
|
dbceb95154 | ||
|
|
99372bce54 | ||
|
|
26dab06063 | ||
|
|
960bcb7093
|
||
|
|
da5d1edaa7
|
||
|
|
ac27cba766 | ||
|
|
61d0bbe4d1 | ||
|
|
405ca0d2ea
|
||
|
|
95863fccd0
|
||
|
|
b4e9fd499f
|
3
.env
3
.env
@@ -1 +1,2 @@
|
|||||||
DATABASE_URL=postgres://user:password@localhost/diesel_demo
|
MONGOURI=mongodb://jheuel:bla@localhost/?retryWrites=true&w=majority
|
||||||
|
SECRET=mila-likes-the-ol-moonwalk-and-that-is-how-she-rolls-bla-bla-bla
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
/target
|
**/target
|
||||||
|
**/dist
|
||||||
|
|||||||
16
.vscode/settings.json
vendored
Normal file
16
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"cSpell.words": [
|
||||||
|
"actix",
|
||||||
|
"bindgen",
|
||||||
|
"creds",
|
||||||
|
"dotenv",
|
||||||
|
"Gloo",
|
||||||
|
"minio",
|
||||||
|
"noopener",
|
||||||
|
"noreferrer",
|
||||||
|
"onclick",
|
||||||
|
"ondragover",
|
||||||
|
"ondrop",
|
||||||
|
"onresize"
|
||||||
|
]
|
||||||
|
}
|
||||||
4023
Cargo.lock
generated
4023
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -8,5 +8,5 @@ license = "MIT"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["frontend", "backend", "common"]
|
members = ["frontend", "backend", "common", "frontend2", "api-boundary"]
|
||||||
default-members = ["backend"]
|
default-members = ["backend"]
|
||||||
|
|||||||
8
api-boundary/Cargo.toml
Normal file
8
api-boundary/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[package]
|
||||||
|
name = "api-boundary"
|
||||||
|
version = "0.0.0"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
67
api-boundary/src/lib.rs
Normal file
67
api-boundary/src/lib.rs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// #[derive(Serialize, Deserialize)]
|
||||||
|
// pub struct Credentials {
|
||||||
|
// pub email: String,
|
||||||
|
// pub password: String,
|
||||||
|
// }
|
||||||
|
|
||||||
|
// #[derive(Clone, Serialize, Deserialize)]
|
||||||
|
// pub struct UserInfo {
|
||||||
|
// pub email: String,
|
||||||
|
// }
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ApiToken {
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Error {
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct UserInfo {
|
||||||
|
pub id: Option<String>,
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct UserWrapper {
|
||||||
|
pub user: UserInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct UserWithToken {
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct UserTokenWrapper {
|
||||||
|
pub user: UserWithToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct Credentials {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct LoginWrapper {
|
||||||
|
pub user: Credentials,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct OutputPicture {
|
||||||
|
pub thumbnail: Option<String>,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
@@ -9,14 +9,23 @@ default-run = "backend"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
actix-web = "4"
|
actix-web = "4"
|
||||||
actix-web-lab = "^0"
|
actix-web-lab = { version = "^0", features = ["spa"] }
|
||||||
actix-files = "0.6"
|
actix-files = "0.6"
|
||||||
|
actix-cors = "0.6.4"
|
||||||
|
actix-session = { version = "0.7.2", features = ["cookie-session"] }
|
||||||
env_logger = "0.9.0"
|
env_logger = "0.9.0"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
diesel = { version = "1.4.8", features = ["postgres", "r2d2"] }
|
|
||||||
diesel_migrations = "1.4"
|
|
||||||
dotenv = "0.15.0"
|
dotenv = "0.15.0"
|
||||||
walkdir = "2"
|
walkdir = "2"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
|
api-boundary = { path = "../api-boundary" }
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
rust-s3 = "0.32.3"
|
||||||
|
futures = "0.3"
|
||||||
|
|
||||||
|
[dependencies.mongodb]
|
||||||
|
version = "2.4.0"
|
||||||
|
default-features = false
|
||||||
|
features = ["async-std-runtime"]
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
# For documentation on how to configure this file,
|
|
||||||
# see diesel.rs/guides/configuring-diesel-cli
|
|
||||||
|
|
||||||
[print_schema]
|
|
||||||
file = "src/schema.rs"
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
-- 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();
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
DROP TABLE pictures
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
CREATE TABLE pictures (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
filepath VARCHAR NOT NULL,
|
|
||||||
created_at INTEGER,
|
|
||||||
focal_length VARCHAR,
|
|
||||||
shutter_speed VARCHAR,
|
|
||||||
width INTEGER NOT NULL,
|
|
||||||
height INTEGER NOT NULL,
|
|
||||||
make VARCHAR,
|
|
||||||
model VARCHAR,
|
|
||||||
lens VARCHAR,
|
|
||||||
orientation VARCHAR,
|
|
||||||
fnumber FLOAT,
|
|
||||||
iso INTEGER,
|
|
||||||
exposure_program VARCHAR,
|
|
||||||
exposure_compensation VARCHAR,
|
|
||||||
thumbnail VARCHAR
|
|
||||||
)
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
extern crate diesel;
|
|
||||||
|
|
||||||
use backend::models::*;
|
|
||||||
use backend::*;
|
|
||||||
use diesel::prelude::*;
|
|
||||||
|
|
||||||
type DbError = Box<dyn std::error::Error + Send + Sync>;
|
|
||||||
|
|
||||||
pub fn list_pictures(conn: &PgConnection) -> Result<Vec<Picture>, DbError> {
|
|
||||||
use self::schema::pictures::dsl::*;
|
|
||||||
|
|
||||||
Ok(pictures
|
|
||||||
.limit(50)
|
|
||||||
.order_by(created_at.desc())
|
|
||||||
.load::<Picture>(conn)?)
|
|
||||||
}
|
|
||||||
2
backend/src/api/mod.rs
Normal file
2
backend/src/api/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod photo_api;
|
||||||
|
pub mod user_api;
|
||||||
148
backend/src/api/photo_api.rs
Normal file
148
backend/src/api/photo_api.rs
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
use actix_session::Session;
|
||||||
|
use actix_web::Result;
|
||||||
|
use actix_web::{
|
||||||
|
get, post,
|
||||||
|
web::{Data, Json},
|
||||||
|
HttpResponse, Responder,
|
||||||
|
};
|
||||||
|
use common::OutputPicture;
|
||||||
|
use mongodb::bson::oid::ObjectId;
|
||||||
|
use s3::bucket::Bucket;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{models::photo_model::Photo, repository::mongodb_repo::MongoRepo};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct PhotosWrapper {
|
||||||
|
photos: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct DetailPhoto {
|
||||||
|
key: String,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct DetailPhotoWrapper {
|
||||||
|
photos: Vec<DetailPhoto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct PhotosUrlsWrapper {
|
||||||
|
photos: Vec<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return an opaque 500 while preserving the error's root cause for logging.
|
||||||
|
fn e500<T>(e: T) -> actix_web::Error
|
||||||
|
where
|
||||||
|
T: std::fmt::Debug + std::fmt::Display + 'static,
|
||||||
|
{
|
||||||
|
actix_web::error::ErrorInternalServerError(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/api/photos/upload")]
|
||||||
|
pub async fn get_presigned_post_urls(
|
||||||
|
db: Data<MongoRepo>,
|
||||||
|
bucket: Data<Bucket>,
|
||||||
|
request: Json<PhotosWrapper>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, actix_web::Error> {
|
||||||
|
let Some(user_id) = session.get::<String>("user_id").map_err(e500)? else {
|
||||||
|
return Ok(HttpResponse::Unauthorized().body("Not authorized"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(_) = db.get_user(&user_id).await else {
|
||||||
|
return Ok(HttpResponse::Unauthorized().body("Not authorized"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let photos: Vec<(String, String)> = request
|
||||||
|
.photos
|
||||||
|
.iter()
|
||||||
|
.map(|x| {
|
||||||
|
(
|
||||||
|
x.clone(),
|
||||||
|
bucket
|
||||||
|
.presign_put(format!("/{}/{}", user_id, x), 86400, None)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(PhotosUrlsWrapper { photos }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/api/photos/upload/done")]
|
||||||
|
pub async fn upload_done(
|
||||||
|
db: Data<MongoRepo>,
|
||||||
|
request: Json<DetailPhotoWrapper>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, actix_web::Error> {
|
||||||
|
let Some(user_id) = session.get::<String>("user_id").map_err(e500)? else {
|
||||||
|
return Ok(HttpResponse::Unauthorized().body("Not authorized"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(_) = db.get_user(&user_id).await else {
|
||||||
|
return Ok(HttpResponse::Unauthorized().body("Not authorized"));
|
||||||
|
};
|
||||||
|
|
||||||
|
for p in &request.photos {
|
||||||
|
db.create_photo(Photo {
|
||||||
|
id: None,
|
||||||
|
user_id: ObjectId::parse_str(&user_id).unwrap(),
|
||||||
|
key: p.key.clone(),
|
||||||
|
width: p.width,
|
||||||
|
height: p.height,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(e500)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(&request))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/api/photos/get")]
|
||||||
|
async fn get_user_photos(
|
||||||
|
db: Data<MongoRepo>,
|
||||||
|
bucket: Data<Bucket>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<impl Responder> {
|
||||||
|
let Some(user_id) = session.get::<String>("user_id").map_err(e500)? else {
|
||||||
|
return Ok(HttpResponse::Unauthorized().body("Not authorized"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(_user) = db.get_user(&user_id).await else {
|
||||||
|
return Ok(HttpResponse::Unauthorized().body("Not authorized"));
|
||||||
|
};
|
||||||
|
let photos: Vec<OutputPicture> = db
|
||||||
|
.get_photos(&user_id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|p| {
|
||||||
|
// let mut w: u32 = x.width.try_into().unwrap();
|
||||||
|
// let mut h: u32 = x.height.try_into().unwrap();
|
||||||
|
// if let Some(o) = &x.orientation {
|
||||||
|
// if o == "Rotate 270 CW" {
|
||||||
|
// swap(&mut w, &mut h);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
let url = bucket
|
||||||
|
.presign_get(format!("/{}/{}", user_id, p.key.clone()), 86400, None)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
OutputPicture {
|
||||||
|
thumbnail: Some(url),
|
||||||
|
// thumbnail: Some(String::from("https://upload.wikimedia.org/wikipedia/commons/thumb/b/b4/Mila_Kunis_2018.jpg/220px-Mila_Kunis_2018.jpg")),
|
||||||
|
// thumbnail: x.thumbnail.clone(),
|
||||||
|
// width: w,
|
||||||
|
// height: h,
|
||||||
|
width: p.width,
|
||||||
|
height: p.height,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(photos))
|
||||||
|
}
|
||||||
156
backend/src/api/user_api.rs
Normal file
156
backend/src/api/user_api.rs
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
use crate::{models::user_model::User, repository::mongodb_repo::MongoRepo};
|
||||||
|
use actix_session::Session;
|
||||||
|
use actix_web::{
|
||||||
|
delete, get, post, put,
|
||||||
|
web::{Data, Json, Path},
|
||||||
|
HttpResponse,
|
||||||
|
};
|
||||||
|
|
||||||
|
use api_boundary::*;
|
||||||
|
use mongodb::bson::oid::ObjectId;
|
||||||
|
|
||||||
|
// Return an opaque 500 while preserving the error's root cause for logging.
|
||||||
|
fn e500<T>(e: T) -> actix_web::Error
|
||||||
|
where
|
||||||
|
T: std::fmt::Debug + std::fmt::Display + 'static,
|
||||||
|
{
|
||||||
|
actix_web::error::ErrorInternalServerError(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/api/user")]
|
||||||
|
pub async fn is_logged_in(
|
||||||
|
db: Data<MongoRepo>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, actix_web::Error> {
|
||||||
|
if let Some(user_id) = session.get::<String>("user_id").map_err(e500)? {
|
||||||
|
if let Ok(user) = db.get_user(&user_id).await {
|
||||||
|
let token = user.username.clone();
|
||||||
|
log::info!("success");
|
||||||
|
return Ok(HttpResponse::Ok().json(UserTokenWrapper {
|
||||||
|
user: UserWithToken {
|
||||||
|
id: user_id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
password: user.password,
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(HttpResponse::Unauthorized().body("Not logged in"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/api/user")]
|
||||||
|
pub async fn create_user(db: Data<MongoRepo>, request: Json<UserWrapper>) -> HttpResponse {
|
||||||
|
let data = User {
|
||||||
|
id: None,
|
||||||
|
username: request.user.username.to_owned(),
|
||||||
|
email: request.user.email.to_owned(),
|
||||||
|
password: request.user.password.to_owned(),
|
||||||
|
};
|
||||||
|
let user_detail = db.create_user(data).await;
|
||||||
|
match user_detail {
|
||||||
|
Ok(user) => HttpResponse::Ok().json(user),
|
||||||
|
Err(err) => HttpResponse::InternalServerError().body(err.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/api/users/login")]
|
||||||
|
pub async fn login(
|
||||||
|
db: Data<MongoRepo>,
|
||||||
|
request: Json<LoginWrapper>,
|
||||||
|
session: Session,
|
||||||
|
) -> HttpResponse {
|
||||||
|
let user = db.get_user_from_email(&request.user.email).await;
|
||||||
|
let Ok(user) = user else {
|
||||||
|
return HttpResponse::Unauthorized().body("Login failed");
|
||||||
|
};
|
||||||
|
if user.password != request.user.password {
|
||||||
|
return HttpResponse::Unauthorized().body("Login failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(user_id) = user.id else {
|
||||||
|
return HttpResponse::Unauthorized().body("Login failed");
|
||||||
|
};
|
||||||
|
|
||||||
|
session.renew();
|
||||||
|
if let Err(e) = session.insert("user_id", user_id.to_string()) {
|
||||||
|
return HttpResponse::InternalServerError().body(e.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = user.username.clone();
|
||||||
|
HttpResponse::Ok().json(ApiToken { token })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/api/users/logout")]
|
||||||
|
pub async fn logout(db: Data<MongoRepo>, session: Session) -> HttpResponse {
|
||||||
|
if let Some(_user_id) = session.get::<String>("user_id").unwrap_or(None) {
|
||||||
|
return HttpResponse::Ok().json(());
|
||||||
|
}
|
||||||
|
HttpResponse::BadRequest().json(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/api/user/{id}")]
|
||||||
|
pub async fn get_user(db: Data<MongoRepo>, path: Path<String>) -> HttpResponse {
|
||||||
|
let id = path.into_inner();
|
||||||
|
if id.is_empty() {
|
||||||
|
return HttpResponse::BadRequest().body("invalid ID");
|
||||||
|
}
|
||||||
|
let user_detail = db.get_user(&id).await;
|
||||||
|
match user_detail {
|
||||||
|
Ok(user) => HttpResponse::Ok().json(user),
|
||||||
|
Err(err) => HttpResponse::InternalServerError().body(err.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[put("/api/user/{id}")]
|
||||||
|
pub async fn update_user(
|
||||||
|
db: Data<MongoRepo>,
|
||||||
|
path: Path<String>,
|
||||||
|
request: Json<UserWrapper>,
|
||||||
|
) -> HttpResponse {
|
||||||
|
let id = path.into_inner();
|
||||||
|
if id.is_empty() {
|
||||||
|
return HttpResponse::BadRequest().body("invalid ID");
|
||||||
|
};
|
||||||
|
let data = User {
|
||||||
|
id: Some(ObjectId::parse_str(&id).unwrap()),
|
||||||
|
username: request.user.username.to_owned(),
|
||||||
|
email: request.user.email.to_owned(),
|
||||||
|
password: request.user.password.to_owned(),
|
||||||
|
};
|
||||||
|
let update_result = db.update_user(&id, data).await;
|
||||||
|
match update_result {
|
||||||
|
Ok(update) => {
|
||||||
|
if update.matched_count == 1 {
|
||||||
|
let updated_user_info = db.get_user(&id).await;
|
||||||
|
match updated_user_info {
|
||||||
|
Ok(user) => HttpResponse::Ok().json(user),
|
||||||
|
Err(err) => HttpResponse::InternalServerError().body(err.to_string()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HttpResponse::NotFound().body("No user found with specified ID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => HttpResponse::InternalServerError().body(err.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[delete("/api/user/{id}")]
|
||||||
|
pub async fn delete_user(db: Data<MongoRepo>, path: Path<String>) -> HttpResponse {
|
||||||
|
let id = path.into_inner();
|
||||||
|
if id.is_empty() {
|
||||||
|
return HttpResponse::BadRequest().body("invalid ID");
|
||||||
|
};
|
||||||
|
let result = db.delete_user(&id).await;
|
||||||
|
match result {
|
||||||
|
Ok(res) => {
|
||||||
|
if res.deleted_count == 1 {
|
||||||
|
HttpResponse::Ok().json("User successfully deleted!")
|
||||||
|
} else {
|
||||||
|
HttpResponse::NotFound().json("User with specified ID not found!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => HttpResponse::InternalServerError().body(err.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,236 +1,236 @@
|
|||||||
extern crate diesel;
|
// extern crate diesel;
|
||||||
use backend::create_picture;
|
// use backend::create_picture;
|
||||||
use backend::establish_connection;
|
// use backend::establish_connection;
|
||||||
use backend::models::NewPicture;
|
// use backend::models::NewPicture;
|
||||||
|
|
||||||
// use backend::*;
|
// // use backend::*;
|
||||||
use std::ffi::OsStr;
|
// use std::ffi::OsStr;
|
||||||
use std::path::Path;
|
// use std::path::Path;
|
||||||
use std::time::UNIX_EPOCH;
|
// use std::time::UNIX_EPOCH;
|
||||||
use std::{fs, process::Command};
|
// use std::{fs, process::Command};
|
||||||
use walkdir::{DirEntry, WalkDir};
|
// use walkdir::{DirEntry, WalkDir};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
// use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
// #[derive(Serialize, Deserialize)]
|
||||||
struct PhotoExif {
|
// struct PhotoExif {
|
||||||
#[serde(default, alias = "FocalLength")]
|
// #[serde(default, alias = "FocalLength")]
|
||||||
focal_length: Option<String>,
|
// focal_length: Option<String>,
|
||||||
#[serde(default, alias = "ShutterSpeed")]
|
// #[serde(default, alias = "ShutterSpeed")]
|
||||||
shutter_speed: Option<String>,
|
// shutter_speed: Option<String>,
|
||||||
#[serde(alias = "ImageWidth")]
|
// #[serde(alias = "ImageWidth")]
|
||||||
width: i32,
|
// width: i32,
|
||||||
#[serde(alias = "ImageHeight")]
|
// #[serde(alias = "ImageHeight")]
|
||||||
height: i32,
|
// height: i32,
|
||||||
#[serde(default, alias = "Make")]
|
// #[serde(default, alias = "Make")]
|
||||||
make: Option<String>,
|
// make: Option<String>,
|
||||||
#[serde(default, alias = "Model")]
|
// #[serde(default, alias = "Model")]
|
||||||
model: Option<String>,
|
// model: Option<String>,
|
||||||
#[serde(default, alias = "LensID")]
|
// #[serde(default, alias = "LensID")]
|
||||||
lens: Option<String>,
|
// lens: Option<String>,
|
||||||
#[serde(default, alias = "Orientation")]
|
// #[serde(default, alias = "Orientation")]
|
||||||
orientation: Option<String>,
|
// orientation: Option<String>,
|
||||||
#[serde(default, alias = "FNumber")]
|
// #[serde(default, alias = "FNumber")]
|
||||||
fnumber: Option<f64>,
|
// fnumber: Option<f64>,
|
||||||
#[serde(default, alias = "ExposureProgram")]
|
// #[serde(default, alias = "ExposureProgram")]
|
||||||
exposure_program: Option<String>,
|
// exposure_program: Option<String>,
|
||||||
#[serde(default, alias = "CreateDate")]
|
// #[serde(default, alias = "CreateDate")]
|
||||||
created_at: Option<i32>,
|
// created_at: Option<i32>,
|
||||||
#[serde(default, alias = "ISO")]
|
// #[serde(default, alias = "ISO")]
|
||||||
iso: Option<i32>,
|
// iso: Option<i32>,
|
||||||
#[serde(default = "MaybeString::default", alias = "ExposureCompensation")]
|
// #[serde(default = "MaybeString::default", alias = "ExposureCompensation")]
|
||||||
exposure_compensation: MaybeString,
|
// exposure_compensation: MaybeString,
|
||||||
}
|
// }
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
// #[derive(Serialize, Deserialize)]
|
||||||
#[serde(untagged)]
|
// #[serde(untagged)]
|
||||||
enum MaybeString {
|
// enum MaybeString {
|
||||||
Number(i32),
|
// Number(i32),
|
||||||
Str(String),
|
// Str(String),
|
||||||
None,
|
// None,
|
||||||
}
|
// }
|
||||||
|
|
||||||
impl MaybeString {
|
// impl MaybeString {
|
||||||
fn default() -> MaybeString {
|
// fn default() -> MaybeString {
|
||||||
MaybeString::None
|
// MaybeString::None
|
||||||
}
|
// }
|
||||||
fn to_opt_string(&self) -> Option<String> {
|
// fn to_opt_string(&self) -> Option<String> {
|
||||||
if let MaybeString::Str(exp_comp) = &self {
|
// if let MaybeString::Str(exp_comp) = &self {
|
||||||
Some(exp_comp.clone())
|
// Some(exp_comp.clone())
|
||||||
} else {
|
// } else {
|
||||||
None
|
// None
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
fn is_hidden(entry: &DirEntry) -> bool {
|
// fn is_hidden(entry: &DirEntry) -> bool {
|
||||||
entry
|
// entry
|
||||||
.file_name()
|
// .file_name()
|
||||||
.to_str()
|
// .to_str()
|
||||||
.map(|s| s.starts_with('.'))
|
// .map(|s| s.starts_with('.'))
|
||||||
.unwrap_or(false)
|
// .unwrap_or(false)
|
||||||
}
|
// }
|
||||||
|
|
||||||
fn is_image(entry: &DirEntry) -> bool {
|
// fn is_image(entry: &DirEntry) -> bool {
|
||||||
let allowed_extensions = ["cr2", "cr3", "jpg", "jpeg"];
|
// let allowed_extensions = ["cr2", "cr3", "jpg", "jpeg"];
|
||||||
|
|
||||||
let extension = if let Some(ext) = entry.path().extension() {
|
// let extension = if let Some(ext) = entry.path().extension() {
|
||||||
ext
|
// ext
|
||||||
} else {
|
// } else {
|
||||||
OsStr::new("")
|
// OsStr::new("")
|
||||||
};
|
// };
|
||||||
|
|
||||||
if allowed_extensions
|
// if allowed_extensions
|
||||||
.iter()
|
// .iter()
|
||||||
.all(|&v| v != extension.to_ascii_lowercase())
|
// .all(|&v| v != extension.to_ascii_lowercase())
|
||||||
{
|
// {
|
||||||
return false;
|
// return false;
|
||||||
}
|
// }
|
||||||
true
|
// true
|
||||||
}
|
// }
|
||||||
|
|
||||||
static PICTURE_PATH: &str = "./pictures";
|
// static PICTURE_PATH: &str = "./pictures";
|
||||||
static LIBRARY_PATH: &str = "./examples";
|
// static LIBRARY_PATH: &str = "./examples";
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let connection = establish_connection();
|
// let connection = establish_connection();
|
||||||
|
|
||||||
WalkDir::new(LIBRARY_PATH)
|
// WalkDir::new(LIBRARY_PATH)
|
||||||
.into_iter()
|
// .into_iter()
|
||||||
.filter_map(Result::ok)
|
// .filter_map(Result::ok)
|
||||||
.filter(|e| !e.file_type().is_dir())
|
// .filter(|e| !e.file_type().is_dir())
|
||||||
.filter(|e| !is_hidden(e))
|
// .filter(|e| !is_hidden(e))
|
||||||
.filter(is_image)
|
// .filter(is_image)
|
||||||
.into_iter()
|
// .into_iter()
|
||||||
.for_each(|path| {
|
// .for_each(|path| {
|
||||||
let thumbnail = if let Ok(t) = extract_preview(path.path()) {
|
// let thumbnail = if let Ok(t) = extract_preview(path.path()) {
|
||||||
t
|
// t
|
||||||
} else {
|
// } else {
|
||||||
println!("Could not create thumbnail");
|
// println!("Could not create thumbnail");
|
||||||
return;
|
// return;
|
||||||
};
|
// };
|
||||||
let thumbnail = std::path::PathBuf::from(thumbnail.strip_prefix(PICTURE_PATH).unwrap());
|
// let thumbnail = std::path::PathBuf::from(thumbnail.strip_prefix(PICTURE_PATH).unwrap());
|
||||||
|
|
||||||
let output = Command::new("exiftool")
|
// let output = Command::new("exiftool")
|
||||||
.arg("-j")
|
// .arg("-j")
|
||||||
.arg("-d")
|
// .arg("-d")
|
||||||
.arg("%s")
|
// .arg("%s")
|
||||||
.arg(path.path())
|
// .arg(path.path())
|
||||||
.output()
|
// .output()
|
||||||
.expect("failed to execute exiftool");
|
// .expect("failed to execute exiftool");
|
||||||
let pel: Vec<PhotoExif> = serde_json::from_slice(&output.stdout).unwrap();
|
// let pel: Vec<PhotoExif> = serde_json::from_slice(&output.stdout).unwrap();
|
||||||
let pe = &pel[0];
|
// let pe = &pel[0];
|
||||||
|
|
||||||
println!("pe = {}", serde_json::to_string_pretty(pe).unwrap());
|
// println!("pe = {}", serde_json::to_string_pretty(pe).unwrap());
|
||||||
|
|
||||||
let created_at: Option<i32> = if let Some(c) = pe.created_at {
|
// let created_at: Option<i32> = if let Some(c) = pe.created_at {
|
||||||
Some(c)
|
// Some(c)
|
||||||
} else {
|
// } else {
|
||||||
let metadata = fs::metadata(&path.path()).unwrap();
|
// let metadata = fs::metadata(&path.path()).unwrap();
|
||||||
if let Ok(time) = metadata.created() {
|
// if let Ok(time) = metadata.created() {
|
||||||
Some(
|
// Some(
|
||||||
time.duration_since(UNIX_EPOCH)
|
// time.duration_since(UNIX_EPOCH)
|
||||||
.unwrap()
|
// .unwrap()
|
||||||
.as_secs()
|
// .as_secs()
|
||||||
.try_into()
|
// .try_into()
|
||||||
.unwrap(),
|
// .unwrap(),
|
||||||
)
|
// )
|
||||||
} else {
|
// } else {
|
||||||
println!("Not supported on this platform or filesystem");
|
// println!("Not supported on this platform or filesystem");
|
||||||
None
|
// None
|
||||||
}
|
// }
|
||||||
};
|
// };
|
||||||
|
|
||||||
let filepath = path.path().to_string_lossy().into_owned();
|
// let filepath = path.path().to_string_lossy().into_owned();
|
||||||
|
|
||||||
let new_picture = NewPicture {
|
// let new_picture = NewPicture {
|
||||||
filepath: filepath.clone(),
|
// filepath: filepath.clone(),
|
||||||
created_at,
|
// created_at,
|
||||||
focal_length: pe.focal_length.clone(),
|
// focal_length: pe.focal_length.clone(),
|
||||||
shutter_speed: pe.shutter_speed.clone(),
|
// shutter_speed: pe.shutter_speed.clone(),
|
||||||
width: pe.width,
|
// width: pe.width,
|
||||||
height: pe.height,
|
// height: pe.height,
|
||||||
make: pe.make.clone(),
|
// make: pe.make.clone(),
|
||||||
model: pe.model.clone(),
|
// model: pe.model.clone(),
|
||||||
lens: pe.lens.clone(),
|
// lens: pe.lens.clone(),
|
||||||
orientation: pe.orientation.clone(),
|
// orientation: pe.orientation.clone(),
|
||||||
fnumber: pe.fnumber,
|
// fnumber: pe.fnumber,
|
||||||
iso: pe.iso,
|
// iso: pe.iso,
|
||||||
exposure_program: pe.exposure_program.clone(),
|
// exposure_program: pe.exposure_program.clone(),
|
||||||
exposure_compensation: pe.exposure_compensation.to_opt_string(),
|
// exposure_compensation: pe.exposure_compensation.to_opt_string(),
|
||||||
thumbnail: Some(thumbnail.into_os_string().into_string().unwrap()),
|
// thumbnail: Some(thumbnail.into_os_string().into_string().unwrap()),
|
||||||
};
|
// };
|
||||||
|
|
||||||
let pic = create_picture(&connection, new_picture);
|
// let pic = create_picture(&connection, new_picture);
|
||||||
println!("Created picture with filepath={} and id={}", filepath, pic);
|
// println!("Created picture with filepath={} and id={}", filepath, pic);
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
|
|
||||||
fn extract_preview(path: &Path) -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
|
// fn extract_preview(path: &Path) -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
|
||||||
let file_name = if let Some(p) = path.file_name() {
|
// let file_name = if let Some(p) = path.file_name() {
|
||||||
p
|
// p
|
||||||
} else {
|
// } else {
|
||||||
OsStr::new("")
|
// OsStr::new("")
|
||||||
};
|
// };
|
||||||
let parent = if let Some(p) = path.parent() {
|
// let parent = if let Some(p) = path.parent() {
|
||||||
p
|
// p
|
||||||
} else {
|
// } else {
|
||||||
Path::new(LIBRARY_PATH)
|
// Path::new(LIBRARY_PATH)
|
||||||
};
|
// };
|
||||||
let relative_parent = parent
|
// let relative_parent = parent
|
||||||
.strip_prefix(LIBRARY_PATH)
|
// .strip_prefix(LIBRARY_PATH)
|
||||||
.expect("Could not remove prefix");
|
// .expect("Could not remove prefix");
|
||||||
|
|
||||||
let thumb_path = Path::new(PICTURE_PATH).join(relative_parent);
|
// let thumb_path = Path::new(PICTURE_PATH).join(relative_parent);
|
||||||
|
|
||||||
if !thumb_path.exists() {
|
// if !thumb_path.exists() {
|
||||||
fs::create_dir_all(&thumb_path).unwrap_or_else(|e| {
|
// fs::create_dir_all(&thumb_path).unwrap_or_else(|e| {
|
||||||
panic!("Could not create directory {}: {}", thumb_path.display(), e)
|
// panic!("Could not create directory {}: {}", thumb_path.display(), e)
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
|
|
||||||
let mut thumbnail = thumb_path.join(file_name);
|
// let mut thumbnail = thumb_path.join(file_name);
|
||||||
thumbnail.set_extension("jpg");
|
// thumbnail.set_extension("jpg");
|
||||||
|
|
||||||
let extension = path.extension().unwrap();
|
// let extension = path.extension().unwrap();
|
||||||
let jpegs = ["jpg", "jpeg"];
|
// let jpegs = ["jpg", "jpeg"];
|
||||||
if jpegs.iter().any(|&x| x == extension.to_ascii_lowercase()) {
|
// if jpegs.iter().any(|&x| x == extension.to_ascii_lowercase()) {
|
||||||
match fs::copy(path, &thumbnail) {
|
// match fs::copy(path, &thumbnail) {
|
||||||
Ok(_it) => return Ok(thumbnail),
|
// Ok(_it) => return Ok(thumbnail),
|
||||||
Err(err) => return Err(err.into()),
|
// Err(err) => return Err(err.into()),
|
||||||
};
|
// };
|
||||||
}
|
// }
|
||||||
let _output_thumb = Command::new("exiftool")
|
// let _output_thumb = Command::new("exiftool")
|
||||||
.arg("-if")
|
// .arg("-if")
|
||||||
.arg("$jpgfromraw")
|
// .arg("$jpgfromraw")
|
||||||
.arg("-b")
|
// .arg("-b")
|
||||||
.arg("-jpgfromraw")
|
// .arg("-jpgfromraw")
|
||||||
.arg("-w")
|
// .arg("-w")
|
||||||
.arg(thumb_path.join("%f.jpg"))
|
// .arg(thumb_path.join("%f.jpg"))
|
||||||
.arg("-execute")
|
// .arg("-execute")
|
||||||
.arg("-if")
|
// .arg("-if")
|
||||||
.arg("$previewimage")
|
// .arg("$previewimage")
|
||||||
.arg("-b")
|
// .arg("-b")
|
||||||
.arg("-previewimage")
|
// .arg("-previewimage")
|
||||||
.arg("-w")
|
// .arg("-w")
|
||||||
.arg(thumb_path.join("%f.jpg"))
|
// .arg(thumb_path.join("%f.jpg"))
|
||||||
.arg("-execute")
|
// .arg("-execute")
|
||||||
.arg("-tagsfromfile")
|
// .arg("-tagsfromfile")
|
||||||
.arg("@")
|
// .arg("@")
|
||||||
.arg("-srcfile")
|
// .arg("-srcfile")
|
||||||
.arg(thumb_path.join("%f.jpg"))
|
// .arg(thumb_path.join("%f.jpg"))
|
||||||
.arg("-overwrite_original")
|
// .arg("-overwrite_original")
|
||||||
.arg("-common_args")
|
// .arg("-common_args")
|
||||||
.arg("--ext")
|
// .arg("--ext")
|
||||||
.arg("jpg")
|
// .arg("jpg")
|
||||||
.arg(path)
|
// .arg(path)
|
||||||
.output()
|
// .output()
|
||||||
.expect("failed to execute exiftool to extract thumbnail");
|
// .expect("failed to execute exiftool to extract thumbnail");
|
||||||
// println!("{:?}", _output_thumb);
|
// // println!("{:?}", _output_thumb);
|
||||||
|
|
||||||
if thumbnail.exists() {
|
// if thumbnail.exists() {
|
||||||
Ok(thumbnail)
|
// Ok(thumbnail)
|
||||||
} else {
|
// } else {
|
||||||
Err("Could not create thumbnail".into())
|
// Err("Could not create thumbnail".into())
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
extern crate diesel;
|
// extern crate diesel;
|
||||||
|
|
||||||
use self::models::*;
|
// use self::models::*;
|
||||||
use backend::*;
|
// use backend::*;
|
||||||
use diesel::prelude::*;
|
// use diesel::prelude::*;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
use self::schema::pictures::dsl::*;
|
// use self::schema::pictures::dsl::*;
|
||||||
|
|
||||||
let connection = establish_connection();
|
// let connection = establish_connection();
|
||||||
let results = pictures
|
// let results = pictures
|
||||||
.limit(5)
|
// .limit(5)
|
||||||
.load::<Picture>(&connection)
|
// .load::<Picture>(&connection)
|
||||||
.expect("Error loading pictures");
|
// .expect("Error loading pictures");
|
||||||
|
|
||||||
println!("Displaying {} pictures", results.len());
|
// println!("Displaying {} pictures", results.len());
|
||||||
for picture in results {
|
// for picture in results {
|
||||||
println!("filepath: {}", picture.filepath);
|
// println!("filepath: {}", picture.filepath);
|
||||||
println!("\tid: {}", picture.id);
|
// println!("\tid: {}", picture.id);
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
#[macro_use]
|
|
||||||
extern crate diesel;
|
|
||||||
extern crate dotenv;
|
|
||||||
|
|
||||||
pub mod models;
|
|
||||||
pub mod schema;
|
|
||||||
|
|
||||||
use self::models::NewPicture;
|
|
||||||
use diesel::pg::PgConnection;
|
|
||||||
use diesel::prelude::*;
|
|
||||||
use dotenv::dotenv;
|
|
||||||
use std::env;
|
|
||||||
|
|
||||||
pub fn establish_connection() -> PgConnection {
|
|
||||||
dotenv().ok();
|
|
||||||
|
|
||||||
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
|
||||||
PgConnection::establish(&database_url)
|
|
||||||
.unwrap_or_else(|_| panic!("Error connecting to {}", database_url))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_picture(conn: &PgConnection, new_picture: NewPicture) -> usize {
|
|
||||||
use schema::pictures;
|
|
||||||
|
|
||||||
diesel::insert_into(pictures::table)
|
|
||||||
.values(&new_picture)
|
|
||||||
.execute(conn)
|
|
||||||
.expect("Error saving new picture")
|
|
||||||
}
|
|
||||||
@@ -1,100 +1,108 @@
|
|||||||
#[macro_use]
|
mod api;
|
||||||
extern crate diesel;
|
mod models;
|
||||||
|
mod repository;
|
||||||
|
|
||||||
#[macro_use]
|
use api::photo_api::get_presigned_post_urls;
|
||||||
extern crate diesel_migrations;
|
use api::photo_api::get_user_photos;
|
||||||
|
use api::user_api::{create_user, delete_user, get_user, is_logged_in, login, update_user};
|
||||||
|
use repository::mongodb_repo::MongoRepo;
|
||||||
|
|
||||||
use std::mem::swap;
|
use s3::bucket::Bucket;
|
||||||
|
use s3::creds::Credentials;
|
||||||
|
use s3::region::Region;
|
||||||
|
|
||||||
use actix_files as fs;
|
use actix_cors::Cors;
|
||||||
|
use actix_session::{
|
||||||
|
config::CookieContentSecurity, storage::CookieSessionStore, SessionMiddleware,
|
||||||
|
};
|
||||||
use actix_web::{
|
use actix_web::{
|
||||||
|
cookie,
|
||||||
// get, middleware, post, web, App, Error, HttpRequest, HttpResponse, HttpServer,
|
// get, middleware, post, web, App, Error, HttpRequest, HttpResponse, HttpServer,
|
||||||
get,
|
// get,
|
||||||
|
// middleware,
|
||||||
|
http,
|
||||||
middleware,
|
middleware,
|
||||||
web,
|
web::Data,
|
||||||
App,
|
App,
|
||||||
HttpServer,
|
HttpServer,
|
||||||
};
|
};
|
||||||
use actix_web::{Responder, Result};
|
|
||||||
use actix_web_lab::web::spa;
|
use actix_web_lab::web::spa;
|
||||||
use backend::establish_connection;
|
|
||||||
use diesel::prelude::*;
|
|
||||||
use diesel::r2d2::{self, ConnectionManager};
|
|
||||||
|
|
||||||
// use uuid::Uuid;
|
use crate::api::photo_api::upload_done;
|
||||||
|
use crate::api::user_api::logout;
|
||||||
mod actions;
|
|
||||||
mod models;
|
|
||||||
mod schema;
|
|
||||||
|
|
||||||
use common::OutputPicture;
|
|
||||||
|
|
||||||
type DbPool = r2d2::Pool<ConnectionManager<PgConnection>>;
|
|
||||||
|
|
||||||
#[get("/api/pictures/")]
|
|
||||||
async fn get_pictures(pool: web::Data<DbPool>) -> Result<impl Responder> {
|
|
||||||
let conn = pool.get().unwrap();
|
|
||||||
let pics = if let Ok(p) = actions::list_pictures(&conn) {
|
|
||||||
p
|
|
||||||
} else {
|
|
||||||
vec![]
|
|
||||||
};
|
|
||||||
|
|
||||||
let pics: Vec<OutputPicture> = pics
|
|
||||||
.iter()
|
|
||||||
.map(|x| {
|
|
||||||
let mut w: u32 = x.width.try_into().unwrap();
|
|
||||||
let mut h: u32 = x.height.try_into().unwrap();
|
|
||||||
if let Some(o) = &x.orientation {
|
|
||||||
if o == "Rotate 270 CW" {
|
|
||||||
swap(&mut w, &mut h);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
OutputPicture {
|
|
||||||
thumbnail: x.thumbnail.clone(),
|
|
||||||
width: w,
|
|
||||||
height: h,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(web::Json(pics))
|
|
||||||
}
|
|
||||||
|
|
||||||
embed_migrations!("migrations");
|
|
||||||
|
|
||||||
#[actix_web::main]
|
#[actix_web::main]
|
||||||
async fn main() -> std::io::Result<()> {
|
async fn main() -> std::io::Result<()> {
|
||||||
dotenv::dotenv().ok();
|
dotenv::dotenv().ok();
|
||||||
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
|
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
|
||||||
|
|
||||||
let connection = establish_connection();
|
let secret_key = dotenv::var("SECRET").expect("SECRET not found");
|
||||||
|
|
||||||
// // This will run the necessary migrations.
|
|
||||||
// embedded_migrations::run(&connection).expect("Could not migrate database.");
|
|
||||||
|
|
||||||
// By default the output is thrown out. If you want to redirect it to stdout, you
|
|
||||||
// should call embedded_migrations::run_with_output.
|
|
||||||
embedded_migrations::run_with_output(&connection, &mut std::io::stdout())
|
|
||||||
.expect("Could not migrate database.");
|
|
||||||
|
|
||||||
// set up database connection pool
|
|
||||||
let conn_spec = std::env::var("DATABASE_URL").expect("DATABASE_URL");
|
|
||||||
let manager = ConnectionManager::<PgConnection>::new(conn_spec);
|
|
||||||
let pool = r2d2::Pool::builder()
|
|
||||||
.build(manager)
|
|
||||||
.expect("Failed to create pool.");
|
|
||||||
|
|
||||||
let host = "0.0.0.0";
|
let host = "0.0.0.0";
|
||||||
let port = 8081;
|
let port = 8081;
|
||||||
log::info!("starting HTTP server at http://{}:{}", host, port);
|
log::info!("starting HTTP server at http://{}:{}", host, port);
|
||||||
|
|
||||||
|
let db = MongoRepo::init().await;
|
||||||
|
let db = Data::new(db);
|
||||||
|
|
||||||
|
let bucket_name = "photos";
|
||||||
|
let region = Region::Custom {
|
||||||
|
region: "minio".parse().unwrap(),
|
||||||
|
endpoint: "http://127.0.0.1:9000".to_owned(),
|
||||||
|
};
|
||||||
|
let credentials = Credentials {
|
||||||
|
access_key: Some("OPyibtgH1RISFIPs".to_owned()),
|
||||||
|
secret_key: Some("LesNB0p5oZEcJy3rACHEloityS1Y6nYc".to_owned()),
|
||||||
|
security_token: None,
|
||||||
|
session_token: None,
|
||||||
|
expiration: None,
|
||||||
|
};
|
||||||
|
let bucket = if let Ok(mut bucket) = Bucket::new(bucket_name, region, credentials) {
|
||||||
|
bucket.set_path_style();
|
||||||
|
bucket
|
||||||
|
} else {
|
||||||
|
panic!("Could not create bucket");
|
||||||
|
};
|
||||||
|
let bucket = Data::new(bucket);
|
||||||
|
|
||||||
HttpServer::new(move || {
|
HttpServer::new(move || {
|
||||||
|
let cors = Cors::default()
|
||||||
|
.allowed_origin_fn(|origin, _req_head| {
|
||||||
|
origin
|
||||||
|
.as_bytes()
|
||||||
|
.windows("localhost".len())
|
||||||
|
.filter(|&x| x == "localhost".as_bytes())
|
||||||
|
.count()
|
||||||
|
> 0
|
||||||
|
})
|
||||||
|
.allowed_methods(vec!["GET", "POST"])
|
||||||
|
.allowed_headers(vec![http::header::AUTHORIZATION, http::header::ACCEPT])
|
||||||
|
.allowed_header(http::header::CONTENT_TYPE)
|
||||||
|
.max_age(3600);
|
||||||
|
|
||||||
App::new()
|
App::new()
|
||||||
.app_data(web::Data::new(pool.clone()))
|
.wrap(cors)
|
||||||
|
.wrap(
|
||||||
|
SessionMiddleware::builder(
|
||||||
|
CookieSessionStore::default(),
|
||||||
|
cookie::Key::from(&secret_key.clone().into_bytes()),
|
||||||
|
)
|
||||||
|
.cookie_content_security(CookieContentSecurity::Private)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.app_data(db.clone())
|
||||||
|
.app_data(bucket.clone())
|
||||||
.wrap(middleware::Logger::default())
|
.wrap(middleware::Logger::default())
|
||||||
.service(get_pictures)
|
.service(get_presigned_post_urls)
|
||||||
.service(fs::Files::new("/api/pictures/", "./pictures/"))
|
.service(upload_done)
|
||||||
|
.service(get_user_photos)
|
||||||
|
.service(is_logged_in)
|
||||||
|
.service(login)
|
||||||
|
.service(create_user)
|
||||||
|
.service(get_user)
|
||||||
|
.service(update_user)
|
||||||
|
.service(delete_user)
|
||||||
|
.service(logout)
|
||||||
.service(
|
.service(
|
||||||
spa()
|
spa()
|
||||||
.index_file("./dist/index.html")
|
.index_file("./dist/index.html")
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
use serde::Serialize;
|
|
||||||
|
|
||||||
use super::schema::pictures;
|
|
||||||
|
|
||||||
#[derive(Queryable, Serialize)]
|
|
||||||
pub struct Picture {
|
|
||||||
pub id: i32,
|
|
||||||
pub filepath: String,
|
|
||||||
pub created_at: Option<i32>,
|
|
||||||
pub focal_length: Option<String>,
|
|
||||||
pub shutter_speed: Option<String>,
|
|
||||||
pub width: i32,
|
|
||||||
pub height: i32,
|
|
||||||
pub make: Option<String>,
|
|
||||||
pub model: Option<String>,
|
|
||||||
pub lens: Option<String>,
|
|
||||||
pub orientation: Option<String>,
|
|
||||||
pub fnumber: Option<f64>,
|
|
||||||
pub iso: Option<i32>,
|
|
||||||
pub exposure_program: Option<String>,
|
|
||||||
pub exposure_compensation: Option<String>,
|
|
||||||
pub thumbnail: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Insertable)]
|
|
||||||
#[table_name = "pictures"]
|
|
||||||
pub struct NewPicture {
|
|
||||||
pub filepath: String,
|
|
||||||
pub created_at: Option<i32>,
|
|
||||||
pub focal_length: Option<String>,
|
|
||||||
pub shutter_speed: Option<String>,
|
|
||||||
pub width: i32,
|
|
||||||
pub height: i32,
|
|
||||||
pub make: Option<String>,
|
|
||||||
pub model: Option<String>,
|
|
||||||
pub lens: Option<String>,
|
|
||||||
pub orientation: Option<String>,
|
|
||||||
pub fnumber: Option<f64>,
|
|
||||||
pub iso: Option<i32>,
|
|
||||||
pub exposure_program: Option<String>,
|
|
||||||
pub exposure_compensation: Option<String>,
|
|
||||||
pub thumbnail: Option<String>,
|
|
||||||
}
|
|
||||||
2
backend/src/models/mod.rs
Normal file
2
backend/src/models/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod photo_model;
|
||||||
|
pub mod user_model;
|
||||||
12
backend/src/models/photo_model.rs
Normal file
12
backend/src/models/photo_model.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
use mongodb::bson::oid::ObjectId;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Photo {
|
||||||
|
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<ObjectId>,
|
||||||
|
pub user_id: ObjectId,
|
||||||
|
pub key: String,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
11
backend/src/models/user_model.rs
Normal file
11
backend/src/models/user_model.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
use mongodb::bson::oid::ObjectId;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct User {
|
||||||
|
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<ObjectId>,
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
1
backend/src/repository/mod.rs
Normal file
1
backend/src/repository/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod mongodb_repo;
|
||||||
141
backend/src/repository/mongodb_repo.rs
Normal file
141
backend/src/repository/mongodb_repo.rs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
use std::env;
|
||||||
|
extern crate dotenv;
|
||||||
|
use dotenv::dotenv;
|
||||||
|
|
||||||
|
use crate::models::photo_model::Photo;
|
||||||
|
use crate::models::user_model::User;
|
||||||
|
use futures::stream::TryStreamExt;
|
||||||
|
use mongodb::{
|
||||||
|
bson::{doc, oid::ObjectId},
|
||||||
|
error::Error,
|
||||||
|
results::{DeleteResult, InsertOneResult, UpdateResult},
|
||||||
|
Client, Collection,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct MongoRepo {
|
||||||
|
user_col: Collection<User>,
|
||||||
|
photo_col: Collection<Photo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MongoRepo {
|
||||||
|
pub async fn init() -> Self {
|
||||||
|
dotenv().ok();
|
||||||
|
let uri = match env::var("MONGOURI") {
|
||||||
|
Ok(v) => v.to_string(),
|
||||||
|
Err(_) => format!("Error loading env variable"),
|
||||||
|
};
|
||||||
|
let client = Client::with_uri_str(uri).await.unwrap();
|
||||||
|
let db = client.database("photos");
|
||||||
|
let user_col: Collection<User> = db.collection("User");
|
||||||
|
let photo_col: Collection<Photo> = db.collection("Photo");
|
||||||
|
MongoRepo {
|
||||||
|
user_col,
|
||||||
|
photo_col,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_user(&self, new_user: User) -> Result<InsertOneResult, Error> {
|
||||||
|
let new_doc = User {
|
||||||
|
id: None,
|
||||||
|
username: new_user.username,
|
||||||
|
email: new_user.email,
|
||||||
|
password: new_user.password,
|
||||||
|
};
|
||||||
|
let user = self
|
||||||
|
.user_col
|
||||||
|
.insert_one(new_doc, None)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.expect("Error creating user");
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user(&self, id: &String) -> Result<User, Error> {
|
||||||
|
let obj_id = ObjectId::parse_str(id).unwrap();
|
||||||
|
let filter = doc! {"_id": obj_id};
|
||||||
|
let user_detail = self
|
||||||
|
.user_col
|
||||||
|
.find_one(filter, None)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.expect("Error getting user's detail");
|
||||||
|
Ok(user_detail.unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_from_email(&self, email: &String) -> Result<User, Error> {
|
||||||
|
let filter = doc! {"email": email};
|
||||||
|
let user_detail = self
|
||||||
|
.user_col
|
||||||
|
.find_one(filter, None)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.expect("Error getting user's detail");
|
||||||
|
Ok(user_detail.unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_user(&self, id: &String, new_user: User) -> Result<UpdateResult, Error> {
|
||||||
|
let obj_id = ObjectId::parse_str(id).unwrap();
|
||||||
|
let filter = doc! {"_id": obj_id};
|
||||||
|
let new_doc = doc! {
|
||||||
|
"$set":
|
||||||
|
{
|
||||||
|
"id": new_user.id,
|
||||||
|
"username": new_user.username,
|
||||||
|
"email": new_user.email,
|
||||||
|
"password": new_user.password,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let updated_doc = self
|
||||||
|
.user_col
|
||||||
|
.update_one(filter, new_doc, None)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.expect("Error updating user");
|
||||||
|
Ok(updated_doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_user(&self, id: &String) -> Result<DeleteResult, Error> {
|
||||||
|
let obj_id = ObjectId::parse_str(id).unwrap();
|
||||||
|
let filter = doc! {"_id": obj_id};
|
||||||
|
let user_detail = self
|
||||||
|
.user_col
|
||||||
|
.delete_one(filter, None)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.expect("Error deleting user");
|
||||||
|
Ok(user_detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_photo(&self, new_photo: Photo) -> Result<InsertOneResult, Error> {
|
||||||
|
let new_doc = Photo {
|
||||||
|
id: None,
|
||||||
|
user_id: new_photo.user_id,
|
||||||
|
key: new_photo.key,
|
||||||
|
width: new_photo.width,
|
||||||
|
height: new_photo.height,
|
||||||
|
};
|
||||||
|
let photo = self
|
||||||
|
.photo_col
|
||||||
|
.insert_one(new_doc, None)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.expect("Error creating user");
|
||||||
|
Ok(photo)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_photos(&self, id: &String) -> Result<Vec<Photo>, Error> {
|
||||||
|
let obj_id = ObjectId::parse_str(id).unwrap();
|
||||||
|
let filter = doc! {"user_id": obj_id};
|
||||||
|
let mut cursor = self
|
||||||
|
.photo_col
|
||||||
|
.find(filter, None)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.expect("Error getting photos cursor");
|
||||||
|
let mut photos = Vec::new();
|
||||||
|
while let Some(photo) = cursor.try_next().await? {
|
||||||
|
photos.push(photo);
|
||||||
|
}
|
||||||
|
Ok(photos)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
table! {
|
|
||||||
pictures (id) {
|
|
||||||
id -> Int4,
|
|
||||||
filepath -> Varchar,
|
|
||||||
created_at -> Nullable<Int4>,
|
|
||||||
focal_length -> Nullable<Varchar>,
|
|
||||||
shutter_speed -> Nullable<Varchar>,
|
|
||||||
width -> Int4,
|
|
||||||
height -> Int4,
|
|
||||||
make -> Nullable<Varchar>,
|
|
||||||
model -> Nullable<Varchar>,
|
|
||||||
lens -> Nullable<Varchar>,
|
|
||||||
orientation -> Nullable<Varchar>,
|
|
||||||
fnumber -> Nullable<Float8>,
|
|
||||||
iso -> Nullable<Int4>,
|
|
||||||
exposure_program -> Nullable<Varchar>,
|
|
||||||
exposure_compensation -> Nullable<Varchar>,
|
|
||||||
thumbnail -> Nullable<Varchar>,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,23 +2,23 @@ version: '3.1'
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
db:
|
# db:
|
||||||
image: postgres
|
# image: postgres
|
||||||
restart: always
|
# restart: always
|
||||||
volumes:
|
# volumes:
|
||||||
- ./data:/var/lib/postgresql/data
|
# - ./data:/var/lib/postgresql/data
|
||||||
ports:
|
# ports:
|
||||||
- 5432:5432
|
# - 5432:5432
|
||||||
environment:
|
# environment:
|
||||||
- POSTGRES_DB=diesel_demo
|
# - POSTGRES_DB=diesel_demo
|
||||||
- POSTGRES_USER=user
|
# - POSTGRES_USER=user
|
||||||
- POSTGRES_PASSWORD=password
|
# - POSTGRES_PASSWORD=password
|
||||||
|
|
||||||
adminer:
|
# adminer:
|
||||||
image: adminer
|
# image: adminer
|
||||||
restart: always
|
# restart: always
|
||||||
ports:
|
# ports:
|
||||||
- 3000:8080
|
# - 3000:8080
|
||||||
|
|
||||||
# photos:
|
# photos:
|
||||||
# build: .
|
# build: .
|
||||||
@@ -31,3 +31,40 @@ services:
|
|||||||
# - db
|
# - db
|
||||||
# links:
|
# links:
|
||||||
# - db
|
# - db
|
||||||
|
|
||||||
|
mongo:
|
||||||
|
image: mongo
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
MONGO_INITDB_ROOT_USERNAME: jheuel
|
||||||
|
MONGO_INITDB_ROOT_PASSWORD: bla
|
||||||
|
ports:
|
||||||
|
- 27017:27017
|
||||||
|
|
||||||
|
mongo-express:
|
||||||
|
image: mongo-express
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- 8082:8081
|
||||||
|
environment:
|
||||||
|
ME_CONFIG_MONGODB_ADMINUSERNAME: jheuel
|
||||||
|
ME_CONFIG_MONGODB_ADMINPASSWORD: bla
|
||||||
|
ME_CONFIG_MONGODB_URL: mongodb://jheuel:bla@mongo:27017/
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: quay.io/minio/minio
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
ports:
|
||||||
|
- 9000:9000
|
||||||
|
- 9001:9001
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: miliminioadmin
|
||||||
|
MINIO_ROOT_PASSWORD: miliminioadmin
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 20s
|
||||||
|
retries: 3
|
||||||
|
hostname: minio
|
||||||
|
volumes:
|
||||||
|
- ./s3_data:/data
|
||||||
|
|||||||
@@ -7,20 +7,32 @@ license = "MIT"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
console_error_panic_hook = "0.1.6"
|
console_error_panic_hook = "0.1.6"
|
||||||
wasm-bindgen = "=0.2.82"
|
wasm-bindgen = "0.2.84"
|
||||||
wasm-bindgen-futures = "0.4.32"
|
wasm-bindgen-futures = "0.4.32"
|
||||||
gloo-net = "0.2.3"
|
gloo-net = "0.2.3"
|
||||||
|
gloo-storage = "0.2"
|
||||||
weblog = "0.3.0"
|
weblog = "0.3.0"
|
||||||
|
web-sys = {version = "0.3.61", features = ["Window", "DataTransfer", "DataTransferItemList", "DataTransferItem", "FileSystemEntry", "FileSystemDirectoryEntry", "FileSystemDirectoryReader", "FileSystemDirectoryReader", "DragEvent"]}
|
||||||
|
js-sys = "0.3.61"
|
||||||
wee_alloc = "0.4.5"
|
wee_alloc = "0.4.5"
|
||||||
ybc = { git = "https://github.com/jheuel/ybc", branch = "yew-0-19-update" }
|
ybc = { git = "https://github.com/jheuel/ybc", branch = "alpha-v0.4" }
|
||||||
yew = "0.19"
|
yew = "0.20"
|
||||||
yew-hooks = "0.1.56"
|
yew-router = "0.17"
|
||||||
|
yew-hooks = "0.2.0"
|
||||||
pathfinding = "3.0.13"
|
pathfinding = "3.0.13"
|
||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
anyhow = "1.0.58"
|
anyhow = "1.0.58"
|
||||||
yewtil = { version = "0.4.0", features = ["neq"] }
|
yewtil = { version = "0.4.0", features = ["neq"] }
|
||||||
|
thiserror = "1"
|
||||||
|
lazy_static = "1.4"
|
||||||
|
parking_lot = "0.12"
|
||||||
|
dotenv = "0.15.0"
|
||||||
|
dotenv_codegen = "0.15.0"
|
||||||
|
kamadak-exif = "0.5.5"
|
||||||
|
image-meta = "0.1.2"
|
||||||
|
image = { version = "0.24.5", default-features = false, features = ["gif", "jpeg", "ico", "png", "pnm", "tga", "tiff", "webp", "bmp", "hdr", "dxt", "dds", "farbfeld", "jpeg_rayon"] }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
body {}
|
body {}
|
||||||
|
|
||||||
|
.dragover_highlight {
|
||||||
|
background-color: rgb(215, 215, 215);
|
||||||
|
}
|
||||||
|
|||||||
97
frontend/src/components/base_page.rs
Normal file
97
frontend/src/components/base_page.rs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
use crate::hooks::use_user_context;
|
||||||
|
use crate::Route;
|
||||||
|
use ybc::NavbarFixed::Top;
|
||||||
|
use yew::prelude::*;
|
||||||
|
use yew_hooks::prelude::*;
|
||||||
|
use yew_router::prelude::*;
|
||||||
|
|
||||||
|
#[derive(PartialEq, Properties)]
|
||||||
|
pub struct Props {
|
||||||
|
pub children: Children,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component]
|
||||||
|
pub fn BasePage(props: &Props) -> Html {
|
||||||
|
let navigator = use_navigator().unwrap();
|
||||||
|
let user_ctx = use_user_context();
|
||||||
|
|
||||||
|
let node = use_node_ref();
|
||||||
|
let size = use_size(node.clone());
|
||||||
|
|
||||||
|
let authenticated = user_ctx.is_authenticated();
|
||||||
|
let title = if authenticated {
|
||||||
|
html! {"Photos"}
|
||||||
|
} else {
|
||||||
|
html! {"No photos"}
|
||||||
|
};
|
||||||
|
|
||||||
|
let navbrand = html! {
|
||||||
|
<ybc::NavbarItem>
|
||||||
|
<ybc::Title
|
||||||
|
classes={classes!("has-text-white")}
|
||||||
|
size={ybc::HeaderSize::Is4}>
|
||||||
|
{title}
|
||||||
|
</ybc::Title>
|
||||||
|
</ybc::NavbarItem>
|
||||||
|
};
|
||||||
|
|
||||||
|
let account_button = if authenticated {
|
||||||
|
let onclick = {
|
||||||
|
Callback::from(move |_| {
|
||||||
|
user_ctx.logout();
|
||||||
|
})
|
||||||
|
};
|
||||||
|
html! {
|
||||||
|
<ybc::ButtonAnchor
|
||||||
|
classes={classes!("is-outlined")}
|
||||||
|
rel={String::from("noopener noreferrer")}
|
||||||
|
target={String::from("_blank")}
|
||||||
|
href=""
|
||||||
|
{onclick}>
|
||||||
|
{"logout"}
|
||||||
|
</ybc::ButtonAnchor>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let onclick = Callback::from(move |_| navigator.push(&Route::Login));
|
||||||
|
html! {
|
||||||
|
<ybc::Button
|
||||||
|
// classes={classes!("is-outlined")}
|
||||||
|
{onclick}>
|
||||||
|
{"login"}
|
||||||
|
</ybc::Button>
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let navstart = html! {};
|
||||||
|
let navend = html! {
|
||||||
|
<>
|
||||||
|
// <ybc::NavbarItem>
|
||||||
|
// <ybc::Button
|
||||||
|
// // classes={classes!("is-outlined")}
|
||||||
|
// >
|
||||||
|
// {"Photos"}
|
||||||
|
// </ybc::Button>
|
||||||
|
// </ybc::NavbarItem>
|
||||||
|
<ybc::NavbarItem>
|
||||||
|
{account_button}
|
||||||
|
</ybc::NavbarItem>
|
||||||
|
</>
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<>
|
||||||
|
<ybc::Navbar
|
||||||
|
fixed={Top}
|
||||||
|
classes={classes!("is-info")}
|
||||||
|
padded={true}
|
||||||
|
{navbrand}
|
||||||
|
{navstart}
|
||||||
|
{navend}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{ props.children.clone() }
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
}
|
||||||
106
frontend/src/components/home.js
Normal file
106
frontend/src/components/home.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
function join(a, b) {
|
||||||
|
if (a === "" && b === "") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (a === "") return b;
|
||||||
|
if (b === "") return a;
|
||||||
|
return a + "/" + b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEntries(reader) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
reader.readEntries(entries => {
|
||||||
|
resolve(entries);
|
||||||
|
}, error => reject(error));
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilesDataTransferItems(dataTransferItems) {
|
||||||
|
function traverseFileTreePromise(item, path = "", folder) {
|
||||||
|
return new Promise(async resolve => {
|
||||||
|
if (item === null || typeof item === 'undefined') {
|
||||||
|
// nothing to do
|
||||||
|
} else if (item.isFile) {
|
||||||
|
item.file(file => {
|
||||||
|
file.filepath = join(path, file.name); //save full path
|
||||||
|
folder.push(file);
|
||||||
|
resolve(file);
|
||||||
|
});
|
||||||
|
} else if (item.isDirectory) {
|
||||||
|
let dirReader = item.createReader();
|
||||||
|
let resultEntries = [];
|
||||||
|
|
||||||
|
let read = async function() {
|
||||||
|
let entries = await readEntries(dirReader);
|
||||||
|
if (entries.length > 0) {
|
||||||
|
resultEntries = resultEntries.concat(entries);
|
||||||
|
await read();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await read();
|
||||||
|
|
||||||
|
let entriesPromises = [];
|
||||||
|
let subfolder = [];
|
||||||
|
folder.push({ name: item.name, subfolder: subfolder });
|
||||||
|
for (let entry of resultEntries)
|
||||||
|
entriesPromises.push(
|
||||||
|
traverseFileTreePromise(entry, join(path, item.name), subfolder)
|
||||||
|
);
|
||||||
|
resolve(Promise.all(entriesPromises));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let files = [];
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let entriesPromises = [];
|
||||||
|
for (let it of dataTransferItems) {
|
||||||
|
let entry;
|
||||||
|
if (typeof it.webkitGetAsEntry === 'function') {
|
||||||
|
entry = it.webkitGetAsEntry();
|
||||||
|
} else if (typeof it.getAsEntry === 'function') {
|
||||||
|
entry = it.getAsEntry();
|
||||||
|
}
|
||||||
|
entriesPromises.push(
|
||||||
|
traverseFileTreePromise(entry, "", files)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Promise.all(entriesPromises).then(entries => {
|
||||||
|
resolve(files);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function get_files_data_transfer_items(data_transfer_items) {
|
||||||
|
return getFilesDataTransferItems(data_transfer_items);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function read_file(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
resolve(reader.result)
|
||||||
|
};
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsArrayBuffer(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upload(content, content_type, url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
body: new Blob([content], { type: content_type }),
|
||||||
|
}).then((resp) => {
|
||||||
|
if (resp.status >= 200 && resp.status < 300) {
|
||||||
|
resolve(resp);
|
||||||
|
} else {
|
||||||
|
reject(resp);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
307
frontend/src/components/home.rs
Normal file
307
frontend/src/components/home.rs
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
use std::io::{BufWriter, Cursor};
|
||||||
|
|
||||||
|
use super::BasePage;
|
||||||
|
use crate::gallery::Grid;
|
||||||
|
use crate::hooks::use_user_context;
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
|
||||||
|
use image::{imageops, io::Reader as ImageReader, ImageOutputFormat};
|
||||||
|
use js_sys::{Array, ArrayBuffer, Promise};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use wasm_bindgen::{prelude::wasm_bindgen, JsCast, JsValue};
|
||||||
|
use web_sys::{
|
||||||
|
DataTransferItemList, Element, File, FileReader, FileSystemDirectoryEntry, FileSystemEntry,
|
||||||
|
HtmlElement,
|
||||||
|
};
|
||||||
|
|
||||||
|
use common::OutputPicture;
|
||||||
|
use weblog::console_log;
|
||||||
|
use yew::prelude::*;
|
||||||
|
use yew_hooks::prelude::*;
|
||||||
|
|
||||||
|
pub struct MetaData {
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct PhotosWrapper {
|
||||||
|
photos: Vec<String>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct PhotosUrlsWrapper {
|
||||||
|
photos: Vec<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct DetailPhoto {
|
||||||
|
key: String,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct DetailPhotoWrapper {
|
||||||
|
photos: Vec<DetailPhoto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(Home)]
|
||||||
|
pub fn home() -> Html {
|
||||||
|
let user_ctx = use_user_context();
|
||||||
|
|
||||||
|
let node = use_node_ref();
|
||||||
|
let size = use_size(node.clone());
|
||||||
|
|
||||||
|
let pictures = use_state(std::vec::Vec::new);
|
||||||
|
|
||||||
|
{
|
||||||
|
let pictures = pictures.clone();
|
||||||
|
use_effect_with_deps(
|
||||||
|
move |_| {
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let url = "/api/photos/get";
|
||||||
|
let fetched_pictures: Vec<OutputPicture> = Request::get(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
pictures.set(fetched_pictures);
|
||||||
|
});
|
||||||
|
|| ()
|
||||||
|
},
|
||||||
|
(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let drag_node = node.clone();
|
||||||
|
let ondrop = Callback::from(move |e: DragEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
if let Some(element) = drag_node.cast::<HtmlElement>() {
|
||||||
|
element.class_list().remove_1("dragover_highlight").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let items = e.data_transfer().unwrap().items();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let promise = get_files_data_transfer_items(items);
|
||||||
|
let result = wasm_bindgen_futures::JsFuture::from(promise).await.unwrap();
|
||||||
|
|
||||||
|
console_log!(&result);
|
||||||
|
|
||||||
|
let traverse = |d: JsValue| {
|
||||||
|
let mut new_files: Vec<JsValue> = Vec::new();
|
||||||
|
let mut files: Vec<File> = Vec::new();
|
||||||
|
|
||||||
|
if let Ok(fse) = d.clone().dyn_into::<File>() {
|
||||||
|
console_log!(&fse);
|
||||||
|
files.push(fse.clone());
|
||||||
|
} else if let Ok(a) = d.clone().dyn_into::<js_sys::Array>() {
|
||||||
|
for i in 0..a.length() {
|
||||||
|
let f = a.get(i);
|
||||||
|
new_files.push(f);
|
||||||
|
}
|
||||||
|
} else if let Ok(o) = d.clone().dyn_into::<js_sys::Object>() {
|
||||||
|
if let Ok(subfolder) = js_sys::Reflect::get(&o, &JsValue::from_str("subfolder"))
|
||||||
|
{
|
||||||
|
new_files.push(subfolder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(new_files, files)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut files: Vec<File> = Vec::new();
|
||||||
|
let mut new_files = vec![result];
|
||||||
|
|
||||||
|
loop {
|
||||||
|
console_log!(files.len());
|
||||||
|
let Some(current) = new_files.pop() else {
|
||||||
|
console_log!("break");
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
let (news, fs) = traverse(current);
|
||||||
|
news.iter().for_each(|x| new_files.push(x.clone()));
|
||||||
|
fs.iter().for_each(|x| files.push(x.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let uiae: Vec<String> = files
|
||||||
|
.iter()
|
||||||
|
.map(|x| {
|
||||||
|
if let Ok(filepath) = js_sys::Reflect::get(x, &JsValue::from_str("filepath")) {
|
||||||
|
filepath.as_string().unwrap_or("".to_string())
|
||||||
|
} else {
|
||||||
|
"".to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
console_log!("end", uiae.join("\n"));
|
||||||
|
|
||||||
|
let photos: PhotosUrlsWrapper = Request::post("/api/photos/upload")
|
||||||
|
.json(&PhotosWrapper {
|
||||||
|
photos: uiae.clone(),
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
console_log!("{}", serde_json::to_string(&photos).unwrap());
|
||||||
|
|
||||||
|
let mut metadata: Vec<MetaData> = Vec::new();
|
||||||
|
let mut promises: Vec<Promise> = Vec::new();
|
||||||
|
for (file, (key, url)) in files.iter().zip(photos.photos) {
|
||||||
|
console_log!("start working on: ", &file.name(), &url);
|
||||||
|
let promise = read_file(file.clone());
|
||||||
|
if let Ok(content) = wasm_bindgen_futures::JsFuture::from(promise).await {
|
||||||
|
console_log!("start copy");
|
||||||
|
let buffer: ArrayBuffer = content.dyn_into().unwrap();
|
||||||
|
let typed_buffer: js_sys::Uint8Array = js_sys::Uint8Array::new(&buffer);
|
||||||
|
let mut buf = vec![0; typed_buffer.length() as usize];
|
||||||
|
typed_buffer.copy_to(&mut buf);
|
||||||
|
console_log!("stop copy");
|
||||||
|
let buf = buf;
|
||||||
|
|
||||||
|
let mut md = MetaData {
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
};
|
||||||
|
console_log!("start meta");
|
||||||
|
if let Ok(meta) = image_meta::load_from_buf(&buf) {
|
||||||
|
console_log!(format!(
|
||||||
|
"dims: {}x{}",
|
||||||
|
meta.dimensions.width, meta.dimensions.height
|
||||||
|
));
|
||||||
|
console_log!(format!("animation: {:?}", meta.is_animation()));
|
||||||
|
console_log!(format!("format: {:?}", meta.format));
|
||||||
|
md.height = meta.dimensions.height;
|
||||||
|
md.width = meta.dimensions.width;
|
||||||
|
}
|
||||||
|
console_log!("stop meta");
|
||||||
|
|
||||||
|
console_log!("start thumb");
|
||||||
|
if let Ok(reader) = ImageReader::new(Cursor::new(&buf)).with_guessed_format() {
|
||||||
|
if let Ok(img) = reader.decode() {
|
||||||
|
console_log!("done reading");
|
||||||
|
md.height = img.height();
|
||||||
|
md.width = img.width();
|
||||||
|
let ratio = img.width() as f32 / img.height() as f32;
|
||||||
|
let thumb = img.thumbnail((800_f32 * ratio) as u32, 800);
|
||||||
|
// let thumb = img.resize((800_f32 * ratio) as u32, 800, imageops::FilterType::Nearest);
|
||||||
|
let mut cursor = Cursor::new(Vec::new());
|
||||||
|
if thumb
|
||||||
|
.write_to(&mut cursor, ImageOutputFormat::Jpeg(85))
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
let thumb_buf = cursor.get_ref();
|
||||||
|
console_log!("buf", serde_json::to_string(thumb_buf).unwrap());
|
||||||
|
let array =
|
||||||
|
js_sys::Uint8Array::new_with_length(thumb_buf.len() as u32);
|
||||||
|
console_log!("start copy");
|
||||||
|
array.copy_from(cursor.get_ref());
|
||||||
|
console_log!("stop copy");
|
||||||
|
let promise =
|
||||||
|
upload(array.buffer(), "image/jpeg".to_string(), url.clone());
|
||||||
|
match wasm_bindgen_futures::JsFuture::from(promise).await {
|
||||||
|
Ok(result) => console_log!(result),
|
||||||
|
Err(e) => console_log!("errooooor", e),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
console_log!("Could not create thumbnail.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console_log!("stop thumb");
|
||||||
|
|
||||||
|
// let exifreader = exif::Reader::new();
|
||||||
|
// let mut cursor = Cursor::new(&buf);
|
||||||
|
// if let Ok(exif) = exifreader.read_from_container(&mut cursor) {
|
||||||
|
// for f in exif.fields() {
|
||||||
|
// console_log!(format!(
|
||||||
|
// "{} {} {}",
|
||||||
|
// f.tag,
|
||||||
|
// f.ifd_num,
|
||||||
|
// f.display_value().with_unit(&exif)
|
||||||
|
// ));
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
metadata.push(md);
|
||||||
|
|
||||||
|
// promises.push(upload(buffer, file.type_(), url.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let photos: DetailPhotoWrapper = Request::post("/api/photos/upload/done")
|
||||||
|
.json(&DetailPhotoWrapper {
|
||||||
|
photos: uiae
|
||||||
|
.iter()
|
||||||
|
.zip(metadata)
|
||||||
|
.map(|(p, md)| DetailPhoto {
|
||||||
|
key: p.clone(),
|
||||||
|
width: md.width,
|
||||||
|
height: md.height,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
console_log!("all uploaded");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let drag_node = node.clone();
|
||||||
|
let ondragenter = Callback::from(move |e: DragEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
// e.data_transfer().unwrap().set_drop_effect("move");
|
||||||
|
if let Some(element) = drag_node.cast::<HtmlElement>() {
|
||||||
|
element.class_list().remove_1("dragover_highlight").unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let drag_node = node.clone();
|
||||||
|
let ondragleave = Callback::from(move |e: DragEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
if let Some(element) = drag_node.cast::<HtmlElement>() {
|
||||||
|
element.class_list().remove_1("dragover_highlight").unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let drag_node = node.clone();
|
||||||
|
let ondragover = Callback::from(move |e: DragEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
if let Some(element) = drag_node.cast::<HtmlElement>() {
|
||||||
|
element.class_list().add_1("dragover_highlight").unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let body = if user_ctx.is_authenticated() {
|
||||||
|
html! {
|
||||||
|
<Grid
|
||||||
|
pictures={(*pictures).clone()}
|
||||||
|
width={size.0}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
};
|
||||||
|
html! {
|
||||||
|
<BasePage>
|
||||||
|
<div ref={node} {ondrop} {ondragenter} {ondragleave} {ondragover}>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
</BasePage>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen(module = "/src/components/home.js")]
|
||||||
|
extern "C" {
|
||||||
|
fn get_files_data_transfer_items(data_transfer_items: DataTransferItemList) -> js_sys::Promise;
|
||||||
|
fn read_file(file: File) -> js_sys::Promise;
|
||||||
|
fn upload(content: ArrayBuffer, content_type: String, url: String) -> js_sys::Promise;
|
||||||
|
}
|
||||||
48
frontend/src/components/list_errors.rs
Normal file
48
frontend/src/components/list_errors.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
#[derive(Properties, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Props {
|
||||||
|
pub error: Option<Error>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(ListErrors)]
|
||||||
|
pub fn list_errors(props: &Props) -> Html {
|
||||||
|
if let Some(error) = &props.error {
|
||||||
|
html! {
|
||||||
|
<ul class="error-messages">
|
||||||
|
{
|
||||||
|
match error {
|
||||||
|
Error::UnprocessableEntity(error_info) => {
|
||||||
|
html! {
|
||||||
|
<>
|
||||||
|
{for error_info.errors.iter().map(|(key, value)| {
|
||||||
|
html! {
|
||||||
|
<li>
|
||||||
|
{ key }
|
||||||
|
{for value.iter().map(|e| {
|
||||||
|
html! {
|
||||||
|
<>{" "} {e}</>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
html! {
|
||||||
|
<li>{error}</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
148
frontend/src/components/login.rs
Normal file
148
frontend/src/components/login.rs
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
use web_sys::HtmlInputElement;
|
||||||
|
|
||||||
|
use ybc::TileCtx::{Ancestor, Child, Parent};
|
||||||
|
use ybc::TileSize::*;
|
||||||
|
use yew::prelude::*;
|
||||||
|
use yew_hooks::prelude::*;
|
||||||
|
use yew_router::prelude::*;
|
||||||
|
|
||||||
|
use super::BasePage;
|
||||||
|
use crate::components::list_errors::ListErrors;
|
||||||
|
use crate::hooks::use_user_context;
|
||||||
|
use crate::services::auth::*;
|
||||||
|
use crate::types::{LoginInfo, LoginInfoWrapper, UserInfoWrapper};
|
||||||
|
use crate::Route;
|
||||||
|
|
||||||
|
/// Login page
|
||||||
|
#[function_component(Login)]
|
||||||
|
pub fn login_page() -> Html {
|
||||||
|
let user_ctx = use_user_context();
|
||||||
|
let login_info = use_state(LoginInfo::default);
|
||||||
|
let user_login = {
|
||||||
|
let login_info = login_info.clone();
|
||||||
|
use_async(async move {
|
||||||
|
let request = LoginInfoWrapper {
|
||||||
|
user: (*login_info).clone(),
|
||||||
|
};
|
||||||
|
login(&request).send::<UserInfoWrapper>().await
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
use_effect_with_deps(
|
||||||
|
move |user_login| {
|
||||||
|
if let Some(user_info) = &user_login.data {
|
||||||
|
user_ctx.login(user_info.user.clone());
|
||||||
|
}
|
||||||
|
|| ()
|
||||||
|
},
|
||||||
|
user_login.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let onsubmit = {
|
||||||
|
let user_login = user_login.clone();
|
||||||
|
Callback::from(move |e: SubmitEvent| {
|
||||||
|
e.prevent_default(); /* Prevent event propagation */
|
||||||
|
user_login.run();
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let oninput_email = {
|
||||||
|
let login_info = login_info.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
let mut info = (*login_info).clone();
|
||||||
|
info.email = input.value();
|
||||||
|
login_info.set(info);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let oninput_password = {
|
||||||
|
let login_info = login_info.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
let mut info = (*login_info).clone();
|
||||||
|
info.password = input.value();
|
||||||
|
login_info.set(info);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let form = html! {
|
||||||
|
<div class="auth-page">
|
||||||
|
<div class="container page">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 offset-md-3 col-xs-12">
|
||||||
|
<h1 class="text-xs-center">{ "Sign In" }</h1>
|
||||||
|
<p class="text-xs-center">
|
||||||
|
<Link<Route> to={Route::Register}>
|
||||||
|
{ "Need an account?" }
|
||||||
|
</Link<Route>>
|
||||||
|
</p>
|
||||||
|
<ListErrors error={user_login.error.clone()} />
|
||||||
|
<form {onsubmit}>
|
||||||
|
<fieldset>
|
||||||
|
<fieldset class="form-group">
|
||||||
|
<input
|
||||||
|
class="form-control form-control-lg"
|
||||||
|
type="email"
|
||||||
|
placeholder="Email"
|
||||||
|
value={login_info.email.clone()}
|
||||||
|
oninput={oninput_email}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="form-group">
|
||||||
|
<input
|
||||||
|
class="form-control form-control-lg"
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
value={login_info.password.clone()}
|
||||||
|
oninput={oninput_password}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
<button
|
||||||
|
class="btn btn-lg btn-primary pull-xs-right"
|
||||||
|
type="submit"
|
||||||
|
disabled=false>
|
||||||
|
{ "Sign in" }
|
||||||
|
</button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
};
|
||||||
|
|
||||||
|
let hero_body = html! {
|
||||||
|
<ybc::Container
|
||||||
|
fluid=true
|
||||||
|
|
||||||
|
classes={
|
||||||
|
classes!(
|
||||||
|
"is-centered",
|
||||||
|
"is-light",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ybc::Tile ctx={Ancestor}>
|
||||||
|
<ybc::Tile ctx={Parent} vertical=true size={Twelve}>
|
||||||
|
<ybc::Tile ctx={Child} classes={classes!("box")}>
|
||||||
|
{form}
|
||||||
|
</ybc::Tile>
|
||||||
|
</ybc::Tile>
|
||||||
|
</ybc::Tile>
|
||||||
|
</ybc::Container>
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<BasePage>
|
||||||
|
<ybc::Hero
|
||||||
|
classes={
|
||||||
|
classes!(
|
||||||
|
"is-light",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
size={ybc::HeroSize::FullheightWithNavbar}
|
||||||
|
body={hero_body}
|
||||||
|
>
|
||||||
|
</ybc::Hero>
|
||||||
|
</BasePage>
|
||||||
|
}
|
||||||
|
}
|
||||||
16
frontend/src/components/mod.rs
Normal file
16
frontend/src/components/mod.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
pub mod home;
|
||||||
|
pub use home::Home;
|
||||||
|
|
||||||
|
pub mod register;
|
||||||
|
pub use register::Register;
|
||||||
|
|
||||||
|
pub mod login;
|
||||||
|
pub use login::Login;
|
||||||
|
|
||||||
|
pub mod user_context_provider;
|
||||||
|
pub use user_context_provider::*;
|
||||||
|
|
||||||
|
pub mod base_page;
|
||||||
|
pub use base_page::*;
|
||||||
|
|
||||||
|
pub mod list_errors;
|
||||||
168
frontend/src/components/register.rs
Normal file
168
frontend/src/components/register.rs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
use web_sys::HtmlInputElement;
|
||||||
|
|
||||||
|
use ybc::TileCtx::{Ancestor, Child, Parent};
|
||||||
|
use ybc::TileSize::*;
|
||||||
|
use yew::prelude::*;
|
||||||
|
use yew_hooks::prelude::*;
|
||||||
|
use yew_router::prelude::*;
|
||||||
|
|
||||||
|
use super::BasePage;
|
||||||
|
use crate::components::list_errors::ListErrors;
|
||||||
|
use crate::hooks::use_user_context;
|
||||||
|
use crate::services::auth::*;
|
||||||
|
use crate::types::{RegisterInfo, RegisterInfoWrapper, UserInfoWrapper};
|
||||||
|
use crate::Route;
|
||||||
|
|
||||||
|
/// Register page
|
||||||
|
#[function_component(Register)]
|
||||||
|
pub fn register_page() -> Html {
|
||||||
|
let user_ctx = use_user_context();
|
||||||
|
let register_info = use_state(RegisterInfo::default);
|
||||||
|
let user_register = {
|
||||||
|
let register_info = register_info.clone();
|
||||||
|
use_async(async move {
|
||||||
|
let request = RegisterInfoWrapper {
|
||||||
|
user: (*register_info).clone(),
|
||||||
|
};
|
||||||
|
register(&request).send::<UserInfoWrapper>().await
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
use_effect_with_deps(
|
||||||
|
move |user_register| {
|
||||||
|
if let Some(user_info) = &user_register.data {
|
||||||
|
user_ctx.login(user_info.user.clone());
|
||||||
|
}
|
||||||
|
|| ()
|
||||||
|
},
|
||||||
|
user_register.clone(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let onsubmit = {
|
||||||
|
let user_register = user_register.clone();
|
||||||
|
Callback::from(move |e: SubmitEvent| {
|
||||||
|
e.prevent_default(); /* Prevent event propagation */
|
||||||
|
user_register.run();
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let oninput_username = {
|
||||||
|
let register_info = register_info.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
let mut info = (*register_info).clone();
|
||||||
|
info.username = input.value();
|
||||||
|
register_info.set(info);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let oninput_email = {
|
||||||
|
let register_info = register_info.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
let mut info = (*register_info).clone();
|
||||||
|
info.email = input.value();
|
||||||
|
register_info.set(info);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let oninput_password = {
|
||||||
|
let register_info = register_info.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
let mut info = (*register_info).clone();
|
||||||
|
info.password = input.value();
|
||||||
|
register_info.set(info);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let form = html! {
|
||||||
|
<div class="auth-page">
|
||||||
|
<div class="container page">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 offset-md-3 col-xs-12">
|
||||||
|
<h1 class="text-xs-center">{ "Sign Up" }</h1>
|
||||||
|
<p class="text-xs-center">
|
||||||
|
<Link<Route> to={Route::Login}>
|
||||||
|
{ "Have an account?" }
|
||||||
|
</Link<Route>>
|
||||||
|
</p>
|
||||||
|
<ListErrors error={user_register.error.clone()} />
|
||||||
|
<form {onsubmit}>
|
||||||
|
<fieldset>
|
||||||
|
<fieldset class="form-group">
|
||||||
|
<input
|
||||||
|
class="form-control form-control-lg"
|
||||||
|
type="text"
|
||||||
|
placeholder="Username"
|
||||||
|
value={register_info.username.clone()}
|
||||||
|
oninput={oninput_username}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="form-group">
|
||||||
|
<input
|
||||||
|
class="form-control form-control-lg"
|
||||||
|
type="email"
|
||||||
|
placeholder="Email"
|
||||||
|
value={register_info.email.clone()}
|
||||||
|
oninput={oninput_email}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="form-group">
|
||||||
|
<input
|
||||||
|
class="form-control form-control-lg"
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
value={register_info.password.clone()}
|
||||||
|
oninput={oninput_password}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
<button
|
||||||
|
class="btn btn-lg btn-primary pull-xs-right"
|
||||||
|
type="submit"
|
||||||
|
disabled=false>
|
||||||
|
{ "Sign up" }
|
||||||
|
</button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
};
|
||||||
|
|
||||||
|
let hero_body = html! {
|
||||||
|
<ybc::Container
|
||||||
|
fluid=true
|
||||||
|
|
||||||
|
classes={
|
||||||
|
classes!(
|
||||||
|
"is-centered",
|
||||||
|
"is-light",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ybc::Tile ctx={Ancestor}>
|
||||||
|
<ybc::Tile ctx={Parent} vertical=true size={Twelve}>
|
||||||
|
<ybc::Tile ctx={Child} classes={classes!("box")}>
|
||||||
|
{form}
|
||||||
|
</ybc::Tile>
|
||||||
|
</ybc::Tile>
|
||||||
|
</ybc::Tile>
|
||||||
|
</ybc::Container>
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<BasePage>
|
||||||
|
<ybc::Hero
|
||||||
|
classes={
|
||||||
|
classes!(
|
||||||
|
"is-light",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
size={ybc::HeroSize::FullheightWithNavbar}
|
||||||
|
body={hero_body}
|
||||||
|
>
|
||||||
|
</ybc::Hero>
|
||||||
|
</BasePage>
|
||||||
|
}
|
||||||
|
}
|
||||||
56
frontend/src/components/user_context_provider.rs
Normal file
56
frontend/src/components/user_context_provider.rs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
//! User context provider.
|
||||||
|
|
||||||
|
use weblog::console_log;
|
||||||
|
use yew::prelude::*;
|
||||||
|
use yew_hooks::prelude::*;
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
use crate::services::{auth::*, get_token, set_token};
|
||||||
|
use crate::types::{UserInfo, UserInfoWrapper};
|
||||||
|
|
||||||
|
#[derive(Properties, Clone, PartialEq)]
|
||||||
|
pub struct Props {
|
||||||
|
pub children: Children,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User context provider.
|
||||||
|
#[function_component(UserContextProvider)]
|
||||||
|
pub fn user_context_provider(props: &Props) -> Html {
|
||||||
|
let user_ctx = use_state(UserInfo::default);
|
||||||
|
let current_user = use_async(async move { current().send::<UserInfoWrapper>().await });
|
||||||
|
|
||||||
|
{
|
||||||
|
let current_user = current_user.clone();
|
||||||
|
use_mount(move || {
|
||||||
|
if get_token().is_some() {
|
||||||
|
current_user.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let user_ctx = user_ctx.clone();
|
||||||
|
use_effect_with_deps(
|
||||||
|
move |current_user| {
|
||||||
|
if let Some(user_info) = ¤t_user.data {
|
||||||
|
user_ctx.set(user_info.user.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(error) = ¤t_user.error {
|
||||||
|
match error {
|
||||||
|
Error::Unauthorized | Error::Forbidden => set_token(None),
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|| ()
|
||||||
|
},
|
||||||
|
current_user,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<ContextProvider<UseStateHandle<UserInfo>> context={user_ctx}>
|
||||||
|
{ for props.children.iter() }
|
||||||
|
</ContextProvider<UseStateHandle<UserInfo>>>
|
||||||
|
}
|
||||||
|
}
|
||||||
49
frontend/src/error.rs
Normal file
49
frontend/src/error.rs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
//! Error type for error handling
|
||||||
|
|
||||||
|
use crate::types::ErrorInfo;
|
||||||
|
use thiserror::Error as ThisError;
|
||||||
|
|
||||||
|
/// Define all possible errors
|
||||||
|
#[derive(ThisError, Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Error {
|
||||||
|
/// 401
|
||||||
|
#[error("Unauthorized")]
|
||||||
|
Unauthorized,
|
||||||
|
|
||||||
|
/// 403
|
||||||
|
#[error("Forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
|
||||||
|
/// 404
|
||||||
|
#[error("Not Found")]
|
||||||
|
NotFound,
|
||||||
|
|
||||||
|
/// 422
|
||||||
|
#[error("Unprocessable Entity: {0:?}")]
|
||||||
|
UnprocessableEntity(ErrorInfo),
|
||||||
|
|
||||||
|
/// 500
|
||||||
|
#[error("Internal Server Error")]
|
||||||
|
InternalServerError,
|
||||||
|
|
||||||
|
/// serde deserialize error
|
||||||
|
#[error("Deserialize Error")]
|
||||||
|
DeserializeError,
|
||||||
|
|
||||||
|
/// request error
|
||||||
|
#[error("Http Request Error")]
|
||||||
|
RequestError,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
pub fn from_status_code(status: u16, reported_error: Error) -> Self {
|
||||||
|
match status {
|
||||||
|
401 => Error::Unauthorized,
|
||||||
|
403 => Error::Forbidden,
|
||||||
|
404 => Error::NotFound,
|
||||||
|
500 => Error::InternalServerError,
|
||||||
|
422 => reported_error,
|
||||||
|
_ => Error::RequestError,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,9 +16,12 @@ pub struct GridProps {
|
|||||||
|
|
||||||
#[function_component(Grid)]
|
#[function_component(Grid)]
|
||||||
pub fn grid(props: &GridProps) -> Html {
|
pub fn grid(props: &GridProps) -> Html {
|
||||||
let target_height = 300;
|
let target_height = 200;
|
||||||
let container_width = if props.width == 0 { 0 } else { props.width - 4 };
|
let container_width = if props.width == 0 { 0 } else { props.width - 4 };
|
||||||
let margin = 2;
|
let margin = 2;
|
||||||
|
if container_width == 0 {
|
||||||
|
return html! {};
|
||||||
|
}
|
||||||
let dimensions = compute_row_layout(
|
let dimensions = compute_row_layout(
|
||||||
container_width,
|
container_width,
|
||||||
target_height,
|
target_height,
|
||||||
@@ -44,15 +47,24 @@ pub fn grid(props: &GridProps) -> Html {
|
|||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div style={
|
<div style={
|
||||||
concat!(
|
concat!(
|
||||||
"display: flex;",
|
"display: flex;",
|
||||||
"flex-wrap: wrap;",
|
"flex-wrap: wrap;",
|
||||||
"flex-direction: row;",
|
"flex-direction: row;",
|
||||||
|
"min-height: 10vh",
|
||||||
)}>
|
)}>
|
||||||
{ props.pictures.iter().zip(dimensions).map(|(p, d)|
|
{ props.pictures.iter().zip(dimensions).map(|(p, d)|
|
||||||
html!{<Picture margin={margin} picture={p.clone()} width={d.width} height={d.height} />}
|
html!{
|
||||||
|
<Picture
|
||||||
|
margin={margin}
|
||||||
|
picture={p.clone()}
|
||||||
|
width={d.width}
|
||||||
|
height={d.height}
|
||||||
|
/>
|
||||||
|
}
|
||||||
).collect::<Html>()}
|
).collect::<Html>()}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use pathfinding::prelude::dijkstra;
|
use pathfinding::prelude::dijkstra;
|
||||||
|
use weblog::console_log;
|
||||||
|
|
||||||
pub struct Rect {
|
pub struct Rect {
|
||||||
pub width: u32,
|
pub width: u32,
|
||||||
@@ -6,7 +7,8 @@ pub struct Rect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_common_height(row: &[Rect], container_width: u32, margin: u32) -> f32 {
|
pub fn get_common_height(row: &[Rect], container_width: u32, margin: u32) -> f32 {
|
||||||
let row_width: u32 = container_width - row.len() as u32 * (margin * 2);
|
debug_assert!(container_width > (row.len() as u32) * (margin * 2));
|
||||||
|
let row_width: u32 = container_width - (row.len() as u32) * (margin * 2);
|
||||||
let total_aspect_ratio: f32 = row
|
let total_aspect_ratio: f32 = row
|
||||||
.iter()
|
.iter()
|
||||||
.map(|p| (p.width as f32) / (p.height as f32))
|
.map(|p| (p.width as f32) / (p.height as f32))
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
use common::OutputPicture;
|
use common::OutputPicture;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use wasm_bindgen_futures::JsFuture;
|
||||||
|
use web_sys::{window, Blob, HtmlElement, Request, RequestInit, Response, Url};
|
||||||
|
use weblog::console_log;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Properties, PartialEq)]
|
#[derive(Clone, Debug, Properties, PartialEq)]
|
||||||
@@ -12,16 +16,49 @@ pub struct PictureProps {
|
|||||||
#[function_component(Picture)]
|
#[function_component(Picture)]
|
||||||
pub fn picture(props: &PictureProps) -> Html {
|
pub fn picture(props: &PictureProps) -> Html {
|
||||||
let thumb = if let Some(thumb) = &props.picture.thumbnail {
|
let thumb = if let Some(thumb) = &props.picture.thumbnail {
|
||||||
format!("/api/pictures/{}", thumb)
|
format!("{}", thumb)
|
||||||
} else {
|
} else {
|
||||||
"".into()
|
"".into()
|
||||||
};
|
};
|
||||||
let margin = props.margin.to_string();
|
let margin = props.margin.to_string();
|
||||||
let width = props.width.to_string();
|
let width = props.width.to_string();
|
||||||
let height = props.height.to_string();
|
let height = props.height.to_string();
|
||||||
|
|
||||||
|
let node = use_node_ref();
|
||||||
|
|
||||||
|
{
|
||||||
|
let node = node.clone();
|
||||||
|
use_effect_with_deps(
|
||||||
|
move |_| {
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let mut opts = RequestInit::new();
|
||||||
|
opts.method("GET");
|
||||||
|
let request = Request::new_with_str_and_init(&thumb, &opts).unwrap();
|
||||||
|
let window = web_sys::window().unwrap();
|
||||||
|
let resp = JsFuture::from(window.fetch_with_request(&request))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let resp: Response = resp.dyn_into().unwrap();
|
||||||
|
let blob: Blob = JsFuture::from(resp.blob().unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.dyn_into()
|
||||||
|
.unwrap();
|
||||||
|
let url = Url::create_object_url_with_blob(&blob).unwrap();
|
||||||
|
|
||||||
|
if let Some(element) = node.cast::<HtmlElement>() {
|
||||||
|
element.set_attribute("src", &url).unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|| ()
|
||||||
|
},
|
||||||
|
(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<img style={format!("margin: {}px; display: block;", margin)} src={thumb} width={width} height={height} />
|
<img ref={node} style={format!("margin: {}px; display: block;", margin)} width={width} height={height} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
frontend/src/hooks/mod.rs
Normal file
3
frontend/src/hooks/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
mod use_user_context;
|
||||||
|
|
||||||
|
pub use use_user_context::*;
|
||||||
73
frontend/src/hooks/use_user_context.rs
Normal file
73
frontend/src/hooks/use_user_context.rs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
use std::fmt;
|
||||||
|
use std::ops::Deref;
|
||||||
|
|
||||||
|
use yew::prelude::*;
|
||||||
|
use yew_router::prelude::*;
|
||||||
|
|
||||||
|
use crate::services::set_token;
|
||||||
|
use crate::types::UserInfo;
|
||||||
|
use crate::Route;
|
||||||
|
|
||||||
|
/// State handle for the [`use_user_context`] hook.
|
||||||
|
pub struct UseUserContextHandle {
|
||||||
|
inner: UseStateHandle<UserInfo>,
|
||||||
|
navigator: Navigator,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UseUserContextHandle {
|
||||||
|
pub fn login(&self, value: UserInfo) {
|
||||||
|
// Set global token after logged in
|
||||||
|
set_token(Some(value.token.clone()));
|
||||||
|
self.inner.set(value);
|
||||||
|
// Redirect to home page
|
||||||
|
self.navigator.push(&Route::Home);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn logout(&self) {
|
||||||
|
// Clear global token after logged out
|
||||||
|
set_token(None);
|
||||||
|
self.inner.set(UserInfo::default());
|
||||||
|
// Redirect to home page
|
||||||
|
self.navigator.push(&Route::Home);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deref for UseUserContextHandle {
|
||||||
|
type Target = UserInfo;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clone for UseUserContextHandle {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: self.inner.clone(),
|
||||||
|
navigator: self.navigator.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialEq for UseUserContextHandle {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
*self.inner == *other.inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for UseUserContextHandle {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("UseUserContextHandle")
|
||||||
|
.field("value", &format!("{:?}", *self.inner))
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This hook is used to manage user context.
|
||||||
|
#[hook]
|
||||||
|
pub fn use_user_context() -> UseUserContextHandle {
|
||||||
|
let inner = use_context::<UseStateHandle<UserInfo>>().unwrap();
|
||||||
|
let navigator = use_navigator().unwrap();
|
||||||
|
|
||||||
|
UseUserContextHandle { inner, navigator }
|
||||||
|
}
|
||||||
@@ -3,111 +3,52 @@
|
|||||||
#[global_allocator]
|
#[global_allocator]
|
||||||
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
|
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
|
||||||
|
|
||||||
use console_error_panic_hook::set_once as set_panic_hook;
|
mod components;
|
||||||
use gloo_net::http::Request;
|
mod error;
|
||||||
use wasm_bindgen::prelude::*;
|
|
||||||
use yew_hooks::prelude::*;
|
|
||||||
// use weblog::console_log;
|
|
||||||
|
|
||||||
use ybc::NavbarFixed::Top;
|
|
||||||
use ybc::TileCtx::{Ancestor, Child, Parent};
|
|
||||||
use ybc::TileSize::*;
|
|
||||||
|
|
||||||
mod gallery;
|
mod gallery;
|
||||||
use common::OutputPicture;
|
mod hooks;
|
||||||
use gallery::Grid;
|
mod services;
|
||||||
|
mod types;
|
||||||
|
|
||||||
|
use components::{Home, Login, Register, UserContextProvider};
|
||||||
|
use console_error_panic_hook::set_once as set_panic_hook;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
|
use yew_router::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Routable, PartialEq)]
|
||||||
|
enum Route {
|
||||||
|
#[at("/")]
|
||||||
|
Home,
|
||||||
|
#[at("/register")]
|
||||||
|
Register,
|
||||||
|
#[at("/login")]
|
||||||
|
Login,
|
||||||
|
#[not_found]
|
||||||
|
#[at("/404")]
|
||||||
|
NotFound,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn switch(routes: Route) -> Html {
|
||||||
|
match routes {
|
||||||
|
Route::Home => html! { <Home /> },
|
||||||
|
Route::Register => html! { <Register /> },
|
||||||
|
Route::Login => html! { <Login /> },
|
||||||
|
Route::NotFound => html! { <h1>{ "404" }</h1> },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[function_component(App)]
|
#[function_component(App)]
|
||||||
fn app() -> Html {
|
fn app() -> Html {
|
||||||
let node = use_node_ref();
|
|
||||||
let size = use_size(node.clone());
|
|
||||||
|
|
||||||
let pictures = use_state(std::vec::Vec::new);
|
|
||||||
{
|
|
||||||
let pictures = pictures.clone();
|
|
||||||
use_effect_with_deps(
|
|
||||||
move |_| {
|
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
|
||||||
let url = "/api/pictures/";
|
|
||||||
let fetched_pictures: Vec<OutputPicture> = Request::get(url)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
pictures.set(fetched_pictures);
|
|
||||||
});
|
|
||||||
|| ()
|
|
||||||
},
|
|
||||||
(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let navbrand = html! {
|
|
||||||
<ybc::NavbarItem>
|
|
||||||
<ybc::Title
|
|
||||||
classes={classes!("has-text-white")}
|
|
||||||
size={ybc::HeaderSize::Is4}>
|
|
||||||
{"Photos"}
|
|
||||||
</ybc::Title>
|
|
||||||
</ybc::NavbarItem>
|
|
||||||
};
|
|
||||||
let navstart = html! {};
|
|
||||||
let navend = html! {
|
|
||||||
<>
|
|
||||||
<ybc::NavbarItem>
|
|
||||||
<ybc::ButtonAnchor
|
|
||||||
classes={classes!("is-outlined")}
|
|
||||||
rel={String::from("noopener noreferrer")}
|
|
||||||
target={String::from("_blank")}
|
|
||||||
href="">
|
|
||||||
{"Photos"}
|
|
||||||
</ybc::ButtonAnchor>
|
|
||||||
</ybc::NavbarItem>
|
|
||||||
</>
|
|
||||||
};
|
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<>
|
<BrowserRouter>
|
||||||
<ybc::Navbar
|
<UserContextProvider>
|
||||||
fixed={Top}
|
<Switch<Route> render={switch} />
|
||||||
classes={classes!("is-info")}
|
</UserContextProvider>
|
||||||
padded={true}
|
</BrowserRouter>
|
||||||
{navbrand}
|
|
||||||
{navstart}
|
|
||||||
{navend}
|
|
||||||
/>
|
|
||||||
<ybc::Container fluid=true classes={classes!("is-centered")}>
|
|
||||||
<ybc::Tile ctx={Ancestor}>
|
|
||||||
<ybc::Tile ctx={Parent} vertical=true size={Twelve}>
|
|
||||||
<ybc::Tile ctx={Child} classes={classes!("box")}>
|
|
||||||
<div ref={node} style={
|
|
||||||
concat!(
|
|
||||||
"position: 'relative';",
|
|
||||||
)}>
|
|
||||||
<Grid
|
|
||||||
pictures={(*pictures).clone()}
|
|
||||||
width={size.0}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ybc::Tile>
|
|
||||||
</ybc::Tile>
|
|
||||||
</ybc::Tile>
|
|
||||||
</ybc::Container>
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[wasm_bindgen(inline_js = "export function snippetTest() { console.log('Hello from JS FFI!'); }")]
|
|
||||||
extern "C" {
|
|
||||||
fn snippetTest();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
set_panic_hook();
|
set_panic_hook();
|
||||||
snippetTest();
|
yew::Renderer::<App>::new().render();
|
||||||
|
|
||||||
yew::start_app::<App>();
|
|
||||||
}
|
}
|
||||||
|
|||||||
22
frontend/src/services/auth.rs
Normal file
22
frontend/src/services/auth.rs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
pub use super::requests::{request_delete, request_get, request_post, request_put, Request};
|
||||||
|
use crate::types;
|
||||||
|
|
||||||
|
/// Get current user info
|
||||||
|
pub fn current() -> Request {
|
||||||
|
request_get("/api/user")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Login a user
|
||||||
|
pub fn login(login_info: &types::LoginInfoWrapper) -> Request {
|
||||||
|
request_post("/api/users/login", login_info)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a new user
|
||||||
|
pub fn register(register_info: &types::RegisterInfoWrapper) -> Request {
|
||||||
|
request_post("/api/user", register_info)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save info of current user
|
||||||
|
pub fn save(user_update_info: &types::UserUpdateInfoWrapper) -> Request {
|
||||||
|
request_put("/api/user", user_update_info)
|
||||||
|
}
|
||||||
40
frontend/src/services/mod.rs
Normal file
40
frontend/src/services/mod.rs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod requests;
|
||||||
|
|
||||||
|
use dotenv_codegen::dotenv;
|
||||||
|
|
||||||
|
pub use requests::{request_delete, request_get, request_post, request_put};
|
||||||
|
|
||||||
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
|
||||||
|
const TOKEN_KEY: &str = "jheuel-token";
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
/// Jwt token read from local storage.
|
||||||
|
pub static ref TOKEN: RwLock<Option<String>> = {
|
||||||
|
if let Ok(token) = LocalStorage::get(TOKEN_KEY) {
|
||||||
|
RwLock::new(Some(token))
|
||||||
|
} else {
|
||||||
|
RwLock::new(None)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set jwt token to local storage.
|
||||||
|
pub fn set_token(token: Option<String>) {
|
||||||
|
if let Some(t) = token.clone() {
|
||||||
|
LocalStorage::set(TOKEN_KEY, t).expect("failed to set");
|
||||||
|
} else {
|
||||||
|
LocalStorage::delete(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
let mut token_lock = TOKEN.write();
|
||||||
|
*token_lock = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get jwt token from lazy static.
|
||||||
|
pub fn get_token() -> Option<String> {
|
||||||
|
let token_lock = TOKEN.read();
|
||||||
|
token_lock.clone()
|
||||||
|
}
|
||||||
76
frontend/src/services/requests.rs
Normal file
76
frontend/src/services/requests.rs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
use crate::error::Error;
|
||||||
|
use crate::types;
|
||||||
|
use gloo_net::http::{Method, Request as GlooRequest};
|
||||||
|
use weblog::console_log;
|
||||||
|
|
||||||
|
pub struct Request(Option<GlooRequest>);
|
||||||
|
|
||||||
|
fn request<B: serde::Serialize>(method: gloo_net::http::Method, url: &str, body: &B) -> Request {
|
||||||
|
let builder = GlooRequest::new(&url)
|
||||||
|
.method(method)
|
||||||
|
.header("Content-Type", "application/json");
|
||||||
|
let req = Request(Some(builder)).set_token();
|
||||||
|
Request(req.0.and_then(|r| r.json(body).ok()))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Request {
|
||||||
|
fn set_token(self) -> Self {
|
||||||
|
let Some(r) = self.0 else {
|
||||||
|
return Self(None);
|
||||||
|
};
|
||||||
|
let builder = match super::get_token() {
|
||||||
|
Some(token) => r.header("Authorization", &format!("Token {}", token)),
|
||||||
|
None => r,
|
||||||
|
};
|
||||||
|
Self(Some(builder))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send<T: serde::de::DeserializeOwned>(self) -> Result<T, Error> {
|
||||||
|
let Some(r) = self.0 else {
|
||||||
|
return Err(Error::RequestError);
|
||||||
|
};
|
||||||
|
let response = r.send().await;
|
||||||
|
|
||||||
|
if let Ok(data) = response {
|
||||||
|
if data.status() == 200 {
|
||||||
|
let data: Result<T, _> = data.json::<T>().await;
|
||||||
|
if let Ok(data) = data {
|
||||||
|
Ok(data)
|
||||||
|
} else {
|
||||||
|
Err(Error::DeserializeError)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let status = data.status();
|
||||||
|
let error = match data.json::<types::ErrorInfo>().await {
|
||||||
|
Ok(data) => Error::UnprocessableEntity(data),
|
||||||
|
Err(_) => Error::DeserializeError,
|
||||||
|
};
|
||||||
|
Err(Error::from_status_code(status, error))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Err(Error::RequestError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request_delete(url: &str) -> Request {
|
||||||
|
Request(Some(GlooRequest::new(&url).method(Method::DELETE))).set_token()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request_get(url: &str) -> Request {
|
||||||
|
Request(Some(GlooRequest::new(&url).method(Method::GET))).set_token()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request_post<B>(url: &str, body: &B) -> Request
|
||||||
|
where
|
||||||
|
B: serde::Serialize,
|
||||||
|
{
|
||||||
|
request(Method::POST, url, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request_put<B>(url: &str, body: &B) -> Request
|
||||||
|
where
|
||||||
|
B: serde::Serialize,
|
||||||
|
{
|
||||||
|
request(Method::PUT, url, body)
|
||||||
|
}
|
||||||
64
frontend/src/types/auth.rs
Normal file
64
frontend/src/types/auth.rs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LoginInfo {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LoginInfoWrapper {
|
||||||
|
pub user: LoginInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RegisterInfo {
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RegisterInfoWrapper {
|
||||||
|
pub user: RegisterInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UserInfo {
|
||||||
|
pub email: String,
|
||||||
|
pub token: String,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserInfo {
|
||||||
|
pub fn is_authenticated(&self) -> bool {
|
||||||
|
!self.token.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UserInfoWrapper {
|
||||||
|
pub user: UserInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UserUpdateInfo {
|
||||||
|
pub email: String,
|
||||||
|
pub username: String,
|
||||||
|
pub password: Option<String>,
|
||||||
|
pub image: String,
|
||||||
|
pub bio: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UserUpdateInfoWrapper {
|
||||||
|
pub user: UserUpdateInfo,
|
||||||
|
}
|
||||||
16
frontend/src/types/mod.rs
Normal file
16
frontend/src/types/mod.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub use auth::{
|
||||||
|
LoginInfo, LoginInfoWrapper, RegisterInfo, RegisterInfoWrapper, UserInfo, UserInfoWrapper,
|
||||||
|
UserUpdateInfo, UserUpdateInfoWrapper,
|
||||||
|
};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ErrorInfo {
|
||||||
|
pub errors: HashMap<String, Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type DeleteWrapper = HashMap<(), ()>;
|
||||||
25
frontend2/Cargo.toml
Normal file
25
frontend2/Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "frontend2"
|
||||||
|
version = "0.0.0"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
api-boundary = { path = "../api-boundary" }
|
||||||
|
|
||||||
|
|
||||||
|
leptos = { version = "0.2.4", features = ["stable"] }
|
||||||
|
leptos_router = { version = "0.2.4", features = ["stable", "csr"] }
|
||||||
|
|
||||||
|
log = "0.4"
|
||||||
|
console_error_panic_hook = "0.1"
|
||||||
|
console_log = "0.2"
|
||||||
|
gloo-net = "0.2"
|
||||||
|
gloo-storage = "0.2"
|
||||||
|
serde = "1.0"
|
||||||
|
thiserror = "1.0"
|
||||||
|
wasm-bindgen = "0.2.84"
|
||||||
|
wasm-bindgen-futures = "0.4.32"
|
||||||
|
web-sys = {version = "0.3.61", features = ["Window", "DomRectReadOnly", "DataTransfer", "DataTransferItemList", "DataTransferItem", "FileSystemEntry", "FileSystemDirectoryEntry", "FileSystemDirectoryReader", "FileSystemDirectoryReader", "DragEvent", "ResizeObserverEntry", "ResizeObserver"]}
|
||||||
|
js-sys = "0.3.61"
|
||||||
|
pathfinding = "3.0.13"
|
||||||
2
frontend2/Trunk.toml
Normal file
2
frontend2/Trunk.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[[proxy]]
|
||||||
|
backend = "http://localhost:8081/api"
|
||||||
7
frontend2/index.html
Normal file
7
frontend2/index.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<link data-trunk rel="rust" data-wasm-opt="z" data-weak-refs/>
|
||||||
|
</head>
|
||||||
|
<body></body>
|
||||||
|
</html>
|
||||||
95
frontend2/src/api.rs
Normal file
95
frontend2/src/api.rs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
use gloo_net::http::{Request, Response};
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use api_boundary::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct UnauthorizedApi {
|
||||||
|
url: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AuthorizedApi {
|
||||||
|
url: &'static str,
|
||||||
|
token: ApiToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UnauthorizedApi {
|
||||||
|
pub const fn new(url: &'static str) -> Self {
|
||||||
|
Self { url }
|
||||||
|
}
|
||||||
|
pub async fn register(&self, credentials: &Credentials) -> Result<()> {
|
||||||
|
let url = format!("{}/user", self.url);
|
||||||
|
let response = Request::post(&url).json(credentials)?.send().await?;
|
||||||
|
into_json(response).await
|
||||||
|
}
|
||||||
|
pub async fn login(&self, credentials: &LoginWrapper) -> Result<AuthorizedApi> {
|
||||||
|
let url = format!("{}/users/login", self.url);
|
||||||
|
let response = Request::post(&url).json(credentials)?.send().await?;
|
||||||
|
let token = into_json(response).await?;
|
||||||
|
Ok(AuthorizedApi::new(self.url, token))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthorizedApi {
|
||||||
|
pub const fn new(url: &'static str, token: ApiToken) -> Self {
|
||||||
|
Self { url, token }
|
||||||
|
}
|
||||||
|
fn auth_header_value(&self) -> String {
|
||||||
|
format!("Bearer {}", self.token.token)
|
||||||
|
}
|
||||||
|
async fn send<T>(&self, req: Request) -> Result<T>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
{
|
||||||
|
let response = req
|
||||||
|
.header("Authorization", &self.auth_header_value())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
into_json(response).await
|
||||||
|
}
|
||||||
|
pub async fn logout(&self) -> Result<()> {
|
||||||
|
let url = format!("{}/users/logout", self.url);
|
||||||
|
self.send(Request::post(&url)).await
|
||||||
|
}
|
||||||
|
pub async fn user_info(&self) -> Result<UserTokenWrapper> {
|
||||||
|
let url = format!("{}/user", self.url);
|
||||||
|
self.send(Request::get(&url)).await
|
||||||
|
}
|
||||||
|
pub async fn get_photos(&self) -> Result<Vec<OutputPicture>> {
|
||||||
|
let url = format!("{}/photos/get", self.url);
|
||||||
|
self.send(Request::get(&url)).await
|
||||||
|
}
|
||||||
|
pub fn token(&self) -> &ApiToken {
|
||||||
|
&self.token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error(transparent)]
|
||||||
|
Fetch(#[from] gloo_net::Error),
|
||||||
|
#[error("{0:?}")]
|
||||||
|
Api(api_boundary::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<api_boundary::Error> for Error {
|
||||||
|
fn from(e: api_boundary::Error) -> Self {
|
||||||
|
Self::Api(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn into_json<T>(response: Response) -> Result<T>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
{
|
||||||
|
// ensure we've got 2xx status
|
||||||
|
if response.ok() {
|
||||||
|
Ok(response.json().await?)
|
||||||
|
} else {
|
||||||
|
Err(response.json::<api_boundary::Error>().await?.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
72
frontend2/src/components/credentials.rs
Normal file
72
frontend2/src/components/credentials.rs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
use leptos::{ev, *};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn CredentialsForm(
|
||||||
|
cx: Scope,
|
||||||
|
title: &'static str,
|
||||||
|
action_label: &'static str,
|
||||||
|
action: Action<(String, String), ()>,
|
||||||
|
error: Signal<Option<String>>,
|
||||||
|
disabled: Signal<bool>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let (password, set_password) = create_signal(cx, String::new());
|
||||||
|
let (email, set_email) = create_signal(cx, String::new());
|
||||||
|
|
||||||
|
let dispatch_action = move || action.dispatch((email.get(), password.get()));
|
||||||
|
|
||||||
|
let button_is_disabled = Signal::derive(cx, move || {
|
||||||
|
disabled.get() || password.get().is_empty() || email.get().is_empty()
|
||||||
|
});
|
||||||
|
|
||||||
|
view! { cx,
|
||||||
|
<form on:submit=|ev|ev.prevent_default()>
|
||||||
|
<p>{ title }</p>
|
||||||
|
{move || error.get().map(|err| view!{ cx,
|
||||||
|
<p style ="color:red;" >{ err }</p>
|
||||||
|
})}
|
||||||
|
<input
|
||||||
|
type = "email"
|
||||||
|
required
|
||||||
|
placeholder = "Email address"
|
||||||
|
prop:disabled = move || disabled.get()
|
||||||
|
on:keyup = move |ev: ev::KeyboardEvent| {
|
||||||
|
let val = event_target_value(&ev);
|
||||||
|
set_email.update(|v|*v = val);
|
||||||
|
}
|
||||||
|
// The `change` event fires when the browser fills the form automatically,
|
||||||
|
on:change = move |ev| {
|
||||||
|
let val = event_target_value(&ev);
|
||||||
|
set_email.update(|v|*v = val);
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type = "password"
|
||||||
|
required
|
||||||
|
placeholder = "Password"
|
||||||
|
prop:disabled = move || disabled.get()
|
||||||
|
on:keyup = move |ev: ev::KeyboardEvent| {
|
||||||
|
match &*ev.key() {
|
||||||
|
"Enter" => {
|
||||||
|
dispatch_action();
|
||||||
|
}
|
||||||
|
_=> {
|
||||||
|
let val = event_target_value(&ev);
|
||||||
|
set_password.update(|p|*p = val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The `change` event fires when the browser fills the form automatically,
|
||||||
|
on:change = move |ev| {
|
||||||
|
let val = event_target_value(&ev);
|
||||||
|
set_password.update(|p|*p = val);
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
prop:disabled = move || button_is_disabled.get()
|
||||||
|
on:click = move |_| dispatch_action()
|
||||||
|
>
|
||||||
|
{ action_label }
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
}
|
||||||
4
frontend2/src/components/mod.rs
Normal file
4
frontend2/src/components/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod credentials;
|
||||||
|
pub mod navbar;
|
||||||
|
|
||||||
|
pub use self::{credentials::*, navbar::*};
|
||||||
28
frontend2/src/components/navbar.rs
Normal file
28
frontend2/src/components/navbar.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
use leptos::*;
|
||||||
|
use leptos_router::*;
|
||||||
|
|
||||||
|
use crate::Page;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn NavBar<F>(cx: Scope, logged_in: Signal<bool>, on_logout: F) -> impl IntoView
|
||||||
|
where
|
||||||
|
F: Fn() + 'static + Clone,
|
||||||
|
{
|
||||||
|
view! { cx,
|
||||||
|
<nav>
|
||||||
|
<Show
|
||||||
|
when = move || logged_in.get()
|
||||||
|
fallback = |cx| view! { cx,
|
||||||
|
<A href=Page::Login.path() >"Login"</A>
|
||||||
|
" | "
|
||||||
|
<A href=Page::Register.path() >"Register"</A>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<a href="#" on:click={
|
||||||
|
let on_logout = on_logout.clone();
|
||||||
|
move |_| on_logout()
|
||||||
|
}>"Logout"</a>
|
||||||
|
</Show>
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
|
}
|
||||||
68
frontend2/src/gallery/grid.rs
Normal file
68
frontend2/src/gallery/grid.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
use crate::gallery::picture::PictureProps;
|
||||||
|
use crate::gallery::Picture;
|
||||||
|
use api_boundary::OutputPicture;
|
||||||
|
use leptos::{component, view, IntoView, Scope, Signal};
|
||||||
|
|
||||||
|
use super::layout::{compute_row_layout, Rect};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Grid(cx: Scope, photos: Vec<OutputPicture>, width: u32) -> impl IntoView {
|
||||||
|
let target_height = 200;
|
||||||
|
let container_width = if width == 0 { 0 } else { width - 4 };
|
||||||
|
let margin = 2;
|
||||||
|
if container_width == 0 {
|
||||||
|
return view! {cx, <div></div>};
|
||||||
|
}
|
||||||
|
let dimensions = compute_row_layout(
|
||||||
|
container_width,
|
||||||
|
target_height,
|
||||||
|
margin,
|
||||||
|
&photos
|
||||||
|
.iter()
|
||||||
|
.map(|p| Rect {
|
||||||
|
width: p.width,
|
||||||
|
height: p.height,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
let dimensions = if let Some(d) = dimensions {
|
||||||
|
d
|
||||||
|
} else {
|
||||||
|
photos
|
||||||
|
.iter()
|
||||||
|
.map(|p| Rect {
|
||||||
|
width: p.width,
|
||||||
|
height: p.height,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let stl = format!(
|
||||||
|
"{}\n{}\n{}\n{}\n",
|
||||||
|
"display: flex;",
|
||||||
|
"flex-wrap: wrap;",
|
||||||
|
"flex-direction: row;",
|
||||||
|
"min-height: 10vh; border: 1px solid black;",
|
||||||
|
);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
cx,
|
||||||
|
<div style=stl id="grid">
|
||||||
|
{
|
||||||
|
photos
|
||||||
|
.iter()
|
||||||
|
.zip(dimensions)
|
||||||
|
.map(|(p, d)| {
|
||||||
|
view!{cx,
|
||||||
|
<Picture
|
||||||
|
margin={margin}
|
||||||
|
picture={p.clone()}
|
||||||
|
width={d.width}
|
||||||
|
height={d.height} />
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
95
frontend2/src/gallery/layout.rs
Normal file
95
frontend2/src/gallery/layout.rs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
use pathfinding::prelude::dijkstra;
|
||||||
|
|
||||||
|
pub struct Rect {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_common_height(row: &[Rect], container_width: u32, margin: u32) -> f32 {
|
||||||
|
debug_assert!(container_width > (row.len() as u32) * (margin * 2));
|
||||||
|
let row_width: u32 = container_width - (row.len() as u32) * (margin * 2);
|
||||||
|
let total_aspect_ratio: f32 = row
|
||||||
|
.iter()
|
||||||
|
.map(|p| (p.width as f32) / (p.height as f32))
|
||||||
|
.sum();
|
||||||
|
row_width as f32 / total_aspect_ratio
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cost(
|
||||||
|
photos: &[Rect],
|
||||||
|
i: usize,
|
||||||
|
j: usize,
|
||||||
|
width: u32,
|
||||||
|
target_height: u32,
|
||||||
|
margin: u32,
|
||||||
|
) -> u32 {
|
||||||
|
let common_height = get_common_height(&photos[i..j], width, margin);
|
||||||
|
(common_height - target_height as f32).powi(2) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn make_successors(
|
||||||
|
target_height: u32,
|
||||||
|
container_width: u32,
|
||||||
|
photos: &Vec<Rect>,
|
||||||
|
node_search_limit: usize,
|
||||||
|
margin: u32,
|
||||||
|
) -> Vec<Vec<(usize, u32)>> {
|
||||||
|
let mut results = vec![Vec::new(); photos.len()];
|
||||||
|
(0..photos.len()).for_each(|start| {
|
||||||
|
for j in start + 1..photos.len() + 1 {
|
||||||
|
if j - start > node_search_limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
results[start].push((
|
||||||
|
j,
|
||||||
|
cost(photos, start, j, container_width, target_height, margin),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn calc_node_search_limit(target_row_height: u32, container_width: u32) -> usize {
|
||||||
|
let row_aspect_ratio = container_width as f32 / target_row_height as f32;
|
||||||
|
(row_aspect_ratio / 1.5) as usize + 8
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compute_row_layout(
|
||||||
|
container_width: u32,
|
||||||
|
target_height: u32,
|
||||||
|
margin: u32,
|
||||||
|
photos: &Vec<Rect>,
|
||||||
|
) -> Option<Vec<Rect>> {
|
||||||
|
if photos.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let node_search_limit = calc_node_search_limit(target_height, container_width);
|
||||||
|
|
||||||
|
let successors = make_successors(
|
||||||
|
target_height,
|
||||||
|
container_width,
|
||||||
|
photos,
|
||||||
|
node_search_limit,
|
||||||
|
margin,
|
||||||
|
);
|
||||||
|
let path = dijkstra(&0, |p| successors[*p].clone(), |p| *p == photos.len());
|
||||||
|
let (path, _cost) = if let Some(p) = path {
|
||||||
|
p
|
||||||
|
} else {
|
||||||
|
(Vec::new(), 0)
|
||||||
|
};
|
||||||
|
let mut dimensions: Vec<Rect> = Vec::with_capacity(photos.len());
|
||||||
|
for i in 1..path.len() {
|
||||||
|
let row = &photos[path[i - 1]..path[i]];
|
||||||
|
let height = get_common_height(row, container_width, margin) as u32;
|
||||||
|
(path[i - 1]..path[i]).for_each(|j| {
|
||||||
|
let ratio = photos[j].width as f32 / photos[j].height as f32;
|
||||||
|
dimensions.push(Rect {
|
||||||
|
width: (height as f32 * ratio) as u32,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Some(dimensions)
|
||||||
|
}
|
||||||
7
frontend2/src/gallery/mod.rs
Normal file
7
frontend2/src/gallery/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
pub mod layout;
|
||||||
|
|
||||||
|
pub mod grid;
|
||||||
|
pub use grid::Grid;
|
||||||
|
|
||||||
|
pub mod picture;
|
||||||
|
pub use picture::Picture;
|
||||||
22
frontend2/src/gallery/picture.rs
Normal file
22
frontend2/src/gallery/picture.rs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
use api_boundary::OutputPicture;
|
||||||
|
use leptos::{component, view, IntoView, Scope};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Picture(
|
||||||
|
cx: Scope,
|
||||||
|
picture: OutputPicture,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
margin: u32,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let stl = format!("margin: {}px; display: block;", margin);
|
||||||
|
view! {cx,
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src=picture.thumbnail.unwrap_or("".to_string())
|
||||||
|
style=stl
|
||||||
|
width=width
|
||||||
|
height=height />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
}
|
||||||
189
frontend2/src/lib.rs
Normal file
189
frontend2/src/lib.rs
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
|
use leptos::*;
|
||||||
|
use leptos_router::*;
|
||||||
|
|
||||||
|
use api_boundary::*;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use wasm_bindgen_futures::JsFuture;
|
||||||
|
use web_sys::Url;
|
||||||
|
|
||||||
|
mod api;
|
||||||
|
mod components;
|
||||||
|
mod gallery;
|
||||||
|
mod pages;
|
||||||
|
|
||||||
|
pub(crate) mod web_sys_ext;
|
||||||
|
|
||||||
|
use self::{components::*, pages::*};
|
||||||
|
|
||||||
|
const DEFAULT_API_URL: &str = "/api";
|
||||||
|
const API_TOKEN_STORAGE_KEY: &str = "photos-super-fancy-token";
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn App(cx: Scope) -> impl IntoView {
|
||||||
|
// -- signals -- //
|
||||||
|
|
||||||
|
let authorized_api = create_rw_signal(cx, None::<api::AuthorizedApi>);
|
||||||
|
let user_info = create_rw_signal(cx, None::<UserWithToken>);
|
||||||
|
let photos = create_rw_signal(cx, None::<Vec<OutputPicture>>);
|
||||||
|
let urls = create_rw_signal(cx, None::<HashMap<String, String>>);
|
||||||
|
let logged_in = Signal::derive(cx, move || authorized_api.get().is_some());
|
||||||
|
|
||||||
|
// -- actions -- //
|
||||||
|
|
||||||
|
let fetch_user_info = create_action(cx, move |_| async move {
|
||||||
|
match authorized_api.get() {
|
||||||
|
Some(api) => match api.user_info().await {
|
||||||
|
Ok(info) => {
|
||||||
|
log::info!("{:?}", info);
|
||||||
|
user_info.update(|i| *i = Some(info.user));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::error!("Unable to fetch user info: {err}")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
log::error!("Unable to fetch user info: not logged in")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let get_url = create_action(cx, move |thumb: &String| {
|
||||||
|
let thumb = thumb.clone();
|
||||||
|
async move {
|
||||||
|
let resp = Request::get(&thumb).send().await.unwrap();
|
||||||
|
if resp.ok() {
|
||||||
|
let blob = JsFuture::from(resp.as_raw().blob().unwrap()).await.unwrap();
|
||||||
|
let url = Url::create_object_url_with_blob(&blob.dyn_into().unwrap()).unwrap();
|
||||||
|
urls.update(|u| match u {
|
||||||
|
Some(_u) => {
|
||||||
|
let mut n = _u.clone();
|
||||||
|
n.insert(thumb, url);
|
||||||
|
*u = Some(n);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let mut n = HashMap::new();
|
||||||
|
n.insert(thumb, url);
|
||||||
|
*u = Some(n);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let fetch_photos = create_action(cx, move |_| async move {
|
||||||
|
match authorized_api.get() {
|
||||||
|
Some(api) => match api.get_photos().await {
|
||||||
|
Ok(info) => {
|
||||||
|
log::info!("{:?}", info);
|
||||||
|
info.iter().for_each(|p| {
|
||||||
|
if let Some(thumb) = p.thumbnail.clone() {
|
||||||
|
get_url.dispatch(thumb);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
photos.update(|i| *i = Some(info));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::error!("Unable to fetch photos: {err}")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
log::error!("Unable to fetch photos: not logged in")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let logout = create_action(cx, move |_| async move {
|
||||||
|
match authorized_api.get() {
|
||||||
|
Some(api) => match api.logout().await {
|
||||||
|
Ok(_) => {
|
||||||
|
authorized_api.update(|a| *a = None);
|
||||||
|
user_info.update(|i| *i = None);
|
||||||
|
log::debug!("logout: delete token from LocalStorage");
|
||||||
|
LocalStorage::delete(API_TOKEN_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::error!("Unable to logout: {err}")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
log::error!("Unable to logout user: not logged in")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -- callbacks -- //
|
||||||
|
|
||||||
|
let on_logout = move || {
|
||||||
|
logout.dispatch(());
|
||||||
|
};
|
||||||
|
|
||||||
|
// -- init API -- //
|
||||||
|
|
||||||
|
let unauthorized_api = api::UnauthorizedApi::new(DEFAULT_API_URL);
|
||||||
|
if let Ok(token) = LocalStorage::get(API_TOKEN_STORAGE_KEY) {
|
||||||
|
let api = api::AuthorizedApi::new(DEFAULT_API_URL, token);
|
||||||
|
authorized_api.update(|a| *a = Some(api));
|
||||||
|
fetch_user_info.dispatch(());
|
||||||
|
fetch_photos.dispatch(());
|
||||||
|
}
|
||||||
|
|
||||||
|
log::debug!("User is logged in: {}", logged_in.get());
|
||||||
|
|
||||||
|
// -- effects -- //
|
||||||
|
|
||||||
|
create_effect(cx, move |_| {
|
||||||
|
log::debug!("API authorization state changed");
|
||||||
|
match authorized_api.get() {
|
||||||
|
Some(api) => {
|
||||||
|
log::debug!("API is now authorized: save token in LocalStorage");
|
||||||
|
LocalStorage::set(API_TOKEN_STORAGE_KEY, api.token()).expect("LocalStorage::set");
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
log::debug!("API is no longer authorized: delete token from LocalStorage");
|
||||||
|
LocalStorage::delete(API_TOKEN_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! { cx,
|
||||||
|
<Router>
|
||||||
|
<NavBar logged_in on_logout />
|
||||||
|
<main>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path=Page::Home.path()
|
||||||
|
view=move |cx| view! { cx,
|
||||||
|
<Home user_info = user_info.into() photos=photos.into() urls=urls.into() />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path=Page::Login.path()
|
||||||
|
view=move |cx| view! { cx,
|
||||||
|
<Login
|
||||||
|
api = unauthorized_api
|
||||||
|
on_success = move |api| {
|
||||||
|
log::info!("Successfully logged in");
|
||||||
|
authorized_api.update(|v| *v = Some(api));
|
||||||
|
let navigate = use_navigate(cx);
|
||||||
|
navigate(Page::Home.path(), Default::default()).expect("Home route");
|
||||||
|
fetch_user_info.dispatch(());
|
||||||
|
fetch_photos.dispatch(());
|
||||||
|
} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path=Page::Register.path()
|
||||||
|
view=move |cx| view! { cx,
|
||||||
|
<Register api = unauthorized_api />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</Router>
|
||||||
|
}
|
||||||
|
}
|
||||||
9
frontend2/src/main.rs
Normal file
9
frontend2/src/main.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
use leptos::*;
|
||||||
|
|
||||||
|
use frontend2::*;
|
||||||
|
|
||||||
|
pub fn main() {
|
||||||
|
_ = console_log::init_with_level(log::Level::Debug);
|
||||||
|
console_error_panic_hook::set_once();
|
||||||
|
mount_to_body(|cx| view! { cx, <App /> })
|
||||||
|
}
|
||||||
89
frontend2/src/pages/home.rs
Normal file
89
frontend2/src/pages/home.rs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::gallery::Grid;
|
||||||
|
use crate::web_sys_ext::ResizeObserver;
|
||||||
|
use crate::Page;
|
||||||
|
use crate::{gallery::grid::GridProps, web_sys_ext::ResizeObserverEntry};
|
||||||
|
use api_boundary::{OutputPicture, UserWithToken};
|
||||||
|
use leptos::*;
|
||||||
|
use leptos_router::*;
|
||||||
|
use wasm_bindgen::prelude::Closure;
|
||||||
|
use wasm_bindgen::{JsCast, UnwrapThrowExt};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Home(
|
||||||
|
cx: Scope,
|
||||||
|
user_info: Signal<Option<UserWithToken>>,
|
||||||
|
photos: Signal<Option<Vec<OutputPicture>>>,
|
||||||
|
urls: Signal<Option<HashMap<String, String>>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let width = create_rw_signal(cx, 0);
|
||||||
|
|
||||||
|
create_effect(cx, move |_| {
|
||||||
|
let closure = Closure::wrap(Box::new(move |entries: Vec<ResizeObserverEntry>| {
|
||||||
|
for entry in &entries {
|
||||||
|
let element = entry.target();
|
||||||
|
width.update(|i| *i = element.client_width() as u32);
|
||||||
|
log::info!("{:?}", width.get());
|
||||||
|
}
|
||||||
|
}) as Box<dyn Fn(Vec<ResizeObserverEntry>)>);
|
||||||
|
let observer = ResizeObserver::new(closure.as_ref().unchecked_ref()).unwrap_throw();
|
||||||
|
|
||||||
|
// Forget the closure to keep it alive
|
||||||
|
closure.forget();
|
||||||
|
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Some(document) = window.document() {
|
||||||
|
if let Ok(Some(element)) = document.query_selector("body") {
|
||||||
|
observer.observe(&element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
move || observer.disconnect()
|
||||||
|
});
|
||||||
|
|
||||||
|
let photos = move || match photos.get() {
|
||||||
|
Some(photos) => {
|
||||||
|
let photos = if let Some(urls) = urls.get() {
|
||||||
|
photos
|
||||||
|
.iter()
|
||||||
|
.map(|p| {
|
||||||
|
if let Some(thumb) = &p.thumbnail {
|
||||||
|
OutputPicture {
|
||||||
|
thumbnail: urls.get(thumb).cloned(),
|
||||||
|
..p.clone()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
p.clone()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
photos
|
||||||
|
};
|
||||||
|
view! { cx,
|
||||||
|
<Grid photos=photos width=width.get() />
|
||||||
|
}
|
||||||
|
.into_view(cx)
|
||||||
|
}
|
||||||
|
None => view! { cx,
|
||||||
|
<></>
|
||||||
|
}
|
||||||
|
.into_view(cx),
|
||||||
|
};
|
||||||
|
|
||||||
|
view! { cx,
|
||||||
|
<h2>"Leptos Login example"</h2>
|
||||||
|
{move || match user_info.get() {
|
||||||
|
Some(info) => view!{ cx,
|
||||||
|
<p>"You are logged in with "{ info.email }"."</p>
|
||||||
|
}.into_view(cx),
|
||||||
|
None => view!{ cx,
|
||||||
|
<p>"You are not logged in."</p>
|
||||||
|
<A href=Page::Login.path() >"Login now."</A>
|
||||||
|
}.into_view(cx)
|
||||||
|
}}
|
||||||
|
{photos}
|
||||||
|
}
|
||||||
|
}
|
||||||
64
frontend2/src/pages/login.rs
Normal file
64
frontend2/src/pages/login.rs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
use leptos::*;
|
||||||
|
use leptos_router::*;
|
||||||
|
|
||||||
|
use api_boundary::*;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::{self, AuthorizedApi, UnauthorizedApi},
|
||||||
|
components::credentials::*,
|
||||||
|
Page,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Login<F>(cx: Scope, api: UnauthorizedApi, on_success: F) -> impl IntoView
|
||||||
|
where
|
||||||
|
F: Fn(AuthorizedApi) + 'static + Clone,
|
||||||
|
{
|
||||||
|
let (login_error, set_login_error) = create_signal(cx, None::<String>);
|
||||||
|
let (wait_for_response, set_wait_for_response) = create_signal(cx, false);
|
||||||
|
|
||||||
|
let login_action = create_action(cx, move |(email, password): &(String, String)| {
|
||||||
|
log::debug!("Try to login with {email}");
|
||||||
|
let email = email.to_string();
|
||||||
|
let password = password.to_string();
|
||||||
|
let credentials = LoginWrapper {
|
||||||
|
user: Credentials { email, password },
|
||||||
|
};
|
||||||
|
let on_success = on_success.clone();
|
||||||
|
async move {
|
||||||
|
set_wait_for_response.update(|w| *w = true);
|
||||||
|
let result = api.login(&credentials).await;
|
||||||
|
set_wait_for_response.update(|w| *w = false);
|
||||||
|
match result {
|
||||||
|
Ok(res) => {
|
||||||
|
set_login_error.update(|e| *e = None);
|
||||||
|
on_success(res);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let msg = match err {
|
||||||
|
api::Error::Fetch(js_err) => {
|
||||||
|
format!("{js_err:?}")
|
||||||
|
}
|
||||||
|
api::Error::Api(err) => err.message,
|
||||||
|
};
|
||||||
|
error!("Unable to login with {}: {msg}", credentials.user.email);
|
||||||
|
set_login_error.update(|e| *e = Some(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let disabled = Signal::derive(cx, move || wait_for_response.get());
|
||||||
|
|
||||||
|
view! { cx,
|
||||||
|
<CredentialsForm
|
||||||
|
title = "Please login to your account"
|
||||||
|
action_label = "Login"
|
||||||
|
action = login_action
|
||||||
|
error = login_error.into()
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<p>"Don't have an account?"</p>
|
||||||
|
<A href=Page::Register.path()>"Register"</A>
|
||||||
|
}
|
||||||
|
}
|
||||||
23
frontend2/src/pages/mod.rs
Normal file
23
frontend2/src/pages/mod.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
pub mod home;
|
||||||
|
pub mod login;
|
||||||
|
pub mod register;
|
||||||
|
|
||||||
|
pub use self::{home::*, login::*, register::*};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
|
pub enum Page {
|
||||||
|
#[default]
|
||||||
|
Home,
|
||||||
|
Login,
|
||||||
|
Register,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Page {
|
||||||
|
pub fn path(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Home => "/",
|
||||||
|
Self::Login => "/login",
|
||||||
|
Self::Register => "/register",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
frontend2/src/pages/register.rs
Normal file
74
frontend2/src/pages/register.rs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
use leptos::*;
|
||||||
|
use leptos_router::*;
|
||||||
|
|
||||||
|
use api_boundary::*;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::{self, UnauthorizedApi},
|
||||||
|
components::credentials::*,
|
||||||
|
Page,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Register(cx: Scope, api: UnauthorizedApi) -> impl IntoView {
|
||||||
|
let (register_response, set_register_response) = create_signal(cx, None::<()>);
|
||||||
|
let (register_error, set_register_error) = create_signal(cx, None::<String>);
|
||||||
|
let (wait_for_response, set_wait_for_response) = create_signal(cx, false);
|
||||||
|
|
||||||
|
let register_action = create_action(cx, move |(email, password): &(String, String)| {
|
||||||
|
let email = email.to_string();
|
||||||
|
let password = password.to_string();
|
||||||
|
let credentials = Credentials { email, password };
|
||||||
|
log!("Try to register new account for {}", credentials.email);
|
||||||
|
async move {
|
||||||
|
set_wait_for_response.update(|w| *w = true);
|
||||||
|
let result = api.register(&credentials).await;
|
||||||
|
set_wait_for_response.update(|w| *w = false);
|
||||||
|
match result {
|
||||||
|
Ok(res) => {
|
||||||
|
set_register_response.update(|v| *v = Some(res));
|
||||||
|
set_register_error.update(|e| *e = None);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let msg = match err {
|
||||||
|
api::Error::Fetch(js_err) => {
|
||||||
|
format!("{js_err:?}")
|
||||||
|
}
|
||||||
|
api::Error::Api(err) => err.message,
|
||||||
|
};
|
||||||
|
log::warn!(
|
||||||
|
"Unable to register new account for {}: {msg}",
|
||||||
|
credentials.email
|
||||||
|
);
|
||||||
|
set_register_error.update(|e| *e = Some(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let disabled = Signal::derive(cx, move || wait_for_response.get());
|
||||||
|
|
||||||
|
view! { cx,
|
||||||
|
<Show
|
||||||
|
when = move || register_response.get().is_some()
|
||||||
|
fallback = move |_| view!{ cx,
|
||||||
|
<CredentialsForm
|
||||||
|
title = "Please enter the desired credentials"
|
||||||
|
action_label = "Register"
|
||||||
|
action = register_action
|
||||||
|
error = register_error.into()
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<p>"Your already have an account?"</p>
|
||||||
|
<A href=Page::Login.path()>"Login"</A>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p>"You have successfully registered."</p>
|
||||||
|
<p>
|
||||||
|
"You can now "
|
||||||
|
<A href=Page::Login.path()>"login"</A>
|
||||||
|
" with your new account."
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
}
|
||||||
107
frontend2/src/web_sys_ext.rs
Normal file
107
frontend2/src/web_sys_ext.rs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
//! ResizeObserver/ClipboardEvent in web-sys is unstable and
|
||||||
|
//! requires `--cfg=web_sys_unstable_apis` to be activated,
|
||||||
|
//! which is inconvenient, so copy the binding code here for now.
|
||||||
|
#![allow(unused_imports)]
|
||||||
|
#![allow(clippy::unused_unit)]
|
||||||
|
use wasm_bindgen::{self, prelude::*};
|
||||||
|
use web_sys::{DataTransfer, DomRectReadOnly, Element, Event, EventTarget};
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
extern "C" {
|
||||||
|
# [wasm_bindgen (extends = :: js_sys :: Object , js_name = ResizeObserver , typescript_type = "ResizeObserver")]
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub type ResizeObserver;
|
||||||
|
|
||||||
|
#[wasm_bindgen(catch, constructor, js_class = "ResizeObserver")]
|
||||||
|
pub fn new(callback: &::js_sys::Function) -> Result<ResizeObserver, JsValue>;
|
||||||
|
|
||||||
|
# [wasm_bindgen (method , structural , js_class = "ResizeObserver" , js_name = disconnect)]
|
||||||
|
pub fn disconnect(this: &ResizeObserver);
|
||||||
|
|
||||||
|
# [wasm_bindgen (method , structural , js_class = "ResizeObserver" , js_name = observe)]
|
||||||
|
pub fn observe(this: &ResizeObserver, target: &Element);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
extern "C" {
|
||||||
|
# [wasm_bindgen (extends = :: js_sys :: Object , js_name = ResizeObserverEntry , typescript_type = "ResizeObserverEntry")]
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub type ResizeObserverEntry;
|
||||||
|
|
||||||
|
# [wasm_bindgen (structural , method , getter , js_class = "ResizeObserverEntry" , js_name = target)]
|
||||||
|
pub fn target(this: &ResizeObserverEntry) -> Element;
|
||||||
|
|
||||||
|
# [wasm_bindgen (structural , method , getter , js_class = "ResizeObserverEntry" , js_name = contentRect)]
|
||||||
|
pub fn content_rect(this: &ResizeObserverEntry) -> DomRectReadOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
extern "C" {
|
||||||
|
# [wasm_bindgen (extends = Event , extends = :: js_sys :: Object , js_name = ClipboardEvent , typescript_type = "ClipboardEvent")]
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub type ClipboardEvent;
|
||||||
|
|
||||||
|
# [wasm_bindgen (structural , method , getter , js_class = "ClipboardEvent" , js_name = clipboardData)]
|
||||||
|
pub fn clipboard_data(this: &ClipboardEvent) -> Option<DataTransfer>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
extern "C" {
|
||||||
|
# [wasm_bindgen (extends = EventTarget , extends = :: js_sys :: Object , js_name = Clipboard , typescript_type = "Clipboard")]
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub type Clipboard;
|
||||||
|
|
||||||
|
# [wasm_bindgen (method , structural , js_class = "Clipboard" , js_name = read)]
|
||||||
|
pub fn read(this: &Clipboard) -> ::js_sys::Promise;
|
||||||
|
|
||||||
|
# [wasm_bindgen (method , structural , js_class = "Clipboard" , js_name = readText)]
|
||||||
|
pub fn read_text(this: &Clipboard) -> ::js_sys::Promise;
|
||||||
|
|
||||||
|
# [wasm_bindgen (method , structural , js_class = "Clipboard" , js_name = write)]
|
||||||
|
pub fn write(this: &Clipboard, data: &::wasm_bindgen::JsValue) -> ::js_sys::Promise;
|
||||||
|
|
||||||
|
# [wasm_bindgen (method , structural , js_class = "Clipboard" , js_name = writeText)]
|
||||||
|
pub fn write_text(this: &Clipboard, data: &str) -> ::js_sys::Promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
extern "C" {
|
||||||
|
# [wasm_bindgen (extends = :: js_sys :: Object , js_name = Navigator , typescript_type = "Navigator")]
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub type Navigator;
|
||||||
|
|
||||||
|
# [wasm_bindgen (structural , method , getter , js_class = "Navigator" , js_name = clipboard)]
|
||||||
|
pub fn clipboard(this: &Navigator) -> Option<Clipboard>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
extern "C" {
|
||||||
|
# [wasm_bindgen (extends = EventTarget , extends = :: js_sys :: Object , js_name = Window , typescript_type = "Window")]
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub type Window;
|
||||||
|
|
||||||
|
# [wasm_bindgen (structural , method , getter , js_class = "Window" , js_name = navigator)]
|
||||||
|
pub fn navigator(this: &Window) -> Navigator;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
extern "C" {
|
||||||
|
# [wasm_bindgen (extends = :: js_sys :: Object , js_name = ClipboardItem , typescript_type = "ClipboardItem")]
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub type ClipboardItem;
|
||||||
|
|
||||||
|
#[wasm_bindgen(catch, constructor, js_class = "ClipboardItem")]
|
||||||
|
pub fn new(item: &::js_sys::Object) -> Result<ClipboardItem, JsValue>;
|
||||||
|
|
||||||
|
# [wasm_bindgen (structural , method , getter , js_class = "ClipboardItem" , js_name = types)]
|
||||||
|
pub fn types(this: &ClipboardItem) -> ::js_sys::Array;
|
||||||
|
|
||||||
|
# [wasm_bindgen (method , structural , js_class = "ClipboardItem" , js_name = getType)]
|
||||||
|
pub fn get_type(this: &ClipboardItem, type_: &str) -> ::js_sys::Promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn window() -> Option<Window> {
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
|
||||||
|
js_sys::global().dyn_into::<Window>().ok()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user