rewrite phase 5
This commit is contained in:
+14
-14
@@ -69,20 +69,20 @@
|
|||||||
|
|
||||||
| # | Item | ✅ | ✔️ |
|
| # | Item | ✅ | ✔️ |
|
||||||
|---|------|----|----|
|
|---|------|----|----|
|
||||||
| 27 | `ui/Chip.tsx` — small badge component | [ ] | [ ] |
|
| 27 | `ui/Chip.tsx` — small badge component | [x] | [ ] |
|
||||||
| 28 | `ui/Button.tsx` — styled button | [ ] | [ ] |
|
| 28 | `ui/Button.tsx` — styled button | [x] | [x] |
|
||||||
| 29 | `ui/LoadingSpinner.tsx` — loading indicator | [ ] | [ ] |
|
| 29 | `ui/LoadingSpinner.tsx` — loading indicator | [x] | [ ] |
|
||||||
| 30 | `event/LeaveByBadge.tsx` — countdown badge | [ ] | [ ] |
|
| 30 | `event/LeaveByBadge.tsx` — countdown badge | [x] | [ ] |
|
||||||
| 31 | `event/JourneyList.tsx` — departure rows | [ ] | [ ] |
|
| 31 | `event/JourneyList.tsx` — departure rows | [x] | [ ] |
|
||||||
| 32 | `event/TrainSection.tsx` — train data in event card | [ ] | [ ] |
|
| 32 | `event/TrainSection.tsx` — train data in event card | [x] | [ ] |
|
||||||
| 33 | `event/BikeSection.tsx` — bicycle data in event card (NEW) | [ ] | [ ] |
|
| 33 | `event/BikeSection.tsx` — bicycle data in event card (NEW) | [x] | [ ] |
|
||||||
| 34 | `event/EventCard.tsx` — composes train + bike sections | [ ] | [ ] |
|
| 34 | `event/EventCard.tsx` — composes train + bike sections | [x] | [ ] |
|
||||||
| 35 | `calendar/UrlTab.tsx` | [ ] | [ ] |
|
| 35 | `calendar/UrlTab.tsx` | [x] | [ ] |
|
||||||
| 36 | `calendar/FileTab.tsx` | [ ] | [ ] |
|
| 36 | `calendar/FileTab.tsx` | [x] | [x] |
|
||||||
| 37 | `calendar/CalendarPanel.tsx` | [ ] | [ ] |
|
| 37 | `calendar/CalendarPanel.tsx` | [x] | [x] |
|
||||||
| 38 | `add-event/AddEventModal.tsx` | [ ] | [ ] |
|
| 38 | `add-event/AddEventModal.tsx` | [x] | [x] |
|
||||||
| 39 | `layout/Header.tsx` | [ ] | [ ] |
|
| 39 | `layout/Header.tsx` | [x] | [x] |
|
||||||
| 40 | `layout/Navbar.tsx` (NEW) | [ ] | [ ] |
|
| 40 | `layout/Navbar.tsx` (NEW) | [x] | [x] |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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<AddEventModalProps> = ({ 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 (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||||
|
onClick={handleBackdropClick}
|
||||||
|
>
|
||||||
|
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full ${className}`}>
|
||||||
|
<div className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">Add Manual Event</h3>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="event-title" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Event Title
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="event-title"
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="event-destination"
|
||||||
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
|
>
|
||||||
|
Destination
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="event-destination"
|
||||||
|
type="text"
|
||||||
|
value={destination}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="event-date" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="event-date"
|
||||||
|
type="date"
|
||||||
|
value={eventDate}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="event-time" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Time
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="event-time"
|
||||||
|
type="time"
|
||||||
|
value={eventTime}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end space-x-3 mt-6">
|
||||||
|
<Button type="button" variant="secondary" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? <>Add Event</> : <>Add Event</>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddEventModal;
|
||||||
@@ -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<void>;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CalendarPanel: React.FC<CalendarPanelProps> = ({ onLoadCalendar, loading, error, className = "" }) => {
|
||||||
|
const [activeTab, setActiveTab] = useState<"url" | "file">("url");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden ${className}`}>
|
||||||
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Import Calendar</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="border-b border-gray-200 dark:border-gray-700 mb-4">
|
||||||
|
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
|
||||||
|
<button
|
||||||
|
className={`pb-2 px-1 border-b-2 font-medium text-sm ${activeTab === "url" ? "border-blue-500 text-blue-600" : "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"}`}
|
||||||
|
onClick={() => setActiveTab("url")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
URL
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`pb-2 px-1 border-b-2 font-medium text-sm ${activeTab === "file" ? "border-blue-500 text-blue-600" : "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"}`}
|
||||||
|
onClick={() => setActiveTab("file")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
File
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{activeTab === "url" ? (
|
||||||
|
<UrlTab onLoadCalendar={(url) => onLoadCalendar(url)} loading={loading} error={error} />
|
||||||
|
) : (
|
||||||
|
<FileTab onLoadCalendar={(file) => onLoadCalendar(file)} loading={loading} error={error} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CalendarPanel;
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useRef, useState } from "react";
|
||||||
|
import Button from "@/app/ui/Button";
|
||||||
|
|
||||||
|
type FileTabProps = {
|
||||||
|
onLoadCalendar: (file: File) => Promise<void>;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FileTab: React.FC<FileTabProps> = ({ onLoadCalendar, loading, error, className = "" }) => {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className={`p-4 ${className}`}>
|
||||||
|
<div
|
||||||
|
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer ${dragging ? "border-blue-500 bg-blue-50" : "border-gray-300"}`}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<input type="file" ref={fileInputRef} onChange={handleFileSelect} accept=".ics" className="hidden" />
|
||||||
|
{loading ? <>Loading calendar...</> : <>Click to upload or drag and drop an .ics file here</>}
|
||||||
|
</div>
|
||||||
|
{error && <div className="mt-4 text-red-600 text-sm">{error}</div>}
|
||||||
|
<Button onClick={() => fileInputRef.current?.click()} disabled={loading}>
|
||||||
|
{loading ? <>Select File</> : <>Select File</>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileTab;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import Button from "@/app/ui/Button";
|
||||||
|
|
||||||
|
type UrlTabProps = {
|
||||||
|
onLoadCalendar: (url: string) => Promise<void>;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const UrlTab: React.FC<UrlTabProps> = ({ onLoadCalendar, loading, error, className = "" }) => {
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (url.trim()) {
|
||||||
|
await onLoadCalendar(url.trim());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`p-4 ${className}`}>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="calendar-url" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Calendar URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="calendar-url"
|
||||||
|
type="url"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <div className="text-red-600 text-sm">{error}</div>}
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? <>Load Calendar</> : <>Load Calendar</>}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UrlTab;
|
||||||
@@ -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<BikeSectionProps> = ({ bikeRoute, bikeLoading, bikeError, onRefresh, className = "" }) => {
|
||||||
|
if (!bikeRoute && !bikeLoading && !bikeError) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden mt-4 ${className}`}>
|
||||||
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Bicycle Route</h3>
|
||||||
|
</div>
|
||||||
|
{onRefresh && (
|
||||||
|
<Button variant="secondary" size="sm" onClick={onRefresh}>
|
||||||
|
{bikeLoading ? <>Refresh</> : <>Refresh</>}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
{bikeLoading ? (
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<LoadingSpinner size="md" />
|
||||||
|
</div>
|
||||||
|
) : bikeError ? (
|
||||||
|
<div className="text-center py-4 text-red-600">Error: {bikeError}</div>
|
||||||
|
) : bikeRoute ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600 dark:text-gray-300">Distance</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{Math.round(bikeRoute.distance / 1000)} km ({Math.round(bikeRoute.distance)} m)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600 dark:text-gray-300">Duration</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{Math.floor(bikeRoute.duration / 60)} min {bikeRoute.duration % 60} s
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{bikeRoute.steps && bikeRoute.steps.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h4 className="font-medium text-gray-800 dark:text-white mb-2">Steps</h4>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{bikeRoute.steps.map((step, index) => (
|
||||||
|
<li key={index} className="text-sm">
|
||||||
|
<span className="font-medium text-blue-600 dark:text-blue-400">{step.name}</span>
|
||||||
|
<span className="ml-2 text-gray-600 dark:text-gray-300">{step.instruction}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BikeSection;
|
||||||
@@ -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<EventCardProps> = ({
|
||||||
|
event,
|
||||||
|
tripData,
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={
|
||||||
|
`bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden ${className}`
|
||||||
|
}>
|
||||||
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white">{event.title}</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300">{event.destination}</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-4 flex items-center">
|
||||||
|
<LeaveByBadge countdown={calculateCountdown(event.eventTime)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<TrainSection
|
||||||
|
journeys={tripData.journeys}
|
||||||
|
eventTime={event.eventTime}
|
||||||
|
destName={event.destination}
|
||||||
|
loading={tripData.loading}
|
||||||
|
/>
|
||||||
|
<BikeSection
|
||||||
|
bikeRoute={tripData.bikeRoute}
|
||||||
|
bikeLoading={tripData.bikeLoading || false}
|
||||||
|
bikeError={tripData.bikeError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EventCard;
|
||||||
@@ -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<JourneyListProps> = ({ journeys, eventTime, className = "" }) => {
|
||||||
|
if (journeys.length === 0) {
|
||||||
|
return <div className={`text-center py-8 text-gray-500 ${className}`}>No journeys found</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className={`space-y-3 ${className}`}>
|
||||||
|
{journeys.map((journey) => (
|
||||||
|
<li key={journey.id} className="border-b border-gray-200 pb-3 last:border-b-0 last:pb-0">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="font-medium">{formatTime(journey.sD)}</span>
|
||||||
|
<span className="ml-2 text-sm text-gray-500">{journey.platform}</span>
|
||||||
|
{journey.delay > 0 && (
|
||||||
|
<span className="ml-2 text-sm font-medium text-orange-600">{`+${journey.delay}'`}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-right">
|
||||||
|
<span className={`font-medium ${journey.cancelled ? "text-red-600" : "text-gray-800"}`}>
|
||||||
|
{journey.cancelled ? "Cancelled" : journey.trains.join(" → ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-right">
|
||||||
|
<LeaveByBadge countdown={calculateCountdown(eventTime)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
{journey.changes > 0 ? <span>Change(s): {journey.changes}</span> : <span>Direct</span>}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default JourneyList;
|
||||||
@@ -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<LeaveByBadgeProps> = ({ countdown, className = '' }) => {
|
||||||
|
return (
|
||||||
|
<Chip className={
|
||||||
|
`${className} ${countdown.color} ${countdown.urgent ? 'animate-pulse' : ''}`
|
||||||
|
}>
|
||||||
|
{countdown.label}
|
||||||
|
</Chip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LeaveByBadge;
|
||||||
@@ -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<TrainSectionProps> = ({
|
||||||
|
journeys,
|
||||||
|
eventTime,
|
||||||
|
destName,
|
||||||
|
loading,
|
||||||
|
onRefresh,
|
||||||
|
className = "",
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden ${className}`}>
|
||||||
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Trains</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
To {destName} <span className="font-mono">{formatDateTime(eventTime)}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{onRefresh && (
|
||||||
|
<Button variant="secondary" size="sm" onClick={onRefresh}>
|
||||||
|
{loading ? <>Refresh</> : <>Refresh</>}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-0">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<JourneyList journeys={journeys} eventTime={eventTime} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TrainSection;
|
||||||
@@ -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<HeaderProps> = ({ className = "" }) => {
|
||||||
|
const [showAddEventModal, setShowAddEventModal] = useState(false);
|
||||||
|
const { status } = useServerHealth();
|
||||||
|
const { events } = useEventsStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className={`bg-white dark:bg-gray-800 shadow-sm ${className}`}>
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between h-16">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
<span className="text-blue-600">ÖBB</span> Planner
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="hidden md:flex items-center text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<span>
|
||||||
|
<strong>{events.length}</strong>
|
||||||
|
</span>
|
||||||
|
<span className="ml-1">events</span>
|
||||||
|
{status === true ? (
|
||||||
|
<span className="ml-2 text-green-600">Online</span>
|
||||||
|
) : status === false ? (
|
||||||
|
<span className="ml-2 text-red-600">Offline</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => setShowAddEventModal(true)}>
|
||||||
|
Add Event
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AddEventModal isOpen={showAddEventModal} onClose={() => setShowAddEventModal(false)} />
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
||||||
@@ -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<NavbarProps> = ({ className = "" }) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ name: "Home", href: "/" },
|
||||||
|
{ name: "Calendar", href: "/calendar" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className={`bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 ${className}`}>
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between h-16">
|
||||||
|
<div className="flex space-x-8">
|
||||||
|
{navLinks.map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className={`text-sm font-medium ${pathname === link.href ? "text-blue-600 border-b-2 border-blue-600" : "text-gray-500 hover:text-gray-700 hover:border-gray-300"}`}
|
||||||
|
>
|
||||||
|
{link.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Navbar;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
|
variant?: "primary" | "secondary" | "danger";
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
};
|
||||||
|
|
||||||
|
const Button: React.FC<ButtonProps> = ({ 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 (
|
||||||
|
<button className={`${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${className}`} {...props}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Button;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type ChipProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Chip: React.FC<ChipProps> = ({ children, className = '' }) => {
|
||||||
|
return (
|
||||||
|
<span className={
|
||||||
|
`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200 ${className}`
|
||||||
|
}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Chip;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type LoadingSpinnerProps = {
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||||
|
size = 'md',
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'w-4 h-4',
|
||||||
|
md: 'w-8 h-8',
|
||||||
|
lg: 'w-12 h-12',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={
|
||||||
|
`inline-block animate-spin rounded-full border-4 border-solid border-blue-500 border-t-transparent ${sizeClasses[size]} ${className}`
|
||||||
|
}>
|
||||||
|
<span className="sr-only">Loading...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoadingSpinner;
|
||||||
@@ -1,21 +1,21 @@
|
|||||||
import { createContext, useContext, useState, useCallback, ReactNode } from "react";
|
import { createContext, useContext, useState, useCallback, ReactNode } from "react";
|
||||||
import type { CalendarEvent } from "@/types";
|
import type { Event } from "@/types";
|
||||||
|
|
||||||
interface EventsContextType {
|
interface EventsContextType {
|
||||||
events: CalendarEvent[];
|
events: Event[];
|
||||||
addEvent: (event: CalendarEvent) => void;
|
addEvent: (event: Event) => void;
|
||||||
updateEvent: (id: string, updates: Partial<CalendarEvent>) => void;
|
updateEvent: (id: string, updates: Partial<Event>) => void;
|
||||||
removeEvent: (id: string) => void;
|
removeEvent: (id: string) => void;
|
||||||
clearEvents: () => void;
|
clearEvents: () => void;
|
||||||
setEvents: (events: CalendarEvent[]) => void;
|
setEvents: (events: Event[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EventsContext = createContext<EventsContextType | undefined>(undefined);
|
const EventsContext = createContext<EventsContextType | undefined>(undefined);
|
||||||
|
|
||||||
export function EventsProvider({ children }: { children: ReactNode }) {
|
export function EventsProvider({ children }: { children: ReactNode }) {
|
||||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
|
|
||||||
const addEvent = useCallback((event: CalendarEvent) => {
|
const addEvent = useCallback((event: Event) => {
|
||||||
setEvents((prev) => {
|
setEvents((prev) => {
|
||||||
const exists = prev.some((e) => e.id === event.id);
|
const exists = prev.some((e) => e.id === event.id);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
@@ -25,10 +25,8 @@ export function EventsProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateEvent = useCallback((id: string, updates: Partial<CalendarEvent>) => {
|
const updateEvent = useCallback((id: string, updates: Partial<Event>) => {
|
||||||
setEvents((prev) =>
|
setEvents((prev) => prev.map((event) => (event.id === id ? { ...event, ...updates } : event)));
|
||||||
prev.map((event) => (event.id === id ? { ...event, ...updates } : event))
|
|
||||||
);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const removeEvent = useCallback((id: string) => {
|
const removeEvent = useCallback((id: string) => {
|
||||||
@@ -40,14 +38,16 @@ export function EventsProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EventsContext.Provider value={{
|
<EventsContext.Provider
|
||||||
|
value={{
|
||||||
events,
|
events,
|
||||||
addEvent,
|
addEvent,
|
||||||
updateEvent,
|
updateEvent,
|
||||||
removeEvent,
|
removeEvent,
|
||||||
clearEvents,
|
clearEvents,
|
||||||
setEvents
|
setEvents,
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</EventsContext.Provider>
|
</EventsContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user