feat: add manager (web portal)
This commit is contained in:
parent
9fe074eb91
commit
c390975a8d
@ -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
93
assets/index.html
Normal 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>
|
@ -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;
|
||||
|
@ -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
108
src/manager.rs
Normal 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"
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user