Commit eb9dfe048755486e320b0444b09882e27d01a720

Authored by Georg GH. Hopp
2 parents 7619ffd5 0b0c5fee

Merge branch 'create-rust-mogwai-client-code-for-file-upload-component-3' into 'master'

Create rust mogwai client code for file upload component 3

Closes #3

See merge request !2
  1 +# Steffie's Artshop (Arbeitstitel)
  2 +
  3 +A pure rust implementation of a small shop system. This is
  4 +ment to host the Arts created by my wife Stefanie and give
  5 +the opportunity to sell some of these of offer art services.
  6 +
  7 +## Description
  8 +
  9 +[:Description:]
  10 +
  11 +## Requirements
  12 +
  13 +This will be mostly self sufficient. It comes with its own web
  14 +server. One needs a browser to see the results.
  15 +
  16 +## Dependencies
  17 +
  18 +- rust
  19 +- mogwai
  20 +- actix-web
  21 +- actix-file
  22 +- serde
  23 +- serde-json
  24 +- diffy
  25 +
  26 +## Contributing
  27 +
  28 +Currently this is a lot of lerning for myself and I prefer to
  29 +work on this alone as long as I am not settled with it.
  30 +
  31 +Anyway, I will look into suggestions when they arrive me.
  32 +
  33 +## License
  34 +
  35 +Copyright © 2022 Georg Hopp
  36 +
  37 +This program is free software: you can redistribute it and/or modify
  38 +it under the terms of the GNU General Public License as published by
  39 +the Free Software Foundation, either version 3 of the License, or
  40 +(at your option) any later version.
  41 +
  42 +This program is distributed in the hope that it will be useful,
  43 +but WITHOUT ANY WARRANTY; without even the implied warranty of
  44 +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  45 +GNU General Public License for more details.
  46 +
  47 +You should have received a copy of the GNU General Public License
  48 +along with this program. If not, see <http://www.gnu.org/licenses/>.
  49 +
  50 +## Author
  51 +
  52 +Georg Hopp <georg@steffers.org>
... ...
... ... @@ -15,3 +15,24 @@ Statt also ein **undo** auf eine frühere Version zuzulassen kann man in der
15 15 aktuellen Version auf frühere Stände zurück gehen und diese gegebenenfalls
16 16 noch anpassen. Dieser Stand wird wieder ein neuer Patch gegen die gegenwärtige
17 17 Version.
  18 +
  19 +# Gedanken zum file upload.
  20 +Der finale Dateiname sollte ein eindeutiger Hashwert des content sein.
  21 +Um die erzeugung des Hash schnell zu halten sollte es ausreichen nur einen
  22 +Teil vom Anfang und von Ende und der Mitte zu hashen. Das habe ich schon mal
  23 +in einem anderen Projekt gemacht... [SUCHE]
  24 +
  25 +Die Dateien sollten dann in einem zweistufigem Verzeichnisbaum gespeichert
  26 +werden bei dem die Verzeichnisse mit den ersten Zeichen des Hashs beginnnen.
  27 +
  28 +Weitere Metainformationen wie Dateiname, Mime-Type, etc. werden in der
  29 +Datenbank gespeichert.
  30 +
  31 +Die ausgelieferten Bilder sollen eine steganographisch eingebettet Copyright
  32 +Information enthalten oder aber mit einem Wassermark versehen sein. Es gibt
  33 +ein rust crate steganography das für die erste Anwendung geeignet scheint.
  34 +Für die zweite Anwendung ist evtl. photon_rs geeignet.
  35 +
  36 +Uploads ueber die fetch API koennen zur Zeit scheinbar nicht wirklich einen
  37 +Progressbar haben. Daher werde ich wohl einen spinner anzeigen und dann Error
  38 +oder Ok...
... ...
... ... @@ -22,3 +22,14 @@
22 22 ## Restful API
23 23 [ReadTheDocs apiguide](https://apiguide.readthedocs.io/en/latest/build_and_publish/use_RESTful_urls.html 'ReadTheDocs apiguide')
24 24 [RestfulApi](https://restfulapi.net/versioning/ 'RestfulApi')
  25 +
  26 +## HTML5 file upload
  27 +[Pure HTML5 file upload](https://www.script-tutorials.com/pure-html5-file-upload/, 'Pure HTML5 file upload')
  28 +[HTML5 File Upload Example](https://www.webcodegeeks.com/html5/html5-file-upload-example/, 'HTML5 File Upload Example')
  29 +[How to upload files on the server using Fetch API](https://attacomsian.com/blog/uploading-files-using-fetch-api, 'How to upload files on the server using Fetch API')
  30 +[Stackoverflow 1](https://stackoverflow.com/questions/36453950/upload-file-with-fetch-api-in-javascript-and-show-progress 'Stackoverflow 1')
  31 +[GitHub Fetch Issues](https://github.com/github/fetch/issues/89 'GitHub Fetch Issues')
  32 +
  33 +## CSS spinner...
  34 +[How to Create Loading Spinner With CSS](https://www.w3docs.com/snippets/css/how-to-create-loading-spinner-with-css.html 'How to Create Loading Spinner With CSS')
  35 +[Loading.io - Pure CSS Loader](https://loading.io/css/ 'Loading.io - Pure CSS Loader')
... ...
  1 +.upload {
  2 + float: left;
  3 + width: 400px;
  4 + padding: 1em;
  5 + border-radius: .5em;
  6 + border: 1px solid #ddd;
  7 + background: #f7f7f7;
  8 +}
  9 +
  10 +.upload ul {
  11 + padding-left: 5px;
  12 +}
  13 +
  14 +.upload > ul {
  15 + max-height: 15em;
  16 + width: calc(100% - 10px);
  17 + overflow-y: auto;
  18 + overflow-x: hidden;
  19 + background: #ffffff;
  20 + border-radius: .35em;
  21 + border: 2px solid #bbb;
  22 + cursor: default;
  23 +}
  24 +
  25 +.upload > ul > li {
  26 + display: flex;
  27 + border: 1px solid black;
  28 + width: fit-content;
  29 +}
  30 +
  31 +.upload > ul > li > ul > li {
  32 + display: block;
  33 +}
  34 +
1 35 .markdown {
2   - float: left;
3   - padding: 1em;
4   - border-radius: .5em;
5   - border: 1px solid #ddd;
6   - background: #f7f7f7;
  36 + float: left;
  37 + padding: 1em;
  38 + border-radius: .5em;
  39 + border: 1px solid #ddd;
  40 + background: #f7f7f7;
7 41 }
8 42
9 43 .markdown p {
10   - text-align: justify;
11   - text-indent: .5em;
12   - margin-block: .5em;
  44 + text-align: justify;
  45 + text-indent: .5em;
  46 + margin-block: .5em;
13 47 }
14 48
15 49 .markdown > div:first-child {
16   - position: fixed;
  50 + position: fixed;
17 51 width: inherit;
18 52 z-index: 10;
19 53 }
20 54
21 55 .markdown > div:first-child > div {
22 56 position: relative;
23   - left: .5em;
  57 + left: .5em;
24 58 width: inherit;
25 59 }
26 60
27 61 .markdown > div:first-child > pre {
28   - white-space: pre-wrap;
29   - height: 15em;
  62 + white-space: pre-wrap;
  63 + height: 15em;
30 64 width: calc(100% - 10px);
31   - overflow-y: auto;
32   - overflow-x: hidden;
33   - background: #ffffff;
34   - border-radius: .35em;
35   - border: 2px solid #bbb;
36   - margin: 2px 0px;
  65 + overflow-y: auto;
  66 + overflow-x: hidden;
  67 + background: #ffffff;
  68 + border-radius: .35em;
  69 + border: 2px solid #bbb;
  70 + margin: 2px 0px;
37 71 opacity: .95;
38 72 }
39 73
40 74 .markdown > div:first-child > div > div {
41   - display: inline;
  75 + display: inline;
42 76 }
43 77
44 78 .markdown > div:first-child > div ul {
45   - height: 15em;
46   - list-style-type: none;
47   - margin: 2px 0px;
48   - overflow-y: auto;
49   - padding-left: 0px;
50   - position: absolute;
51   - scroll-behavior: auto;
52   - top: 1.8em;
  79 + height: 15em;
  80 + list-style-type: none;
  81 + margin: 2px 0px;
  82 + overflow-y: auto;
  83 + padding-left: 0px;
  84 + position: absolute;
  85 + scroll-behavior: auto;
  86 + top: 1.8em;
53 87 }
54 88
55 89 .markdown blockquote {
56   - border-left: 5px solid #ccc;
57   - margin: .5em 10px;
58   - padding: .5em 10px;
  90 + border-left: 5px solid #ccc;
  91 + margin: .5em 10px;
  92 + padding: .5em 10px;
59 93 }
60 94
61 95 .markdown blockquote p {
62   - text-align: left;
63   - text-indent: 0;
64   - margin-block: 0;
  96 + text-align: left;
  97 + text-indent: 0;
  98 + margin-block: 0;
65 99 }
66 100
67 101 .markdown .footnote-definition > * {
68   - display: inline;
  102 + display: inline;
  103 +}
  104 +
  105 +@keyframes spinner {
  106 + 0% {
  107 + transform: translate3d(-50%, -50%, 0) rotate(0deg);
  108 + }
  109 + 100% {
  110 + transform: translate3d(-50%, -50%, 0) rotate(360deg);
  111 + }
  112 +}
  113 +
  114 +.spin::before {
  115 + animation: 1.5s linear infinite spinner;
  116 + animation-play-state: inherit;
  117 + border: solid 5px #cfd0d1;
  118 + border-bottom-color: #1c87c9;
  119 + border-radius: 50%;
  120 + content: "";
  121 + height: 1.5em;
  122 + width: 1.5em;
  123 + position: absolute;
  124 + top: 10%;
  125 + left: 1.5em;
  126 + transform: translate3d(-50%, -50%, 0);
  127 + will-change: transform;
69 128 }
... ...
... ... @@ -36,20 +36,28 @@ version = "^0.5"
36 36 [dependencies.web-sys]
37 37 version = "^0.3"
38 38 features = [
39   - "Document",
40   - "DomParser",
41   - "Headers",
42   - "HtmlElement",
43   - "HtmlInputElement",
44   - "MouseEvent",
45   - "Node",
46   - "Request",
47   - "RequestInit",
48   - "RequestMode",
49   - "Response",
50   - "SupportedType",
51   - "Window",
52   -]
  39 + "Blob",
  40 + "CanvasRenderingContext2d",
  41 + "Document",
  42 + "DomParser",
  43 + "File",
  44 + "FileList",
  45 + "FileReader",
  46 + "ImageBitmap",
  47 + "Headers",
  48 + "HtmlCanvasElement",
  49 + "HtmlElement",
  50 + "HtmlInputElement",
  51 + "MouseEvent",
  52 + "Node",
  53 + "ReadableStream",
  54 + "Request",
  55 + "RequestInit",
  56 + "RequestMode",
  57 + "Response",
  58 + "SupportedType",
  59 + "Window",
  60 + ]
53 61
54 62 [dev-dependencies]
55 63 wasm-bindgen-test = "0.2"
... ...
... ... @@ -66,17 +66,6 @@ impl Markdown {
66 66 }
67 67 }
68 68
69   - pub(crate) fn _to_html_string(&self) -> String {
70   - use pulldown_cmark::{Parser, Options, html};
71   -
72   - let mut html_out = String::new();
73   - let parser = Parser::new_ext(&self.json.content, Options::all());
74   -
75   - html::push_html(&mut html_out, parser);
76   - html_out
77   - }
78   -
79   -
80 69 fn status_error<I: Display>(status :I) -> Error {
81 70 let err_str = format!("Invalid response status: {}", status);
82 71 Error::from(err_str.as_str())
... ...
1 1 pub(crate) mod markdown;
  2 +pub(crate) mod upload;
... ...
  1 +use std::fmt::Display;
  2 +
  3 +use super::super::error::*;
  4 +use super::super::client::Client;
  5 +use crate::upload::upload::Upload;
  6 +
  7 +#[derive(Debug, Clone)]
  8 +pub struct UploadApi {
  9 + client :Client,
  10 +}
  11 +
  12 +impl UploadApi {
  13 + pub(crate) async fn new() -> Result<UploadApi> {
  14 + let client = Client::new()?;
  15 + Ok(UploadApi { client })
  16 + }
  17 +
  18 + pub(crate) async fn store(&self, upload :&Upload) -> Result<&UploadApi> {
  19 + let response = self.client.post_stream( "/api/v0/upload"
  20 + , &upload.mime_type()
  21 + , upload.data() ).await?;
  22 + match response.status() {
  23 + 200 => Ok(self),
  24 + status => Err(Self::status_error(status)),
  25 + }
  26 + }
  27 +
  28 + fn status_error<I: Display>(status :I) -> Error {
  29 + let err_str = format!("Invalid response status: {}", status);
  30 + Error::from(err_str.as_str())
  31 + }
  32 +}
... ...
1 1 use js_sys::JsString;
2 2 use mogwai::prelude::*;
3 3 use wasm_bindgen::prelude::*;
4   -use web_sys::{Window, window, Response, Request, RequestInit, RequestMode};
  4 +use web_sys::{Window, window, Response, Request, RequestInit, RequestMode, ReadableStream};
5 5 use super::error::*;
6 6
7 7 use std::result::Result as StdResult;
... ... @@ -25,7 +25,7 @@ impl Client {
25 25
26 26 pub async fn get(&self, url :&str) -> Result<(Response, String)> {
27 27 let mut init = RequestInit::new();
28   - let request = REQUEST( &url
  28 + let request = REQUEST( url
29 29 , init . method("GET")
30 30 . mode(RequestMode::Cors) )?;
31 31
... ... @@ -46,7 +46,7 @@ impl Client {
46 46
47 47 pub async fn put(&self, url :&str, data :&str) -> Result<Response> {
48 48 let mut init = RequestInit::new();
49   - let request = REQUEST( &url
  49 + let request = REQUEST( url
50 50 , init . method("PUT")
51 51 . mode(RequestMode::Cors)
52 52 . body(Some(&data.into())) )?;
... ... @@ -61,4 +61,25 @@ impl Client {
61 61
62 62 Ok(response)
63 63 }
  64 +
  65 + pub async fn post_stream( &self
  66 + , url :&str
  67 + , mime_type :&str
  68 + , data :ReadableStream) -> Result<Response> {
  69 + let mut init = RequestInit::new();
  70 + let request = REQUEST( url
  71 + , init . method("POST")
  72 + . mode(RequestMode::Cors)
  73 + . body(Some(&data.into())) )?;
  74 +
  75 + request . headers()
  76 + . set("Content-Type", mime_type)?;
  77 +
  78 + let response = JsFuture::from( self.window
  79 + . fetch_with_request(&request))
  80 + . await?
  81 + . dyn_into::<Response>()?;
  82 +
  83 + Ok(response)
  84 + }
64 85 }
... ...
1 1 pub(crate) mod markdown;
  2 +pub(crate) mod upload;
... ...
  1 +use mogwai::prelude::*;
  2 +use web_sys::{HtmlInputElement, HtmlCanvasElement, CanvasRenderingContext2d};
  3 +
  4 +use crate::api::upload::UploadApi;
  5 +
  6 +use super::upload::Upload;
  7 +
  8 +#[derive(Clone, Debug)]
  9 +pub(super) enum UploadLogic {
  10 + Add(DomEvent),
  11 + Remove(usize),
  12 + Upload,
  13 +}
  14 +
  15 +pub(super) async fn upload_preview_logic( mut rx_canvas :broadcast::Receiver<Dom>
  16 + , mut rx_click :broadcast::Receiver<DomEvent>
  17 + , upload :Upload ) {
  18 + if let Some(dom) = rx_canvas.next().await {
  19 + if let Either::Left(c) = dom.inner_read() {
  20 + let canvas = c.to_owned().dyn_into::<HtmlCanvasElement>().unwrap();
  21 + let context = canvas
  22 + . get_context("2d").unwrap().unwrap()
  23 + . dyn_into::<CanvasRenderingContext2d>().unwrap();
  24 + context
  25 + . draw_image_with_image_bitmap_and_dw_and_dh(
  26 + &upload.bitmap()
  27 + , 0.0, 0.0
  28 + , canvas.width() as f64, canvas.height() as f64 )
  29 + . unwrap();
  30 + }
  31 + }
  32 +
  33 + while let Some(_) = rx_click.next().await {
  34 + upload.tx_logic . try_broadcast(UploadLogic::Remove(upload.id))
  35 + . unwrap();
  36 + }
  37 +}
  38 +
  39 +pub(super) async fn upload_logic( mut rx_logic :broadcast::Receiver<UploadLogic>
  40 + , tx_logic :broadcast::Sender<UploadLogic>
  41 + , tx_previews: mpmc::Sender<ListPatch<ViewBuilder<Dom>>>
  42 + ) {
  43 + let mut uploads: ListPatchModel<Upload> = ListPatchModel::new();
  44 + let api = UploadApi::new().await.unwrap();
  45 +
  46 + mogwai::spawn(uploads.stream().for_each(move |patch| {
  47 + let patch = patch.map(|u| u.into());
  48 + let tx_previews = tx_previews.clone();
  49 + async move {
  50 + tx_previews.send(patch).await.unwrap();
  51 + }
  52 + }));
  53 +
  54 + let mut next_id = 0;
  55 +
  56 + while let Some(msg) = rx_logic.next().await {
  57 + match msg {
  58 + UploadLogic::Add(event) =>
  59 + if let Either::Left(inner) = event.clone_inner() {
  60 + let filelist = inner.dyn_into::<Event>().unwrap()
  61 + . target().unwrap()
  62 + . dyn_into::<HtmlInputElement>().unwrap()
  63 + . files().unwrap();
  64 +
  65 + for index in 0..filelist.length() {
  66 + let file = filelist.item(index).unwrap();
  67 + let tx_logic = tx_logic.clone();
  68 + uploads.list_patch_push( Upload::new(next_id, file, tx_logic)
  69 + . await);
  70 + next_id += 1;
  71 + }
  72 + },
  73 + UploadLogic::Remove(id) => {
  74 + let mut found = None;
  75 +
  76 + for (upload, index) in uploads.read().await.iter().zip(0..) {
  77 + if upload.id == id {
  78 + found = Some(index);
  79 + break;
  80 + }
  81 + }
  82 +
  83 + if let Some(index) = found {
  84 + uploads.list_patch_remove(index).unwrap();
  85 + }
  86 + },
  87 + UploadLogic::Upload => {
  88 + for upload in uploads.read().await.iter() {
  89 + match api.store(upload).await {
  90 + Ok(_) => (),
  91 + Err(e) => log::error!("{:?}", e),
  92 + }
  93 + }
  94 + }
  95 + }
  96 + }
  97 +}
... ...
  1 +mod logic;
  2 +pub(crate) mod upload;
  3 +mod view;
  4 +
  5 +use mogwai::prelude::*;
  6 +
  7 +use self::{view::upload_view, logic::upload_logic};
  8 +
  9 +pub(crate) async fn new() -> Component<Dom> {
  10 + let (tx_logic, rx_logic) = broadcast::bounded(1);
  11 + let (tx_previews, rx_previews) = mpmc::bounded(1);
  12 +
  13 + let view = upload_view(tx_logic.clone(), rx_previews);
  14 + let logic = upload_logic(rx_logic, tx_logic, tx_previews);
  15 +
  16 + Component::from(view).with_logic(logic)
  17 +}
... ...
  1 +use mogwai::{prelude::*, utils::window};
  2 +use web_sys::{File, ImageBitmap, ReadableStream};
  3 +
  4 +use super::{view::upload_preview_view, logic::{upload_preview_logic, UploadLogic}};
  5 +
  6 +#[derive(Clone, Debug)]
  7 +pub(crate) struct Upload {
  8 + pub(super) id :usize,
  9 + file :File,
  10 + bitmap :ImageBitmap,
  11 + pub(super) tx_logic :broadcast::Sender<UploadLogic>,
  12 +}
  13 +
  14 +impl Upload {
  15 + pub(super) async fn new( id :usize
  16 + , file :File
  17 + , tx_logic :broadcast::Sender<UploadLogic>
  18 + ) -> Upload {
  19 + let bitmap = window()
  20 + . create_image_bitmap_with_blob(&file.clone().into())
  21 + . unwrap();
  22 + let bitmap = JsFuture::from(bitmap)
  23 + . await.unwrap()
  24 + . dyn_into::<ImageBitmap>().unwrap();
  25 +
  26 + Self { id, file, bitmap, tx_logic }
  27 + }
  28 +
  29 + pub(crate) fn mime_type(&self) -> String {
  30 + self.file.type_()
  31 + }
  32 +
  33 + pub(crate) fn data(&self) -> ReadableStream {
  34 + self.file.stream()
  35 + }
  36 +
  37 + pub(super) fn bitmap(&self) -> ImageBitmap {
  38 + self.to_owned().bitmap
  39 + }
  40 +}
  41 +
  42 +impl From<Upload> for Component<Dom> {
  43 + fn from(upload: Upload) -> Self {
  44 + let (tx_canvas, rx_canvas) = broadcast::bounded(1);
  45 + let (tx_click, rx_click) = broadcast::bounded(1);
  46 +
  47 + let view = upload_preview_view( tx_canvas
  48 + , tx_click
  49 + , upload.file.name()
  50 + , upload.file.size()
  51 + , upload.file.type_()
  52 + , upload.file.last_modified() );
  53 + let logic = upload_preview_logic(rx_canvas, rx_click, upload);
  54 +
  55 + Component::from(view).with_logic(logic)
  56 + }
  57 +}
  58 +
  59 +impl From<Upload> for ViewBuilder<Dom> {
  60 + fn from(upload: Upload) -> Self {
  61 + let component :Component<Dom> = upload.into();
  62 + component.into()
  63 + }
  64 +}
... ...
  1 +use mogwai::prelude::*;
  2 +
  3 +use crate::component::upload::logic::UploadLogic;
  4 +
  5 +pub(super) fn upload_preview_view( tx_canvas :broadcast::Sender<Dom>
  6 + , tx_click :broadcast::Sender<DomEvent>
  7 + , filename :String
  8 + , size :f64
  9 + , mime_type :String
  10 + , mtime :f64
  11 + ) -> ViewBuilder<Dom> {
  12 + let post_build = move |dom: &mut Dom| {
  13 + tx_canvas.try_broadcast(dom.clone()).unwrap();
  14 + };
  15 +
  16 + builder! {
  17 + <li style:display="flex"
  18 + on:click=tx_click.sink()>
  19 + <canvas width="75px"
  20 + height="75px"
  21 + post:build=post_build />
  22 + <ul>
  23 + <li>{format!("filename: {}", filename)}</li>
  24 + <li>{format!("size: {}", size)}</li>
  25 + <li>{format!("mime type: {}", mime_type)}</li>
  26 + <li>{format!("modification time: {}", mtime)}</li>
  27 + </ul>
  28 + </li>
  29 + }
  30 +}
  31 +
  32 +pub(super) fn upload_view( tx_logic: broadcast::Sender<UploadLogic>
  33 + , rx_previews: mpmc::Receiver<ListPatch<ViewBuilder<Dom>>>
  34 + ) -> ViewBuilder<Dom> {
  35 + let select_filter = tx_logic.sink()
  36 + . contra_map(|e| UploadLogic::Add(e));
  37 + let upload_filter = tx_logic.sink()
  38 + . contra_map(|_| UploadLogic::Upload);
  39 +
  40 +// <div class="spin"></div>
  41 + builder! {
  42 + <div class="upload">
  43 + <div>
  44 + <input type="file"
  45 + multiple="multiple"
  46 + accept="image/*"
  47 + on:change=select_filter />
  48 + <button on:click=upload_filter>"Upload"</button>
  49 + </div>
  50 + <ul patch:children=rx_previews>
  51 + </ul>
  52 + </div>
  53 + }
  54 +}
... ...
... ... @@ -6,7 +6,7 @@ mod component;
6 6
7 7 use std::panic;
8 8
9   -use crate::component::markdown;
  9 +use crate::component::*;
10 10 use log::Level;
11 11 use mogwai::prelude::*;
12 12 use wasm_bindgen::prelude::*;
... ... @@ -16,8 +16,14 @@ pub async fn main() -> Result<(), JsValue> {
16 16 panic::set_hook(Box::new(console_error_panic_hook::hook));
17 17 console_log::init_with_level(Level::Trace).unwrap();
18 18
19   - let comp = markdown::new().await;
  19 + let md = markdown::new().await;
  20 + let comp = upload::new().await;
20 21
21   - let page = Component::from(builder! {{comp}});
  22 + let page = Component::from(builder! {
  23 + <div>
  24 + {comp}
  25 + {md}
  26 + </div>
  27 + });
22 28 page.build()?.run()
23 29 }
... ...
Please register or login to post a comment