feat: uploading images

This commit is contained in:
Asger Juul Brunshøj 2025-01-18 17:04:02 +01:00
parent 51372e4886
commit 5fd2b1206a
7 changed files with 110801 additions and 36 deletions

View File

@ -30,7 +30,6 @@ pub fn hydrate() {
.without_time()
.with_ansi(false)
.init();
tracing::warn!("test");
leptos::mount::hydrate_body(App);
}

View File

@ -4,12 +4,30 @@ use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Wall {
pub rows: u64,
pub cols: u64,
pub holds: BTreeMap<HoldPosition, Hold>,
}
impl Wall {
pub fn new(rows: u64, cols: u64) -> Self {
let mut holds = BTreeMap::new();
for row in 0..rows {
for col in 0..cols {
let position = HoldPosition { row, col };
let hold = Hold { position, image: None };
holds.insert(position, hold);
}
}
Self { rows, cols, holds }
}
}
impl Default for Wall {
fn default() -> Self {
Self::new(12, 12)
}
}
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Copy)]
pub struct HoldPosition {

View File

@ -1,3 +1,4 @@
use crate::codec::ron::RonCodec;
use crate::components::header::Header;
use crate::models;
use crate::models::HoldPosition;
@ -8,6 +9,7 @@ use leptos::prelude::*;
use serde::Deserialize;
use serde::Serialize;
use server_fn::codec::Cbor;
use std::ops::Deref;
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use web_sys::FileList;
@ -24,7 +26,7 @@ pub fn EditWall() -> impl leptos::IntoView {
<div class="container mx-auto">
<Header />
<Await future=load let:data>
<Ready data=data.to_owned() />
<Ready data=data.deref().to_owned() />
</Await>
</div>
</div>
@ -34,16 +36,10 @@ pub fn EditWall() -> impl leptos::IntoView {
#[component]
fn Ready(data: InitialData) -> impl leptos::IntoView {
tracing::debug!("ready");
let mut hold_positions = vec![];
for row in 0..(data.wall.rows) {
for col in 0..(data.wall.cols) {
hold_positions.push(HoldPosition { row, col });
}
}
let mut holds = vec![];
for &hold_position in &hold_positions {
holds.push(view! { <Hold hold_position /> });
for hold in data.wall.holds.values().cloned() {
holds.push(view! { <Hold hold /> });
}
let grid_classes = format!("grid grid-rows-{} grid-cols-{} gap-2", data.wall.rows, data.wall.cols);
@ -52,7 +48,8 @@ fn Ready(data: InitialData) -> impl leptos::IntoView {
}
#[component]
fn Hold(hold_position: HoldPosition) -> impl leptos::IntoView {
fn Hold(hold: models::Hold) -> impl leptos::IntoView {
let hold_position = hold.position;
let file_input_ref = NodeRef::<Input>::new();
let open_camera = move |_| {
@ -63,6 +60,11 @@ fn Hold(hold_position: HoldPosition) -> impl leptos::IntoView {
let upload = Action::from(ServerAction::<SetImage>::new());
let hold = Signal::derive(move || {
let refreshed = upload.value().get().map(Result::unwrap);
refreshed.unwrap_or(hold.clone())
});
// Callback to handle file selection
let on_file_input = move |event: Event| {
let files: FileList = event.target().unwrap().unchecked_ref::<web_sys::HtmlInputElement>().files().unwrap();
@ -93,9 +95,18 @@ fn Hold(hold_position: HoldPosition) -> impl leptos::IntoView {
on_load.forget();
};
let img = move || {
hold.read().image.as_ref().map(|img| {
let src = format!("/files/holds/{}", img.filename);
view! {
<img class="object-cover w-full h-full" src=src />
}
})
};
view! {
<button on:click=open_camera>
<div class="bg-indigo-100 aspect-square rounded"></div>
<div class="bg-indigo-100 aspect-square rounded">{ img }</div>
</button>
<input
@ -121,26 +132,26 @@ pub struct Image {
}
#[server]
async fn load_initial_data() -> Result<InitialData, ServerFnError> {
async fn load_initial_data() -> Result<RonCodec<InitialData>, ServerFnError> {
use crate::server::state::State;
let state = expect_context::<State>();
let wall = state.persistent.with(|s| s.wall.clone()).await;
Ok(InitialData { wall })
Ok(RonCodec::new(InitialData { wall }))
}
#[server(name = SetImage, input = Cbor)]
#[tracing::instrument(skip(image))]
async fn set_image(hold_position: HoldPosition, image: Image) -> Result<(), ServerFnError> {
async fn set_image(hold_position: HoldPosition, image: Image) -> Result<models::Hold, ServerFnError> {
tracing::info!("Setting image, {}, {} bytes", image.file_name, image.file_contents.len());
use crate::server::state::State;
// TODO: Fix file extension presumption, and possibly use uuid
let filename = format!("row{}_col{}.jpg", hold_position.row, hold_position.col);
tokio::fs::create_dir_all("datastore").await?;
tokio::fs::write(format!("datastore/{filename}"), image.file_contents).await?;
tokio::fs::create_dir_all("datastore/public/holds").await?;
tokio::fs::write(format!("datastore/public/holds/{filename}"), image.file_contents).await?;
let state = expect_context::<State>();
state
@ -152,5 +163,8 @@ async fn set_image(hold_position: HoldPosition, image: Image) -> Result<(), Serv
})
.await?;
Ok(())
// Return updated hold
let hold = state.persistent.with(|s| s.wall.holds.get(&hold_position).cloned().unwrap()).await;
Ok(hold)
}

View File

@ -1,12 +1,12 @@
use crate::codec::ron::RonCodec;
use crate::components::header::Header;
use crate::models;
use crate::models::HoldPosition;
use crate::models::HoldRole;
use leptos::prelude::*;
use leptos::reactive::graph::ReactiveNode;
use serde::Deserialize;
use serde::Serialize;
use std::ops::Deref;
#[component]
pub fn Wall() -> impl leptos::IntoView {
@ -20,7 +20,7 @@ pub fn Wall() -> impl leptos::IntoView {
<div class="container mx-auto">
<Header />
<Await future=load let:data>
<Ready data=data.to_owned() />
<Ready data=data.deref().to_owned() />
</Await>
</div>
</div>
@ -29,12 +29,7 @@ pub fn Wall() -> impl leptos::IntoView {
#[component]
fn Ready(data: InitialData) -> impl leptos::IntoView {
let mut hold_positions = vec![];
for row in 0..(data.wall.rows) {
for col in 0..(data.wall.cols) {
hold_positions.push(HoldPosition { row, col });
}
}
tracing::debug!("ready");
let (current_problem, current_problem_writer) = signal(None::<models::Problem>);
let problem_fetcher = LocalResource::new(move || async move {
@ -44,11 +39,11 @@ fn Ready(data: InitialData) -> impl leptos::IntoView {
});
let mut cells = vec![];
for &hold_position in &hold_positions {
for (&hold_position, hold) in &data.wall.holds {
let role = move || current_problem.get().and_then(|problem| problem.holds.get(&hold_position).copied());
let role = Signal::derive(role);
let cell = view! { <Hold role /> };
let cell = view! { <Hold role hold=hold.clone()/> };
cells.push(cell);
}
@ -61,7 +56,7 @@ fn Ready(data: InitialData) -> impl leptos::IntoView {
}
#[component]
fn Hold(#[prop(into)] role: Signal<Option<HoldRole>>) -> impl leptos::IntoView {
fn Hold(hold: models::Hold, #[prop(into)] role: Signal<Option<HoldRole>>) -> impl leptos::IntoView {
let class = move || {
let role_classes = match role.get() {
Some(HoldRole::Start) => Some("outline outline-offset-2 outline-green-500"),
@ -78,7 +73,14 @@ fn Hold(#[prop(into)] role: Signal<Option<HoldRole>>) -> impl leptos::IntoView {
s
};
view! { <div class=class></div> }
let img = hold.image.map(|img| {
let src = format!("/files/holds/{}", img.filename);
view! {
<img class="hover:object-scale-down object-cover w-full h-full" src=src />
}
});
view! { <div class=class>{ img }</div> }
}
#[derive(Serialize, Deserialize, Clone)]
@ -87,13 +89,13 @@ pub struct InitialData {
}
#[server]
async fn load_initial_data() -> Result<InitialData, ServerFnError> {
async fn load_initial_data() -> Result<RonCodec<InitialData>, ServerFnError> {
use crate::server::state::State;
let state = expect_context::<State>();
let wall = state.persistent.with(|s| s.wall.clone()).await;
Ok(InitialData { wall })
Ok(RonCodec::new(InitialData { wall }))
}
#[server]

View File

@ -9,6 +9,7 @@ use state::PersistentState;
use state::State;
use std::collections::BTreeMap;
use std::path::Path;
use tower_http::services::ServeDir;
use tracing::level_filters::LevelFilter;
use type_toppings::ResultExt;
@ -135,9 +136,9 @@ async fn serve() -> Result<(), Error> {
let leptos_options = leptos_options.clone();
move || shell(leptos_options.clone())
})
.nest_service("/files", file_service("datastore/public"))
.fallback(leptos_axum::file_and_error_handler(shell))
.with_state(leptos_options);
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!("Listening on http://{addr}");
axum::serve(listener, app.into_make_service()).await?;
@ -145,6 +146,12 @@ async fn serve() -> Result<(), Error> {
Ok(())
}
#[tracing::instrument(skip(path))]
fn file_service(path: impl AsRef<Path>) -> ServeDir {
tracing::debug!("Creating file service for {}", path.as_ref().display());
ServeDir::new(path)
}
#[tracing::instrument]
async fn load_state() -> Result<State, Error> {
tracing::info!("Loading state");

View File

@ -98,7 +98,7 @@ impl<T> Persistent<T> {
T: Serialize,
{
tracing::debug!("Persisting state");
let serialized = ron::to_string(state).map_err(|source| Error::Serialize { source })?;
let serialized = ron::ser::to_string_pretty(state, ron::ser::PrettyConfig::default()).map_err(|source| Error::Serialize { source })?;
tokio::fs::write(file_path, serialized).await.map_err(|source| Error::Write {
file_path: file_path.to_owned(),
source,

110727
state.ron

File diff suppressed because one or more lines are too long