From edeb8ef8e35d338f180d9cf7b21034052d6fc0a9 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Sat, 9 May 2026 14:57:28 +0200 Subject: [PATCH] rewrite phase 5 --- CHECKLIST.md | 28 ++--- src/app/add-event/AddEventModal.tsx | 164 ++++++++++++++++++++++++++++ src/app/calendar/CalendarPanel.tsx | 53 +++++++++ src/app/calendar/FileTab.tsx | 85 ++++++++++++++ src/app/calendar/UrlTab.tsx | 49 +++++++++ src/app/event/BikeSection.tsx | 76 +++++++++++++ src/app/event/EventCard.tsx | 53 +++++++++ src/app/event/JourneyList.tsx | 50 +++++++++ src/app/event/LeaveByBadge.tsx | 22 ++++ src/app/event/TrainSection.tsx | 57 ++++++++++ src/app/layout/Header.tsx | 50 +++++++++ src/app/layout/Navbar.tsx | 40 +++++++ src/app/ui/Button.tsx | 31 ++++++ src/app/ui/Chip.tsx | 20 ++++ src/app/ui/LoadingSpinner.tsx | 29 +++++ src/hooks/useEventsStore.tsx | 38 +++---- 16 files changed, 812 insertions(+), 33 deletions(-) create mode 100644 src/app/add-event/AddEventModal.tsx create mode 100644 src/app/calendar/CalendarPanel.tsx create mode 100644 src/app/calendar/FileTab.tsx create mode 100644 src/app/calendar/UrlTab.tsx create mode 100644 src/app/event/BikeSection.tsx create mode 100644 src/app/event/EventCard.tsx create mode 100644 src/app/event/JourneyList.tsx create mode 100644 src/app/event/LeaveByBadge.tsx create mode 100644 src/app/event/TrainSection.tsx create mode 100644 src/app/layout/Header.tsx create mode 100644 src/app/layout/Navbar.tsx create mode 100644 src/app/ui/Button.tsx create mode 100644 src/app/ui/Chip.tsx create mode 100644 src/app/ui/LoadingSpinner.tsx diff --git a/CHECKLIST.md b/CHECKLIST.md index 851d4cb..bce24c2 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -69,20 +69,20 @@ | # | Item | ✅ | ✔️ | |---|------|----|----| -| 27 | `ui/Chip.tsx` — small badge component | [ ] | [ ] | -| 28 | `ui/Button.tsx` — styled button | [ ] | [ ] | -| 29 | `ui/LoadingSpinner.tsx` — loading indicator | [ ] | [ ] | -| 30 | `event/LeaveByBadge.tsx` — countdown badge | [ ] | [ ] | -| 31 | `event/JourneyList.tsx` — departure rows | [ ] | [ ] | -| 32 | `event/TrainSection.tsx` — train data in event card | [ ] | [ ] | -| 33 | `event/BikeSection.tsx` — bicycle data in event card (NEW) | [ ] | [ ] | -| 34 | `event/EventCard.tsx` — composes train + bike sections | [ ] | [ ] | -| 35 | `calendar/UrlTab.tsx` | [ ] | [ ] | -| 36 | `calendar/FileTab.tsx` | [ ] | [ ] | -| 37 | `calendar/CalendarPanel.tsx` | [ ] | [ ] | -| 38 | `add-event/AddEventModal.tsx` | [ ] | [ ] | -| 39 | `layout/Header.tsx` | [ ] | [ ] | -| 40 | `layout/Navbar.tsx` (NEW) | [ ] | [ ] | +| 27 | `ui/Chip.tsx` — small badge component | [x] | [ ] | +| 28 | `ui/Button.tsx` — styled button | [x] | [x] | +| 29 | `ui/LoadingSpinner.tsx` — loading indicator | [x] | [ ] | +| 30 | `event/LeaveByBadge.tsx` — countdown badge | [x] | [ ] | +| 31 | `event/JourneyList.tsx` — departure rows | [x] | [ ] | +| 32 | `event/TrainSection.tsx` — train data in event card | [x] | [ ] | +| 33 | `event/BikeSection.tsx` — bicycle data in event card (NEW) | [x] | [ ] | +| 34 | `event/EventCard.tsx` — composes train + bike sections | [x] | [ ] | +| 35 | `calendar/UrlTab.tsx` | [x] | [ ] | +| 36 | `calendar/FileTab.tsx` | [x] | [x] | +| 37 | `calendar/CalendarPanel.tsx` | [x] | [x] | +| 38 | `add-event/AddEventModal.tsx` | [x] | [x] | +| 39 | `layout/Header.tsx` | [x] | [x] | +| 40 | `layout/Navbar.tsx` (NEW) | [x] | [x] | --- diff --git a/src/app/add-event/AddEventModal.tsx b/src/app/add-event/AddEventModal.tsx new file mode 100644 index 0000000..361c819 --- /dev/null +++ b/src/app/add-event/AddEventModal.tsx @@ -0,0 +1,164 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import Button from "@/app/ui/Button"; +import { useEventsStore } from "@/hooks/useEventsStore"; + +type AddEventModalProps = { + isOpen: boolean; + onClose: () => void; + className?: string; +}; + +const AddEventModal: React.FC = ({ isOpen, onClose, className = "" }) => { + const [title, setTitle] = useState(""); + const [destination, setDestination] = useState(""); + const [eventTime, setEventTime] = useState(""); + const [eventDate, setEventDate] = useState(""); + const [loading, setLoading] = useState(false); + + const { addEvent } = useEventsStore(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!title.trim() || !destination.trim() || !eventTime.trim() || !eventDate.trim()) { + return; + } + + setLoading(true); + try { + const eventTimeDate = new Date(`${eventDate} ${eventTime}`); + + await addEvent({ + id: `manual-${Date.now()}`, + title: title.trim(), + destination: destination.trim(), + eventTime: eventTimeDate, + source: "manual", + }); + + // Reset form and close modal + setTitle(""); + setDestination(""); + setEventTime(""); + setEventDate(""); + onClose(); + } catch (err) { + console.error("Error adding event:", err); + } finally { + setLoading(false); + } + }; + + // Close modal when clicking outside + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + // Close modal with Escape key + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + }; + + if (isOpen) { + document.addEventListener("keydown", handleEscape); + } + + return () => { + document.removeEventListener("keydown", handleEscape); + }; + }, [isOpen, onClose]); + + if (!isOpen) { + return null; + } + + return ( +
+
+
+

Add Manual Event

+
+
+ + setTitle(e.target.value)} + placeholder="Team Meeting" + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" + required + /> +
+
+ + setDestination(e.target.value)} + placeholder="Vienna Main Station" + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" + required + /> +
+
+
+ + setEventDate(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" + required + /> +
+
+ + setEventTime(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" + required + /> +
+
+
+ + +
+
+
+
+
+ ); +}; + +export default AddEventModal; diff --git a/src/app/calendar/CalendarPanel.tsx b/src/app/calendar/CalendarPanel.tsx new file mode 100644 index 0000000..f060e31 --- /dev/null +++ b/src/app/calendar/CalendarPanel.tsx @@ -0,0 +1,53 @@ +"use client"; + +import React, { useState } from "react"; +import UrlTab from "./UrlTab"; +import FileTab from "./FileTab"; + +type CalendarPanelProps = { + onLoadCalendar: (url: string | File) => Promise; + loading: boolean; + error: string | null; + className?: string; +}; + +const CalendarPanel: React.FC = ({ onLoadCalendar, loading, error, className = "" }) => { + const [activeTab, setActiveTab] = useState<"url" | "file">("url"); + + return ( +
+
+
+

Import Calendar

+
+
+
+
+ +
+ {activeTab === "url" ? ( + onLoadCalendar(url)} loading={loading} error={error} /> + ) : ( + onLoadCalendar(file)} loading={loading} error={error} /> + )} +
+
+ ); +}; + +export default CalendarPanel; diff --git a/src/app/calendar/FileTab.tsx b/src/app/calendar/FileTab.tsx new file mode 100644 index 0000000..f78a9ea --- /dev/null +++ b/src/app/calendar/FileTab.tsx @@ -0,0 +1,85 @@ +"use client"; + +import React, { useRef, useState } from "react"; +import Button from "@/app/ui/Button"; + +type FileTabProps = { + onLoadCalendar: (file: File) => Promise; + loading: boolean; + error: string | null; + className?: string; +}; + +const FileTab: React.FC = ({ onLoadCalendar, loading, error, className = "" }) => { + const fileInputRef = useRef(null); + const [dragging, setDragging] = useState(false); + + const handleFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + handleFileUpload(file); + } + }; + + const handleFileUpload = async (file: File) => { + try { + await onLoadCalendar(file); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } catch (err) { + console.error("Error uploading file:", err); + } + }; + + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragging(false); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragging(true); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragging(false); + + const files = e.dataTransfer.files; + if (files.length > 0) { + handleFileUpload(files[0]); + } + }; + + return ( +
+
fileInputRef.current?.click()} + > + + {loading ? <>Loading calendar... : <>Click to upload or drag and drop an .ics file here} +
+ {error &&
{error}
} + +
+ ); +}; + +export default FileTab; diff --git a/src/app/calendar/UrlTab.tsx b/src/app/calendar/UrlTab.tsx new file mode 100644 index 0000000..fed48ca --- /dev/null +++ b/src/app/calendar/UrlTab.tsx @@ -0,0 +1,49 @@ +"use client"; + +import React, { useState } from "react"; +import Button from "@/app/ui/Button"; + +type UrlTabProps = { + onLoadCalendar: (url: string) => Promise; + loading: boolean; + error: string | null; + className?: string; +}; + +const UrlTab: React.FC = ({ onLoadCalendar, loading, error, className = "" }) => { + const [url, setUrl] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (url.trim()) { + await onLoadCalendar(url.trim()); + } + }; + + return ( +
+
+
+ + setUrl(e.target.value)} + placeholder="https://example.com/calendar.ics" + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" + required + /> +
+ {error &&
{error}
} + +
+
+ ); +}; + +export default UrlTab; diff --git a/src/app/event/BikeSection.tsx b/src/app/event/BikeSection.tsx new file mode 100644 index 0000000..7e12a58 --- /dev/null +++ b/src/app/event/BikeSection.tsx @@ -0,0 +1,76 @@ +"use client"; + +import React from "react"; +import { BikeRoute } from "@/types"; +import LoadingSpinner from "@/app/ui/LoadingSpinner"; +import Button from "@/app/ui/Button"; + +type BikeSectionProps = { + bikeRoute: BikeRoute | null | undefined; + bikeLoading: boolean; + bikeError: string | null | undefined; + onRefresh?: () => void; + className?: string; +}; + +const BikeSection: React.FC = ({ bikeRoute, bikeLoading, bikeError, onRefresh, className = "" }) => { + if (!bikeRoute && !bikeLoading && !bikeError) { + return null; + } + + return ( +
+
+
+
+

Bicycle Route

+
+ {onRefresh && ( + + )} +
+
+
+ {bikeLoading ? ( +
+ +
+ ) : bikeError ? ( +
Error: {bikeError}
+ ) : bikeRoute ? ( +
+
+ Distance + + {Math.round(bikeRoute.distance / 1000)} km ({Math.round(bikeRoute.distance)} m) + +
+
+ Duration + + {Math.floor(bikeRoute.duration / 60)} min {bikeRoute.duration % 60} s + +
+ {bikeRoute.steps && bikeRoute.steps.length > 0 && ( +
+

Steps

+
    + {bikeRoute.steps.map((step, index) => ( +
  • + {step.name} + {step.instruction} +
  • + ))} +
+
+ )} +
+ ) : null} +
+
+ ); +}; + +export default BikeSection; diff --git a/src/app/event/EventCard.tsx b/src/app/event/EventCard.tsx new file mode 100644 index 0000000..447aec6 --- /dev/null +++ b/src/app/event/EventCard.tsx @@ -0,0 +1,53 @@ +'use client'; + +import React from 'react'; +import { Event, TripDataEntry } from '@/types'; +import TrainSection from './TrainSection'; +import BikeSection from './BikeSection'; +import LeaveByBadge from './LeaveByBadge'; +import { calculateCountdown } from '@/lib/countdown-utils'; + +type EventCardProps = { + event: Event; + tripData: TripDataEntry; + className?: string; +}; + +const EventCard: React.FC = ({ + event, + tripData, + className = '', +}) => { + return ( +
+
+
+
+

{event.title}

+

{event.destination}

+
+
+ +
+
+
+
+ + +
+
+ ); +}; + +export default EventCard; diff --git a/src/app/event/JourneyList.tsx b/src/app/event/JourneyList.tsx new file mode 100644 index 0000000..9ec802b --- /dev/null +++ b/src/app/event/JourneyList.tsx @@ -0,0 +1,50 @@ +"use client"; + +import React from "react"; +import { Journey } from "@/types"; +import { formatTime } from "@/lib/formatting"; +import LeaveByBadge from "./LeaveByBadge"; +import { calculateCountdown } from "@/lib/countdown-utils"; + +type JourneyListProps = { + journeys: Journey[]; + eventTime: Date; + className?: string; +}; + +const JourneyList: React.FC = ({ journeys, eventTime, className = "" }) => { + if (journeys.length === 0) { + return
No journeys found
; + } + + return ( +
    + {journeys.map((journey) => ( +
  • +
    +
    + {formatTime(journey.sD)} + {journey.platform} + {journey.delay > 0 && ( + {`+${journey.delay}'`} + )} +
    +
    + + {journey.cancelled ? "Cancelled" : journey.trains.join(" → ")} + +
    +
    + +
    +
    +
    + {journey.changes > 0 ? Change(s): {journey.changes} : Direct} +
    +
  • + ))} +
+ ); +}; + +export default JourneyList; diff --git a/src/app/event/LeaveByBadge.tsx b/src/app/event/LeaveByBadge.tsx new file mode 100644 index 0000000..9529a6c --- /dev/null +++ b/src/app/event/LeaveByBadge.tsx @@ -0,0 +1,22 @@ +'use client'; + +import React from 'react'; +import { CountdownInfo } from '@/types'; +import Chip from '@/app/ui/Chip'; + +type LeaveByBadgeProps = { + countdown: CountdownInfo; + className?: string; +}; + +const LeaveByBadge: React.FC = ({ countdown, className = '' }) => { + return ( + + {countdown.label} + + ); +}; + +export default LeaveByBadge; diff --git a/src/app/event/TrainSection.tsx b/src/app/event/TrainSection.tsx new file mode 100644 index 0000000..ed8bbae --- /dev/null +++ b/src/app/event/TrainSection.tsx @@ -0,0 +1,57 @@ +"use client"; + +import React from "react"; +import { Journey } from "@/types"; +import { formatDateTime } from "@/lib/formatting"; +import JourneyList from "./JourneyList"; +import LoadingSpinner from "@/app/ui/LoadingSpinner"; +import Button from "@/app/ui/Button"; + +type TrainSectionProps = { + journeys: Journey[]; + eventTime: Date; + destName: string; + loading: boolean; + onRefresh?: () => void; + className?: string; +}; + +const TrainSection: React.FC = ({ + journeys, + eventTime, + destName, + loading, + onRefresh, + className = "", +}) => { + return ( +
+
+
+
+

Trains

+

+ To {destName} {formatDateTime(eventTime)} +

+
+ {onRefresh && ( + + )} +
+
+
+ {loading ? ( +
+ +
+ ) : ( + + )} +
+
+ ); +}; + +export default TrainSection; diff --git a/src/app/layout/Header.tsx b/src/app/layout/Header.tsx new file mode 100644 index 0000000..198e104 --- /dev/null +++ b/src/app/layout/Header.tsx @@ -0,0 +1,50 @@ +"use client"; + +import React, { useState } from "react"; +import Button from "@/app/ui/Button"; +import AddEventModal from "@/app/add-event/AddEventModal"; +import { useServerHealth } from "@/hooks/useServerHealth"; +import { useEventsStore } from "@/hooks/useEventsStore"; + +type HeaderProps = { + className?: string; +}; + +const Header: React.FC = ({ className = "" }) => { + const [showAddEventModal, setShowAddEventModal] = useState(false); + const { status } = useServerHealth(); + const { events } = useEventsStore(); + + return ( +
+
+
+
+

+ ÖBB Planner +

+
+
+
+ + {events.length} + + events + {status === true ? ( + Online + ) : status === false ? ( + Offline + ) : null} +
+ +
+
+
+ setShowAddEventModal(false)} /> +
+ ); +}; + +export default Header; diff --git a/src/app/layout/Navbar.tsx b/src/app/layout/Navbar.tsx new file mode 100644 index 0000000..2901d43 --- /dev/null +++ b/src/app/layout/Navbar.tsx @@ -0,0 +1,40 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +type NavbarProps = { + className?: string; +}; + +const Navbar: React.FC = ({ className = "" }) => { + const pathname = usePathname(); + + const navLinks = [ + { name: "Home", href: "/" }, + { name: "Calendar", href: "/calendar" }, + ]; + + return ( + + ); +}; + +export default Navbar; diff --git a/src/app/ui/Button.tsx b/src/app/ui/Button.tsx new file mode 100644 index 0000000..d998d2a --- /dev/null +++ b/src/app/ui/Button.tsx @@ -0,0 +1,31 @@ +"use client"; + +import React from "react"; + +type ButtonProps = React.ButtonHTMLAttributes & { + variant?: "primary" | "secondary" | "danger"; + size?: "sm" | "md" | "lg"; +}; + +const Button: React.FC = ({ children, className = "", variant = "primary", size = "md", ...props }) => { + const baseStyles = "font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2"; + const variantStyles = { + primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500", + secondary: + "bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-500", + danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500", + }; + const sizeStyles = { + sm: "px-3 py-1.5 text-sm", + md: "px-4 py-2 text-base", + lg: "px-6 py-3 text-lg", + }; + + return ( + + ); +}; + +export default Button; diff --git a/src/app/ui/Chip.tsx b/src/app/ui/Chip.tsx new file mode 100644 index 0000000..81e54f9 --- /dev/null +++ b/src/app/ui/Chip.tsx @@ -0,0 +1,20 @@ +'use client'; + +import React from 'react'; + +type ChipProps = { + children: React.ReactNode; + className?: string; +}; + +const Chip: React.FC = ({ children, className = '' }) => { + return ( + + {children} + + ); +}; + +export default Chip; diff --git a/src/app/ui/LoadingSpinner.tsx b/src/app/ui/LoadingSpinner.tsx new file mode 100644 index 0000000..fe4b156 --- /dev/null +++ b/src/app/ui/LoadingSpinner.tsx @@ -0,0 +1,29 @@ +'use client'; + +import React from 'react'; + +type LoadingSpinnerProps = { + size?: 'sm' | 'md' | 'lg'; + className?: string; +}; + +const LoadingSpinner: React.FC = ({ + size = 'md', + className = '', +}) => { + const sizeClasses = { + sm: 'w-4 h-4', + md: 'w-8 h-8', + lg: 'w-12 h-12', + }; + + return ( +
+ Loading... +
+ ); +}; + +export default LoadingSpinner; diff --git a/src/hooks/useEventsStore.tsx b/src/hooks/useEventsStore.tsx index 5f430e4..e2e7a98 100644 --- a/src/hooks/useEventsStore.tsx +++ b/src/hooks/useEventsStore.tsx @@ -1,21 +1,21 @@ import { createContext, useContext, useState, useCallback, ReactNode } from "react"; -import type { CalendarEvent } from "@/types"; +import type { Event } from "@/types"; interface EventsContextType { - events: CalendarEvent[]; - addEvent: (event: CalendarEvent) => void; - updateEvent: (id: string, updates: Partial) => void; + events: Event[]; + addEvent: (event: Event) => void; + updateEvent: (id: string, updates: Partial) => void; removeEvent: (id: string) => void; clearEvents: () => void; - setEvents: (events: CalendarEvent[]) => void; + setEvents: (events: Event[]) => void; } const EventsContext = createContext(undefined); export function EventsProvider({ children }: { children: ReactNode }) { - const [events, setEvents] = useState([]); + const [events, setEvents] = useState([]); - const addEvent = useCallback((event: CalendarEvent) => { + const addEvent = useCallback((event: Event) => { setEvents((prev) => { const exists = prev.some((e) => e.id === event.id); if (exists) { @@ -25,10 +25,8 @@ export function EventsProvider({ children }: { children: ReactNode }) { }); }, []); - const updateEvent = useCallback((id: string, updates: Partial) => { - setEvents((prev) => - prev.map((event) => (event.id === id ? { ...event, ...updates } : event)) - ); + const updateEvent = useCallback((id: string, updates: Partial) => { + setEvents((prev) => prev.map((event) => (event.id === id ? { ...event, ...updates } : event))); }, []); const removeEvent = useCallback((id: string) => { @@ -40,14 +38,16 @@ export function EventsProvider({ children }: { children: ReactNode }) { }, []); return ( - + {children} );