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.
This commit is contained in:
parent
0c2eda7621
commit
b9a399be64
21
src/app.rs
21
src/app.rs
@ -8,6 +8,7 @@ use leptos_router::{
|
||||
use crate::{
|
||||
components::{
|
||||
header::Header,
|
||||
modal::{ Modal, ModalState },
|
||||
nav::Nav,
|
||||
sidebar::Sidebar,
|
||||
Character,
|
||||
@ -19,17 +20,26 @@ use crate::{
|
||||
|
||||
/// Root component of the app.
|
||||
///
|
||||
/// Sets up meta context, creates the `Store<Dashboard>` (mock data for now)
|
||||
/// and injects it as context, then renders the shell: header, sidebar, nav
|
||||
/// and the routed page. Routes: `/` character, `/inv` inventories,
|
||||
/// `/wiki` wiki; anything else falls to `NotFound`.
|
||||
/// Sets up meta context, creates the `Store<Dashboard>` (mock data; the
|
||||
/// hydrated client swaps in the saved state, see `state::persist`) and
|
||||
/// injects it as context, then renders the shell: header, sidebar, nav,
|
||||
/// the routed page and the modal overlay. Routes: `/` character, `/inv`
|
||||
/// inventories, `/wiki` wiki; anything else falls to `NotFound`.
|
||||
#[component]
|
||||
pub fn App() -> impl IntoView {
|
||||
// Provides context that manages stylesheets, titles, meta tags, etc.
|
||||
provide_meta_context();
|
||||
provide_context(Store::new(Dashboard::mock()));
|
||||
let store = Store::new(Dashboard::mock());
|
||||
provide_context(store.clone());
|
||||
provide_context(ModalState::new());
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
// Client-only: load the saved state after mount, then autosave.
|
||||
#[cfg(feature = "hydrate")]
|
||||
crate::state::persist::init_hydrated(&store);
|
||||
|
||||
let modal = expect_context::<ModalState>();
|
||||
|
||||
view! {
|
||||
// injects a stylesheet into the document <head>
|
||||
// id=leptos means cargo-leptos will hot-reload this stylesheet
|
||||
@ -53,6 +63,7 @@ pub fn App() -> impl IntoView {
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
<Modal state=modal />
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,13 +1,24 @@
|
||||
use crate::prelude::*;
|
||||
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 Save/Load/Clear buttons (not wired yet — see
|
||||
/// ROADMAP phase 1).
|
||||
/// 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();
|
||||
@ -25,6 +36,69 @@ pub fn Header () -> impl IntoView {
|
||||
}
|
||||
});
|
||||
|
||||
// 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()>
|
||||
@ -46,11 +120,12 @@ pub fn Header () -> impl IntoView {
|
||||
<button on:click=move |_| adjust_session( 1) title="Следующая сессия" class="adjust" id="session-forward">">"</button>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button>Save</button>
|
||||
<button>Load</button>
|
||||
<button>Clear</button>
|
||||
<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>
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user