From c390975a8d80c2920419e1e67cbd9598fe00f027 Mon Sep 17 00:00:00 2001
From: itsscb <dev@itsscb.de>
Date: Tue, 29 Oct 2024 22:34:52 +0100
Subject: [PATCH] feat: add manager (web portal)

---
 Cargo.toml        |   5 +++
 assets/index.html |  93 +++++++++++++++++++++++++++++++++++++++
 src/lib.rs        |   5 ++-
 src/library.rs    |  39 ++++++++++++++++-
 src/manager.rs    | 108 ++++++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 246 insertions(+), 4 deletions(-)
 create mode 100644 assets/index.html
 create mode 100644 src/manager.rs

diff --git a/Cargo.toml b/Cargo.toml
index ab1f8b6..630deb1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,10 +4,15 @@ version = "0.1.0"
 edition = "2021"
 
 [dependencies]
+axum = { version = "0.7.7", features = ["multipart"] }
 crossbeam-channel = "0.5.13"
 rand = "0.8.5"
 rodio = "0.19.0"
 rusb = "0.9.4"
 serde = { version = "1.0.210", features = ["derive", "rc"] }
 serde_json = "1.0.132"
+tokio = { version = "1.41.0", features = ["macros", "rt-multi-thread"] }
+tower-http = { version = "0.6.1", features = ["fs"] }
+tracing = { version = "0.1.40", features = ["async-await"] }
+tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
 wifi-rs = "0.2.4"
diff --git a/assets/index.html b/assets/index.html
new file mode 100644
index 0000000..bf07b7d
--- /dev/null
+++ b/assets/index.html
@@ -0,0 +1,93 @@
+<!-- index.html -->
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Marlinbox</title>
+    <link rel="stylesheet" href="assets/styles.css">
+
+    <script>
+        async function sendPairRequest() {
+            try {
+                const response = await fetch('/pair');
+                const data = await response.text();
+                showToast(data);
+            } catch (error) {
+                showToast('An error occurred');
+            }
+        }
+        function showToast(message) {
+            const toast = document.createElement('div');
+            toast.textContent = message;
+            toast.style.position = 'fixed';
+            toast.style.bottom = '15%';
+            toast.classList = 'text-3xl font-bold uppercase bg-green-300 text-white rounded-xl p-x-2 p-y-4 md:w-[25%] w-[60%] text-center transform transition-transform duration-500 ease-in-out translate-y-full opacity-0';
+            document.body.appendChild(toast);
+            setTimeout(() => {
+                toast.classList.remove('translate-y-full', 'opacity-0');
+                toast.classList.add('translate-y-0', 'opacity-100');
+            }, 100); // Slight delay to trigger the transition
+            setTimeout(() => {
+                toast.classList.remove('translate-y-0', 'opacity-100');
+                toast.classList.add('translate-y-full', 'opacity-0');
+                setTimeout(() => {
+                    document.body.removeChild(toast);
+                }, 500); // Wait for the transition to complete before removing
+            }, 5000);
+        }
+
+
+        // document.getElementById('card-add').addEventListener('click', sendPairRequest);
+    </script>
+</head>
+
+
+<body>
+    <div class="flex flex-col items-center gap-y-12 md:gap-y-24">
+        <div></div>
+        <section class="flex flex-col gap-y-2 text-center">
+            <h1 class="text-4xl md:text-7xl font-bold uppercase tracking-widest">Marlinbox</h1>
+            <p class="text-xl md:text-3xl">The Open Source Music Box</p>
+        </section>
+        <section class="flex flex-row justify-between md:justify-evenly md:gap-x-20 w-[65%] md:w-[35%]">
+            <div class="flex flex-col justify-center gap-y-6 md:gap-y-20">
+                <button id="vol-up"
+                    class="text-xl md:text-3xl font-bold uppercase bg-cyan-300 text-white rounded-xl p-2 w-16 md:w-28 flex flex-col items-center">
+                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="#fff">
+                        <path
+                            d="M560-131v-82q90-26 145-100t55-168q0-94-55-168T560-749v-82q124 28 202 125.5T840-481q0 127-78 224.5T560-131ZM120-360v-240h160l200-200v640L280-360H120Zm440 40v-322q47 22 73.5 66t26.5 96q0 51-26.5 94.5T560-320ZM400-606l-86 86H200v80h114l86 86v-252ZM300-480Z" />
+                    </svg>
+                </button>
+                <button id="vol-down"
+                    class="text-xl md:text-3xl font-bold uppercase bg-cyan-300 text-white rounded-xl p-2 w-16 md:w-28 flex flex-col items-center">
+                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="#fff">
+                        <path
+                            d="M200-360v-240h160l200-200v640L360-360H200Zm440 40v-322q45 21 72.5 65t27.5 97q0 53-27.5 96T640-320ZM480-606l-86 86H280v80h114l86 86v-252ZM380-480Z" />
+                    </svg>
+                </button>
+            </div>
+            <div class="flex flex-col justify-center gap-y-6 md:gap-y-20">
+                <button id="card-add"
+                    class="text-xl md:text-3xl font-bold uppercase bg-cyan-300 text-white rounded-xl p-2 w-16 md:w-28 flex flex-col items-center"
+                    onclick="sendPairRequest()">
+                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="#fff">
+                        <path
+                            d="M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720v240H160v240h400v80H160Zm0-480h640v-80H160v80ZM760-80v-120H640v-80h120v-120h80v120h120v80H840v120h-80ZM160-240v-480 480Z" />
+                    </svg>
+                </button>
+                <button id="card-manage"
+                    class="text-xl md:text-3xl font-bold uppercase bg-cyan-300 text-white rounded-xl p-2 w-16 md:w-28 flex flex-col items-center">
+                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="#fff">
+                        <path
+                            d="M160-240v-320 13-173 480Zm0-400h640v-80H160v80Zm303 480H160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720v213q-35-25-76.5-39T716-560q-57 0-107.5 21.5T520-480H160v240h279q3 21 9 41t15 39Zm213 80-12-60q-12-5-22.5-10.5T620-164l-58 18-40-68 46-40q-2-13-2-26t2-26l-46-40 40-68 58 18q11-8 21.5-13.5T664-420l12-60h80l12 60q12 5 22.5 10.5T812-396l58-18 40 68-46 40q2 13 2 26t-2 26l46 40-40 68-58-18q-11 8-21.5 13.5T768-140l-12 60h-80Zm40-120q33 0 56.5-23.5T796-280q0-33-23.5-56.5T716-360q-33 0-56.5 23.5T636-280q0 33 23.5 56.5T716-200Z" />
+                    </svg>
+                </button>
+            </div>
+        </section>
+    </div>
+
+</body>
+
+</html>
\ No newline at end of file
diff --git a/src/lib.rs b/src/lib.rs
index c79616e..cba2d42 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,6 +1,7 @@
 pub mod card;
 pub mod card_reader;
 pub mod error;
-pub mod library;
-pub mod portal;
+mod library;
+pub mod manager;
 pub mod service;
+pub use library::Library;
diff --git a/src/library.rs b/src/library.rs
index aab6148..a1b23fa 100644
--- a/src/library.rs
+++ b/src/library.rs
@@ -1,9 +1,10 @@
-use std::{collections::HashMap, sync::Arc};
+use std::{collections::HashMap, io::Write, sync::Arc};
 
 use rand::seq::SliceRandom;
 use serde::{Deserialize, Serialize};
 
-use crate::card::Card;
+use crate::{card::Card, error::Error};
+use std::fs::File;
 
 #[derive(Debug, Serialize, Deserialize)]
 pub struct Library {
@@ -18,10 +19,44 @@ impl Library {
         }
     }
 
+    /// Saves the library to a file.
+    ///
+    /// # Arguments
+    ///
+    /// * `file_path` - The path to the file where the library will be saved.
+    ///
+    /// # Errors
+    ///
+    /// Returns an `Error` if there was an error writing the library to the file.
+    pub fn save_to_file<P: AsRef<str>>(&self, file_path: P) -> Result<(), Error> {
+        let serialized = serde_json::to_string(self)?;
+        let mut file = File::create(file_path.as_ref())?;
+        file.write_all(serialized.as_bytes())?;
+        Ok(())
+    }
+
+    /// Loads the library from a file.
+    ///
+    /// # Arguments
+    ///
+    /// * `file_path` - The path to the file from which the library will be loaded.
+    ///
+    /// # Errors
+    ///
+    /// Returns an `Error` if there was an error reading the library from the file.
+    pub fn from_file<P: AsRef<str>>(file_path: P) -> Result<Self, Error> {
+        let file = File::open(file_path.as_ref())?;
+        Ok(serde_json::from_reader(file)?)
+    }
+
     pub fn add(&mut self, card_id: &str) {
         self.music.insert(card_id.into(), None);
     }
 
+    pub fn remove(&mut self, card_id: &str) -> Option<Card> {
+        self.music.remove_entry(card_id).and_then(|(_, v)| v)
+    }
+
     pub fn update(&mut self, card_id: &str, music_file: Option<Card>) {
         self.music
             .insert(card_id.into(), music_file.map(Into::into));
diff --git a/src/manager.rs b/src/manager.rs
new file mode 100644
index 0000000..15fefa9
--- /dev/null
+++ b/src/manager.rs
@@ -0,0 +1,108 @@
+use std::{fs, path::PathBuf, sync::Arc};
+
+use axum::{
+    extract::{Multipart, State},
+    response::IntoResponse,
+    routing::{get, post},
+};
+use crossbeam_channel::{Receiver, Sender};
+use tokio::runtime::Runtime;
+use tower_http::services::{ServeDir, ServeFile};
+use tracing::{debug, error, info};
+
+/// Starts the Manager and listens for incoming connections.
+///
+/// # Arguments
+///
+/// * `connection_string` - A string representing the connection address and port.
+/// * `tx_pairing` - An Arc-wrapped Sender used for message passing.
+/// * `rx_shutdown` - A Receiver used to receive shutdown signals.
+///
+/// # Returns
+///
+/// Returns `Ok(())` if the server started successfully, otherwise returns an error.
+pub fn serve(
+    connection_string: &str,
+    tx_pairing: Arc<Sender<()>>,
+    rx_shutdown: Arc<Receiver<()>>,
+) -> Result<(), Box<dyn std::error::Error>> {
+    let rt = Runtime::new()?;
+    let app = axum::Router::new()
+        .nest_service("/assets", ServeDir::new(PathBuf::from("assets")))
+        .route_service("/", ServeFile::new(PathBuf::from("assets/index.html")))
+        .route("/pair", get(handler))
+        .route("/failed", get(|| async { "Failed to send message" }))
+        .route("/upload", post(upload_file))
+        .with_state(tx_pairing);
+
+    rt.block_on(async {
+        let listener = match tokio::net::TcpListener::bind(connection_string).await {
+            Ok(it) => {
+                info!("Manager listening on: {connection_string}");
+                it
+            }
+            Err(err) => {
+                error!("Manager failed to bind to {connection_string}: {err}");
+                return;
+            }
+        };
+        match axum::serve(listener, app)
+            .with_graceful_shutdown(async move {
+                match rx_shutdown.recv() {
+                    Ok(()) => info!("Manager shutting down"),
+                    Err(err) => error!("Manager failed to shut down: {err}"),
+                }
+            })
+            .await
+        {
+            Ok(()) => info!("Manager listening on {connection_string}"),
+            Err(err) => error!("Manager failed to start: {err}"),
+        }
+    });
+    Ok(())
+}
+
+async fn handler(State(state): State<Arc<Sender<()>>>) -> impl IntoResponse {
+    match state.send(()) {
+        Ok(()) => {
+            info!("Sent pairing request");
+            "Pairing"
+        }
+        Err(err) => {
+            error!("Failed to send pairing request: {err}");
+            "Failed to start pairing"
+        }
+    }
+}
+
+async fn upload_file(mut multipart: Multipart) -> impl IntoResponse {
+    while let Ok(resp) = multipart.next_field().await {
+        if let Some(field) = resp {
+            let Some(filename) = field.file_name() else {
+                return "No file name provided";
+            };
+            let filepath = PathBuf::from(format!("./uploads/{filename}"));
+
+            if let Err(err) = fs::create_dir_all("uploads") {
+                error!("Failed to create directory: {err}");
+                return "Failed to create directory";
+            }
+
+            let data = match field.bytes().await {
+                Ok(bytes) => bytes,
+                Err(err) => {
+                    error!("Failed to read file: {err}");
+                    return "Failed to read file";
+                }
+            };
+            if let Err(err) = fs::write(&filepath, data) {
+                error!("Failed to write file: {err}");
+                return "Failed to write file";
+            }
+            debug!("File uploaded: {}", filepath.to_string_lossy());
+        } else {
+            break;
+        }
+    }
+    "File uploaded"
+}