2025-09-12 04:23:09 +00:00
|
|
|
|
use chrono::TimeDelta;
|
2025-09-13 13:53:48 +00:00
|
|
|
|
use leptos::prelude::*;
|
2026-08-16 08:28:19 +00:00
|
|
|
|
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 };
|
2026-08-16 09:31:22 +00:00
|
|
|
|
use crate::state::undo::HistoryHandle;
|
2025-09-11 07:27:57 +00:00
|
|
|
|
|
2026-08-04 06:21:25 +00:00
|
|
|
|
/// Campaign header: editable name, campaign image, in-game date and
|
2026-08-16 09:31:22 +00:00
|
|
|
|
/// session steppers, the persistence controls, and undo/redo.
|
2026-08-16 08:28:19 +00:00
|
|
|
|
///
|
|
|
|
|
|
/// 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
|
2026-08-16 09:31:22 +00:00
|
|
|
|
/// (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.
|
2025-09-11 07:27:57 +00:00
|
|
|
|
#[component]
|
|
|
|
|
|
pub fn Header () -> impl IntoView {
|
|
|
|
|
|
let state = expect_context::<Context>();
|
2026-08-16 08:28:19 +00:00
|
|
|
|
let modal = expect_context::<ModalState>();
|
2026-08-16 09:31:22 +00:00
|
|
|
|
let history = expect_context::<HistoryHandle>();
|
|
|
|
|
|
let undo_depth = history.undo_depth();
|
|
|
|
|
|
let redo_depth = history.redo_depth();
|
2026-08-16 08:28:19 +00:00
|
|
|
|
let file_input = NodeRef::<leptos::html::Input>::new();
|
2025-09-11 07:27:57 +00:00
|
|
|
|
let campaign = state.campaign();
|
|
|
|
|
|
let date = state.date();
|
2025-09-12 04:23:09 +00:00
|
|
|
|
let session = state.session();
|
|
|
|
|
|
let image = state.campaign_image();
|
|
|
|
|
|
|
2026-08-04 06:21:25 +00:00
|
|
|
|
// Shifts the in-game date by ±1 day (saturating, via checked_add_signed).
|
2025-09-12 04:23:09 +00:00
|
|
|
|
let adjust_day = move |adjustment: i64| date.update(|d| {
|
|
|
|
|
|
*d = d.checked_add_signed(TimeDelta::days(adjustment)).unwrap();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-04 06:21:25 +00:00
|
|
|
|
// Shifts the session counter by ±1, guarding against underflow.
|
2025-09-12 04:23:09 +00:00
|
|
|
|
let adjust_session = move |adjustment: isize| session.update(|s| {
|
|
|
|
|
|
if let Some(new) = s.checked_add_signed(adjustment) {
|
|
|
|
|
|
*s = new;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-16 08:28:19 +00:00
|
|
|
|
// 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("");
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-09-11 07:27:57 +00:00
|
|
|
|
view! {
|
|
|
|
|
|
<header>
|
2025-09-12 04:23:09 +00:00
|
|
|
|
<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>
|
2025-09-12 07:21:25 +00:00
|
|
|
|
<input id="campaign-name" type="text" class="header-input header-input-1 campaign-name" bind:value=campaign/>
|
2025-09-12 04:23:09 +00:00
|
|
|
|
<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">
|
2026-08-16 08:28:19 +00:00
|
|
|
|
<button on:click=on_save>"Сохранить"</button>
|
|
|
|
|
|
<button on:click=on_load>"Загрузить"</button>
|
|
|
|
|
|
<button on:click=on_clear>"Очистить"</button>
|
2026-08-16 09:31:22 +00:00
|
|
|
|
<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>
|
2025-09-12 04:23:09 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
2026-08-16 08:28:19 +00:00
|
|
|
|
<input type="file" accept=".json,application/json" style="display: none" node_ref=file_input on:change=on_file/>
|
2025-09-11 07:27:57 +00:00
|
|
|
|
</header>
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|