Add print preview modal with hour range selection
Implements comprehensive print functionality for calendar views: - Print preview modal with live preview and zoom controls - Hour range selection for week view (start/end hours) - Print-specific CSS to hide UI elements and optimize layout - Event repositioning to align with visible time labels - Support for both 30-minute and 15-minute time increments - Mouse wheel zoom functionality in preview 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
use crate::components::{
|
||||
CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, ViewMode, WeekView,
|
||||
CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, PrintPreviewModal, ViewMode, WeekView,
|
||||
};
|
||||
use crate::models::ical::VEvent;
|
||||
use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService};
|
||||
@@ -389,6 +389,15 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
})
|
||||
};
|
||||
|
||||
// Handle print calendar preview
|
||||
let show_print_preview = use_state(|| false);
|
||||
let on_print = {
|
||||
let show_print_preview = show_print_preview.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
show_print_preview.set(true);
|
||||
})
|
||||
};
|
||||
|
||||
// Handle drag-to-create event
|
||||
let on_create_event = {
|
||||
let show_create_modal = show_create_modal.clone();
|
||||
@@ -457,6 +466,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
on_today={on_today}
|
||||
time_increment={Some(*time_increment)}
|
||||
on_time_increment_toggle={Some(on_time_increment_toggle)}
|
||||
on_print={Some(on_print)}
|
||||
/>
|
||||
|
||||
{
|
||||
@@ -563,6 +573,32 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
// Print preview modal
|
||||
{
|
||||
if *show_print_preview {
|
||||
html! {
|
||||
<PrintPreviewModal
|
||||
on_close={{
|
||||
let show_print_preview = show_print_preview.clone();
|
||||
Callback::from(move |_| {
|
||||
show_print_preview.set(false);
|
||||
})
|
||||
}}
|
||||
view_mode={props.view.clone()}
|
||||
current_date={*current_date}
|
||||
selected_date={*selected_date}
|
||||
events={(*events).clone()}
|
||||
user_info={props.user_info.clone()}
|
||||
external_calendars={props.external_calendars.clone()}
|
||||
time_increment={*time_increment}
|
||||
today={today}
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ pub struct CalendarHeaderProps {
|
||||
pub time_increment: Option<u32>,
|
||||
#[prop_or_default]
|
||||
pub on_time_increment_toggle: Option<Callback<MouseEvent>>,
|
||||
#[prop_or_default]
|
||||
pub on_print: Option<Callback<MouseEvent>>,
|
||||
}
|
||||
|
||||
#[function_component(CalendarHeader)]
|
||||
@@ -39,6 +41,17 @@ pub fn calendar_header(props: &CalendarHeaderProps) -> Html {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
{
|
||||
if let Some(print_callback) = &props.on_print {
|
||||
html! {
|
||||
<button class="print-button" onclick={print_callback.clone()} title="Print Calendar">
|
||||
{"🖨️"}
|
||||
</button>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<h2 class="month-year">{title}</h2>
|
||||
<div class="header-right">
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod external_calendar_modal;
|
||||
pub mod login;
|
||||
pub mod mobile_warning_modal;
|
||||
pub mod month_view;
|
||||
pub mod print_preview_modal;
|
||||
pub mod recurring_edit_modal;
|
||||
pub mod route_handler;
|
||||
pub mod sidebar;
|
||||
@@ -32,6 +33,7 @@ pub use event_modal::EventModal;
|
||||
pub use login::Login;
|
||||
pub use mobile_warning_modal::MobileWarningModal;
|
||||
pub use month_view::MonthView;
|
||||
pub use print_preview_modal::PrintPreviewModal;
|
||||
pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal};
|
||||
pub use route_handler::RouteHandler;
|
||||
pub use sidebar::{Sidebar, Theme, ViewMode};
|
||||
|
||||
243
frontend/src/components/print_preview_modal.rs
Normal file
243
frontend/src/components/print_preview_modal.rs
Normal file
@@ -0,0 +1,243 @@
|
||||
use crate::components::{ViewMode, WeekView, MonthView};
|
||||
use crate::models::ical::VEvent;
|
||||
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
|
||||
use chrono::NaiveDate;
|
||||
use std::collections::HashMap;
|
||||
use wasm_bindgen::{closure::Closure, JsCast};
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct PrintPreviewModalProps {
|
||||
pub on_close: Callback<()>,
|
||||
pub view_mode: ViewMode,
|
||||
pub current_date: NaiveDate,
|
||||
pub selected_date: NaiveDate,
|
||||
pub events: HashMap<NaiveDate, Vec<VEvent>>,
|
||||
pub user_info: Option<UserInfo>,
|
||||
pub external_calendars: Vec<ExternalCalendar>,
|
||||
pub time_increment: u32,
|
||||
pub today: NaiveDate,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn PrintPreviewModal(props: &PrintPreviewModalProps) -> Html {
|
||||
let start_hour = use_state(|| 6u32);
|
||||
let end_hour = use_state(|| 22u32);
|
||||
let zoom_level = use_state(|| 0.4f64); // Default 40% zoom
|
||||
|
||||
let close_modal = {
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |_| {
|
||||
on_close.emit(());
|
||||
})
|
||||
};
|
||||
|
||||
let backdrop_click = {
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
if e.target() == e.current_target() {
|
||||
on_close.emit(());
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_start_hour_change = {
|
||||
let start_hour = start_hour.clone();
|
||||
let end_hour = end_hour.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let target = e.target_dyn_into::<web_sys::HtmlSelectElement>();
|
||||
if let Some(select) = target {
|
||||
if let Ok(hour) = select.value().parse::<u32>() {
|
||||
if hour < *end_hour {
|
||||
start_hour.set(hour);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_end_hour_change = {
|
||||
let start_hour = start_hour.clone();
|
||||
let end_hour = end_hour.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let target = e.target_dyn_into::<web_sys::HtmlSelectElement>();
|
||||
if let Some(select) = target {
|
||||
if let Ok(hour) = select.value().parse::<u32>() {
|
||||
if hour > *start_hour && hour <= 24 {
|
||||
end_hour.set(hour);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_print = {
|
||||
let start_hour = *start_hour;
|
||||
let end_hour = *end_hour;
|
||||
let view_mode = props.view_mode.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Some(document) = window.document() {
|
||||
if let Some(body) = document.body() {
|
||||
// Add print attributes to body for CSS targeting
|
||||
if view_mode == ViewMode::Week {
|
||||
let _ = body.set_attribute("data-print-start-hour", &start_hour.to_string());
|
||||
let _ = body.set_attribute("data-print-end-hour", &end_hour.to_string());
|
||||
}
|
||||
let _ = body.set_attribute("data-print-mode", "true");
|
||||
|
||||
// Trigger print
|
||||
if let Err(e) = window.print() {
|
||||
web_sys::console::log_1(&format!("Print failed: {:?}", e).into());
|
||||
}
|
||||
|
||||
// Clean up attributes after a short delay
|
||||
let cleanup_body = body.clone();
|
||||
let cleanup_callback = Closure::wrap(Box::new(move || {
|
||||
let _ = cleanup_body.remove_attribute("data-print-start-hour");
|
||||
let _ = cleanup_body.remove_attribute("data-print-end-hour");
|
||||
let _ = cleanup_body.remove_attribute("data-print-mode");
|
||||
}) as Box<dyn FnMut()>);
|
||||
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||
cleanup_callback.as_ref().unchecked_ref(),
|
||||
1000
|
||||
);
|
||||
cleanup_callback.forget();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let format_hour = |hour: u32| -> String {
|
||||
if hour == 0 {
|
||||
"12 AM".to_string()
|
||||
} else if hour < 12 {
|
||||
format!("{} AM", hour)
|
||||
} else if hour == 12 {
|
||||
"12 PM".to_string()
|
||||
} else {
|
||||
format!("{} PM", hour - 12)
|
||||
}
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="modal-backdrop print-preview-modal-backdrop" onclick={backdrop_click}>
|
||||
<div class="modal-content print-preview-modal">
|
||||
<div class="modal-header">
|
||||
<h3>{"Print Preview"}</h3>
|
||||
<button class="modal-close" onclick={close_modal.clone()}>{"×"}</button>
|
||||
</div>
|
||||
<div class="modal-body print-preview-body">
|
||||
<div class="print-preview-controls">
|
||||
{
|
||||
if props.view_mode == ViewMode::Week {
|
||||
html! {
|
||||
<>
|
||||
<div class="control-group">
|
||||
<label for="start-hour">{"Start Hour:"}</label>
|
||||
<select id="start-hour" onchange={on_start_hour_change}>
|
||||
{
|
||||
(0..24).map(|hour| {
|
||||
html! {
|
||||
<option value={hour.to_string()} selected={hour == *start_hour}>
|
||||
{format_hour(hour)}
|
||||
</option>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label for="end-hour">{"End Hour:"}</label>
|
||||
<select id="end-hour" onchange={on_end_hour_change}>
|
||||
{
|
||||
(1..=24).map(|hour| {
|
||||
html! {
|
||||
<option value={hour.to_string()} selected={hour == *end_hour}>
|
||||
{if hour == 24 { "12 AM".to_string() } else { format_hour(hour) }}
|
||||
</option>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="hour-range-info">
|
||||
{format!("Will print from {} to {}",
|
||||
format_hour(*start_hour),
|
||||
if *end_hour == 24 { "12 AM".to_string() } else { format_hour(*end_hour) }
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="month-info">
|
||||
{"Will print entire month view"}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
<div class="zoom-display-info">
|
||||
<label>{"Zoom: "}</label>
|
||||
<span>{format!("{}%", (*zoom_level * 100.0) as i32)}</span>
|
||||
<span class="zoom-hint">{"(scroll to zoom)"}</span>
|
||||
</div>
|
||||
<div class="preview-actions">
|
||||
<button class="btn-primary" onclick={on_print}>{"Print"}</button>
|
||||
<button class="btn-secondary" onclick={close_modal}>{"Cancel"}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="print-preview-display" onwheel={{
|
||||
let zoom_level = zoom_level.clone();
|
||||
Callback::from(move |e: WheelEvent| {
|
||||
e.prevent_default(); // Prevent page scroll
|
||||
let delta_y = e.delta_y();
|
||||
let zoom_change = if delta_y < 0.0 { 1.1 } else { 1.0 / 1.1 };
|
||||
let new_zoom = (*zoom_level * zoom_change).clamp(0.2, 1.5);
|
||||
zoom_level.set(new_zoom);
|
||||
})
|
||||
}}>
|
||||
<div class="print-preview-paper"
|
||||
data-start-hour={start_hour.to_string()}
|
||||
data-end-hour={end_hour.to_string()}
|
||||
style={format!("--print-start-hour: {}; --print-end-hour: {}; transform: scale({}); transform-origin: top center;", *start_hour, *end_hour, *zoom_level)}>
|
||||
<div class="print-preview-content">
|
||||
{
|
||||
match props.view_mode {
|
||||
ViewMode::Week => html! {
|
||||
<WeekView
|
||||
key={format!("week-preview-{}-{}", *start_hour, *end_hour)}
|
||||
current_date={props.current_date}
|
||||
today={props.today}
|
||||
events={props.events.clone()}
|
||||
on_event_click={Callback::noop()}
|
||||
user_info={props.user_info.clone()}
|
||||
external_calendars={props.external_calendars.clone()}
|
||||
time_increment={props.time_increment}
|
||||
/>
|
||||
},
|
||||
ViewMode::Month => html! {
|
||||
<MonthView
|
||||
key={format!("month-preview-{}-{}", *start_hour, *end_hour)}
|
||||
current_month={props.current_date}
|
||||
selected_date={Some(props.selected_date)}
|
||||
today={props.today}
|
||||
events={props.events.clone()}
|
||||
on_day_select={None::<Callback<NaiveDate>>}
|
||||
on_event_click={Callback::noop()}
|
||||
user_info={props.user_info.clone()}
|
||||
external_calendars={props.external_calendars.clone()}
|
||||
/>
|
||||
},
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -438,13 +438,13 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
// Time labels
|
||||
<div class={classes!("time-labels", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
|
||||
{
|
||||
time_labels.iter().map(|time| {
|
||||
time_labels.iter().enumerate().map(|(hour, time)| {
|
||||
let is_quarter_mode = props.time_increment == 15;
|
||||
html! {
|
||||
<div class={classes!(
|
||||
"time-label",
|
||||
if is_quarter_mode { Some("quarter-mode") } else { None }
|
||||
)}>
|
||||
)} data-hour={hour.to_string()}>
|
||||
{time}
|
||||
</div>
|
||||
}
|
||||
@@ -701,10 +701,10 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
>
|
||||
// Time slot backgrounds - 24 hour slots to represent full day
|
||||
{
|
||||
(0..24).map(|_hour| {
|
||||
(0..24).map(|hour| {
|
||||
let slots_per_hour = 60 / props.time_increment;
|
||||
html! {
|
||||
<div class={classes!("time-slot", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
|
||||
<div class={classes!("time-slot", if props.time_increment == 15 { Some("quarter-mode") } else { None })} data-hour={hour.to_string()}>
|
||||
{
|
||||
(0..slots_per_hour).map(|_slot| {
|
||||
let slot_class = if props.time_increment == 15 {
|
||||
|
||||
Reference in New Issue
Block a user