This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1 +1,2 @@
|
||||
/target
|
||||
**/target
|
||||
**/dist
|
||||
|
||||
2615
Cargo.lock
generated
2615
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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
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,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,6 +20,7 @@ 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"
|
||||
|
||||
@@ -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)?)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ use actix_web::{
|
||||
web::{Data, Json},
|
||||
HttpResponse, Responder,
|
||||
};
|
||||
use actix_web_lab::__reexports::tokio;
|
||||
use common::OutputPicture;
|
||||
use mongodb::bson::oid::ObjectId;
|
||||
use s3::bucket::Bucket;
|
||||
@@ -113,7 +112,7 @@ async fn get_user_photos(
|
||||
return Ok(HttpResponse::Unauthorized().body("Not authorized"));
|
||||
};
|
||||
|
||||
let Ok(user) = db.get_user(&user_id).await else {
|
||||
let Ok(_user) = db.get_user(&user_id).await else {
|
||||
return Ok(HttpResponse::Unauthorized().body("Not authorized"));
|
||||
};
|
||||
let photos: Vec<OutputPicture> = db
|
||||
|
||||
@@ -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}")]
|
||||
|
||||
@@ -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")
|
||||
// }
|
||||
@@ -5,7 +5,6 @@ mod repository;
|
||||
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;
|
||||
@@ -21,18 +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;
|
||||
|
||||
use crate::api::photo_api::upload_done;
|
||||
use crate::api::user_api::logout;
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
@@ -105,6 +102,7 @@ async fn main() -> std::io::Result<()> {
|
||||
.service(get_user)
|
||||
.service(update_user)
|
||||
.service(delete_user)
|
||||
.service(logout)
|
||||
.service(
|
||||
spa()
|
||||
.index_file("./dist/index.html")
|
||||
|
||||
@@ -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>,
|
||||
// }
|
||||
// }
|
||||
@@ -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"
|
||||
@@ -32,6 +32,7 @@ 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 = []
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
use std::io::Cursor;
|
||||
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::*;
|
||||
|
||||
use common::OutputPicture;
|
||||
|
||||
pub struct MetaData {
|
||||
width: u32,
|
||||
height: u32,
|
||||
@@ -150,56 +152,86 @@ pub fn home() -> Html {
|
||||
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!("uploading: ", &file.name(), &url);
|
||||
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!(
|
||||
// serde_json::to_string(&buf.len()).unwrap(),
|
||||
// serde_json::to_string(&buf).unwrap()
|
||||
// );
|
||||
console_log!("stop copy");
|
||||
let buf = buf;
|
||||
|
||||
let mut md = MetaData {
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
let meta = image_meta::load_from_buf(&buf).unwrap();
|
||||
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!("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");
|
||||
|
||||
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)
|
||||
));
|
||||
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()));
|
||||
// promises.push(upload(buffer, file.type_(), url.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
for promise in promises {
|
||||
match wasm_bindgen_futures::JsFuture::from(promise).await {
|
||||
Ok(result) => console_log!(result),
|
||||
Err(e) => console_log!("errooooor", e),
|
||||
};
|
||||
}
|
||||
|
||||
let photos: DetailPhotoWrapper = Request::post("/api/photos/upload/done")
|
||||
.json(&DetailPhotoWrapper {
|
||||
photos: uiae
|
||||
|
||||
@@ -47,7 +47,7 @@ pub fn picture(props: &PictureProps) -> Html {
|
||||
let url = Url::create_object_url_with_blob(&blob).unwrap();
|
||||
|
||||
if let Some(element) = node.cast::<HtmlElement>() {
|
||||
element.set_attribute("src", &url);
|
||||
element.set_attribute("src", &url).unwrap();
|
||||
}
|
||||
});
|
||||
|| ()
|
||||
|
||||
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