rewrite phase 5
This commit is contained in:
+14
-14
@@ -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] |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 type { CalendarEvent } from "@/types";
|
||||
import type { Event } from "@/types";
|
||||
|
||||
interface EventsContextType {
|
||||
events: CalendarEvent[];
|
||||
addEvent: (event: CalendarEvent) => void;
|
||||
updateEvent: (id: string, updates: Partial<CalendarEvent>) => void;
|
||||
events: Event[];
|
||||
addEvent: (event: Event) => void;
|
||||
updateEvent: (id: string, updates: Partial<Event>) => void;
|
||||
removeEvent: (id: string) => void;
|
||||
clearEvents: () => void;
|
||||
setEvents: (events: CalendarEvent[]) => void;
|
||||
setEvents: (events: Event[]) => void;
|
||||
}
|
||||
|
||||
const EventsContext = createContext<EventsContextType | undefined>(undefined);
|
||||
|
||||
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) => {
|
||||
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<CalendarEvent>) => {
|
||||
setEvents((prev) =>
|
||||
prev.map((event) => (event.id === id ? { ...event, ...updates } : event))
|
||||
);
|
||||
const updateEvent = useCallback((id: string, updates: Partial<Event>) => {
|
||||
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 (
|
||||
<EventsContext.Provider value={{
|
||||
<EventsContext.Provider
|
||||
value={{
|
||||
events,
|
||||
addEvent,
|
||||
updateEvent,
|
||||
removeEvent,
|
||||
clearEvents,
|
||||
setEvents
|
||||
}}>
|
||||
setEvents,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</EventsContext.Provider>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user