diff --git a/src/app.rs b/src/app.rs index 352cf47..b6e9195 100644 --- a/src/app.rs +++ b/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` (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` (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::(); + view! { // injects a stylesheet into the document // id=leptos means cargo-leptos will hot-reload this stylesheet @@ -53,6 +63,7 @@ pub fn App() -> impl IntoView { + } } diff --git a/src/components/header.rs b/src/components/header.rs index 8cafbdc..7716079 100644 --- a/src/components/header.rs +++ b/src/components/header.rs @@ -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::(); + let modal = expect_context::(); + let file_input = NodeRef::::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::(); + 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! {
@@ -46,11 +120,12 @@ pub fn Header () -> impl IntoView {
- - - + + +
+
} }