aex/src/components/header.rs
yaroslav k b9a399be64 header: Save/Load/Clear buttons wired to persistence
Save downloads dashboard.json, Load opens a file picker and imports
through apply_import with errors reported in the modal, Clear asks for
confirmation then wipes to Dashboard::blank(). App provides the store
and ModalState and mounts the modal; init_hydrated starts autosave.
2026-08-16 11:28:19 +03:00

132 lines
5.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use chrono::TimeDelta;
use leptos::prelude::*;
use wasm_bindgen::closure::Closure;
use wasm_bindgen::JsCast;
use crate::components::modal::ModalState;
use crate::prelude::*;
use crate::state::persist::{ apply_import, clear_all, export_download };
/// Campaign header: editable name, campaign image, in-game date and
/// session steppers, and the persistence controls.
///
/// Save downloads the dashboard as `dashboard.json`, Load imports one via
/// a hidden file picker (with a confirm), Clear wipes to the blank
/// campaign (with a confirm). Changes also autosave to localStorage
/// (see `crate::state::persist`).
#[component]
pub fn Header () -> impl IntoView {
let state = expect_context::<Context>();
let modal = expect_context::<ModalState>();
let file_input = NodeRef::<leptos::html::Input>::new();
let campaign = state.campaign();
let date = state.date();
let session = state.session();
let image = state.campaign_image();
// Shifts the in-game date by ±1 day (saturating, via checked_add_signed).
let adjust_day = move |adjustment: i64| date.update(|d| {
*d = d.checked_add_signed(TimeDelta::days(adjustment)).unwrap();
});
// Shifts the session counter by ±1, guarding against underflow.
let adjust_session = move |adjustment: isize| session.update(|s| {
if let Some(new) = s.checked_add_signed(adjustment) {
*s = new;
}
});
// Save: downloads the current dashboard as a JSON file (backup).
let on_save = {
let state = state.clone();
move |_| export_download(&state)
};
// Load: opens the hidden file picker; the picked file is imported.
let on_load = {
let file_input = file_input.clone();
move |_| {
if let Some(input) = file_input.get() {
let _ = input.click();
}
}
};
// Clear: asks for confirmation, then replaces everything with the
// blank dashboard.
let on_clear = {
let state = state.clone();
let modal = modal.clone();
move |_| {
modal.confirm(
"Очистить всё?",
"Текущее состояние будет заменено пустой кампанией. Это действие нельзя отменить.",
"Очистить",
move || clear_all(&state),
);
}
};
// Import: reads the picked file, applies it, reports errors in a modal.
let on_file = {
let state = state.clone();
let modal = modal.clone();
move |ev: Event| {
let Some(target) = ev.target() else { return };
let input = target.unchecked_into::<web_sys::HtmlInputElement>();
let Some(file) = input.files().and_then(|files| files.item(0)) else {
return;
};
let Ok(reader) = web_sys::FileReader::new() else { return };
let reader_c = reader.clone();
let modal = modal.clone();
let onload = Closure::once(move || {
let text = reader_c
.result()
.ok()
.and_then(|value| value.as_string())
.unwrap_or_default();
match apply_import(&state, &text) {
Ok(()) => {}
Err(err) => modal.error("Ошибка импорта", err.to_string()),
}
});
reader.set_onloadend(Some(onload.as_ref().unchecked_ref()));
onload.forget();
let _ = reader.read_as_text(&file);
input.set_value("");
}
};
view! {
<header>
<Show when=move || !image.get().is_empty()>
<div class="campaign-image">
<img src=image.get()/>
</div>
</Show>
<section>
<h5 class="version">aex v{env!("CARGO_PKG_VERSION")} by mk</h5>
<input id="campaign-name" type="text" class="header-input header-input-1 campaign-name" bind:value=campaign/>
<div class="game-date">
<h4>{move || format!("{}", date.get().format("%d/%m/%Y"))}</h4>
<button on:click=move |_| adjust_day(-1) title="Предыдущий день" class="adjust" id="date-back">"<"</button>
<button on:click=move |_| adjust_day( 1) title="Следующий день" class="adjust" id="date-forward">">"</button>
</div>
<div class="session-number">
<h4>Сессия #{move || format!("{}", session.get())}</h4>
<button on:click=move |_| adjust_session(-1) title="Предыдущая сессия" class="adjust" id="session-back">"<"</button>
<button on:click=move |_| adjust_session( 1) title="Следующая сессия" class="adjust" id="session-forward">">"</button>
</div>
<div class="controls">
<button on:click=on_save>"Сохранить"</button>
<button on:click=on_load>"Загрузить"</button>
<button on:click=on_clear>"Очистить"</button>
</div>
</section>
<input type="file" accept=".json,application/json" style="display: none" node_ref=file_input on:change=on_file/>
</header>
}
}