rewrite phase 5

This commit is contained in:
2026-05-09 14:57:28 +02:00
parent 3d2aea7f7c
commit edeb8ef8e3
16 changed files with 812 additions and 33 deletions
+14 -14
View File
@@ -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] |
---
+164
View File
@@ -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;
+53
View File
@@ -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;
+85
View File
@@ -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;
+49
View File
@@ -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;
+76
View File
@@ -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;
+53
View File
@@ -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;
+50
View File
@@ -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;
+22
View File
@@ -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;
+57
View File
@@ -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;
+50
View File
@@ -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;
+40
View File
@@ -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;
+31
View File
@@ -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;
+20
View File
@@ -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;
+29
View File
@@ -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;
+19 -19
View File
@@ -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={{
events,
addEvent,
updateEvent,
removeEvent,
clearEvents,
setEvents
}}>
<EventsContext.Provider
value={{
events,
addEvent,
updateEvent,
removeEvent,
clearEvents,
setEvents,
}}
>
{children}
</EventsContext.Provider>
);