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:
Connor Johnstone
2025-09-08 14:55:18 -04:00
parent 927cd7d2bb
commit 4cbc495c48
9 changed files with 1778 additions and 6 deletions

View File

@@ -6,7 +6,7 @@ dist = "dist"
BACKEND_API_URL = "http://localhost:3000/api"
[watch]
watch = ["src", "Cargo.toml", "../calendar-models/src", "styles.css", "index.html"]
watch = ["src", "Cargo.toml", "../calendar-models/src", "styles.css", "print.css", "print-preview.css", "index.html"]
ignore = ["../backend/", "../target/"]
[serve]

View File

@@ -6,6 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<base data-trunk-public-url />
<link data-trunk rel="css" href="styles.css">
<link data-trunk rel="css" href="print.css">
<link data-trunk rel="css" href="print-preview.css">
<link data-trunk rel="copy-file" href="styles/google.css">
<link data-trunk rel="icon" href="favicon.ico">
</head>

1174
frontend/print-preview.css Normal file

File diff suppressed because it is too large Load Diff

302
frontend/print.css Normal file
View File

@@ -0,0 +1,302 @@
/* Print-specific styles for calendar printing */
@media print {
/* Hide UI elements that shouldn't be printed */
.app-sidebar,
.current-time-indicator-container,
.current-time-indicator,
.print-button,
.nav-button,
.today-button,
.time-increment-button,
.modal-backdrop,
.create-event-modal,
.event-modal,
.calendar-management-modal,
.context-menu {
display: none !important;
}
/* Remove today highlighting from calendar elements */
.calendar-day.today,
.week-day-header.today,
.week-day-column.today {
background-color: transparent !important;
border-color: var(--border-color) !important;
color: var(--text-color) !important;
}
/* Remove today-specific styling from day numbers */
.calendar-day.today .day-number {
background-color: transparent !important;
color: var(--text-color) !important;
font-weight: normal !important;
}
/* Remove today indicator from week day headers */
.week-day-header.today .weekday-name {
color: var(--text-color) !important;
font-weight: normal !important;
}
/* Page setup */
@page {
size: letter landscape;
margin: 0.5in;
}
/* Make app and main container fill full page width */
.app,
.app-main,
.calendar-view {
width: 100% !important;
max-width: none !important;
margin: 0 !important;
padding: 0 !important;
display: block !important;
}
/* Remove any flexbox constraints that might limit width */
.app {
display: block !important;
}
.app-main {
margin-left: 0 !important; /* Remove sidebar margin */
width: 100% !important;
max-width: none !important;
}
/* Ensure calendar uses full available width */
.calendar-container,
.calendar {
width: 100% !important;
max-width: none !important;
margin: 0 !important;
padding: 0 !important;
}
/* Adjust calendar header for printing */
.calendar-header {
margin-bottom: 0.5rem !important;
padding-bottom: 0.5rem !important;
border-bottom: 1px solid var(--border-color) !important;
}
/* Ensure text is readable in print */
body, html {
color: black !important;
background: white !important;
}
/* Make sure event text is readable */
.week-event,
.calendar-event,
.month-event {
border: 1px solid #333 !important;
color: black !important;
font-size: 0.8rem !important;
line-height: 1.2 !important;
}
/* Ensure month view events are visible */
.calendar-day .event-list .event-item {
background-color: #f5f5f5 !important;
border: 1px solid #333 !important;
color: black !important;
}
/* Week view specific adjustments - force full day view */
.week-view-container {
width: 100% !important;
height: auto !important;
overflow: visible !important;
}
.week-day-column {
border-right: 1px solid #333 !important;
height: auto !important;
overflow: visible !important;
}
.time-column {
border-right: 2px solid #333 !important;
width: 60px !important;
min-width: 60px !important;
height: auto !important;
overflow: visible !important;
}
/* Default time slot sizes - will be adjusted by hour range */
.time-slot {
height: 20px !important;
min-height: 20px !important;
max-height: 20px !important;
border-bottom: 1px solid #ddd !important;
overflow: visible !important;
}
/* Time slot quarters for 15-minute mode */
.time-slot.quarter-mode .time-slot-quarter {
height: 5px !important;
min-height: 5px !important;
max-height: 5px !important;
border-bottom: 1px solid #eee !important;
}
/* Time slot halves for 30-minute mode */
.time-slot .time-slot-half {
height: 10px !important;
min-height: 10px !important;
max-height: 10px !important;
border-bottom: 1px solid #eee !important;
}
/* Make hour boundaries more visible */
.time-slot:nth-child(4n) {
border-bottom: 1px solid #333 !important;
height: 20px !important;
}
/* Dynamic hour range hiding for print mode */
body[data-print-mode="true"][data-print-start-hour="1"] .week-view .time-slot:nth-child(1) { display: none !important; }
body[data-print-mode="true"][data-print-start-hour="2"] .week-view .time-slot:nth-child(-n+2) { display: none !important; }
body[data-print-mode="true"][data-print-start-hour="3"] .week-view .time-slot:nth-child(-n+3) { display: none !important; }
body[data-print-mode="true"][data-print-start-hour="4"] .week-view .time-slot:nth-child(-n+4) { display: none !important; }
body[data-print-mode="true"][data-print-start-hour="5"] .week-view .time-slot:nth-child(-n+5) { display: none !important; }
body[data-print-mode="true"][data-print-start-hour="6"] .week-view .time-slot:nth-child(-n+6) { display: none !important; }
body[data-print-mode="true"][data-print-start-hour="7"] .week-view .time-slot:nth-child(-n+7) { display: none !important; }
body[data-print-mode="true"][data-print-start-hour="8"] .week-view .time-slot:nth-child(-n+8) { display: none !important; }
body[data-print-mode="true"][data-print-start-hour="9"] .week-view .time-slot:nth-child(-n+9) { display: none !important; }
body[data-print-mode="true"][data-print-start-hour="10"] .week-view .time-slot:nth-child(-n+10) { display: none !important; }
body[data-print-mode="true"][data-print-start-hour="11"] .week-view .time-slot:nth-child(-n+11) { display: none !important; }
body[data-print-mode="true"][data-print-start-hour="12"] .week-view .time-slot:nth-child(-n+12) { display: none !important; }
body[data-print-mode="true"][data-print-start-hour="13"] .week-view .time-slot:nth-child(-n+13) { display: none !important; }
body[data-print-mode="true"][data-print-start-hour="14"] .week-view .time-slot:nth-child(-n+14) { display: none !important; }
body[data-print-mode="true"][data-print-start-hour="15"] .week-view .time-slot:nth-child(-n+15) { display: none !important; }
body[data-print-mode="true"][data-print-start-hour="16"] .week-view .time-slot:nth-child(-n+16) { display: none !important; }
body[data-print-mode="true"][data-print-start-hour="17"] .week-view .time-slot:nth-child(-n+17) { display: none !important; }
body[data-print-mode="true"][data-print-start-hour="18"] .week-view .time-slot:nth-child(-n+18) { display: none !important; }
body[data-print-mode="true"][data-print-start-hour="19"] .week-view .time-slot:nth-child(-n+19) { display: none !important; }
body[data-print-mode="true"][data-print-start-hour="20"] .week-view .time-slot:nth-child(-n+20) { display: none !important; }
body[data-print-mode="true"][data-print-start-hour="21"] .week-view .time-slot:nth-child(-n+21) { display: none !important; }
body[data-print-mode="true"][data-print-start-hour="22"] .week-view .time-slot:nth-child(-n+22) { display: none !important; }
body[data-print-mode="true"][data-print-start-hour="23"] .week-view .time-slot:nth-child(-n+23) { display: none !important; }
/* Hide hours after end-hour */
body[data-print-mode="true"][data-print-end-hour="1"] .week-view .time-slot:nth-child(n+2) { display: none !important; }
body[data-print-mode="true"][data-print-end-hour="2"] .week-view .time-slot:nth-child(n+3) { display: none !important; }
body[data-print-mode="true"][data-print-end-hour="3"] .week-view .time-slot:nth-child(n+4) { display: none !important; }
body[data-print-mode="true"][data-print-end-hour="4"] .week-view .time-slot:nth-child(n+5) { display: none !important; }
body[data-print-mode="true"][data-print-end-hour="5"] .week-view .time-slot:nth-child(n+6) { display: none !important; }
body[data-print-mode="true"][data-print-end-hour="6"] .week-view .time-slot:nth-child(n+7) { display: none !important; }
body[data-print-mode="true"][data-print-end-hour="7"] .week-view .time-slot:nth-child(n+8) { display: none !important; }
body[data-print-mode="true"][data-print-end-hour="8"] .week-view .time-slot:nth-child(n+9) { display: none !important; }
body[data-print-mode="true"][data-print-end-hour="9"] .week-view .time-slot:nth-child(n+10) { display: none !important; }
body[data-print-mode="true"][data-print-end-hour="10"] .week-view .time-slot:nth-child(n+11) { display: none !important; }
body[data-print-mode="true"][data-print-end-hour="11"] .week-view .time-slot:nth-child(n+12) { display: none !important; }
body[data-print-mode="true"][data-print-end-hour="12"] .week-view .time-slot:nth-child(n+13) { display: none !important; }
body[data-print-mode="true"][data-print-end-hour="13"] .week-view .time-slot:nth-child(n+14) { display: none !important; }
body[data-print-mode="true"][data-print-end-hour="14"] .week-view .time-slot:nth-child(n+15) { display: none !important; }
body[data-print-mode="true"][data-print-end-hour="15"] .week-view .time-slot:nth-child(n+16) { display: none !important; }
body[data-print-mode="true"][data-print-end-hour="16"] .week-view .time-slot:nth-child(n+17) { display: none !important; }
body[data-print-mode="true"][data-print-end-hour="17"] .week-view .time-slot:nth-child(n+18) { display: none !important; }
body[data-print-mode="true"][data-print-end-hour="18"] .week-view .time-slot:nth-child(n+19) { display: none !important; }
body[data-print-mode="true"][data-print-end-hour="19"] .week-view .time-slot:nth-child(n+20) { display: none !important; }
body[data-print-mode="true"][data-print-end-hour="20"] .week-view .time-slot:nth-child(n+21) { display: none !important; }
body[data-print-mode="true"][data-print-end-hour="21"] .week-view .time-slot:nth-child(n+22) { display: none !important; }
body[data-print-mode="true"][data-print-end-hour="22"] .week-view .time-slot:nth-child(n+23) { display: none !important; }
body[data-print-mode="true"][data-print-end-hour="23"] .week-view .time-slot:nth-child(n+24) { display: none !important; }
/* Force the week grid to show full height */
.week-grid,
.week-content,
.time-grid {
height: auto !important;
max-height: none !important;
overflow: visible !important;
}
/* Ensure all hours are visible by removing any height constraints */
.week-view-container,
.week-view-container > div {
height: auto !important;
max-height: none !important;
overflow: visible !important;
}
/* Month view specific adjustments */
.calendar-grid {
border: 1px solid #333 !important;
}
.calendar-day {
border: 1px solid #333 !important;
min-height: 80px !important;
}
/* Ensure grid lines are visible - handled above in main time-slot rules */
/* Make sure header text is visible */
.calendar-header h2,
.calendar-header .month-year {
color: black !important;
font-size: 1.5rem !important;
}
.week-day-header .weekday-name,
.week-day-header .date-number {
color: black !important;
}
/* Time labels in week view */
.time-label {
color: #666 !important;
font-size: 0.75rem !important;
}
/* Remove any shadows or fancy effects */
* {
box-shadow: none !important;
text-shadow: none !important;
}
/* Ensure proper spacing */
.calendar-day .day-number {
margin-bottom: 0.25rem !important;
}
/* Make sure events don't overlap text in month view */
.calendar-day .event-list {
margin-top: 0.25rem !important;
}
/* Force page break before calendar if needed */
.calendar {
page-break-inside: avoid !important;
}
}
/* Additional print button styling for screen display */
.print-button {
background: rgba(255,255,255,0.2);
border: none;
color: white;
font-size: 1.2rem;
width: 40px;
height: 40px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
margin-left: 0.5rem;
}
.print-button:hover {
background: rgba(255,255,255,0.3);
}
.print-button:active {
transform: scale(0.95);
}

View File

@@ -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>
}
}

View File

@@ -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">

View File

@@ -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};

View 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>
}
}

View File

@@ -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 {