Switch mobile app to dark theme and fix calendar filtering
Switch mobile app to dark theme and fix calendar filtering - Default to dark theme in mobile app, matching web app style - Filter native calendar events by location to exclude empty ones - Add batch edit panel for destinations on web calendar - Add edit support to AddEventModal - Update notification trigger input type export
This commit is contained in:
@@ -1,92 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { format } from "date-fns";
|
||||
import Button from "@/app/ui/Button";
|
||||
import { useEventsStore } from "@/hooks/useEventsStore";
|
||||
import type { Event } from "@timetoleave/core";
|
||||
|
||||
type AddEventModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
editEvent?: Event;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, className = "" }) => {
|
||||
const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, editEvent, className = "" }) => {
|
||||
const isEditing = !!editEvent;
|
||||
const [title, setTitle] = useState("");
|
||||
const [destination, setDestination] = useState("");
|
||||
const [eventTime, setEventTime] = useState("");
|
||||
const [eventDate, setEventDate] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const { addEvent } = useEventsStore();
|
||||
const { addEvent, updateEvent } = useEventsStore();
|
||||
|
||||
// Populate fields when opening in edit mode
|
||||
useEffect(() => {
|
||||
if (editEvent) {
|
||||
setTitle(editEvent.title);
|
||||
setDestination(editEvent.destination);
|
||||
setEventDate(format(editEvent.eventTime, "yyyy-MM-dd"));
|
||||
setEventTime(format(editEvent.eventTime, "HH:mm"));
|
||||
} else {
|
||||
setTitle("");
|
||||
setDestination("");
|
||||
setEventDate("");
|
||||
setEventTime("");
|
||||
}
|
||||
setSuccess(false);
|
||||
}, [editEvent, isOpen]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim() || !destination.trim() || !eventTime.trim() || !eventDate.trim()) {
|
||||
return;
|
||||
}
|
||||
if (!title.trim() || !destination.trim() || !eventTime.trim() || !eventDate.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const eventTimeDate = new Date(`${eventDate} ${eventTime}`);
|
||||
const eventTimeDate = new Date(`${eventDate}T${eventTime}`);
|
||||
|
||||
await addEvent({
|
||||
id: `manual-${Date.now()}`,
|
||||
title: title.trim(),
|
||||
destination: destination.trim(),
|
||||
eventTime: eventTimeDate,
|
||||
source: "manual",
|
||||
});
|
||||
if (isEditing && editEvent) {
|
||||
updateEvent(editEvent.id, {
|
||||
title: title.trim(),
|
||||
destination: destination.trim(),
|
||||
eventTime: eventTimeDate,
|
||||
});
|
||||
} else {
|
||||
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();
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
setSuccess(false);
|
||||
}, 1200);
|
||||
} catch (err) {
|
||||
console.error("Error adding event:", err);
|
||||
console.error("Error saving event:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Close modal when clicking outside
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
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);
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
if (isOpen) document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className={`brand-panel w-full max-w-md rounded-2xl ${className}`}>
|
||||
<div className={`brand-panel w-full max-w-md rounded-2xl relative ${className}`}>
|
||||
<div className="p-6">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.24em] text-[#D946EF]">Manual event</p>
|
||||
<h3 className="mb-6 text-2xl font-bold text-white">Add a departure target</h3>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.24em] text-[#D946EF]">
|
||||
{isEditing ? "Edit event" : "Manual event"}
|
||||
</p>
|
||||
<h3 className="mb-6 text-2xl font-bold text-white">
|
||||
{isEditing ? "Edit departure target" : "Add a departure target"}
|
||||
</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="event-title" className="mb-1 block text-sm font-medium text-[#F4F1EA]/76">
|
||||
@@ -103,10 +119,7 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="event-destination"
|
||||
className="mb-1 block text-sm font-medium text-[#F4F1EA]/76"
|
||||
>
|
||||
<label htmlFor="event-destination" className="mb-1 block text-sm font-medium text-[#F4F1EA]/76">
|
||||
Destination
|
||||
</label>
|
||||
<input
|
||||
@@ -152,11 +165,24 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? <>Add Event</> : <>Add Event</>}
|
||||
{isEditing ? "Save Changes" : "Add Event"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{success && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-2xl bg-[#090816]/90 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-gradient-to-br from-[#34C759] to-[#30d158] text-3xl shadow-lg">
|
||||
✓
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-white">
|
||||
{isEditing ? "Changes saved" : "Event added"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { useEventsStore } from "@/hooks/useEventsStore";
|
||||
import Button from "@/app/ui/Button";
|
||||
|
||||
function sourceLabel(source: string): string {
|
||||
if (source === "manual") return "Manually added";
|
||||
if (source === "google_calendar") return "Google Calendar";
|
||||
if (source.startsWith("calendar:")) {
|
||||
const url = source.slice("calendar:".length);
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return url.slice(0, 40);
|
||||
}
|
||||
}
|
||||
if (source.startsWith("native:")) return `Device calendar (${source.slice(7)})`;
|
||||
return source;
|
||||
}
|
||||
|
||||
export default function BatchEditPanel() {
|
||||
const { events, updateEvent } = useEventsStore();
|
||||
|
||||
const [sourceFilter, setSourceFilter] = useState<string>("__all__");
|
||||
const [destContains, setDestContains] = useState("");
|
||||
const [newDestination, setNewDestination] = useState("");
|
||||
const [applied, setApplied] = useState<number | null>(null);
|
||||
|
||||
const sources = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
for (const e of events) seen.add(e.source);
|
||||
return Array.from(seen).sort();
|
||||
}, [events]);
|
||||
|
||||
const matched = useMemo(() => {
|
||||
return events.filter((e) => {
|
||||
const sourceMatch = sourceFilter === "__all__" || e.source === sourceFilter;
|
||||
const destMatch = destContains.trim() === "" ||
|
||||
e.destination.toLowerCase().includes(destContains.trim().toLowerCase());
|
||||
return sourceMatch && destMatch;
|
||||
});
|
||||
}, [events, sourceFilter, destContains]);
|
||||
|
||||
const handleApply = () => {
|
||||
if (!newDestination.trim() || matched.length === 0) return;
|
||||
for (const e of matched) {
|
||||
updateEvent(e.id, { destination: newDestination.trim() });
|
||||
}
|
||||
setApplied(matched.length);
|
||||
setNewDestination("");
|
||||
setTimeout(() => setApplied(null), 3000);
|
||||
};
|
||||
|
||||
if (events.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="brand-panel overflow-hidden rounded-2xl">
|
||||
<div className="border-b border-white/10 p-4">
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-[0.24em] text-brand-fuchsia">Bulk action</p>
|
||||
<h3 className="text-lg font-semibold text-white">Batch-edit destinations</h3>
|
||||
<p className="mt-1 text-sm text-[#F4F1EA]/55">
|
||||
Replace the destination on multiple events at once — useful when a calendar stores a room number but you need a full address.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="batch-source" className="mb-1 block text-xs font-semibold uppercase tracking-[0.18em] text-[#F4F1EA]/55">
|
||||
Calendar source
|
||||
</label>
|
||||
<select
|
||||
id="batch-source"
|
||||
value={sourceFilter}
|
||||
onChange={(e) => { setSourceFilter(e.target.value); setApplied(null); }}
|
||||
className="brand-input px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="__all__">All sources ({events.length} events)</option>
|
||||
{sources.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{sourceLabel(s)} ({events.filter((e) => e.source === s).length})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="batch-dest-filter" className="mb-1 block text-xs font-semibold uppercase tracking-[0.18em] text-[#F4F1EA]/55">
|
||||
Current destination contains
|
||||
</label>
|
||||
<input
|
||||
id="batch-dest-filter"
|
||||
type="text"
|
||||
value={destContains}
|
||||
onChange={(e) => { setDestContains(e.target.value); setApplied(null); }}
|
||||
placeholder="e.g. HS, Seminarraum, Room …"
|
||||
className="brand-input px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-[#F4F1EA]/55">
|
||||
Matched events ({matched.length})
|
||||
</p>
|
||||
{matched.length === 0 ? (
|
||||
<p className="text-sm text-[#F4F1EA]/40">No events match the current filters.</p>
|
||||
) : (
|
||||
<ul className="max-h-48 overflow-y-auto space-y-1 rounded-xl border border-white/10 bg-black/20 p-2">
|
||||
{matched.map((e) => (
|
||||
<li key={e.id} className="flex items-baseline justify-between gap-3 rounded-lg px-2 py-1.5 text-sm hover:bg-white/[0.04]">
|
||||
<span className="font-medium text-white truncate">{e.title}</span>
|
||||
<span className="shrink-0 text-[#F4F1EA]/46 text-xs">
|
||||
{e.destination} · {format(e.eventTime, "dd MMM")}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Replace with */}
|
||||
<div className="flex gap-3 items-end">
|
||||
<div className="flex-1">
|
||||
<label htmlFor="batch-new-dest" className="mb-1 block text-xs font-semibold uppercase tracking-[0.18em] text-[#F4F1EA]/55">
|
||||
Replace destination with
|
||||
</label>
|
||||
<input
|
||||
id="batch-new-dest"
|
||||
type="text"
|
||||
value={newDestination}
|
||||
onChange={(e) => { setNewDestination(e.target.value); setApplied(null); }}
|
||||
placeholder="e.g. Universitätsplatz 3, 8010 Graz"
|
||||
className="brand-input px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleApply}
|
||||
disabled={!newDestination.trim() || matched.length === 0}
|
||||
className="shrink-0"
|
||||
>
|
||||
Apply to {matched.length} event{matched.length === 1 ? "" : "s"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{applied !== null && (
|
||||
<p className="text-sm font-medium text-emerald-400">
|
||||
✓ Updated destination on {applied} event{applied === 1 ? "" : "s"}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useOriginStation } from "@/hooks/useOriginStation";
|
||||
import CalendarView from "./CalendarView";
|
||||
import DayEvents from "./DayEvents";
|
||||
import CalendarPanel from "./CalendarPanel";
|
||||
import BatchEditPanel from "./BatchEditPanel";
|
||||
|
||||
export default function CalendarPage() {
|
||||
const { events } = useEventsStore();
|
||||
@@ -20,8 +21,9 @@ export default function CalendarPage() {
|
||||
<p className="mt-2 text-brand-light/66">View and manage every appointment from a single departure-focused calendar.</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="mb-6 space-y-4">
|
||||
<CalendarPanel />
|
||||
<BatchEditPanel />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
@@ -11,11 +11,13 @@ import { useClock } from "@/hooks/useClock";
|
||||
import { useDepartureTime } from "@/hooks/useDepartureTime";
|
||||
import { useReminderSettings } from "@/hooks/useReminderSettings";
|
||||
import { useWienerLinien } from "@/hooks/useWienerLinien";
|
||||
import { useEventsStore } from "@/hooks/useEventsStore";
|
||||
import type { Event, Station } from "@timetoleave/core";
|
||||
import TrainSection from "./TrainSection";
|
||||
import BikeSection from "./BikeSection";
|
||||
import WienerLinienSection from "./WienerLinienSection";
|
||||
import CountdownBadge from "@/app/ui/CountdownBadge";
|
||||
import AddEventModal from "@/app/add-event/AddEventModal";
|
||||
|
||||
interface EventCardProps {
|
||||
event: Event;
|
||||
@@ -23,6 +25,15 @@ interface EventCardProps {
|
||||
}
|
||||
|
||||
export default function EventCard({ event, originStation }: EventCardProps) {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const { removeEvent } = useEventsStore();
|
||||
|
||||
const handleRemove = () => {
|
||||
if (window.confirm(`Remove "${event.title}"?`)) {
|
||||
removeEvent(event.id);
|
||||
}
|
||||
};
|
||||
|
||||
const destStation = useDestinationStation(event.destination);
|
||||
|
||||
const {
|
||||
@@ -83,13 +94,30 @@ export default function EventCard({ event, originStation }: EventCardProps) {
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="brand-panel overflow-hidden rounded-2xl p-4 sm:p-5">
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-[0.24em] text-brand-fuchsia">Next stop</p>
|
||||
<h3 className="text-2xl font-bold text-white">{event.title}</h3>
|
||||
</div>
|
||||
<CountdownBadge countdown={countdown} status={status} />
|
||||
<div className="flex items-start gap-2">
|
||||
<CountdownBadge countdown={countdown} status={status} />
|
||||
<button
|
||||
onClick={() => setEditOpen(true)}
|
||||
className="rounded-lg border border-white/10 bg-white/[0.05] px-2.5 py-1.5 text-xs font-medium text-[#F4F1EA]/60 transition-colors hover:border-brand-fuchsia/40 hover:text-white"
|
||||
aria-label="Edit event"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
className="rounded-lg border border-white/10 bg-white/[0.05] px-2.5 py-1.5 text-xs font-medium text-[#F4F1EA]/60 transition-colors hover:border-red-500/50 hover:text-red-400"
|
||||
aria-label="Remove event"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 grid gap-3 sm:grid-cols-2">
|
||||
@@ -178,5 +206,7 @@ export default function EventCard({ event, originStation }: EventCardProps) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<AddEventModal isOpen={editOpen} onClose={() => setEditOpen(false)} editEvent={event} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,6 +95,18 @@ vi.mock("@/hooks/useClock", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useEventsStore", () => ({
|
||||
useEventsStore: () => ({
|
||||
events: [],
|
||||
addEvent: vi.fn(),
|
||||
updateEvent: vi.fn(),
|
||||
removeEvent: vi.fn(),
|
||||
clearEvents: vi.fn(),
|
||||
setEvents: vi.fn(),
|
||||
mergeEvents: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/countdown-utils", () => ({
|
||||
calculateCountdown: () => ({
|
||||
label: "No deadline set",
|
||||
|
||||
Reference in New Issue
Block a user