use std::{net::SocketAddr, path::PathBuf, sync::Arc};

use anyhow::{Context, Result};
use axum::{
    Json, Router,
    body::Body,
    extract::{DefaultBodyLimit, Multipart, Path, State},
    http::{HeaderValue, StatusCode, header},
    response::{Html, IntoResponse, Response},
    routing::{get, post},
};
use chrono::Local;
use clap::Parser;
use image::{DynamicImage, ImageFormat, RgbImage, imageops::FilterType};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tokio::fs;
use tracing::info;

const DISPLAY_WIDTH: u32 = 960;
const DISPLAY_HEIGHT: u32 = 376;

#[derive(Parser, Debug)]
#[command(author, version, about)]
struct Cli {
    #[arg(long, default_value = "0.0.0.0:8080")]
    bind: String,
    #[arg(long, default_value = "/config")]
    config_dir: PathBuf,
}

#[derive(Clone)]
struct AppState {
    monitor_path: PathBuf,
    image_dir: PathBuf,
}

#[derive(Serialize)]
struct StateResponse {
    monitor_path: String,
    image_dir: String,
    setup: SetupView,
    active_images: Vec<String>,
    images: Vec<ImageView>,
}

#[derive(Serialize)]
struct SetupView {
    custom_panel: bool,
    switch_time: String,
}

#[derive(Serialize)]
struct ImageView {
    name: String,
    size: u64,
    url: String,
}

#[derive(Deserialize)]
struct ActivateRequest {
    images: Vec<String>,
    switch_time: Option<u32>,
}

#[derive(Deserialize)]
struct DeleteRequest {
    name: String,
}

#[derive(Serialize)]
struct ErrorResponse {
    error: String,
}

#[tokio::main]
async fn main() -> Result<()> {
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "info,aster_webui=debug".into()),
        )
        .init();

    let cli = Cli::parse();
    let addr: SocketAddr = cli
        .bind
        .parse()
        .with_context(|| format!("Invalid bind address: {}", cli.bind))?;

    let state = Arc::new(AppState {
        monitor_path: cli.config_dir.join("Monitor3.json"),
        image_dir: cli.config_dir.join("img"),
    });

    ensure_layout(&state).await?;

    let app = Router::new()
        .route("/", get(index))
        .route("/healthz", get(healthz))
        .route("/api/state", get(api_state))
        .route("/api/upload", post(api_upload))
        .route("/api/panels/activate", post(api_activate))
        .route("/api/panels/disable", post(api_disable))
        .route("/api/images/delete", post(api_delete))
        .route("/api/images/{name}", get(api_image))
        .layer(DefaultBodyLimit::max(64 * 1024 * 1024))
        .with_state(state);

    info!("Starting aster-webui on {addr}");
    let listener = tokio::net::TcpListener::bind(addr).await?;
    axum::serve(listener, app).await?;
    Ok(())
}

async fn ensure_layout(state: &AppState) -> Result<()> {
    fs::create_dir_all(&state.image_dir).await?;

    if fs::try_exists(&state.monitor_path).await? {
        return Ok(());
    }

    let default = json!({
        "credentials": {
            "username": "admin",
            "password": "123456"
        },
        "setup": {
            "type": 1,
            "offDisplay": true,
            "controlParams": true,
            "controlDiskTemp": true,
            "customPanel": false,
            "language": 1,
            "switchTime": "10",
            "operationMode": 0,
            "theme": 1,
            "diskUpdate": 300,
            "ha_url": "",
            "ha_token": "",
            "refresh": 1
        },
        "mianban": [],
        "diy": []
    });

    save_monitor_json(state, &default).await
}

async fn load_monitor_json(state: &AppState) -> Result<Value> {
    let raw = fs::read_to_string(&state.monitor_path)
        .await
        .with_context(|| format!("Failed to read {:?}", state.monitor_path))?;
    serde_json::from_str(&raw).context("Failed to parse Monitor3.json")
}

async fn save_monitor_json(state: &AppState, value: &Value) -> Result<()> {
    let payload = serde_json::to_string_pretty(value)?;
    fs::write(&state.monitor_path, payload)
        .await
        .with_context(|| format!("Failed to write {:?}", state.monitor_path))
}

fn error_response(status: StatusCode, message: impl Into<String>) -> Response {
    let body = Json(ErrorResponse {
        error: message.into(),
    });
    (status, body).into_response()
}

async fn index() -> Html<&'static str> {
    Html(INDEX_HTML)
}

async fn healthz() -> Json<Value> {
    Json(json!({"ok": true}))
}

async fn api_state(State(state): State<Arc<AppState>>) -> Response {
    match build_state_response(&state).await {
        Ok(payload) => Json(payload).into_response(),
        Err(err) => error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
    }
}

async fn api_upload(State(state): State<Arc<AppState>>, mut multipart: Multipart) -> Response {
    let mut stored = None;

    while let Ok(Some(field)) = multipart.next_field().await {
        if field.name() != Some("file") {
            continue;
        }

        let file_name = field
            .file_name()
            .map(ToString::to_string)
            .unwrap_or_else(|| "panel".into());
        let bytes = match field.bytes().await {
            Ok(bytes) => bytes,
            Err(err) => {
                return error_response(StatusCode::BAD_REQUEST, format!("Upload failed: {err}"));
            }
        };

        match convert_and_store_image(&state, &file_name, &bytes).await {
            Ok(name) => stored = Some(name),
            Err(err) => return error_response(StatusCode::BAD_REQUEST, err.to_string()),
        }
    }

    match stored {
        Some(name) => Json(json!({ "ok": true, "name": name })).into_response(),
        None => error_response(StatusCode::BAD_REQUEST, "No file field provided"),
    }
}

async fn api_activate(
    State(state): State<Arc<AppState>>,
    Json(payload): Json<ActivateRequest>,
) -> Response {
    let switch_time = payload.switch_time.unwrap_or(10).clamp(1, 600);
    let available = match list_images(&state).await {
        Ok(items) => items,
        Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
    };

    let available_names: Vec<String> = available.into_iter().map(|item| item.name).collect();
    let mut valid = Vec::new();
    for name in payload.images {
        if available_names.iter().any(|candidate| candidate == &name) && !valid.contains(&name) {
            valid.push(name);
        }
    }

    if valid.is_empty() {
        return error_response(StatusCode::BAD_REQUEST, "No valid images selected");
    }

    let mut monitor = match load_monitor_json(&state).await {
        Ok(value) => value,
        Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
    };

    set_custom_panels(&mut monitor, true, switch_time, &valid);

    if let Err(err) = save_monitor_json(&state, &monitor).await {
        return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
    }

    Json(json!({
        "ok": true,
        "switchTime": switch_time,
        "activeImages": valid,
    }))
    .into_response()
}

async fn api_disable(State(state): State<Arc<AppState>>) -> Response {
    let mut monitor = match load_monitor_json(&state).await {
        Ok(value) => value,
        Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
    };

    if let Some(setup) = monitor.get_mut("setup").and_then(Value::as_object_mut) {
        setup.insert("customPanel".into(), Value::Bool(false));
    }

    if let Err(err) = save_monitor_json(&state, &monitor).await {
        return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
    }

    Json(json!({ "ok": true })).into_response()
}

async fn api_delete(
    State(state): State<Arc<AppState>>,
    Json(payload): Json<DeleteRequest>,
) -> Response {
    if payload.name.contains('/') || payload.name.contains('\\') {
        return error_response(StatusCode::BAD_REQUEST, "Invalid file name");
    }

    let path = state.image_dir.join(&payload.name);
    match fs::remove_file(&path).await {
        Ok(_) => {}
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
            return error_response(StatusCode::NOT_FOUND, "Image not found");
        }
        Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
    }

    let mut monitor = match load_monitor_json(&state).await {
        Ok(value) => value,
        Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
    };

    let mut active_images = current_active_images(&monitor);
    let switch_time = current_switch_time(&monitor);
    let before = active_images.len();
    active_images.retain(|name| name != &payload.name);
    if active_images.len() != before {
        set_custom_panels(
            &mut monitor,
            !active_images.is_empty(),
            switch_time,
            &active_images,
        );
        if let Err(err) = save_monitor_json(&state, &monitor).await {
            return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
        }
    }

    Json(json!({ "ok": true })).into_response()
}

async fn api_image(Path(name): Path<String>, State(state): State<Arc<AppState>>) -> Response {
    if name.contains('/') || name.contains('\\') {
        return error_response(StatusCode::BAD_REQUEST, "Invalid file name");
    }

    let path = state.image_dir.join(&name);
    let bytes = match fs::read(&path).await {
        Ok(bytes) => bytes,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
            return error_response(StatusCode::NOT_FOUND, "Image not found");
        }
        Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
    };

    let mime = mime_guess::from_path(&path).first_or_octet_stream();
    let mut response = Response::new(Body::from(bytes));
    response.headers_mut().insert(
        header::CONTENT_TYPE,
        HeaderValue::from_str(mime.as_ref()).unwrap_or(HeaderValue::from_static("application/octet-stream")),
    );
    response
}

async fn build_state_response(state: &AppState) -> Result<StateResponse> {
    let monitor = load_monitor_json(state).await?;
    let setup = monitor
        .get("setup")
        .and_then(Value::as_object)
        .cloned()
        .unwrap_or_default();

    let custom_panel = setup
        .get("customPanel")
        .and_then(Value::as_bool)
        .unwrap_or(false);
    let switch_time = setup
        .get("switchTime")
        .and_then(Value::as_str)
        .unwrap_or("10")
        .to_string();

    Ok(StateResponse {
        monitor_path: state.monitor_path.display().to_string(),
        image_dir: state.image_dir.display().to_string(),
        setup: SetupView {
            custom_panel,
            switch_time,
        },
        active_images: current_active_images(&monitor),
        images: list_images(state).await?,
    })
}

async fn list_images(state: &AppState) -> Result<Vec<ImageView>> {
    let mut out = Vec::new();
    let mut dir = fs::read_dir(&state.image_dir).await?;
    while let Some(entry) = dir.next_entry().await? {
        let path = entry.path();
        let file_type = entry.file_type().await?;
        if !file_type.is_file() {
            continue;
        }
        let Some(name) = path.file_name().map(|name| name.to_string_lossy().to_string()) else {
            continue;
        };
        let meta = entry.metadata().await?;
        out.push(ImageView {
            url: format!("/api/images/{name}"),
            name,
            size: meta.len(),
        });
    }
    out.sort_by(|a, b| a.name.cmp(&b.name));
    Ok(out)
}

fn current_active_images(monitor: &Value) -> Vec<String> {
    monitor
        .get("diy")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
        .filter_map(|panel| panel.get("img").and_then(Value::as_str))
        .map(ToString::to_string)
        .collect()
}

fn current_switch_time(monitor: &Value) -> u32 {
    monitor
        .get("setup")
        .and_then(Value::as_object)
        .and_then(|setup| setup.get("switchTime"))
        .and_then(Value::as_str)
        .and_then(|value| value.parse::<u32>().ok())
        .filter(|value| (1..=600).contains(value))
        .unwrap_or(10)
}

fn set_custom_panels(monitor: &mut Value, enabled: bool, switch_time: u32, images: &[String]) {
    let setup = monitor
        .as_object_mut()
        .expect("monitor config must be an object")
        .entry("setup")
        .or_insert_with(|| Value::Object(Default::default()));

    if let Some(setup) = setup.as_object_mut() {
        setup.insert("customPanel".into(), Value::Bool(enabled));
        setup.insert("switchTime".into(), Value::String(switch_time.to_string()));
    }

    monitor["mianban"] = Value::Array(
        (1..=images.len())
            .map(|index| Value::Number((index as u64).into()))
            .collect(),
    );
    monitor["diy"] = Value::Array(
        images
            .iter()
            .map(|name| json!({"type": 5, "img": name, "sensor": []}))
            .collect(),
    );
}

async fn convert_and_store_image(state: &AppState, file_name: &str, bytes: &[u8]) -> Result<String> {
    let image = image::load_from_memory(bytes)
        .with_context(|| format!("Unsupported image format: {file_name}"))?;
    let target_name = make_image_name(file_name);
    let path = state.image_dir.join(&target_name);
    let rendered = render_display_panel(&image);
    rendered
        .save_with_format(&path, ImageFormat::Jpeg)
        .with_context(|| format!("Failed to save {}", path.display()))?;
    Ok(target_name)
}

fn render_display_panel(image: &DynamicImage) -> RgbImage {
    let resized = image.resize(DISPLAY_WIDTH, DISPLAY_HEIGHT, FilterType::Lanczos3);
    let rgb = resized.to_rgb8();

    let mut canvas = RgbImage::from_pixel(
        DISPLAY_WIDTH,
        DISPLAY_HEIGHT,
        image::Rgb([8, 10, 12]),
    );

    let offset_x = ((DISPLAY_WIDTH - rgb.width()) / 2) as i64;
    let offset_y = ((DISPLAY_HEIGHT - rgb.height()) / 2) as i64;
    image::imageops::overlay(&mut canvas, &rgb, offset_x, offset_y);
    canvas
}

fn make_image_name(file_name: &str) -> String {
    let base = file_name
        .rsplit_once('.')
        .map(|(name, _)| name)
        .unwrap_or(file_name);
    let clean: String = base
        .chars()
        .map(|ch| match ch {
            'a'..='z' | 'A'..='Z' | '0'..='9' => ch.to_ascii_lowercase(),
            _ => '-',
        })
        .collect();
    let clean = clean.trim_matches('-');
    let clean = if clean.is_empty() { "panel" } else { clean };
    let timestamp = Local::now().format("%Y%m%d-%H%M%S");
    format!("panel-{timestamp}-{clean}.jpg")
}

const INDEX_HTML: &str = r##"<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>aster-webui</title>
  <style>
    :root {
      --bg: #0d1117;
      --panel: #121821;
      --line: #243243;
      --text: #edf2f7;
      --muted: #92a3b5;
      --accent: #7ce7bf;
      --accent-2: #d8fd72;
      --danger: #ff7f73;
    }
    * { box-sizing: border-box; }
    body {
      margin: 0;
      background:
        radial-gradient(circle at top left, rgba(124,231,191,.15), transparent 30%),
        radial-gradient(circle at bottom right, rgba(216,253,114,.12), transparent 25%),
        var(--bg);
      color: var(--text);
      font-family: "IBM Plex Sans", sans-serif;
    }
    main { width: min(1320px, calc(100% - 32px)); margin: 0 auto; padding: 24px 0 36px; }
    .hero, .content { display: grid; gap: 18px; }
    .hero { grid-template-columns: 1.5fr 1fr; margin-bottom: 18px; }
    .card {
      background: linear-gradient(180deg, rgba(16,22,30,.98), rgba(19,25,35,.9));
      border: 1px solid var(--line);
      border-radius: 26px;
      padding: 22px;
    }
    .hero h1 { margin: 0 0 8px; font-size: clamp(2rem, 3vw, 3.4rem); line-height: 1; }
    .hero p, .muted { color: var(--muted); }
    .status { display: grid; gap: 12px; }
    .status strong { display: block; margin-top: 8px; font-size: 1.5rem; }
    .controls { display: grid; grid-template-columns: 180px 1fr auto; gap: 12px; align-items: end; margin-top: 16px; }
    label { display: grid; gap: 8px; color: var(--muted); font-size: .9rem; }
    input, button {
      border-radius: 14px;
      border: 1px solid var(--line);
      padding: 12px 14px;
      font: inherit;
    }
    input { background: rgba(6,10,15,.8); color: var(--text); }
    .actions { display: flex; gap: 10px; flex-wrap: wrap; }
    button { cursor: pointer; }
    .primary { background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: #071016; font-weight: 700; }
    .ghost { background: rgba(9,14,20,.7); color: var(--text); }
    .danger { background: rgba(255,127,115,.14); color: #ffc7c1; }
    .content { grid-template-columns: 1.45fr 1fr; }
    .gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
    .tile {
      border: 1px solid var(--line);
      border-radius: 20px;
      overflow: hidden;
      background: rgba(8,12,18,.78);
    }
    .tile.selected { outline: 2px solid var(--accent-2); }
    .tile img { width: 100%; aspect-ratio: 960 / 376; object-fit: cover; display: block; }
    .tile-body { padding: 12px; display: grid; gap: 10px; }
    .tile-title { word-break: break-word; }
    .mini { display: flex; gap: 8px; flex-wrap: wrap; }
    .order { display: grid; gap: 10px; }
    .order-item {
      display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: center;
      border: 1px solid var(--line); border-radius: 18px; padding: 12px 14px; background: rgba(8,12,18,.78);
    }
    .index { color: var(--accent-2); font-weight: 700; }
    .toast {
      position: fixed; bottom: 16px; right: 16px; min-width: 260px; max-width: 420px;
      padding: 14px 16px; border-radius: 14px; border: 1px solid var(--line);
      background: rgba(10,14,20,.94); display: none;
    }
    @media (max-width: 1024px) {
      .hero, .content, .controls { grid-template-columns: 1fr; }
    }
  </style>
</head>
<body>
  <main>
    <section class="hero">
      <div class="card">
        <p class="muted">aoostar-rs fork</p>
        <h1>aster-webui</h1>
        <p>Eigene Rust-WebUI fuer Panelbilder, Rotation und `Monitor3.json`-Steuerung. Uploads werden auf 960x376 JPG normalisiert; animierte GIFs nutzen aktuell nur das erste Frame.</p>
        <div class="controls">
          <label>
            <span>Switch Time</span>
            <input id="switchTime" type="number" min="1" max="600" value="10">
          </label>
          <label>
            <span>Upload New Source</span>
            <input id="upload" type="file" accept="image/*">
          </label>
          <div class="actions">
            <button id="apply" class="primary" type="button">Apply Rotation</button>
            <button id="disable" class="danger" type="button">Disable</button>
            <button id="refresh" class="ghost" type="button">Refresh</button>
          </div>
        </div>
      </div>
      <div class="status">
        <div class="card"><span class="muted">Custom Panel</span><strong id="customPanelValue">-</strong></div>
        <div class="card"><span class="muted">Active Images</span><strong id="imageCountValue">-</strong></div>
        <div class="card"><span class="muted">Config Path</span><strong id="configPathValue" style="font-size: 1rem;">-</strong></div>
      </div>
    </section>
    <section class="content">
      <div class="card">
        <h2>Available Images</h2>
        <div id="gallery" class="gallery"></div>
      </div>
      <div class="card">
        <h2>Rotation Order</h2>
        <div id="order" class="order"></div>
      </div>
    </section>
  </main>
  <div id="toast" class="toast"></div>
  <script>
    const state = { images: [], activeImages: [], setup: { switch_time: "10", custom_panel: false } };
    const el = {
      gallery: document.getElementById("gallery"),
      order: document.getElementById("order"),
      switchTime: document.getElementById("switchTime"),
      customPanelValue: document.getElementById("customPanelValue"),
      imageCountValue: document.getElementById("imageCountValue"),
      configPathValue: document.getElementById("configPathValue"),
      upload: document.getElementById("upload"),
      apply: document.getElementById("apply"),
      disable: document.getElementById("disable"),
      refresh: document.getElementById("refresh"),
      toast: document.getElementById("toast"),
    };

    function toast(msg) {
      el.toast.textContent = msg;
      el.toast.style.display = "block";
      clearTimeout(toast.timer);
      toast.timer = setTimeout(() => el.toast.style.display = "none", 2600);
    }

    function bytes(n) {
      if (n < 1024) return n + " B";
      if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
      return (n / 1024 / 1024).toFixed(1) + " MB";
    }

    function move(index, delta) {
      const target = index + delta;
      if (target < 0 || target >= state.activeImages.length) return;
      const next = [...state.activeImages];
      const [item] = next.splice(index, 1);
      next.splice(target, 0, item);
      state.activeImages = next;
      render();
    }

    function toggle(name) {
      if (state.activeImages.includes(name)) {
        state.activeImages = state.activeImages.filter((item) => item !== name);
      } else {
        state.activeImages = [...state.activeImages, name];
      }
      render();
    }

    async function request(url, options = {}) {
      const res = await fetch(url, options);
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.error || "request failed");
      return data;
    }

    async function load() {
      const data = await request("/api/state");
      state.images = data.images;
      state.activeImages = data.active_images;
      state.setup = data.setup;
      el.switchTime.value = data.setup.switch_time || "10";
      el.customPanelValue.textContent = data.setup.custom_panel ? "Active" : "Disabled";
      el.imageCountValue.textContent = String(data.active_images.length);
      el.configPathValue.textContent = data.monitor_path;
      render();
    }

    async function uploadFile(event) {
      const file = event.target.files[0];
      if (!file) return;
      const form = new FormData();
      form.append("file", file);
      await request("/api/upload", { method: "POST", body: form });
      event.target.value = "";
      toast("Upload complete");
      await load();
    }

    async function apply() {
      await request("/api/panels/activate", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          images: state.activeImages,
          switch_time: Number.parseInt(el.switchTime.value || "10", 10),
        }),
      });
      toast("Rotation updated");
      await load();
    }

    async function disablePanels() {
      await request("/api/panels/disable", { method: "POST" });
      toast("Custom panels disabled");
      await load();
    }

    async function removeImage(name) {
      await request("/api/images/delete", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ name }),
      });
      toast("Image deleted");
      await load();
    }

    function render() {
      el.gallery.innerHTML = "";
      for (const image of state.images) {
        const selected = state.activeImages.includes(image.name);
        const tile = document.createElement("article");
        tile.className = "tile" + (selected ? " selected" : "");
        tile.innerHTML = `
          <img src="${image.url}" alt="${image.name}">
          <div class="tile-body">
            <div class="tile-title">${image.name}</div>
            <div class="muted">${bytes(image.size)}</div>
            <div class="mini">
              <button class="ghost" type="button">${selected ? "Remove" : "Add"}</button>
              <button class="danger" type="button">Delete</button>
            </div>
          </div>
        `;
        const [toggleBtn, deleteBtn] = tile.querySelectorAll("button");
        toggleBtn.addEventListener("click", () => toggle(image.name));
        deleteBtn.addEventListener("click", () => removeImage(image.name));
        el.gallery.appendChild(tile);
      }

      el.order.innerHTML = "";
      if (!state.activeImages.length) {
        el.order.innerHTML = `<p class="muted">No active images.</p>`;
        return;
      }
      state.activeImages.forEach((name, index) => {
        const row = document.createElement("div");
        row.className = "order-item";
        row.innerHTML = `
          <div class="index">${index + 1}</div>
          <div>${name}</div>
          <div class="mini">
            <button class="ghost" type="button">Up</button>
            <button class="ghost" type="button">Down</button>
            <button class="danger" type="button">Remove</button>
          </div>
        `;
        const [up, down, remove] = row.querySelectorAll("button");
        up.addEventListener("click", () => move(index, -1));
        down.addEventListener("click", () => move(index, 1));
        remove.addEventListener("click", () => toggle(name));
        el.order.appendChild(row);
      });
    }

    el.upload.addEventListener("change", (event) => uploadFile(event).catch((err) => toast(err.message)));
    el.apply.addEventListener("click", () => apply().catch((err) => toast(err.message)));
    el.disable.addEventListener("click", () => disablePanels().catch((err) => toast(err.message)));
    el.refresh.addEventListener("click", () => load().catch((err) => toast(err.message)));

    load().catch((err) => toast(err.message));
  </script>
</body>
</html>
"##;
