aex/src/components/header.rs
yaroslav k 1f3184baf6 undo: header buttons, shortcuts, wiring
- App provides HistoryHandle as context (both build modes; stacks stay
  empty on the server) and passes it to persist::init_hydrated
- persist: init_hydrated starts undo tracking after the localStorage
  load (first captured state = saved state, not mock); clear_all and
  apply_import call undo::before_replace so they are undoable
- header: Отменить/Вернуть buttons with disabled states driven by the
  reactive stack depths; Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y via a window
  keydown listener that skips editable targets (browser owns those)
- SCSS: disabled control buttons dim out
2026-08-16 12:31:22 +03:00

148 lines
6.2 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 };
use crate::state::undo::HistoryHandle;
/// Campaign header: editable name, campaign image, in-game date and
/// session steppers, the persistence controls, and undo/redo.
///
/// 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`) and feed the undo history (see
/// `crate::state::undo`); the undo/redo buttons and the Ctrl+Z /
/// Ctrl+Shift+Z / Ctrl+Y shortcuts revert the last edit bursts.
#[component]
pub fn Header () -> impl IntoView {
let state = expect_context::<Context>();
let modal = expect_context::<ModalState>();
let history = expect_context::<HistoryHandle>();
let undo_depth = history.undo_depth();
let redo_depth = history.redo_depth();
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>
<button
on:click=move |_| history.undo()
disabled=move || undo_depth.get() == 0
title="Отменить (Ctrl+Z)"
>"Отменить"</button>
<button
on:click=move |_| history.redo()
disabled=move || redo_depth.get() == 0
title="Вернуть (Ctrl+Shift+Z или Ctrl+Y)"
>"Вернуть"</button>
</div>
</section>
<input type="file" accept=".json,application/json" style="display: none" node_ref=file_input on:change=on_file/>
</header>
}
}