diff --git a/package.json b/package.json index cc7c409..bd590fa 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "next-themes": "^0.4.6", "react": "19.1.1", "react-dom": "19.1.1", + "react-to-print": "^3.1.1", "require-in-the-middle": "^7.5.2", "rrule": "^2.8.1", "sharp": "^0.34.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49b3f96..42d183d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: react-dom: specifier: 19.1.1 version: 19.1.1(react@19.1.1) + react-to-print: + specifier: ^3.1.1 + version: 3.1.1(react@19.1.1) require-in-the-middle: specifier: ^7.5.2 version: 7.5.2 @@ -2991,6 +2994,11 @@ packages: '@types/react': optional: true + react-to-print@3.1.1: + resolution: {integrity: sha512-N0MUMhpl8nkGri13BjP7zusj3B/j+1eMOTt8N8PYuhBYGzA4PqTXqcihJ9cZw996dvhV6mBdwafIQCg3Ap5bKg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ~19 + react@19.1.1: resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==} engines: {node: '>=0.10.0'} @@ -6479,6 +6487,10 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + react-to-print@3.1.1(react@19.1.1): + dependencies: + react: 19.1.1 + react@19.1.1: {} readdirp@3.6.0: diff --git a/src/app/globals.css b/src/app/globals.css index a28f17b..fcc70c7 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -108,3 +108,122 @@ background-color: rgb(var(--accent-table)); color: #fafafa; } + +.print-container { + position: fixed; + top: -10000px; + left: -10000px; + opacity: 0; + pointer-events: none; + width: 100vw; +} + +@media print { + :root, + .dark { + --background: 254 255 254 !important; + --foreground: 246 243 244 !important; + --accent: 254 255 254 !important; + --accent-secondary: 254 255 254 !important; + --accent-table: 239 9 51 !important; + --primary: 42 23 27 !important; + --lines: 208 201 203 !important; + color-scheme: light; + } + + body { + background: #ffffff !important; + color: #111827 !important; + font-size: 11px; + line-height: 1.35; + font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + margin: 0; + } + + .print-container { + position: static; + opacity: 1; + pointer-events: auto; + width: auto; + } + + .print-wrapper { + display: grid; + gap: 12px; + } + + .print-header { + display: grid; + gap: 4px; + padding-bottom: 6px; + border-bottom: 1px solid #e5e7eb; + } + + .print-header h1 { + font-size: 1.25rem; + color: #0f172a; + } + + .print-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + border-radius: 8px; + overflow: hidden; + } + + .print-table th, + .print-table td { + border: 1px solid #d1d5db; + padding: 2px 4px; + vertical-align: top; + word-break: break-word; + } + + .print-table thead th { + background-color: #e2e8f0; + font-weight: 600; + color: #0f172a; + } + + .print-table tbody tr:nth-child(odd) td { + background-color: #f8fafc; + } + + .print-table tbody tr:nth-child(even) td { + background-color: #ffffff; + } + + .print-table tbody td:first-child { + background-color: #e0e7ff; + font-weight: 600; + color: #1e3a8a; + } + + .print-lesson { + display: grid; + gap: 1px; + margin-bottom: 3px; + } + + .print-lesson:last-child { + margin-bottom: 0; + } + + .print-lesson__subject { + font-weight: 600; + font-size: 0.85rem; + color: #1f2937; + } + + .print-lesson__meta { + color: #475569; + font-size: 0.7rem; + line-height: 1.3; + } + + .print-no-lessons { + font-size: 0.95rem; + color: #1f2937; + } +} diff --git a/src/components/timetable/Timetable.tsx b/src/components/timetable/Timetable.tsx index d33d5df..9d84b24 100644 --- a/src/components/timetable/Timetable.tsx +++ b/src/components/timetable/Timetable.tsx @@ -1,12 +1,15 @@ "use client"; import { SHORT_HOURS } from "@/constants/settings"; +import { TRANSLATION_DICT } from "@/constants/translations"; import { adjustShortenedLessons } from "@/lib/adjustShortenedLessons"; import { cn } from "@/lib/utils"; import { useSettingsStore, useSettingsWithoutStore } from "@/stores/settings"; +import { useTimetableStore } from "@/stores/timetable"; import type { OptivumTimetable } from "@/types/optivum"; import { CalendarX2 } from "lucide-react"; -import { FC, useMemo, useRef } from "react"; +import { FC, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useReactToPrint } from "react-to-print"; import { ShortLessonSwitcherCell, TableHeaderCell, @@ -38,6 +41,9 @@ export const Timetable: FC = ({ timetable }) => { const setSelectedDayIndex = useSettingsWithoutStore( (state) => state.setSelectedDayIndex, ); + const setPrintTimetable = useTimetableStore( + (state) => state.setPrintTimetable, + ); const hours = useMemo(() => { if (lessonType === "custom") { @@ -58,12 +64,6 @@ export const Timetable: FC = ({ timetable }) => { return Math.max(hourCount, ...lessonCounts, 0); }, [lessons, timetable.hours]); - const hasLessons = useMemo( - () => - lessons.some((day) => day.some((hourLessons) => hourLessons.length > 0)), - [lessons], - ); - const todayIndex = useMemo(() => (new Date().getDay() + 6) % 7, []); const dayNames = timetable.dayNames; const hoursList = useMemo(() => Object.values(hours), [hours]); @@ -72,6 +72,34 @@ export const Timetable: FC = ({ timetable }) => { [hoursList, maxLessons], ); + const lessonsByDay = useMemo( + () => + dayNames.map((_, dayIndex) => { + const dayLessons = lessons[dayIndex] ?? []; + return visibleHours.map((_, hourIndex) => dayLessons[hourIndex] ?? []); + }), + [dayNames, lessons, visibleHours], + ); + + const dayHasLessons = useMemo( + () => lessonsByDay.map((day) => day.some((hour) => hour.length > 0)), + [lessonsByDay], + ); + + const hasLessons = useMemo( + () => dayHasLessons.some(Boolean), + [dayHasLessons], + ); + + const printableRows = useMemo( + () => + visibleHours.map((hour, hourIndex) => ({ + hour, + lessons: lessonsByDay.map((dayLessons) => dayLessons[hourIndex] ?? []), + })), + [lessonsByDay, visibleHours], + ); + const handleDayChange = (newIndex: number) => { if (selectedDayIndex !== newIndex) { setSelectedDayIndex(newIndex); @@ -94,112 +122,240 @@ export const Timetable: FC = ({ timetable }) => { touchStartX.current = null; }; - return ( -
-
- {dayNames.map((dayName) => ( - - ))} -
+ const printableRef = useRef(null); + + const formatDate = useCallback( + (date: Date) => + new Intl.DateTimeFormat("pl-PL", { + dateStyle: "long", + timeStyle: "short", + }).format(date), + [], + ); + + const [printTimestamp, setPrintTimestamp] = useState(() => formatDate(new Date())); + + const pageStyle = useMemo( + () => "@page { size: A4 landscape; margin: 10mm; }", + [], + ); + + const triggerPrint = useReactToPrint({ + contentRef: printableRef, + pageStyle, + documentTitle: timetable.title + ? `Plan lekcji ${TRANSLATION_DICT[timetable.type]} ${timetable.title}` + : "Plan lekcji", + preserveAfterPrint: true, + onBeforePrint: async () => { + setPrintTimestamp(formatDate(new Date())); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + }, + }); + + const handlePrint = useCallback(() => { + triggerPrint(); + }, [triggerPrint]); + useEffect(() => { + setPrintTimetable(handlePrint); + return () => setPrintTimetable(null); + }, [handlePrint, setPrintTimetable]); + + useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => { + if (event.repeat) return; + + const isPrintShortcut = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "p"; + + if (!isPrintShortcut) return; + + event.preventDefault(); + handlePrint(); + }; + + window.addEventListener("keydown", handleKeydown); + + return () => { + window.removeEventListener("keydown", handleKeydown); + }; + }, [handlePrint]); + + return ( + <>
+
+ {dayNames.map((dayName) => ( + + ))} +
+
- {dayNames.map((_, dayIndex) => { - const dayLessons = lessons[dayIndex] ?? []; - const dayHasLessons = dayLessons.some( - (hourLessons) => hourLessons.length > 0, - ); - return ( -
- {dayHasLessons ? ( - - - {visibleHours.map((hour, hourIndex) => ( - - - - - ))} - -
- {(lessons[dayIndex]?.[hourIndex] ?? []).map( - (lessonItem, index) => ( - - ), - )} -
- ) : ( - - )} -
- ); - })} +
+ {dayNames.map((_, dayIndex) => ( +
+ {dayHasLessons[dayIndex] ? ( + + + {printableRows.map(({ hour, lessons: hourLessons }, hourIndex) => ( + + + + + ))} + +
+ {hourLessons[dayIndex].map((lessonItem, index) => ( + + ))} +
+ ) : ( + + )} +
+ ))} +
-
-
- {hasLessons ? ( - - - - - {dayNames.map((dayName) => ( - +
+ {hasLessons ? ( +
- -
+ + + + {dayNames.map((dayName) => ( + + ))} + + + + {printableRows.map(({ hour }, hourIndex) => ( + + + {lessonsByDay.map((day, dayIndex) => ( + + ))} + ))} - - - - {visibleHours.map((hour, hourIndex) => ( - - - {lessons.map((day, dayIndex) => ( - + +
+ +
+ ) : ( + + )} +
+
+ + - + ); }; diff --git a/src/components/topbar/Buttons.tsx b/src/components/topbar/Buttons.tsx index 4419539..2b91801 100644 --- a/src/components/topbar/Buttons.tsx +++ b/src/components/topbar/Buttons.tsx @@ -1,7 +1,11 @@ +"use client"; + import { Button } from "@/components/ui/Button"; import { Skeleton } from "@/components/ui/Skeleton"; +import { useTimetableStore } from "@/stores/timetable"; import { useSettingsWithoutStore } from "@/stores/settings"; -import { MenuIcon, MoonIcon, SunMediumIcon } from "lucide-react"; +import { MenuIcon, MoonIcon, PrinterIcon, SunMediumIcon } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; import { useTheme } from "next-themes"; import { FC, useCallback } from "react"; import { useIsClient } from "usehooks-ts"; @@ -12,6 +16,8 @@ export const TopbarButtons: FC = () => { const { theme, setTheme, systemTheme } = useTheme(); const currentTheme = theme === "system" ? systemTheme : theme; + const printTimetable = useTimetableStore((state) => state.printTimetable); + const toggleSettingsPanel = useSettingsWithoutStore( (state) => state.toggleSettingsPanel, ); @@ -20,11 +26,23 @@ export const TopbarButtons: FC = () => { setTheme(currentTheme === "light" ? "dark" : "light"); }, [currentTheme, setTheme]); - const buttons = [ + interface ButtonConfig { + icon: LucideIcon; + action?: () => void; + ariaLabel: string; + disabled?: boolean; + } + + const buttons: ButtonConfig[] = [ + { + icon: PrinterIcon, + action: printTimetable ? () => printTimetable() : undefined, + ariaLabel: "Drukuj plan lekcji", + disabled: !printTimetable, + }, { icon: currentTheme === "dark" ? SunMediumIcon : MoonIcon, - href: null, - action: toggleTheme, + action: () => toggleTheme(), ariaLabel: currentTheme === "dark" ? "Przełącz na tryb jasny" @@ -32,8 +50,7 @@ export const TopbarButtons: FC = () => { }, { icon: MenuIcon, - href: null, - action: toggleSettingsPanel, + action: () => toggleSettingsPanel(), ariaLabel: "Otwórz panel ustawień", }, ]; @@ -53,13 +70,14 @@ export const TopbarButtons: FC = () => { const IconComponent = button.icon; return ( - ); diff --git a/src/stores/timetable.ts b/src/stores/timetable.ts index 98719cf..8f0a484 100644 --- a/src/stores/timetable.ts +++ b/src/stores/timetable.ts @@ -3,10 +3,14 @@ import { create } from "zustand"; interface TimetableStore { timetable: OptivumTimetable | null; + printTimetable: (() => void) | null; setTimetable: (timetable: OptivumTimetable) => void; + setPrintTimetable: (printFn: (() => void) | null) => void; } export const useTimetableStore = create((set) => ({ timetable: null, + printTimetable: null, setTimetable: (timetable) => set({ timetable }), + setPrintTimetable: (printFn) => set({ printTimetable: printFn }), }));