mirror of
https://github.com/Djeeberjr/fw-anwesenheit.git
synced 2025-07-02 01:04:16 +00:00
Merge pull request #17 from Djeeberjr/feature/idmapping
Feature/idmapping
This commit is contained in:
commit
53584fee24
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -1116,6 +1116,7 @@ dependencies = [
|
|||||||
"rocket_codegen",
|
"rocket_codegen",
|
||||||
"rocket_http",
|
"rocket_http",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"state",
|
"state",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"time",
|
"time",
|
||||||
|
@ -13,7 +13,7 @@ gpio = "0.4.1"
|
|||||||
regex = "1.11.1"
|
regex = "1.11.1"
|
||||||
serde = { version = "1.0.219", features = ["derive"] }
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
serde_json = "1.0.140"
|
serde_json = "1.0.140"
|
||||||
rocket = "0.5.1"
|
rocket = { version = "0.5.1", features = ["json"] }
|
||||||
tokio = { version = "1.44.2", features = ["full"] }
|
tokio = { version = "1.44.2", features = ["full"] }
|
||||||
rust-embed = "8.7.0"
|
rust-embed = "8.7.0"
|
||||||
log = "0.4.27"
|
log = "0.4.27"
|
||||||
|
76
src/id_mapping.rs
Normal file
76
src/id_mapping.rs
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
use crate::tally_id::TallyID;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug, PartialEq, Eq, Clone)]
|
||||||
|
pub struct Name {
|
||||||
|
pub first: String,
|
||||||
|
pub last: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Clone)]
|
||||||
|
pub struct IDMapping {
|
||||||
|
id_map: HashMap<TallyID, Name>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IDMapping {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
IDMapping {
|
||||||
|
id_map: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn map(&self, id: &TallyID) -> Option<&Name> {
|
||||||
|
self.id_map.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_mapping(&mut self, id: TallyID, name: Name) {
|
||||||
|
self.id_map.insert(id, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn basic() {
|
||||||
|
let mut map = IDMapping::new();
|
||||||
|
let id1 = TallyID("A2Fb44".to_owned());
|
||||||
|
let name1 = Name {
|
||||||
|
first: "Max".to_owned(),
|
||||||
|
last: "Mustermann".to_owned(),
|
||||||
|
};
|
||||||
|
|
||||||
|
map.add_mapping(id1.clone(), name1.clone());
|
||||||
|
|
||||||
|
let res = map.map(&id1);
|
||||||
|
|
||||||
|
assert_eq!(res, Some(&name1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple() {
|
||||||
|
let mut map = IDMapping::new();
|
||||||
|
let id1 = TallyID("A2Fb44".to_owned());
|
||||||
|
let name1 = Name {
|
||||||
|
first: "Max".to_owned(),
|
||||||
|
last: "Mustermann".to_owned(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let id2 = TallyID("7D3DF5B5".to_owned());
|
||||||
|
let name2 = Name {
|
||||||
|
first: "First".to_owned(),
|
||||||
|
last: "Last".to_owned(),
|
||||||
|
};
|
||||||
|
|
||||||
|
map.add_mapping(id1.clone(), name1.clone());
|
||||||
|
map.add_mapping(id2.clone(), name2.clone());
|
||||||
|
|
||||||
|
let res = map.map(&id1);
|
||||||
|
assert_eq!(res, Some(&name1));
|
||||||
|
|
||||||
|
let res = map.map(&id2);
|
||||||
|
assert_eq!(res, Some(&name2));
|
||||||
|
}
|
||||||
|
}
|
@ -5,7 +5,7 @@ use std::{
|
|||||||
};
|
};
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
use crate::tally_id::TallyID;
|
use crate::{id_mapping::IDMapping, tally_id::TallyID};
|
||||||
|
|
||||||
/// Represents a single day that IDs can attend
|
/// Represents a single day that IDs can attend
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Deserialize, Serialize)]
|
||||||
@ -18,12 +18,14 @@ pub struct AttendanceDay {
|
|||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Deserialize, Serialize)]
|
||||||
pub struct IDStore {
|
pub struct IDStore {
|
||||||
days: HashMap<String, AttendanceDay>,
|
days: HashMap<String, AttendanceDay>,
|
||||||
|
pub mapping: IDMapping,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IDStore {
|
impl IDStore {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
IDStore {
|
IDStore {
|
||||||
days: HashMap::new(),
|
days: HashMap::new(),
|
||||||
|
mapping: IDMapping::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,10 +84,18 @@ impl IDStore {
|
|||||||
days.sort();
|
days.sort();
|
||||||
|
|
||||||
let header = days.join(seperator);
|
let header = days.join(seperator);
|
||||||
csv.push_str(&format!("ID{}{}\n", seperator, header));
|
csv.push_str(&format!(
|
||||||
|
"ID{seperator}Nachname{seperator}Vorname{seperator}{header}\n"
|
||||||
|
));
|
||||||
|
|
||||||
for user_id in user_ids.iter() {
|
for user_id in user_ids.iter() {
|
||||||
csv.push_str(&user_id.0.to_string());
|
let id = &user_id.0.to_string();
|
||||||
|
let name = self.mapping.map(user_id);
|
||||||
|
|
||||||
|
let firstname = name.map(|e| e.first.clone()).unwrap_or("".to_owned());
|
||||||
|
let lastname = name.map(|e| e.last.clone()).unwrap_or("".to_owned());
|
||||||
|
|
||||||
|
csv.push_str(&format!("{id}{seperator}{lastname}{seperator}{firstname}"));
|
||||||
for day in days.iter() {
|
for day in days.iter() {
|
||||||
let was_there: bool = self
|
let was_there: bool = self
|
||||||
.days
|
.days
|
||||||
@ -95,7 +105,7 @@ impl IDStore {
|
|||||||
.contains(user_id);
|
.contains(user_id);
|
||||||
|
|
||||||
if was_there {
|
if was_there {
|
||||||
csv.push_str(&format!("{}x", seperator));
|
csv.push_str(&format!("{seperator}x"));
|
||||||
} else {
|
} else {
|
||||||
csv.push_str(seperator);
|
csv.push_str(seperator);
|
||||||
}
|
}
|
||||||
|
10
src/main.rs
10
src/main.rs
@ -10,7 +10,7 @@ use std::{env, error::Error, sync::Arc};
|
|||||||
use tally_id::TallyID;
|
use tally_id::TallyID;
|
||||||
use tokio::{
|
use tokio::{
|
||||||
fs, join,
|
fs, join,
|
||||||
sync::{Mutex, mpsc},
|
sync::{Mutex, broadcast, mpsc},
|
||||||
};
|
};
|
||||||
use webserver::start_webserver;
|
use webserver::start_webserver;
|
||||||
|
|
||||||
@ -20,6 +20,7 @@ use mock::{MockBuzzer, MockHotspot, MockLed};
|
|||||||
mod buzzer;
|
mod buzzer;
|
||||||
mod color;
|
mod color;
|
||||||
mod hotspot;
|
mod hotspot;
|
||||||
|
mod id_mapping;
|
||||||
mod id_store;
|
mod id_store;
|
||||||
mod led;
|
mod led;
|
||||||
mod mock;
|
mod mock;
|
||||||
@ -138,7 +139,8 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
|
|
||||||
info!("Starting application");
|
info!("Starting application");
|
||||||
|
|
||||||
let (tx, mut rx) = mpsc::channel::<String>(32);
|
let (tx, mut rx) = broadcast::channel::<String>(32);
|
||||||
|
let sse_tx = tx.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
match run_pm3(tx).await {
|
match run_pm3(tx).await {
|
||||||
@ -178,7 +180,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
|
|
||||||
let channel_store = store.clone();
|
let channel_store = store.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Some(tally_id_string) = rx.recv().await {
|
while let Ok(tally_id_string) = rx.recv().await {
|
||||||
let tally_id = TallyID(tally_id_string);
|
let tally_id = TallyID(tally_id_string);
|
||||||
|
|
||||||
if hotspot_ids.contains(&tally_id) {
|
if hotspot_ids.contains(&tally_id) {
|
||||||
@ -203,7 +205,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
match start_webserver(store.clone()).await {
|
match start_webserver(store.clone(), sse_tx).await {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to start webserver: {e}");
|
error!("Failed to start webserver: {e}");
|
||||||
|
@ -4,12 +4,12 @@ use std::error::Error;
|
|||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
/// Runs the pm3 binary and monitors it's output
|
/// Runs the pm3 binary and monitors it's output
|
||||||
/// The pm3 binary is ether set in the env var PM3_BIN or found in the path
|
/// The pm3 binary is ether set in the env var PM3_BIN or found in the path
|
||||||
/// The ouput is parsed and send via the `tx` channel
|
/// The ouput is parsed and send via the `tx` channel
|
||||||
pub async fn run_pm3(tx: mpsc::Sender<String>) -> Result<(), Box<dyn Error>> {
|
pub async fn run_pm3(tx: broadcast::Sender<String>) -> Result<(), Box<dyn Error>> {
|
||||||
let pm3_path = match env::var("PM3_BIN") {
|
let pm3_path = match env::var("PM3_BIN") {
|
||||||
Ok(path) => path,
|
Ok(path) => path,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@ -35,7 +35,7 @@ pub async fn run_pm3(tx: mpsc::Sender<String>) -> Result<(), Box<dyn Error>> {
|
|||||||
while let Some(line) = reader.next_line().await? {
|
while let Some(line) = reader.next_line().await? {
|
||||||
trace!("PM3: {line}");
|
trace!("PM3: {line}");
|
||||||
if let Some(uid) = super::parser::parse_line(&line) {
|
if let Some(uid) = super::parser::parse_line(&line) {
|
||||||
tx.send(uid).await?;
|
tx.send(uid)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,21 +1,36 @@
|
|||||||
use log::{error, info, warn};
|
use log::{error, info, warn};
|
||||||
use rocket::http::Status;
|
use rocket::http::Status;
|
||||||
use rocket::{Config, State};
|
use rocket::response::stream::{Event, EventStream};
|
||||||
|
use rocket::serde::json::Json;
|
||||||
|
use rocket::{Config, State, post};
|
||||||
use rocket::{get, http::ContentType, response::content::RawHtml, routes};
|
use rocket::{get, http::ContentType, response::content::RawHtml, routes};
|
||||||
use rust_embed::Embed;
|
use rust_embed::Embed;
|
||||||
|
use serde::Deserialize;
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
use tokio::sync::broadcast::Sender;
|
||||||
|
|
||||||
|
use crate::id_mapping::{IDMapping, Name};
|
||||||
use crate::id_store::IDStore;
|
use crate::id_store::IDStore;
|
||||||
|
use crate::tally_id::TallyID;
|
||||||
|
|
||||||
#[derive(Embed)]
|
#[derive(Embed)]
|
||||||
#[folder = "web/dist"]
|
#[folder = "web/dist"]
|
||||||
struct Asset;
|
struct Asset;
|
||||||
|
|
||||||
pub async fn start_webserver(store: Arc<Mutex<IDStore>>) -> Result<(), rocket::Error> {
|
#[derive(Deserialize)]
|
||||||
|
struct NewMapping {
|
||||||
|
id: String,
|
||||||
|
name: Name,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start_webserver(
|
||||||
|
store: Arc<Mutex<IDStore>>,
|
||||||
|
sse_broadcaster: Sender<String>,
|
||||||
|
) -> Result<(), rocket::Error> {
|
||||||
let port = match env::var("HTTP_PORT") {
|
let port = match env::var("HTTP_PORT") {
|
||||||
Ok(port) => port.parse().unwrap_or_else(|_| {
|
Ok(port) => port.parse().unwrap_or_else(|_| {
|
||||||
warn!("Failed to parse HTTP_PORT. Using default 80");
|
warn!("Failed to parse HTTP_PORT. Using default 80");
|
||||||
@ -31,8 +46,19 @@ pub async fn start_webserver(store: Arc<Mutex<IDStore>>) -> Result<(), rocket::E
|
|||||||
};
|
};
|
||||||
|
|
||||||
rocket::custom(config)
|
rocket::custom(config)
|
||||||
.mount("/", routes![static_files, index, export_csv])
|
.mount(
|
||||||
|
"/",
|
||||||
|
routes![
|
||||||
|
static_files,
|
||||||
|
index,
|
||||||
|
export_csv,
|
||||||
|
id_event,
|
||||||
|
get_mapping,
|
||||||
|
add_mapping
|
||||||
|
],
|
||||||
|
)
|
||||||
.manage(store)
|
.manage(store)
|
||||||
|
.manage(sse_broadcaster)
|
||||||
.launch()
|
.launch()
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -57,14 +83,50 @@ fn static_files(file: std::path::PathBuf) -> Option<(ContentType, Vec<u8>)> {
|
|||||||
Some((content_type, asset.data.into_owned()))
|
Some((content_type, asset.data.into_owned()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[get("/api/idevent")]
|
||||||
|
fn id_event(sse_broadcaster: &State<Sender<String>>) -> EventStream![] {
|
||||||
|
let mut rx = sse_broadcaster.subscribe();
|
||||||
|
EventStream! {
|
||||||
|
loop {
|
||||||
|
let msg = rx.recv().await;
|
||||||
|
if let Ok(id) = msg {
|
||||||
|
yield Event::data(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[get("/api/csv")]
|
#[get("/api/csv")]
|
||||||
async fn export_csv(manager: &State<Arc<Mutex<IDStore>>>) -> Result<String, Status> {
|
async fn export_csv(manager: &State<Arc<Mutex<IDStore>>>) -> Result<String, Status> {
|
||||||
info!("Exporting CSV");
|
info!("Exporting CSV");
|
||||||
match manager.lock().await.export_csv() {
|
match manager.lock().await.export_csv() {
|
||||||
Ok(csv) => Ok(csv),
|
Ok(csv) => Ok(csv),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to generate csv: {}", e);
|
error!("Failed to generate csv: {e}");
|
||||||
Err(Status::InternalServerError)
|
Err(Status::InternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[get("/api/mapping")]
|
||||||
|
async fn get_mapping(store: &State<Arc<Mutex<IDStore>>>) -> Json<IDMapping> {
|
||||||
|
Json(store.lock().await.mapping.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/api/mapping", format = "json", data = "<new_mapping>")]
|
||||||
|
async fn add_mapping(store: &State<Arc<Mutex<IDStore>>>, new_mapping: Json<NewMapping>) -> Status {
|
||||||
|
if new_mapping.id.is_empty()
|
||||||
|
|| new_mapping.name.first.is_empty()
|
||||||
|
|| new_mapping.name.last.is_empty()
|
||||||
|
{
|
||||||
|
return Status::BadRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
store
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.mapping
|
||||||
|
.add_mapping(TallyID(new_mapping.id.clone()), new_mapping.name.clone());
|
||||||
|
|
||||||
|
Status::Created
|
||||||
|
}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
|
<head>
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||||
@ -10,19 +9,9 @@
|
|||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
<title>Anwesenheit</title>
|
<title>Anwesenheit</title>
|
||||||
</head>
|
</head>
|
||||||
|
<body>
|
||||||
<body class="bg-gradient-to-br from-blue-100 to-indigo-200 min-h-screen flex flex-col items-center justify-start py-10">
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
<div class="text-center space-y-6 mb-10">
|
</body>
|
||||||
<h1 class="text-3xl sm:text-4xl font-bold text-gray-800">
|
|
||||||
Anwesenheit
|
|
||||||
</div>
|
|
||||||
<a class="px-6 py-3 text-lg font-semibold text-white bg-indigo-600 rounded-2xl shadow-md hover:bg-indigo-700 transition"
|
|
||||||
href="/api/csv" download="anwesenheit.csv">
|
|
||||||
Download CSV
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
1144
web/package-lock.json
generated
1144
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -5,15 +5,20 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "~5.7.2",
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
"vite": "^6.2.0"
|
"@tsconfig/svelte": "^5.0.4",
|
||||||
|
"svelte": "^5.28.1",
|
||||||
|
"svelte-check": "^4.1.6",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"vite": "^6.3.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.4",
|
"@tailwindcss/vite": "^4.1.7",
|
||||||
"tailwindcss": "^4.1.4"
|
"tailwindcss": "^4.1.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
54
web/src/App.svelte
Normal file
54
web/src/App.svelte
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import IDTable from "./lib/IDTable.svelte";
|
||||||
|
import LastId from "./lib/LastID.svelte";
|
||||||
|
import AddIDModal from "./lib/AddIDModal.svelte";
|
||||||
|
|
||||||
|
let lastID: string = $state("");
|
||||||
|
|
||||||
|
let addModal: AddIDModal;
|
||||||
|
let idTable: IDTable;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
let sse = new EventSource("/api/idevent");
|
||||||
|
|
||||||
|
sse.onmessage = (e) => {
|
||||||
|
lastID = e.data;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main
|
||||||
|
class="bg-gradient-to-br from-blue-100 to-indigo-200 min-h-screen flex flex-col items-center justify-start py-10"
|
||||||
|
>
|
||||||
|
<div class="text-center space-y-6 mb-10">
|
||||||
|
<h1 class="text-3xl sm:text-4xl font-bold text-gray-800">Anwesenheit</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="px-6 py-3 text-lg font-semibold text-white bg-indigo-600 rounded-2xl shadow-md hover:bg-indigo-700 transition"
|
||||||
|
href="/api/csv"
|
||||||
|
download="anwesenheit.csv"
|
||||||
|
>
|
||||||
|
Download CSV
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="pt-3 pb-2">
|
||||||
|
<LastId
|
||||||
|
id={lastID}
|
||||||
|
onAdd={(id) => {
|
||||||
|
addModal.open(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<IDTable bind:this={idTable} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AddIDModal
|
||||||
|
bind:this={addModal}
|
||||||
|
onSubmitted={() => {
|
||||||
|
idTable.reloadData();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</main>
|
90
web/src/lib/AddIDModal.svelte
Normal file
90
web/src/lib/AddIDModal.svelte
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Modal from "./Modal.svelte";
|
||||||
|
|
||||||
|
let { onSubmitted }: { onSubmitted?: () => void } = $props();
|
||||||
|
|
||||||
|
let displayID = $state("");
|
||||||
|
let firstName = $state("");
|
||||||
|
let lastName = $state("");
|
||||||
|
|
||||||
|
let modal: Modal;
|
||||||
|
|
||||||
|
export function open(id: string) {
|
||||||
|
displayID = id;
|
||||||
|
modal.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onsubmit() {
|
||||||
|
let data = {
|
||||||
|
id: displayID,
|
||||||
|
name: {
|
||||||
|
first: firstName,
|
||||||
|
last: lastName,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch("/api/mapping", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}).then(() => {
|
||||||
|
onSubmitted?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
firstName = "";
|
||||||
|
lastName = "";
|
||||||
|
displayID = "";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal bind:this={modal}>
|
||||||
|
<form method="dialog" {onsubmit} class="flex flex-col">
|
||||||
|
<label class="form-row">
|
||||||
|
<span>ID:</span>
|
||||||
|
<input type="text" class="form-input" required bind:value={displayID} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-row">
|
||||||
|
<span>Vorname:</span>
|
||||||
|
<input type="text" class="form-input" required bind:value={firstName} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-row">
|
||||||
|
<span>Nachname:</span>
|
||||||
|
<input type="text" class="form-input" required bind:value={lastName} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex justify-end mt-3">
|
||||||
|
<button
|
||||||
|
type="reset"
|
||||||
|
class="mr-5 px-2 py-1 bg-red-500 rounded-2xl shadow-md"
|
||||||
|
onclick={() => {
|
||||||
|
modal.close();
|
||||||
|
|
||||||
|
firstName = "";
|
||||||
|
lastName = "";
|
||||||
|
displayID = "";
|
||||||
|
}}>Abbrechen</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-2 py-1 bg-indigo-600 rounded-2xl shadow-md hover:bg-indigo-700 transition"
|
||||||
|
>Hinzufügen</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "../app.css";
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
@apply flex justify-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
@apply ml-10 border-b-1;
|
||||||
|
}
|
||||||
|
</style>
|
32
web/src/lib/IDMapping.ts
Normal file
32
web/src/lib/IDMapping.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
export interface IDMapping {
|
||||||
|
id_map: IDMap
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDMap {
|
||||||
|
[name: string]: Name
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Name {
|
||||||
|
first: string,
|
||||||
|
last: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addMapping(id: string, firstName: string, lastName: string) {
|
||||||
|
let req = await fetch("/api/mapping", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-type": "application/json; charset=UTF-8"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
id,
|
||||||
|
name: {
|
||||||
|
first: firstName,
|
||||||
|
lastName: lastName,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.status != 200) {
|
||||||
|
console.error(await req.text())
|
||||||
|
}
|
||||||
|
}
|
106
web/src/lib/IDTable.svelte
Normal file
106
web/src/lib/IDTable.svelte
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import type { IDMapping } from "./IDMapping";
|
||||||
|
let data: IDMapping | undefined = $state();
|
||||||
|
|
||||||
|
export async function reloadData() {
|
||||||
|
let res = await fetch("/api/mapping");
|
||||||
|
|
||||||
|
data = await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = $derived(
|
||||||
|
data
|
||||||
|
? Object.entries(data.id_map).map(([id, value]) => ({
|
||||||
|
id,
|
||||||
|
...value,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
let sortKey: keyof (typeof rows)[0] = $state("last");
|
||||||
|
let sortDirection: "asc" | "desc" = $state("asc");
|
||||||
|
|
||||||
|
let rowsSorted = $derived(
|
||||||
|
[...rows].sort((a, b) => {
|
||||||
|
let cmp = String(a[sortKey]).localeCompare(String(b[sortKey]));
|
||||||
|
return sortDirection === "asc" ? cmp : -cmp;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSortClick(key: keyof (typeof rows)[0]) {
|
||||||
|
if (sortKey === key) {
|
||||||
|
sortDirection = sortDirection === "asc" ? "desc" : "asc";
|
||||||
|
} else {
|
||||||
|
sortKey = key;
|
||||||
|
sortDirection = "asc";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function indicator(key: keyof (typeof rows)[0]) {
|
||||||
|
if (sortKey !== key) return "";
|
||||||
|
return sortDirection === "asc" ? "▲" : "▼";
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await reloadData();
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if data == null}
|
||||||
|
Loading...
|
||||||
|
{:else}
|
||||||
|
<div class="bg-indigo-500 py-2 rounded-2xl overflow-x-auto">
|
||||||
|
<table class="px-10">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
class="text-left pr-5 pl-2 cursor-pointer select-none"
|
||||||
|
onclick={() => {
|
||||||
|
handleSortClick("id");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ID
|
||||||
|
<span class="indicator">{indicator("id")}</span>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="text-left pr-5 cursor-pointer select-none"
|
||||||
|
onclick={() => {
|
||||||
|
handleSortClick("last");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Nachname
|
||||||
|
<span class="indicator">{indicator("last")}</span>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="text-left pr-5 cursor-pointer select-none"
|
||||||
|
onclick={() => {
|
||||||
|
handleSortClick("first");
|
||||||
|
}}
|
||||||
|
>Vorname
|
||||||
|
|
||||||
|
<span class="indicator">{indicator("first")}</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each rowsSorted as row}
|
||||||
|
<tr class="even:bg-indigo-600">
|
||||||
|
<td class="whitespace-nowrap pr-5 pl-2 py-1">{row.id}</td>
|
||||||
|
<td class="whitespace-nowrap pr-5">{row.last}</td>
|
||||||
|
<td class="whitespace-nowrap pr-5">{row.first}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style lang="css" scoped>
|
||||||
|
@reference "../app.css";
|
||||||
|
|
||||||
|
.indicator {
|
||||||
|
@apply ml-1 w-4 inline-block;
|
||||||
|
}
|
||||||
|
</style>
|
57
web/src/lib/LastID.svelte
Normal file
57
web/src/lib/LastID.svelte
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { id, onAdd }: { id: string; onAdd?: (id: string) => void } = $props();
|
||||||
|
|
||||||
|
let lastID = id;
|
||||||
|
let flashing = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (lastID != id) {
|
||||||
|
flashing = true;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
flashing = false;
|
||||||
|
}, 1100);
|
||||||
|
}
|
||||||
|
lastID = id;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class=" text-xl text-center">
|
||||||
|
Letzte ID
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<span
|
||||||
|
class="{flashing
|
||||||
|
? 'flash'
|
||||||
|
: ''} font-bold rounded-md px-1 font-mono min-w-36">{id}</span
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="bg-indigo-500 rounded-2xl px-2 cursor-pointer mx-2"
|
||||||
|
onclick={() => {
|
||||||
|
if (onAdd && id != "") {
|
||||||
|
onAdd(id);
|
||||||
|
}
|
||||||
|
}}>+</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.flash {
|
||||||
|
animation: flash-green 1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes flash-green {
|
||||||
|
0% {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
background-color: oklch(59.6% 0.145 163.225);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
background-color: oklch(59.6% 0.145 163.225);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
33
web/src/lib/Modal.svelte
Normal file
33
web/src/lib/Modal.svelte
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
let { children }: { children: Snippet } = $props();
|
||||||
|
|
||||||
|
let dialog: HTMLDialogElement;
|
||||||
|
|
||||||
|
export function open(){
|
||||||
|
dialog.showModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function close(){
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog
|
||||||
|
bind:this={dialog}
|
||||||
|
closedby="any"
|
||||||
|
class="bg-gradient-to-br from-blue-100 to-indigo-200 p-5 center-dialog rounded-2xl backdrop:bg-black/50 backdrop:backdrop-blur-xs"
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.center-dialog {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
@ -1,2 +1,9 @@
|
|||||||
import './style.css'
|
import { mount } from "svelte"
|
||||||
|
import "./app.css"
|
||||||
|
import App from "./App.svelte"
|
||||||
|
|
||||||
|
const app = mount(App, {
|
||||||
|
target: document.getElementById('app')!,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app
|
||||||
|
1
web/src/vite-env.d.ts
vendored
1
web/src/vite-env.d.ts
vendored
@ -1 +1,2 @@
|
|||||||
|
/// <reference types="svelte" />
|
||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
7
web/svelte.config.js
Normal file
7
web/svelte.config.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
|
||||||
|
// for more information about preprocessors
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
}
|
20
web/tsconfig.app.json
Normal file
20
web/tsconfig.app.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
/**
|
||||||
|
* Typecheck JS in `.svelte` and `.js` files by default.
|
||||||
|
* Disable checkJs if you'd like to use dynamic types in JS.
|
||||||
|
* Note that setting allowJs false does not prevent the use
|
||||||
|
* of JS in `.svelte` files.
|
||||||
|
*/
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
|
||||||
|
}
|
@ -1,24 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"files": [],
|
||||||
"target": "ES2020",
|
"references": [
|
||||||
"useDefineForClassFields": true,
|
{ "path": "./tsconfig.app.json" },
|
||||||
"module": "ESNext",
|
{ "path": "./tsconfig.node.json" }
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
]
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noUncheckedSideEffectImports": true
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
}
|
||||||
|
25
web/tsconfig.node.json
Normal file
25
web/tsconfig.node.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
@ -1,8 +1,18 @@
|
|||||||
import { defineConfig } from "vite"
|
import { defineConfig } from "vite"
|
||||||
import tailwindcss from "@tailwindcss/vite"
|
import { svelte } from "@sveltejs/vite-plugin-svelte"
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
|
svelte()
|
||||||
],
|
],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://localhost:8080",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
Loading…
x
Reference in New Issue
Block a user