Compare commits

...

3 Commits

Author SHA1 Message Date
Johannes Heuel
a576e572d9 leptos
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-02 14:31:22 +02:00
Johannes Heuel
db2bf1994e download pictures in the frontend and then create data-urls from blobs 2023-03-08 15:03:36 +01:00
Johannes Heuel
fbcea9e77b make read file and upload separate js functions 2023-03-05 11:54:24 +01:00
40 changed files with 3447 additions and 1113 deletions

3
.gitignore vendored
View File

@@ -1 +1,2 @@
/target
**/target
**/dist

2783
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,5 +8,5 @@ license = "MIT"
[dependencies]
[workspace]
members = ["frontend", "backend", "common"]
members = ["frontend", "backend", "common", "frontend2", "api-boundary"]
default-members = ["backend"]

8
api-boundary/Cargo.toml Normal file
View 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
View 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,
}

View File

@@ -9,7 +9,7 @@ default-run = "backend"
[dependencies]
actix-web = "4"
actix-web-lab = "^0"
actix-web-lab = { version = "^0", features = ["spa"] }
actix-files = "0.6"
actix-cors = "0.6.4"
actix-session = { version = "0.7.2", features = ["cookie-session"] }
@@ -20,10 +20,12 @@ walkdir = "2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
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.2.0"
version = "2.4.0"
default-features = false
features = ["async-std-runtime"]

View File

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

View File

@@ -1,2 +1,2 @@
pub mod photos_api;
pub mod photo_api;
pub mod user_api;

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

View File

@@ -1,57 +0,0 @@
use actix_session::Session;
use actix_web::{
post,
web::{Data, Json},
HttpResponse,
};
use s3::bucket::Bucket;
use serde::{Deserialize, Serialize};
use crate::repository::mongodb_repo::MongoRepo;
#[derive(Debug, Deserialize, Serialize)]
pub struct PhotosWrapper {
photos: Vec<String>,
}
#[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!("/{x}"), 86400, None).unwrap(),
)
})
.collect();
Ok(HttpResponse::Ok().json(PhotosUrlsWrapper { photos }))
}

View File

@@ -5,39 +5,9 @@ use actix_web::{
web::{Data, Json, Path},
HttpResponse,
};
use api_boundary::*;
use mongodb::bson::oid::ObjectId;
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
pub struct UserWrapper {
user: User,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct UserWithToken {
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
pub id: Option<ObjectId>,
pub username: String,
pub email: String,
pub password: String,
pub token: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct UserTokenWrapper {
user: UserWithToken,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Login {
email: String,
password: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct LoginWrapper {
user: Login,
}
// Return an opaque 500 while preserving the error's root cause for logging.
fn e500<T>(e: T) -> actix_web::Error
@@ -58,7 +28,7 @@ pub async fn is_logged_in(
log::info!("success");
return Ok(HttpResponse::Ok().json(UserTokenWrapper {
user: UserWithToken {
id: user.id,
id: user_id,
username: user.username,
email: user.email,
password: user.password,
@@ -109,15 +79,15 @@ pub async fn login(
}
let token = user.username.clone();
HttpResponse::Ok().json(UserTokenWrapper {
user: UserWithToken {
id: user.id,
username: user.username,
email: user.email,
password: user.password,
token,
},
})
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}")]

View File

@@ -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")
// }

View File

@@ -2,9 +2,9 @@ mod api;
mod models;
mod repository;
use api::photos_api::get_presigned_post_urls;
use api::photo_api::get_presigned_post_urls;
use api::photo_api::get_user_photos;
use api::user_api::{create_user, delete_user, get_user, is_logged_in, login, update_user};
use common::OutputPicture;
use repository::mongodb_repo::MongoRepo;
use s3::bucket::Bucket;
@@ -20,41 +20,16 @@ use actix_web::{
// get, middleware, post, web, App, Error, HttpRequest, HttpResponse, HttpServer,
// get,
// middleware,
get,
http,
middleware,
web,
web::Data,
App,
HttpServer,
};
use actix_web::{Responder, Result};
use actix_web_lab::web::spa;
#[get("/api/pictures/")]
async fn get_pictures() -> Result<impl Responder> {
let pics: Vec<OutputPicture> = (1..15)
.map(|_| {
// 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: 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: 100,
height: 100,
}
})
.collect();
Ok(web::Json(pics))
}
use crate::api::photo_api::upload_done;
use crate::api::user_api::logout;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
@@ -119,13 +94,15 @@ async fn main() -> std::io::Result<()> {
.app_data(bucket.clone())
.wrap(middleware::Logger::default())
.service(get_presigned_post_urls)
.service(get_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(
spa()
.index_file("./dist/index.html")

View File

@@ -1 +1,2 @@
pub mod photo_model;
pub mod user_model;

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

View File

@@ -2,15 +2,19 @@ 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, extjson::de::Error, oid::ObjectId},
bson::{doc, oid::ObjectId},
error::Error,
results::{DeleteResult, InsertOneResult, UpdateResult},
Client, Collection,
};
pub struct MongoRepo {
col: Collection<User>,
user_col: Collection<User>,
photo_col: Collection<Photo>,
}
impl MongoRepo {
@@ -22,8 +26,12 @@ impl MongoRepo {
};
let client = Client::with_uri_str(uri).await.unwrap();
let db = client.database("photos");
let col: Collection<User> = db.collection("User");
MongoRepo { col }
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> {
@@ -34,7 +42,7 @@ impl MongoRepo {
password: new_user.password,
};
let user = self
.col
.user_col
.insert_one(new_doc, None)
.await
.ok()
@@ -46,7 +54,7 @@ impl MongoRepo {
let obj_id = ObjectId::parse_str(id).unwrap();
let filter = doc! {"_id": obj_id};
let user_detail = self
.col
.user_col
.find_one(filter, None)
.await
.ok()
@@ -57,7 +65,7 @@ impl MongoRepo {
pub async fn get_user_from_email(&self, email: &String) -> Result<User, Error> {
let filter = doc! {"email": email};
let user_detail = self
.col
.user_col
.find_one(filter, None)
.await
.ok()
@@ -78,7 +86,7 @@ impl MongoRepo {
},
};
let updated_doc = self
.col
.user_col
.update_one(filter, new_doc, None)
.await
.ok()
@@ -90,11 +98,44 @@ impl MongoRepo {
let obj_id = ObjectId::parse_str(id).unwrap();
let filter = doc! {"_id": obj_id};
let user_detail = self
.col
.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)
}
}

View File

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

View File

@@ -7,7 +7,7 @@ license = "MIT"
[dependencies]
console_error_panic_hook = "0.1.6"
wasm-bindgen = "=0.2.84"
wasm-bindgen = "0.2.84"
wasm-bindgen-futures = "0.4.32"
gloo-net = "0.2.3"
gloo-storage = "0.2"
@@ -30,6 +30,9 @@ 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]
default = []

View File

@@ -1,26 +1,52 @@
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(resolve => {
return new Promise(async resolve => {
if (item === null || typeof item === 'undefined') {
// nothing to do
} else if (item.isFile) {
item.file(file => {
file.filepath = path + "/" + file.name; //save full path
file.filepath = join(path, file.name); //save full path
folder.push(file);
resolve(file);
});
} else if (item.isDirectory) {
let dirReader = item.createReader();
dirReader.readEntries(entries => {
let entriesPromises = [];
let subfolder = [];
folder.push({ name: item.name, subfolder: subfolder });
for (let entry of entries)
entriesPromises.push(
traverseFileTreePromise(entry, path + "/" + item.name, subfolder)
);
resolve(Promise.all(entriesPromises));
});
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));
}
});
}
@@ -49,7 +75,7 @@ export function get_files_data_transfer_items(data_transfer_items) {
return getFilesDataTransferItems(data_transfer_items);
}
function read_file(file) {
export function read_file(file) {
return new Promise((resolve, reject) => {
let reader = new FileReader();
reader.onloadend = () => {
@@ -60,23 +86,21 @@ function read_file(file) {
});
}
export function upload_file(file, url) {
export function upload(content, content_type, url) {
return new Promise((resolve, reject) => {
read_file(file).then((content) => {
fetch(url, {
method: 'PUT',
headers: {
'Accept': 'application/json',
'Content-Type': 'multipart/form-data',
},
body: new Blob([content], { type: file.type }),
}).then((resp) => {
if (resp.status >= 200 && resp.status < 300) {
resolve(resp);
} else {
reject(resp);
}
});
}).catch(error => reject(error));
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);
}
});
});
}

View File

@@ -1,21 +1,29 @@
use std::io::{BufWriter, Cursor};
use super::BasePage;
use crate::gallery::Grid;
use crate::hooks::use_user_context;
use gloo_net::http::Request;
use js_sys::{Array, Promise};
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::*;
use common::OutputPicture;
pub struct MetaData {
width: u32,
height: u32,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct PhotosWrapper {
photos: Vec<String>,
@@ -25,6 +33,17 @@ 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();
@@ -39,7 +58,7 @@ pub fn home() -> Html {
use_effect_with_deps(
move |_| {
wasm_bindgen_futures::spawn_local(async move {
let url = "/api/pictures/";
let url = "/api/photos/get";
let fetched_pictures: Vec<OutputPicture> = Request::get(url)
.send()
.await
@@ -118,7 +137,9 @@ pub fn home() -> Html {
console_log!("end", uiae.join("\n"));
let photos: PhotosUrlsWrapper = Request::post("/api/photos/upload")
.json(&PhotosWrapper { photos: uiae })
.json(&PhotosWrapper {
photos: uiae.clone(),
})
.unwrap()
.send()
.await
@@ -128,19 +149,108 @@ pub fn home() -> Html {
.unwrap();
console_log!("{}", serde_json::to_string(&photos).unwrap());
console_log!("", files.len());
let mut metadata: Vec<MetaData> = Vec::new();
let mut promises: Vec<Promise> = Vec::new();
for (file, (_, url)) in files.iter().zip(photos.photos) {
console_log!("uploading: ", &file.name(), &url);
promises.push(upload_file(file.clone(), url.clone()));
}
for promise in promises {
match wasm_bindgen_futures::JsFuture::from(promise).await {
Ok(result) => console_log!(result),
_ => console_log!("errooooor"),
};
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");
});
});
@@ -192,5 +302,6 @@ pub fn home() -> Html {
#[wasm_bindgen(module = "/src/components/home.js")]
extern "C" {
fn get_files_data_transfer_items(data_transfer_items: DataTransferItemList) -> js_sys::Promise;
fn upload_file(file: File, url: String) -> js_sys::Promise;
fn read_file(file: File) -> js_sys::Promise;
fn upload(content: ArrayBuffer, content_type: String, url: String) -> js_sys::Promise;
}

View File

@@ -16,7 +16,7 @@ pub struct GridProps {
#[function_component(Grid)]
pub fn grid(props: &GridProps) -> Html {
let target_height = 100;
let target_height = 200;
let container_width = if props.width == 0 { 0 } else { props.width - 4 };
let margin = 2;
if container_width == 0 {
@@ -54,6 +54,7 @@ pub fn grid(props: &GridProps) -> Html {
"display: flex;",
"flex-wrap: wrap;",
"flex-direction: row;",
"min-height: 10vh",
)}>
{ props.pictures.iter().zip(dimensions).map(|(p, d)|
html!{

View File

@@ -1,4 +1,8 @@
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::*;
#[derive(Clone, Debug, Properties, PartialEq)]
@@ -19,9 +23,42 @@ pub fn picture(props: &PictureProps) -> Html {
let margin = props.margin.to_string();
let width = props.width.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! {
<>
<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} />
</>
}
}

25
frontend2/Cargo.toml Normal file
View 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
View File

@@ -0,0 +1,2 @@
[[proxy]]
backend = "http://localhost:8081/api"

7
frontend2/index.html Normal file
View 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
View 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())
}
}

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

View File

@@ -0,0 +1,4 @@
pub mod credentials;
pub mod navbar;
pub use self::{credentials::*, navbar::*};

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

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

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

View File

@@ -0,0 +1,7 @@
pub mod layout;
pub mod grid;
pub use grid::Grid;
pub mod picture;
pub use picture::Picture;

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

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

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

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

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

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