switch to mongodb, add drag&drop

This commit is contained in:
Johannes Heuel
2023-02-23 18:28:12 +01:00
parent 61d0bbe4d1
commit ac27cba766
38 changed files with 3273 additions and 730 deletions

View File

@@ -7,20 +7,29 @@ license = "MIT"
[dependencies]
console_error_panic_hook = "0.1.6"
wasm-bindgen = "=0.2.82"
wasm-bindgen = "=0.2.84"
wasm-bindgen-futures = "0.4.32"
gloo-net = "0.2.3"
gloo-storage = "0.2"
weblog = "0.3.0"
web-sys = {version = "0.3.61", features = ["Window", "DataTransfer", "DataTransferItemList", "DataTransferItem", "FileSystemEntry", "FileSystemDirectoryEntry", "FileSystemDirectoryReader", "FileSystemDirectoryReader", "DragEvent"]}
js-sys = "0.3.61"
wee_alloc = "0.4.5"
ybc = { git = "https://github.com/jheuel/ybc", branch = "yew-0-19-update" }
yew = "0.19"
yew-hooks = "0.1.56"
ybc = { git = "https://github.com/jheuel/ybc", branch = "alpha-v0.4" }
yew = "0.20"
yew-router = "0.17"
yew-hooks = "0.2.0"
pathfinding = "3.0.13"
common = { path = "../common" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0.58"
yewtil = { version = "0.4.0", features = ["neq"] }
thiserror = "1"
lazy_static = "1.4"
parking_lot = "0.12"
dotenv = "0.15.0"
dotenv_codegen = "0.15.0"
[features]
default = []

View File

@@ -0,0 +1,97 @@
use crate::hooks::use_user_context;
use crate::Route;
use ybc::NavbarFixed::Top;
use yew::prelude::*;
use yew_hooks::prelude::*;
use yew_router::prelude::*;
#[derive(PartialEq, Properties)]
pub struct Props {
pub children: Children,
}
#[function_component]
pub fn BasePage(props: &Props) -> Html {
let navigator = use_navigator().unwrap();
let user_ctx = use_user_context();
let node = use_node_ref();
let size = use_size(node.clone());
let authenticated = user_ctx.is_authenticated();
let title = if authenticated {
html! {"Photos"}
} else {
html! {"No photos"}
};
let navbrand = html! {
<ybc::NavbarItem>
<ybc::Title
classes={classes!("has-text-white")}
size={ybc::HeaderSize::Is4}>
{title}
</ybc::Title>
</ybc::NavbarItem>
};
let account_button = if authenticated {
let onclick = {
Callback::from(move |_| {
user_ctx.logout();
})
};
html! {
<ybc::ButtonAnchor
classes={classes!("is-outlined")}
rel={String::from("noopener noreferrer")}
target={String::from("_blank")}
href=""
{onclick}>
{"logout"}
</ybc::ButtonAnchor>
}
} else {
let onclick = Callback::from(move |_| navigator.push(&Route::Login));
html! {
<ybc::Button
// classes={classes!("is-outlined")}
{onclick}>
{"login"}
</ybc::Button>
}
};
let navstart = html! {};
let navend = html! {
<>
// <ybc::NavbarItem>
// <ybc::Button
// // classes={classes!("is-outlined")}
// >
// {"Photos"}
// </ybc::Button>
// </ybc::NavbarItem>
<ybc::NavbarItem>
{account_button}
</ybc::NavbarItem>
</>
};
html! {
<>
<ybc::Navbar
fixed={Top}
classes={classes!("is-info")}
padded={true}
{navbrand}
{navstart}
{navend}
/>
<div>
{ props.children.clone() }
</div>
</>
}
}

View File

@@ -0,0 +1,41 @@
function getFilesDataTransferItems(dataTransferItems) {
function traverseFileTreePromise(item, path = "", folder) {
return new Promise(resolve => {
if (item.isFile) {
item.file(file => {
file.filepath = 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 files = [];
return new Promise((resolve, reject) => {
let entriesPromises = [];
for (let it of dataTransferItems)
entriesPromises.push(
traverseFileTreePromise(it.webkitGetAsEntry(), "", files)
);
Promise.all(entriesPromises).then(entries => {
resolve(files);
});
});
}
export function get_files_data_transfer_items(data_transfer_items) {
return getFilesDataTransferItems(data_transfer_items);
}

View File

@@ -0,0 +1,132 @@
use super::BasePage;
use crate::gallery::Grid;
use crate::hooks::use_user_context;
use gloo_net::http::Request;
use js_sys::Array;
use wasm_bindgen::{prelude::wasm_bindgen, JsCast, JsValue};
use web_sys::{DataTransferItemList, File, FileSystemDirectoryEntry, FileSystemEntry};
use weblog::console_log;
use yew::prelude::*;
use yew_hooks::prelude::*;
use common::OutputPicture;
#[function_component(Home)]
pub fn home() -> Html {
let user_ctx = use_user_context();
let node = use_node_ref();
let size = use_size(node.clone());
let pictures = use_state(std::vec::Vec::new);
{
let pictures = pictures.clone();
use_effect_with_deps(
move |_| {
wasm_bindgen_futures::spawn_local(async move {
let url = "/api/pictures/";
let fetched_pictures: Vec<OutputPicture> = Request::get(url)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
pictures.set(fetched_pictures);
});
|| ()
},
(),
);
}
let ondrop = Callback::from(move |e: DragEvent| {
e.prevent_default();
let items = e.data_transfer().unwrap().items();
wasm_bindgen_futures::spawn_local(async move {
let promise = get_files_data_transfer_items(items);
let result = wasm_bindgen_futures::JsFuture::from(promise).await.unwrap();
console_log!(&result);
let traverse = |d: JsValue| {
let mut new_files: Vec<JsValue> = Vec::new();
let mut files: Vec<File> = Vec::new();
if let Ok(fse) = d.clone().dyn_into::<File>() {
console_log!(&fse);
// fse.filepath = path;
files.push(fse);
} else if let Ok(a) = d.clone().dyn_into::<js_sys::Array>() {
for i in 0..a.length() {
let f = a.get(i);
new_files.push(f);
}
} else if let Ok(o) = d.clone().dyn_into::<js_sys::Object>() {
if let Ok(subfolder) = js_sys::Reflect::get(&o, &JsValue::from_str("subfolder"))
{
new_files.push(subfolder);
}
}
(new_files, files)
};
let mut files: Vec<File> = Vec::new();
let mut new_files = vec![result];
loop {
console_log!(files.len());
let Some(current) = new_files.pop() else {
console_log!("break");
break;
};
let (news, fs) = traverse(current);
news.iter().for_each(|x| new_files.push(x.clone()));
fs.iter().for_each(|x| files.push(x.clone()));
}
let uiae: Vec<String> = files
.iter()
.map(|x| {
if let Ok(filepath) = js_sys::Reflect::get(&x, &JsValue::from_str("filepath")) {
filepath.as_string().unwrap_or("".to_string())
} else {
"".to_string()
}
})
.collect();
console_log!("end", uiae.join("\n"));
});
});
let ondragover = Callback::from(move |e: DragEvent| {
e.prevent_default();
});
let body = if user_ctx.is_authenticated() {
html! {
<Grid
pictures={(*pictures).clone()}
width={size.0}
/>
}
} else {
html! {}
};
html! {
<BasePage>
<div ref={node} {ondrop} {ondragover}>
{body}
</div>
</BasePage>
}
}
#[wasm_bindgen(module = "/src/components/home.js")]
extern "C" {
fn get_files_data_transfer_items(data_transfer_items: DataTransferItemList) -> js_sys::Promise;
}

View File

@@ -0,0 +1,48 @@
use yew::prelude::*;
use crate::error::Error;
#[derive(Properties, Clone, PartialEq, Eq)]
pub struct Props {
pub error: Option<Error>,
}
#[function_component(ListErrors)]
pub fn list_errors(props: &Props) -> Html {
if let Some(error) = &props.error {
html! {
<ul class="error-messages">
{
match error {
Error::UnprocessableEntity(error_info) => {
html! {
<>
{for error_info.errors.iter().map(|(key, value)| {
html! {
<li>
{ key }
{for value.iter().map(|e| {
html! {
<>{" "} {e}</>
}
})}
</li>
}
})}
</>
}
}
_ => {
html! {
<li>{error}</li>
}
}
}
}
</ul>
}
} else {
html! {}
}
}

View File

@@ -0,0 +1,148 @@
use web_sys::HtmlInputElement;
use ybc::TileCtx::{Ancestor, Child, Parent};
use ybc::TileSize::*;
use yew::prelude::*;
use yew_hooks::prelude::*;
use yew_router::prelude::*;
use super::BasePage;
use crate::components::list_errors::ListErrors;
use crate::hooks::use_user_context;
use crate::services::auth::*;
use crate::types::{LoginInfo, LoginInfoWrapper, UserInfoWrapper};
use crate::Route;
/// Login page
#[function_component(Login)]
pub fn login_page() -> Html {
let user_ctx = use_user_context();
let login_info = use_state(LoginInfo::default);
let user_login = {
let login_info = login_info.clone();
use_async(async move {
let request = LoginInfoWrapper {
user: (*login_info).clone(),
};
login(&request).send::<UserInfoWrapper>().await
})
};
use_effect_with_deps(
move |user_login| {
if let Some(user_info) = &user_login.data {
user_ctx.login(user_info.user.clone());
}
|| ()
},
user_login.clone(),
);
let onsubmit = {
let user_login = user_login.clone();
Callback::from(move |e: SubmitEvent| {
e.prevent_default(); /* Prevent event propagation */
user_login.run();
})
};
let oninput_email = {
let login_info = login_info.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
let mut info = (*login_info).clone();
info.email = input.value();
login_info.set(info);
})
};
let oninput_password = {
let login_info = login_info.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
let mut info = (*login_info).clone();
info.password = input.value();
login_info.set(info);
})
};
let form = html! {
<div class="auth-page">
<div class="container page">
<div class="row">
<div class="col-md-6 offset-md-3 col-xs-12">
<h1 class="text-xs-center">{ "Sign In" }</h1>
<p class="text-xs-center">
<Link<Route> to={Route::Register}>
{ "Need an account?" }
</Link<Route>>
</p>
<ListErrors error={user_login.error.clone()} />
<form {onsubmit}>
<fieldset>
<fieldset class="form-group">
<input
class="form-control form-control-lg"
type="email"
placeholder="Email"
value={login_info.email.clone()}
oninput={oninput_email}
/>
</fieldset>
<fieldset class="form-group">
<input
class="form-control form-control-lg"
type="password"
placeholder="Password"
value={login_info.password.clone()}
oninput={oninput_password}
/>
</fieldset>
<button
class="btn btn-lg btn-primary pull-xs-right"
type="submit"
disabled=false>
{ "Sign in" }
</button>
</fieldset>
</form>
</div>
</div>
</div>
</div>
};
let hero_body = html! {
<ybc::Container
fluid=true
classes={
classes!(
"is-centered",
"is-light",
)
}
>
<ybc::Tile ctx={Ancestor}>
<ybc::Tile ctx={Parent} vertical=true size={Twelve}>
<ybc::Tile ctx={Child} classes={classes!("box")}>
{form}
</ybc::Tile>
</ybc::Tile>
</ybc::Tile>
</ybc::Container>
};
html! {
<BasePage>
<ybc::Hero
classes={
classes!(
"is-light",
)
}
size={ybc::HeroSize::FullheightWithNavbar}
body={hero_body}
>
</ybc::Hero>
</BasePage>
}
}

View File

@@ -0,0 +1,16 @@
pub mod home;
pub use home::Home;
pub mod register;
pub use register::Register;
pub mod login;
pub use login::Login;
pub mod user_context_provider;
pub use user_context_provider::*;
pub mod base_page;
pub use base_page::*;
pub mod list_errors;

View File

@@ -0,0 +1,168 @@
use web_sys::HtmlInputElement;
use ybc::TileCtx::{Ancestor, Child, Parent};
use ybc::TileSize::*;
use yew::prelude::*;
use yew_hooks::prelude::*;
use yew_router::prelude::*;
use super::BasePage;
use crate::components::list_errors::ListErrors;
use crate::hooks::use_user_context;
use crate::services::auth::*;
use crate::types::{RegisterInfo, RegisterInfoWrapper, UserInfoWrapper};
use crate::Route;
/// Register page
#[function_component(Register)]
pub fn register_page() -> Html {
let user_ctx = use_user_context();
let register_info = use_state(RegisterInfo::default);
let user_register = {
let register_info = register_info.clone();
use_async(async move {
let request = RegisterInfoWrapper {
user: (*register_info).clone(),
};
register(&request).send::<UserInfoWrapper>().await
})
};
{
use_effect_with_deps(
move |user_register| {
if let Some(user_info) = &user_register.data {
user_ctx.login(user_info.user.clone());
}
|| ()
},
user_register.clone(),
);
}
let onsubmit = {
let user_register = user_register.clone();
Callback::from(move |e: SubmitEvent| {
e.prevent_default(); /* Prevent event propagation */
user_register.run();
})
};
let oninput_username = {
let register_info = register_info.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
let mut info = (*register_info).clone();
info.username = input.value();
register_info.set(info);
})
};
let oninput_email = {
let register_info = register_info.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
let mut info = (*register_info).clone();
info.email = input.value();
register_info.set(info);
})
};
let oninput_password = {
let register_info = register_info.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
let mut info = (*register_info).clone();
info.password = input.value();
register_info.set(info);
})
};
let form = html! {
<div class="auth-page">
<div class="container page">
<div class="row">
<div class="col-md-6 offset-md-3 col-xs-12">
<h1 class="text-xs-center">{ "Sign Up" }</h1>
<p class="text-xs-center">
<Link<Route> to={Route::Login}>
{ "Have an account?" }
</Link<Route>>
</p>
<ListErrors error={user_register.error.clone()} />
<form {onsubmit}>
<fieldset>
<fieldset class="form-group">
<input
class="form-control form-control-lg"
type="text"
placeholder="Username"
value={register_info.username.clone()}
oninput={oninput_username}
/>
</fieldset>
<fieldset class="form-group">
<input
class="form-control form-control-lg"
type="email"
placeholder="Email"
value={register_info.email.clone()}
oninput={oninput_email}
/>
</fieldset>
<fieldset class="form-group">
<input
class="form-control form-control-lg"
type="password"
placeholder="Password"
value={register_info.password.clone()}
oninput={oninput_password}
/>
</fieldset>
<button
class="btn btn-lg btn-primary pull-xs-right"
type="submit"
disabled=false>
{ "Sign up" }
</button>
</fieldset>
</form>
</div>
</div>
</div>
</div>
};
let hero_body = html! {
<ybc::Container
fluid=true
classes={
classes!(
"is-centered",
"is-light",
)
}
>
<ybc::Tile ctx={Ancestor}>
<ybc::Tile ctx={Parent} vertical=true size={Twelve}>
<ybc::Tile ctx={Child} classes={classes!("box")}>
{form}
</ybc::Tile>
</ybc::Tile>
</ybc::Tile>
</ybc::Container>
};
html! {
<BasePage>
<ybc::Hero
classes={
classes!(
"is-light",
)
}
size={ybc::HeroSize::FullheightWithNavbar}
body={hero_body}
>
</ybc::Hero>
</BasePage>
}
}

View File

@@ -0,0 +1,56 @@
//! User context provider.
use weblog::console_log;
use yew::prelude::*;
use yew_hooks::prelude::*;
use crate::error::Error;
use crate::services::{auth::*, get_token, set_token};
use crate::types::{UserInfo, UserInfoWrapper};
#[derive(Properties, Clone, PartialEq)]
pub struct Props {
pub children: Children,
}
/// User context provider.
#[function_component(UserContextProvider)]
pub fn user_context_provider(props: &Props) -> Html {
let user_ctx = use_state(UserInfo::default);
let current_user = use_async(async move { current().send::<UserInfoWrapper>().await });
{
let current_user = current_user.clone();
use_mount(move || {
if get_token().is_some() {
current_user.run();
}
});
}
{
let user_ctx = user_ctx.clone();
use_effect_with_deps(
move |current_user| {
if let Some(user_info) = &current_user.data {
user_ctx.set(user_info.user.clone());
}
if let Some(error) = &current_user.error {
match error {
Error::Unauthorized | Error::Forbidden => set_token(None),
_ => (),
}
}
|| ()
},
current_user,
)
}
html! {
<ContextProvider<UseStateHandle<UserInfo>> context={user_ctx}>
{ for props.children.iter() }
</ContextProvider<UseStateHandle<UserInfo>>>
}
}

49
frontend/src/error.rs Normal file
View File

@@ -0,0 +1,49 @@
//! Error type for error handling
use crate::types::ErrorInfo;
use thiserror::Error as ThisError;
/// Define all possible errors
#[derive(ThisError, Clone, Debug, PartialEq, Eq)]
pub enum Error {
/// 401
#[error("Unauthorized")]
Unauthorized,
/// 403
#[error("Forbidden")]
Forbidden,
/// 404
#[error("Not Found")]
NotFound,
/// 422
#[error("Unprocessable Entity: {0:?}")]
UnprocessableEntity(ErrorInfo),
/// 500
#[error("Internal Server Error")]
InternalServerError,
/// serde deserialize error
#[error("Deserialize Error")]
DeserializeError,
/// request error
#[error("Http Request Error")]
RequestError,
}
impl Error {
pub fn from_status_code(status: u16, reported_error: Error) -> Self {
match status {
401 => Error::Unauthorized,
403 => Error::Forbidden,
404 => Error::NotFound,
500 => Error::InternalServerError,
422 => reported_error,
_ => Error::RequestError,
}
}
}

View File

@@ -16,7 +16,7 @@ pub struct GridProps {
#[function_component(Grid)]
pub fn grid(props: &GridProps) -> Html {
let target_height = 300;
let target_height = 100;
let container_width = if props.width == 0 { 0 } else { props.width - 4 };
let margin = 2;
let dimensions = compute_row_layout(
@@ -44,6 +44,7 @@ pub fn grid(props: &GridProps) -> Html {
})
.collect()
};
html! {
<div style={
concat!(
@@ -52,7 +53,14 @@ pub fn grid(props: &GridProps) -> Html {
"flex-direction: row;",
)}>
{ props.pictures.iter().zip(dimensions).map(|(p, d)|
html!{<Picture margin={margin} picture={p.clone()} width={d.width} height={d.height} />}
html!{
<Picture
margin={margin}
picture={p.clone()}
width={d.width}
height={d.height}
/>
}
).collect::<Html>()}
</div>
}

View File

@@ -1,4 +1,5 @@
use pathfinding::prelude::dijkstra;
use weblog::console_log;
pub struct Rect {
pub width: u32,
@@ -6,7 +7,8 @@ pub struct Rect {
}
pub fn get_common_height(row: &[Rect], container_width: u32, margin: u32) -> f32 {
let row_width: u32 = container_width - row.len() as u32 * (margin * 2);
debug_assert!(container_width > (row.len() as u32) * (margin * 2));
let row_width: u32 = container_width - (row.len() as u32) * (margin * 2);
let total_aspect_ratio: f32 = row
.iter()
.map(|p| (p.width as f32) / (p.height as f32))

View File

@@ -12,7 +12,7 @@ pub struct PictureProps {
#[function_component(Picture)]
pub fn picture(props: &PictureProps) -> Html {
let thumb = if let Some(thumb) = &props.picture.thumbnail {
format!("/api/pictures/{}", thumb)
format!("{}", thumb)
} else {
"".into()
};

View File

@@ -0,0 +1,3 @@
mod use_user_context;
pub use use_user_context::*;

View File

@@ -0,0 +1,73 @@
use std::fmt;
use std::ops::Deref;
use yew::prelude::*;
use yew_router::prelude::*;
use crate::services::set_token;
use crate::types::UserInfo;
use crate::Route;
/// State handle for the [`use_user_context`] hook.
pub struct UseUserContextHandle {
inner: UseStateHandle<UserInfo>,
navigator: Navigator,
}
impl UseUserContextHandle {
pub fn login(&self, value: UserInfo) {
// Set global token after logged in
set_token(Some(value.token.clone()));
self.inner.set(value);
// Redirect to home page
self.navigator.push(&Route::Home);
}
pub fn logout(&self) {
// Clear global token after logged out
set_token(None);
self.inner.set(UserInfo::default());
// Redirect to home page
self.navigator.push(&Route::Home);
}
}
impl Deref for UseUserContextHandle {
type Target = UserInfo;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl Clone for UseUserContextHandle {
fn clone(&self) -> Self {
Self {
inner: self.inner.clone(),
navigator: self.navigator.clone(),
}
}
}
impl PartialEq for UseUserContextHandle {
fn eq(&self, other: &Self) -> bool {
*self.inner == *other.inner
}
}
impl fmt::Debug for UseUserContextHandle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("UseUserContextHandle")
.field("value", &format!("{:?}", *self.inner))
.finish()
}
}
/// This hook is used to manage user context.
#[hook]
pub fn use_user_context() -> UseUserContextHandle {
let inner = use_context::<UseStateHandle<UserInfo>>().unwrap();
let navigator = use_navigator().unwrap();
UseUserContextHandle { inner, navigator }
}

View File

@@ -3,111 +3,52 @@
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
use console_error_panic_hook::set_once as set_panic_hook;
use gloo_net::http::Request;
use wasm_bindgen::prelude::*;
use yew_hooks::prelude::*;
// use weblog::console_log;
use ybc::NavbarFixed::Top;
use ybc::TileCtx::{Ancestor, Child, Parent};
use ybc::TileSize::*;
mod components;
mod error;
mod gallery;
use common::OutputPicture;
use gallery::Grid;
mod hooks;
mod services;
mod types;
use components::{Home, Login, Register, UserContextProvider};
use console_error_panic_hook::set_once as set_panic_hook;
use yew::prelude::*;
use yew_router::prelude::*;
#[function_component(App)]
fn app() -> Html {
let node = use_node_ref();
let size = use_size(node.clone());
#[derive(Clone, Routable, PartialEq)]
enum Route {
#[at("/")]
Home,
#[at("/register")]
Register,
#[at("/login")]
Login,
#[not_found]
#[at("/404")]
NotFound,
}
let pictures = use_state(std::vec::Vec::new);
{
let pictures = pictures.clone();
use_effect_with_deps(
move |_| {
wasm_bindgen_futures::spawn_local(async move {
let url = "/api/pictures/";
let fetched_pictures: Vec<OutputPicture> = Request::get(url)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
pictures.set(fetched_pictures);
});
|| ()
},
(),
);
}
let navbrand = html! {
<ybc::NavbarItem>
<ybc::Title
classes={classes!("has-text-white")}
size={ybc::HeaderSize::Is4}>
{"Photos"}
</ybc::Title>
</ybc::NavbarItem>
};
let navstart = html! {};
let navend = html! {
<>
<ybc::NavbarItem>
<ybc::ButtonAnchor
classes={classes!("is-outlined")}
rel={String::from("noopener noreferrer")}
target={String::from("_blank")}
href="">
{"Photos"}
</ybc::ButtonAnchor>
</ybc::NavbarItem>
</>
};
html! {
<>
<ybc::Navbar
fixed={Top}
classes={classes!("is-info")}
padded={true}
{navbrand}
{navstart}
{navend}
/>
<ybc::Container fluid=true classes={classes!("is-centered")}>
<ybc::Tile ctx={Ancestor}>
<ybc::Tile ctx={Parent} vertical=true size={Twelve}>
<ybc::Tile ctx={Child} classes={classes!("box")}>
<div ref={node} style={
concat!(
"position: 'relative';",
)}>
<Grid
pictures={(*pictures).clone()}
width={size.0}
/>
</div>
</ybc::Tile>
</ybc::Tile>
</ybc::Tile>
</ybc::Container>
</>
fn switch(routes: Route) -> Html {
match routes {
Route::Home => html! { <Home /> },
Route::Register => html! { <Register /> },
Route::Login => html! { <Login /> },
Route::NotFound => html! { <h1>{ "404" }</h1> },
}
}
#[wasm_bindgen(inline_js = "export function snippetTest() { console.log('Hello from JS FFI!'); }")]
extern "C" {
fn snippetTest();
#[function_component(App)]
fn app() -> Html {
html! {
<BrowserRouter>
<UserContextProvider>
<Switch<Route> render={switch} />
</UserContextProvider>
</BrowserRouter>
}
}
fn main() {
set_panic_hook();
snippetTest();
yew::start_app::<App>();
yew::Renderer::<App>::new().render();
}

View File

@@ -0,0 +1,22 @@
pub use super::requests::{request_delete, request_get, request_post, request_put, Request};
use crate::types;
/// Get current user info
pub fn current() -> Request {
request_get("/api/user")
}
/// Login a user
pub fn login(login_info: &types::LoginInfoWrapper) -> Request {
request_post("/api/users/login", login_info)
}
/// Register a new user
pub fn register(register_info: &types::RegisterInfoWrapper) -> Request {
request_post("/api/user", register_info)
}
/// Save info of current user
pub fn save(user_update_info: &types::UserUpdateInfoWrapper) -> Request {
request_put("/api/user", user_update_info)
}

View File

@@ -0,0 +1,41 @@
pub mod auth;
pub mod requests;
use dotenv_codegen::dotenv;
pub use requests::{request_delete, request_get, request_post, request_put};
use gloo_storage::{LocalStorage, Storage};
use lazy_static::lazy_static;
use parking_lot::RwLock;
const API_ROOT: &str = dotenv!("API_ROOT");
const TOKEN_KEY: &str = "jheuel-token";
lazy_static! {
/// Jwt token read from local storage.
pub static ref TOKEN: RwLock<Option<String>> = {
if let Ok(token) = LocalStorage::get(TOKEN_KEY) {
RwLock::new(Some(token))
} else {
RwLock::new(None)
}
};
}
/// Set jwt token to local storage.
pub fn set_token(token: Option<String>) {
if let Some(t) = token.clone() {
LocalStorage::set(TOKEN_KEY, t).expect("failed to set");
} else {
LocalStorage::delete(TOKEN_KEY);
}
let mut token_lock = TOKEN.write();
*token_lock = token;
}
/// Get jwt token from lazy static.
pub fn get_token() -> Option<String> {
let token_lock = TOKEN.read();
token_lock.clone()
}

View File

@@ -0,0 +1,80 @@
use super::API_ROOT;
use crate::error::Error;
use crate::types;
use gloo_net::http::{Method, Request as GlooRequest};
use weblog::console_log;
pub struct Request(Option<GlooRequest>);
fn request<B: serde::Serialize>(method: gloo_net::http::Method, url: &str, body: &B) -> Request {
let url = format!("{}{}", API_ROOT, url);
let builder = GlooRequest::new(&url)
.method(method)
.header("Content-Type", "application/json");
let req = Request(Some(builder)).set_token();
Request(req.0.and_then(|r| r.json(body).ok()))
}
impl Request {
fn set_token(self) -> Self {
let Some(r) = self.0 else {
return Self(None);
};
let builder = match super::get_token() {
Some(token) => r.header("Authorization", &format!("Token {}", token)),
None => r,
};
Self(Some(builder))
}
pub async fn send<T: serde::de::DeserializeOwned>(self) -> Result<T, Error> {
let Some(r) = self.0 else {
return Err(Error::RequestError);
};
let response = r.send().await;
if let Ok(data) = response {
if data.status() == 200 {
let data: Result<T, _> = data.json::<T>().await;
if let Ok(data) = data {
Ok(data)
} else {
Err(Error::DeserializeError)
}
} else {
let status = data.status();
let error = match data.json::<types::ErrorInfo>().await {
Ok(data) => Error::UnprocessableEntity(data),
Err(_) => Error::DeserializeError,
};
Err(Error::from_status_code(status, error))
}
} else {
Err(Error::RequestError)
}
}
}
pub fn request_delete(url: &str) -> Request {
let url = format!("{}{}", API_ROOT, url);
Request(Some(GlooRequest::new(&url).method(Method::DELETE))).set_token()
}
pub fn request_get(url: &str) -> Request {
let url = format!("{}{}", API_ROOT, url);
Request(Some(GlooRequest::new(&url).method(Method::GET))).set_token()
}
pub fn request_post<B>(url: &str, body: &B) -> Request
where
B: serde::Serialize,
{
request(Method::POST, url, body)
}
pub fn request_put<B>(url: &str, body: &B) -> Request
where
B: serde::Serialize,
{
request(Method::PUT, url, body)
}

View File

@@ -0,0 +1,64 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
#[serde(rename_all = "camelCase")]
pub struct LoginInfo {
pub email: String,
pub password: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct LoginInfoWrapper {
pub user: LoginInfo,
}
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
#[serde(rename_all = "camelCase")]
pub struct RegisterInfo {
pub username: String,
pub email: String,
pub password: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct RegisterInfoWrapper {
pub user: RegisterInfo,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub struct UserInfo {
pub email: String,
pub token: String,
pub username: String,
}
impl UserInfo {
pub fn is_authenticated(&self) -> bool {
!self.token.is_empty()
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct UserInfoWrapper {
pub user: UserInfo,
}
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
#[serde(rename_all = "camelCase")]
pub struct UserUpdateInfo {
pub email: String,
pub username: String,
pub password: Option<String>,
pub image: String,
pub bio: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct UserUpdateInfoWrapper {
pub user: UserUpdateInfo,
}

16
frontend/src/types/mod.rs Normal file
View File

@@ -0,0 +1,16 @@
pub mod auth;
pub use auth::{
LoginInfo, LoginInfoWrapper, RegisterInfo, RegisterInfoWrapper, UserInfo, UserInfoWrapper,
UserUpdateInfo, UserUpdateInfoWrapper,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ErrorInfo {
pub errors: HashMap<String, Vec<String>>,
}
pub type DeleteWrapper = HashMap<(), ()>;