feat: add manager (web portal)

This commit is contained in:
itsscb 2024-10-29 22:34:52 +01:00
parent 9fe074eb91
commit c390975a8d
5 changed files with 246 additions and 4 deletions

View File

@ -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"

93
assets/index.html Normal file
View File

@ -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>

View File

@ -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;

View File

@ -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));

108
src/manager.rs Normal file
View File

@ -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"
}