1285 lines
50 KiB
TypeScript
1285 lines
50 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import ReactMarkdown from "react-markdown";
|
||
import { useParams, useRouter } from "next/navigation";
|
||
import { useAuth } from "@/components/AuthProvider";
|
||
import { c2api } from "@/lib/c2api";
|
||
import type { TripEvent, TripRecord, PlaceResult } from "@/lib/types";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function uid(): string {
|
||
try { return crypto.randomUUID(); } catch { return Math.random().toString(36).slice(2) + Date.now().toString(36); }
|
||
}
|
||
|
||
function toMin(t: string): number {
|
||
const [h, m] = t.split(":").map(Number);
|
||
return h * 60 + (m ?? 0);
|
||
}
|
||
|
||
function fmtDate(iso: string) {
|
||
return new Date(`${iso}T12:00:00`).toLocaleDateString("en-US", {
|
||
month: "short", day: "numeric", year: "numeric",
|
||
});
|
||
}
|
||
|
||
function fmtDayTab(iso: string) {
|
||
const d = new Date(`${iso}T12:00:00`);
|
||
return {
|
||
short: d.toLocaleDateString("en-US", { weekday: "short" }),
|
||
date: d.toLocaleDateString("en-US", { month: "short", day: "numeric" }),
|
||
};
|
||
}
|
||
|
||
function fmtDayHeading(iso: string) {
|
||
return new Date(`${iso}T12:00:00`).toLocaleDateString("en-US", {
|
||
weekday: "long", month: "long", day: "numeric",
|
||
});
|
||
}
|
||
|
||
function fmtTime(t: string | null | undefined): string {
|
||
if (!t) return "";
|
||
const [h, m] = t.split(":").map(Number);
|
||
const d = new Date(); d.setHours(h, m, 0, 0);
|
||
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
|
||
}
|
||
|
||
function dateRange(start: string, end: string): string[] {
|
||
const dates: string[] = [];
|
||
const cur = new Date(`${start}T12:00:00`);
|
||
const endDate = new Date(`${end}T12:00:00`);
|
||
while (cur <= endDate) {
|
||
dates.push(cur.toISOString().slice(0, 10));
|
||
cur.setDate(cur.getDate() + 1);
|
||
}
|
||
return dates;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tag helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const TAG_PALETTE = [
|
||
"bg-violet-900/60 text-violet-300 border-violet-700/50",
|
||
"bg-sky-900/60 text-sky-300 border-sky-700/50",
|
||
"bg-emerald-900/60 text-emerald-300 border-emerald-700/50",
|
||
"bg-amber-900/60 text-amber-300 border-amber-700/50",
|
||
"bg-rose-900/60 text-rose-300 border-rose-700/50",
|
||
"bg-fuchsia-900/60 text-fuchsia-300 border-fuchsia-700/50",
|
||
"bg-cyan-900/60 text-cyan-300 border-cyan-700/50",
|
||
"bg-orange-900/60 text-orange-300 border-orange-700/50",
|
||
];
|
||
|
||
function tagColor(tag: string, availableTags: string[]): string {
|
||
const idx = availableTags.indexOf(tag);
|
||
return TAG_PALETTE[(idx >= 0 ? idx : 0) % TAG_PALETTE.length];
|
||
}
|
||
|
||
function TagPill({ tag, availableTags }: { tag: string; availableTags: string[] }) {
|
||
return (
|
||
<span className={`inline-block text-[10px] font-medium rounded-full px-2 py-0.5 border ${tagColor(tag, availableTags)}`}>
|
||
{tag}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function detectConflicts(events: TripEvent[], overlapTags: string[] = []): Set<string> {
|
||
const timed = events.filter((e) => e.start_time);
|
||
const conflicts = new Set<string>();
|
||
for (let i = 0; i < timed.length; i++) {
|
||
for (let j = i + 1; j < timed.length; j++) {
|
||
const aExempt = timed[i].tags?.some((t) => overlapTags.includes(t));
|
||
const bExempt = timed[j].tags?.some((t) => overlapTags.includes(t));
|
||
if (aExempt || bExempt) continue;
|
||
const aS = toMin(timed[i].start_time!);
|
||
const aE = timed[i].end_time ? toMin(timed[i].end_time!) : aS + 60;
|
||
const bS = toMin(timed[j].start_time!);
|
||
const bE = timed[j].end_time ? toMin(timed[j].end_time!) : bS + 60;
|
||
if (aS < bE && bS < aE) {
|
||
conflicts.add(timed[i].event_id);
|
||
conflicts.add(timed[j].event_id);
|
||
}
|
||
}
|
||
}
|
||
return conflicts;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Places search dropdown (reusable within modal)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function PlaceSearch({
|
||
near,
|
||
onSelect,
|
||
}: {
|
||
near: string;
|
||
onSelect: (p: PlaceResult) => void;
|
||
}) {
|
||
const [query, setQuery] = useState("");
|
||
const [results, setResults] = useState<PlaceResult[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
async function search() {
|
||
if (!query.trim()) return;
|
||
setLoading(true);
|
||
try {
|
||
const r = await c2api.searchPlaces(query, near);
|
||
setResults(r);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-2">
|
||
<div className="flex gap-2">
|
||
<input
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), search())}
|
||
placeholder="Search a place…"
|
||
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={search}
|
||
disabled={loading}
|
||
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-white text-sm rounded-lg px-3 py-2 transition-colors"
|
||
>
|
||
{loading ? "…" : "Search"}
|
||
</button>
|
||
</div>
|
||
{results.length > 0 && (
|
||
<div className="bg-gray-800 border border-gray-700 rounded-lg overflow-hidden divide-y divide-gray-700">
|
||
{results.map((p) => (
|
||
<button
|
||
key={p.place_id}
|
||
type="button"
|
||
onClick={() => { onSelect(p); setResults([]); setQuery(""); }}
|
||
className="w-full text-left px-3 py-2 hover:bg-gray-700 transition-colors"
|
||
>
|
||
<p className="text-white text-sm font-medium">{p.name}</p>
|
||
<p className="text-gray-400 text-xs">{p.address}</p>
|
||
{p.rating && (
|
||
<p className="text-yellow-400 text-xs">★ {p.rating}</p>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Add Event modal
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function AddEventModal({
|
||
trip,
|
||
onClose,
|
||
onAdd,
|
||
prefill,
|
||
editEventId,
|
||
}: {
|
||
trip: TripRecord;
|
||
onClose: () => void;
|
||
onAdd: (body: object) => Promise<void>;
|
||
prefill?: Partial<TripEvent>;
|
||
editEventId?: string;
|
||
}) {
|
||
const isEdit = !!editEventId;
|
||
const availableTags = trip.available_tags ?? [];
|
||
const [title, setTitle] = useState(prefill?.title ?? "");
|
||
const [date, setDate] = useState(prefill?.date ?? trip.start_date);
|
||
const [startTime, setStartTime] = useState(prefill?.start_time ?? "");
|
||
const [endTime, setEndTime] = useState(prefill?.end_time ?? "");
|
||
const [location, setLocation] = useState(prefill?.location ?? "");
|
||
const [mapsLink, setMapsLink] = useState(prefill?.maps_link ?? "");
|
||
const [placeId, setPlaceId] = useState(prefill?.place_id ?? "");
|
||
const [notes, setNotes] = useState(prefill?.notes ?? "");
|
||
const [tags, setTags] = useState<string[]>(prefill?.tags ?? []);
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
function handlePlaceSelect(p: PlaceResult) {
|
||
if (!title) setTitle(p.name);
|
||
setLocation(p.address);
|
||
setMapsLink(p.maps_link);
|
||
setPlaceId(p.place_id);
|
||
}
|
||
|
||
async function handleSubmit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setSaving(true);
|
||
setError(null);
|
||
try {
|
||
await onAdd({
|
||
title,
|
||
date,
|
||
start_time: startTime || null,
|
||
end_time: endTime || null,
|
||
location: location || null,
|
||
maps_link: mapsLink || null,
|
||
place_id: placeId || null,
|
||
notes: notes || null,
|
||
tags,
|
||
});
|
||
onClose();
|
||
} catch {
|
||
setError(isEdit ? "Failed to save changes." : "Failed to add event. Check the date is within the trip range.");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||
<form
|
||
onSubmit={handleSubmit}
|
||
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-lg space-y-4 max-h-[90vh] overflow-y-auto"
|
||
>
|
||
<h2 className="text-white font-bold text-lg">{isEdit ? "Edit Event" : "Add Event"}</h2>
|
||
|
||
<div>
|
||
<label className="text-xs text-gray-400 block mb-1">Title</label>
|
||
<input
|
||
required value={title} onChange={(e) => setTitle(e.target.value)}
|
||
placeholder="Dinner at the bar"
|
||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div>
|
||
<label className="text-xs text-gray-400 block mb-1">Date</label>
|
||
<input
|
||
required type="date"
|
||
min={trip.start_date} max={trip.end_date}
|
||
value={date} onChange={(e) => setDate(e.target.value)}
|
||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-gray-400 block mb-1">Start</label>
|
||
<input
|
||
type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)}
|
||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-gray-400 block mb-1">End</label>
|
||
<input
|
||
type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)}
|
||
min={startTime}
|
||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="text-xs text-gray-400 block mb-1">Find a place</label>
|
||
<PlaceSearch near={trip.location} onSelect={handlePlaceSelect} />
|
||
</div>
|
||
|
||
<div>
|
||
<label className="text-xs text-gray-400 block mb-1">
|
||
Location{" "}
|
||
<span className="text-gray-600">(auto-filled from search, or type manually)</span>
|
||
</label>
|
||
<input
|
||
value={location} onChange={(e) => setLocation(e.target.value)}
|
||
placeholder={`Inherits: ${trip.location}`}
|
||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||
/>
|
||
</div>
|
||
|
||
{mapsLink && (
|
||
<p className="text-xs text-indigo-400">
|
||
Maps link attached —{" "}
|
||
<a href={mapsLink} target="_blank" rel="noopener noreferrer" className="underline">
|
||
preview
|
||
</a>
|
||
</p>
|
||
)}
|
||
|
||
<div>
|
||
<label className="text-xs text-gray-400 block mb-1">Notes (optional)</label>
|
||
<textarea
|
||
value={notes} onChange={(e) => setNotes(e.target.value)} rows={2}
|
||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none resize-none"
|
||
/>
|
||
</div>
|
||
|
||
{availableTags.length > 0 && (
|
||
<div>
|
||
<label className="text-xs text-gray-400 block mb-1">Tags</label>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{availableTags.map((tag) => {
|
||
const active = tags.includes(tag);
|
||
return (
|
||
<button
|
||
key={tag}
|
||
type="button"
|
||
onClick={() => setTags((prev) => active ? prev.filter((t) => t !== tag) : [...prev, tag])}
|
||
className={`text-xs rounded-full px-2.5 py-1 border transition-colors ${
|
||
active ? tagColor(tag, availableTags) : "bg-gray-800 text-gray-500 border-gray-700 hover:border-gray-600 hover:text-gray-400"
|
||
}`}
|
||
>
|
||
{tag}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||
|
||
<div className="flex gap-3 justify-end pt-1">
|
||
<button type="button" onClick={onClose} className="text-sm text-gray-400 hover:text-gray-200 px-4 py-2">
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit" disabled={saving}
|
||
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm rounded-lg px-4 py-2"
|
||
>
|
||
{saving ? (isEdit ? "Saving…" : "Adding…") : (isEdit ? "Save Changes" : "Add Event")}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Day timeline
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const PX_PER_MIN = 1.3; // 78px per hour
|
||
|
||
function DayTimeline({
|
||
events,
|
||
isAdmin,
|
||
onDelete,
|
||
onEdit,
|
||
driveSegments,
|
||
availableTags,
|
||
overlapTags,
|
||
}: {
|
||
events: TripEvent[];
|
||
isAdmin: boolean;
|
||
onDelete: (id: string) => void;
|
||
onEdit: (event: TripEvent) => void;
|
||
driveSegments: { fromId: string; toId: string; text: string }[];
|
||
availableTags: string[];
|
||
overlapTags: string[];
|
||
}) {
|
||
const timed = [...events.filter((e) => e.start_time)].sort(
|
||
(a, b) => toMin(a.start_time!) - toMin(b.start_time!)
|
||
);
|
||
const untimed = events.filter((e) => !e.start_time);
|
||
const isNote = (e: TripEvent) => !!e.tags?.some((t) => overlapTags.includes(t));
|
||
const noteEvents = timed.filter((e) => isNote(e));
|
||
const regularEvents = timed.filter((e) => !isNote(e));
|
||
const conflicts = detectConflicts(events, overlapTags);
|
||
|
||
if (timed.length === 0 && untimed.length === 0) {
|
||
return (
|
||
<p className="text-gray-600 text-sm font-mono py-8 text-center">
|
||
No events on this day yet.
|
||
</p>
|
||
);
|
||
}
|
||
|
||
// Dynamic time range, clamped to 0–1440, min 8h window
|
||
let rangeStart = 7 * 60;
|
||
let rangeEnd = 22 * 60;
|
||
if (timed.length > 0) {
|
||
const starts = timed.map((e) => toMin(e.start_time!));
|
||
const ends = timed.map((e) => (e.end_time ? toMin(e.end_time) : toMin(e.start_time!) + 60));
|
||
rangeStart = Math.max(0, Math.floor(Math.min(...starts) / 60) * 60 - 60);
|
||
rangeEnd = Math.min(24 * 60, Math.ceil(Math.max(...ends) / 60) * 60 + 60);
|
||
if (rangeEnd - rangeStart < 8 * 60) rangeEnd = Math.min(24 * 60, rangeStart + 8 * 60);
|
||
}
|
||
|
||
const hours: number[] = [];
|
||
for (let h = Math.ceil(rangeStart / 60); h <= Math.floor(rangeEnd / 60); h++) hours.push(h);
|
||
const totalHeight = (rangeEnd - rangeStart) * PX_PER_MIN;
|
||
|
||
const driveMap = new Map(driveSegments.map((s) => [s.fromId, s.text]));
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Timed events — visual timeline */}
|
||
{timed.length > 0 && (
|
||
<div className="relative select-none" style={{ height: totalHeight }}>
|
||
{/* Hour gridlines */}
|
||
{hours.map((h) => (
|
||
<div
|
||
key={h}
|
||
style={{ top: (h * 60 - rangeStart) * PX_PER_MIN }}
|
||
className="absolute inset-x-0 flex items-center pointer-events-none"
|
||
>
|
||
<span className="w-14 text-right pr-3 text-xs text-gray-600 font-mono shrink-0 -translate-y-2">
|
||
{h === 0 ? "12 AM" : h < 12 ? `${h} AM` : h === 12 ? "12 PM" : `${h - 12} PM`}
|
||
</span>
|
||
<div className="flex-1 border-t border-gray-800/70" />
|
||
</div>
|
||
))}
|
||
|
||
{/* Note events — overlap-allowed, rendered behind as subtle bands */}
|
||
{noteEvents.map((e) => {
|
||
const startMin = toMin(e.start_time!);
|
||
const endMin = e.end_time ? toMin(e.end_time) : startMin;
|
||
const top = (startMin - rangeStart) * PX_PER_MIN;
|
||
const height = Math.max(1, (endMin - startMin) * PX_PER_MIN);
|
||
|
||
return (
|
||
<div key={e.event_id} className="group">
|
||
{/* Shaded band (only if duration given) */}
|
||
{e.end_time && (
|
||
<div
|
||
style={{ top, height, left: 60, right: 0 }}
|
||
className="absolute bg-gray-800/30 border-l-2 border-gray-600/30 z-0 pointer-events-none"
|
||
/>
|
||
)}
|
||
{/* Dashed marker line + label */}
|
||
<div
|
||
style={{ top, left: 60, right: 0 }}
|
||
className="absolute z-10 flex items-center gap-2"
|
||
>
|
||
<div className="flex-1 border-t border-dashed border-gray-600/40" />
|
||
<span className="text-gray-500 text-[10px] font-mono shrink-0 pr-1">
|
||
{fmtTime(e.start_time)}
|
||
</span>
|
||
<span className="text-gray-500 text-[10px] truncate max-w-[120px] shrink-0">{e.title}</span>
|
||
{isAdmin && (
|
||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||
<button onClick={() => onEdit(e)} className="text-gray-600 hover:text-indigo-400 text-[10px]">Edit</button>
|
||
<button onClick={() => onDelete(e.event_id)} className="text-gray-600 hover:text-red-400 text-xs leading-none">×</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* Regular event blocks */}
|
||
{regularEvents.map((e) => {
|
||
const startMin = toMin(e.start_time!);
|
||
const endMin = e.end_time ? toMin(e.end_time) : startMin + 60;
|
||
const top = (startMin - rangeStart) * PX_PER_MIN;
|
||
const height = Math.max(36, (endMin - startMin) * PX_PER_MIN);
|
||
const isConflict = conflicts.has(e.event_id);
|
||
const drive = driveMap.get(e.event_id);
|
||
|
||
return (
|
||
<div key={e.event_id}>
|
||
<div
|
||
style={{ top, height, left: 60, right: 0 }}
|
||
className={`absolute rounded-lg px-3 py-2 overflow-hidden group transition-colors z-20 ${
|
||
isConflict
|
||
? "bg-red-950/70 border border-red-700/70"
|
||
: "bg-indigo-950/60 border border-indigo-800/50 hover:border-indigo-600/70"
|
||
}`}
|
||
>
|
||
<div className="flex items-start justify-between gap-2 h-full">
|
||
<div className="min-w-0 flex-1">
|
||
<p className="text-white text-xs font-semibold leading-tight truncate">{e.title}</p>
|
||
{height >= 44 && (
|
||
<p className={`text-xs font-mono mt-0.5 ${isConflict ? "text-red-400" : "text-indigo-400"}`}>
|
||
{fmtTime(e.start_time)}
|
||
{e.end_time ? ` – ${fmtTime(e.end_time)}` : ""}
|
||
{isConflict ? " ⚠ conflict" : ""}
|
||
</p>
|
||
)}
|
||
{height >= 60 && !e.location_inherited && e.location && (
|
||
<p className="text-gray-500 text-xs mt-0.5 truncate">{e.location}</p>
|
||
)}
|
||
{height >= 60 && e.notes && (
|
||
<p className="text-gray-600 text-xs mt-0.5 italic truncate">{e.notes}</p>
|
||
)}
|
||
{e.tags?.length > 0 && (
|
||
<div className="flex flex-wrap gap-1 mt-1">
|
||
{e.tags.map((t) => <TagPill key={t} tag={t} availableTags={availableTags} />)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-1 shrink-0">
|
||
{e.maps_link && (
|
||
<a
|
||
href={e.maps_link}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
onClick={(ev) => ev.stopPropagation()}
|
||
className="text-indigo-400 hover:text-indigo-300 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||
>
|
||
Maps
|
||
</a>
|
||
)}
|
||
{isAdmin && (
|
||
<>
|
||
<button
|
||
onClick={() => onEdit(e)}
|
||
className="text-gray-600 hover:text-indigo-400 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||
>
|
||
Edit
|
||
</button>
|
||
<button
|
||
onClick={() => onDelete(e.event_id)}
|
||
className="text-gray-600 hover:text-red-400 text-base leading-none opacity-0 group-hover:opacity-100 transition-opacity"
|
||
>
|
||
×
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{drive && (
|
||
<div
|
||
style={{ top: top + height + 2, left: 60 }}
|
||
className="absolute text-xs text-gray-600 font-mono flex items-center gap-1 z-20"
|
||
>
|
||
<span className="text-gray-700">↓</span> {drive} drive
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* Untimed / all-day events */}
|
||
{untimed.length > 0 && (
|
||
<div className="space-y-1.5">
|
||
<p className="text-xs font-mono text-gray-600 uppercase tracking-wider">Unscheduled</p>
|
||
{untimed.map((e) => (
|
||
<div
|
||
key={e.event_id}
|
||
className="bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 flex items-center justify-between group"
|
||
>
|
||
<div className="min-w-0">
|
||
<p className="text-white text-sm">{e.title}</p>
|
||
{!e.location_inherited && e.location && (
|
||
<p className="text-gray-500 text-xs mt-0.5">{e.location}</p>
|
||
)}
|
||
{e.notes && <p className="text-gray-600 text-xs italic mt-0.5">{e.notes}</p>}
|
||
{e.tags?.length > 0 && (
|
||
<div className="flex flex-wrap gap-1 mt-1">
|
||
{e.tags.map((t) => <TagPill key={t} tag={t} availableTags={availableTags} />)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
{e.maps_link && (
|
||
<a href={e.maps_link} target="_blank" rel="noopener noreferrer"
|
||
className="text-xs text-indigo-400 hover:text-indigo-300 opacity-0 group-hover:opacity-100 transition-opacity">
|
||
Maps
|
||
</a>
|
||
)}
|
||
{isAdmin && (
|
||
<>
|
||
<button onClick={() => onEdit(e)}
|
||
className="text-xs text-gray-600 hover:text-indigo-400 opacity-0 group-hover:opacity-100 transition-opacity">
|
||
Edit
|
||
</button>
|
||
<button onClick={() => onDelete(e.event_id)}
|
||
className="text-gray-600 hover:text-red-400 text-sm opacity-0 group-hover:opacity-100 transition-opacity">
|
||
×
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// AI Assistant panel
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface ChatMessage {
|
||
id: string;
|
||
role: "user" | "assistant";
|
||
content: string;
|
||
suggestions?: SuggestionCard[];
|
||
}
|
||
|
||
interface SuggestionCard {
|
||
title: string;
|
||
date?: string;
|
||
start_time?: string;
|
||
end_time?: string;
|
||
location?: string;
|
||
maps_link?: string;
|
||
notes?: string;
|
||
tags?: string[];
|
||
dismissed?: boolean;
|
||
added?: boolean;
|
||
}
|
||
|
||
const CHAT_STORAGE_KEY = (tripId: string) => `drb-trip-chat-${tripId}`;
|
||
|
||
function AssistantPanel({
|
||
trip,
|
||
onAddEvent,
|
||
}: {
|
||
trip: TripRecord & { events: TripEvent[] };
|
||
onAddEvent: (event: TripEvent) => void;
|
||
}) {
|
||
const storageKey = CHAT_STORAGE_KEY(trip.trip_id);
|
||
const [messages, setMessages] = useState<ChatMessage[]>(() => {
|
||
try {
|
||
const saved = localStorage.getItem(storageKey);
|
||
return saved ? JSON.parse(saved) : [];
|
||
} catch { return []; }
|
||
});
|
||
const [input, setInput] = useState("");
|
||
const [loading, setLoading] = useState(false);
|
||
const bottomRef = useRef<HTMLDivElement>(null);
|
||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||
|
||
useEffect(() => {
|
||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||
}, [messages]);
|
||
|
||
useEffect(() => {
|
||
try { localStorage.setItem(storageKey, JSON.stringify(messages)); } catch { /* quota */ }
|
||
}, [messages, storageKey]);
|
||
|
||
async function send() {
|
||
const text = input.trim();
|
||
if (!text || loading) return;
|
||
setInput("");
|
||
|
||
const userMsg: ChatMessage = { id: uid(), role: "user", content: text };
|
||
setMessages((prev) => [...prev, userMsg]);
|
||
setLoading(true);
|
||
|
||
const history = messages.map((m) => ({ role: m.role, content: m.content }));
|
||
|
||
try {
|
||
const res = await c2api.tripChat(trip.trip_id, text, history);
|
||
const assistantMsg: ChatMessage = {
|
||
id: uid(),
|
||
role: "assistant",
|
||
content: res.reply,
|
||
suggestions: (res.suggestions as unknown as SuggestionCard[]) ?? [],
|
||
};
|
||
setMessages((prev) => [...prev, assistantMsg]);
|
||
} catch {
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{ id: uid(), role: "assistant", content: "Something went wrong. Try again." },
|
||
]);
|
||
} finally {
|
||
setLoading(false);
|
||
inputRef.current?.focus();
|
||
}
|
||
}
|
||
|
||
async function approveSuggestion(msgId: string, idx: number, s: SuggestionCard) {
|
||
try {
|
||
const event = await c2api.createTripEvent(trip.trip_id, {
|
||
title: s.title,
|
||
date: s.date ?? trip.start_date,
|
||
start_time: s.start_time ?? null,
|
||
end_time: s.end_time ?? null,
|
||
location: s.location ?? null,
|
||
maps_link: s.maps_link ?? null,
|
||
notes: s.notes ?? null,
|
||
tags: s.tags ?? [],
|
||
});
|
||
onAddEvent(event);
|
||
setMessages((prev) =>
|
||
prev.map((m) =>
|
||
m.id !== msgId ? m :
|
||
{ ...m, suggestions: m.suggestions?.map((sg, i) => i === idx ? { ...sg, added: true } : sg) }
|
||
)
|
||
);
|
||
} catch {
|
||
// fail silently — user can try again
|
||
}
|
||
}
|
||
|
||
function dismissSuggestion(msgId: string, idx: number) {
|
||
setMessages((prev) =>
|
||
prev.map((m) =>
|
||
m.id !== msgId ? m :
|
||
{ ...m, suggestions: m.suggestions?.map((sg, i) => i === idx ? { ...sg, dismissed: true } : sg) }
|
||
)
|
||
);
|
||
}
|
||
|
||
function clearChat() {
|
||
setMessages([]);
|
||
try { localStorage.removeItem(storageKey); } catch { /* ignore */ }
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
{/* Header */}
|
||
<div className="px-4 py-3 border-b border-gray-800 shrink-0 flex items-start justify-between gap-2">
|
||
<div>
|
||
<p className="text-white text-sm font-semibold">Trip Assistant</p>
|
||
<p className="text-gray-500 text-xs mt-0.5">
|
||
Tell me what you want to do — I can search places and suggest events.
|
||
</p>
|
||
</div>
|
||
{messages.length > 0 && (
|
||
<button
|
||
onClick={clearChat}
|
||
className="text-xs text-gray-600 hover:text-gray-400 transition-colors shrink-0 mt-0.5"
|
||
>
|
||
Clear
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Messages */}
|
||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
|
||
{messages.length === 0 && (
|
||
<div className="space-y-2">
|
||
{[
|
||
`Find dinner spots near ${trip.location}`,
|
||
"Plan a full day for us on the first day",
|
||
"Everyone needs to be at the hotel by 6 PM, what should we do before?",
|
||
"Suggest some bars or nightlife for the last night",
|
||
].map((prompt) => (
|
||
<button
|
||
key={prompt}
|
||
onClick={() => { setInput(prompt); }}
|
||
className="w-full text-left bg-gray-800/60 hover:bg-gray-800 border border-gray-700/50 rounded-lg px-3 py-2 text-gray-400 text-xs transition-colors"
|
||
>
|
||
{prompt}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{messages.map((msg) => (
|
||
<div key={msg.id} className={`flex flex-col ${msg.role === "user" ? "items-end" : "items-start"} gap-2`}>
|
||
<div
|
||
className={`rounded-xl px-3 py-2 text-sm max-w-[90%] ${
|
||
msg.role === "user"
|
||
? "bg-indigo-600 text-white"
|
||
: "bg-gray-800 text-gray-200"
|
||
}`}
|
||
>
|
||
{msg.role === "user" ? msg.content : (
|
||
<ReactMarkdown
|
||
components={{
|
||
p: ({ children }) => <p className="mb-1 last:mb-0">{children}</p>,
|
||
ul: ({ children }) => <ul className="list-disc list-inside space-y-0.5 my-1">{children}</ul>,
|
||
ol: ({ children }) => <ol className="list-decimal list-inside space-y-0.5 my-1">{children}</ol>,
|
||
li: ({ children }) => <li>{children}</li>,
|
||
strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>,
|
||
a: ({ href, children }) => (
|
||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-indigo-400 hover:text-indigo-300 underline">
|
||
{children}
|
||
</a>
|
||
),
|
||
}}
|
||
>
|
||
{msg.content}
|
||
</ReactMarkdown>
|
||
)}
|
||
</div>
|
||
|
||
{/* Suggestion cards */}
|
||
{msg.suggestions?.map((s, idx) => {
|
||
if (s.dismissed) return null;
|
||
return (
|
||
<div
|
||
key={idx}
|
||
className={`w-full border rounded-xl p-3 space-y-1.5 transition-all ${
|
||
s.added
|
||
? "border-green-800/50 bg-green-950/30"
|
||
: "border-gray-700 bg-gray-900"
|
||
}`}
|
||
>
|
||
<p className="text-white text-sm font-semibold">{s.title}</p>
|
||
<div className="text-xs text-gray-400 space-y-0.5">
|
||
{s.date && (
|
||
<p>
|
||
{fmtDate(s.date)}
|
||
{s.start_time && ` · ${fmtTime(s.start_time)}`}
|
||
{s.end_time && ` – ${fmtTime(s.end_time)}`}
|
||
</p>
|
||
)}
|
||
{s.location && <p className="truncate">{s.location}</p>}
|
||
{s.notes && <p className="text-gray-500 italic">{s.notes}</p>}
|
||
{s.tags && s.tags.length > 0 && (
|
||
<div className="flex flex-wrap gap-1 pt-0.5">
|
||
{s.tags.map((t) => <TagPill key={t} tag={t} availableTags={trip.available_tags ?? []} />)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{s.maps_link && (
|
||
<a href={s.maps_link} target="_blank" rel="noopener noreferrer"
|
||
className="text-xs text-indigo-400 hover:text-indigo-300">
|
||
View on Maps
|
||
</a>
|
||
)}
|
||
{!s.added ? (
|
||
<div className="flex gap-2 pt-1">
|
||
<button
|
||
onClick={() => approveSuggestion(msg.id, idx, s)}
|
||
className="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white text-xs rounded-lg py-1.5 transition-colors"
|
||
>
|
||
Add to Itinerary
|
||
</button>
|
||
<button
|
||
onClick={() => dismissSuggestion(msg.id, idx)}
|
||
className="px-3 text-gray-500 hover:text-gray-300 text-xs transition-colors"
|
||
>
|
||
Skip
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<p className="text-green-400 text-xs pt-1">Added to itinerary</p>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
))}
|
||
|
||
{loading && (
|
||
<div className="flex items-start">
|
||
<div className="bg-gray-800 rounded-xl px-3 py-2 text-gray-400 text-sm">
|
||
<span className="animate-pulse">Thinking…</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div ref={bottomRef} />
|
||
</div>
|
||
|
||
{/* Input */}
|
||
<div className="px-3 py-3 border-t border-gray-800 shrink-0">
|
||
<div className="flex gap-2">
|
||
<textarea
|
||
ref={inputRef}
|
||
value={input}
|
||
onChange={(e) => { setInput(e.target.value); e.target.style.height = "auto"; e.target.style.height = `${Math.min(e.target.scrollHeight, 120)}px`; }}
|
||
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && (e.preventDefault(), send())}
|
||
placeholder="What do you want to do?"
|
||
disabled={loading}
|
||
rows={1}
|
||
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 disabled:opacity-50 resize-none overflow-hidden"
|
||
/>
|
||
<button
|
||
onClick={send}
|
||
disabled={loading || !input.trim()}
|
||
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 text-white text-sm rounded-lg px-4 py-2 transition-colors shrink-0"
|
||
>
|
||
Send
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Main page
|
||
// ---------------------------------------------------------------------------
|
||
|
||
type FullTrip = TripRecord & { events: TripEvent[] };
|
||
|
||
export default function TripDetailPage() {
|
||
const { id } = useParams<{ id: string }>();
|
||
const router = useRouter();
|
||
const { isAdmin, user } = useAuth();
|
||
|
||
const [trip, setTrip] = useState<FullTrip | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [selectedDay, setSelectedDay] = useState<string>("");
|
||
const [showAdd, setShowAdd] = useState(false);
|
||
const [editEvent, setEditEvent] = useState<TripEvent | null>(null);
|
||
const [driveTimes, setDriveTimes] = useState<Record<string, { fromId: string; toId: string; text: string }[]>>({});
|
||
const [tagInput, setTagInput] = useState("");
|
||
const [inviteInput, setInviteInput] = useState("");
|
||
|
||
const load = useCallback(async () => {
|
||
try {
|
||
const data = await c2api.getTrip(id);
|
||
setTrip(data as FullTrip);
|
||
if (!selectedDay) {
|
||
setSelectedDay(data.start_date);
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [id, selectedDay]);
|
||
|
||
useEffect(() => { if (user) load(); }, [id, user]);
|
||
|
||
// Fetch drive times for a given day
|
||
useEffect(() => {
|
||
if (!trip || !selectedDay || driveTimes[selectedDay]) return;
|
||
|
||
const dayEvents = trip.events
|
||
.filter((e) => e.date === selectedDay && e.start_time && !e.location_inherited && e.location)
|
||
.sort((a, b) => toMin(a.start_time!) - toMin(b.start_time!));
|
||
|
||
if (dayEvents.length < 2) return;
|
||
|
||
(async () => {
|
||
const segments: { fromId: string; toId: string; text: string }[] = [];
|
||
for (let i = 0; i < dayEvents.length - 1; i++) {
|
||
try {
|
||
const r = await c2api.getDirections(dayEvents[i].location, dayEvents[i + 1].location);
|
||
if (r.duration_text) {
|
||
segments.push({ fromId: dayEvents[i].event_id, toId: dayEvents[i + 1].event_id, text: r.duration_text });
|
||
}
|
||
} catch { /* skip on error */ }
|
||
}
|
||
if (segments.length > 0) {
|
||
setDriveTimes((prev) => ({ ...prev, [selectedDay]: segments }));
|
||
}
|
||
})();
|
||
}, [trip, selectedDay]);
|
||
|
||
async function handleDeleteTrip() {
|
||
if (!trip) return;
|
||
try { await c2api.deleteTrip(trip.trip_id); router.push("/trips"); }
|
||
catch (e) { console.error(e); }
|
||
}
|
||
|
||
async function handleDeleteEvent(eventId: string) {
|
||
if (!trip) return;
|
||
try {
|
||
await c2api.deleteTripEvent(trip.trip_id, eventId);
|
||
setTrip((prev) => prev ? { ...prev, events: prev.events.filter((e) => e.event_id !== eventId) } : prev);
|
||
} catch (e) { console.error(e); }
|
||
}
|
||
|
||
async function handleUpdateEvent(body: object) {
|
||
if (!trip || !editEvent) return;
|
||
const updated = await c2api.updateTripEvent(trip.trip_id, editEvent.event_id, body);
|
||
setTrip((prev) => {
|
||
if (!prev) return prev;
|
||
const events = prev.events.map((e) => e.event_id === updated.event_id ? updated : e)
|
||
.sort((a, b) => a.date.localeCompare(b.date) || (a.start_time ?? "").localeCompare(b.start_time ?? ""));
|
||
return { ...prev, events };
|
||
});
|
||
setEditEvent(null);
|
||
}
|
||
|
||
async function handleAddEvent(body: object) {
|
||
if (!trip) return;
|
||
const event = await c2api.createTripEvent(trip.trip_id, body);
|
||
setTrip((prev) => {
|
||
if (!prev) return prev;
|
||
const events = [...prev.events, event].sort(
|
||
(a, b) => a.date.localeCompare(b.date) || (a.start_time ?? "").localeCompare(b.start_time ?? "")
|
||
);
|
||
return { ...prev, events };
|
||
});
|
||
// Switch to the event's day
|
||
if ((body as { date?: string }).date) {
|
||
setSelectedDay((body as { date: string }).date);
|
||
}
|
||
}
|
||
|
||
function handleAssistantAddEvent(event: TripEvent) {
|
||
setTrip((prev) => {
|
||
if (!prev) return prev;
|
||
const events = [...prev.events, event].sort(
|
||
(a, b) => a.date.localeCompare(b.date) || (a.start_time ?? "").localeCompare(b.start_time ?? "")
|
||
);
|
||
return { ...prev, events };
|
||
});
|
||
// Refresh trip to pick up any new tags the AI may have created
|
||
c2api.getTrip(id).then((data) => setTrip((prev) => prev ? { ...prev, available_tags: (data as FullTrip).available_tags } : prev)).catch(() => {});
|
||
}
|
||
|
||
async function handleAddTag() {
|
||
if (!trip || !tagInput.trim()) return;
|
||
const tag = tagInput.trim();
|
||
if (trip.available_tags?.includes(tag)) { setTagInput(""); return; }
|
||
const available = [...(trip.available_tags ?? []), tag];
|
||
const overlap = trip.overlap_tags ?? [];
|
||
await c2api.updateTripTags(trip.trip_id, available, overlap);
|
||
setTrip((prev) => prev ? { ...prev, available_tags: available } : prev);
|
||
setTagInput("");
|
||
}
|
||
|
||
async function handleRemoveTag(tag: string) {
|
||
if (!trip) return;
|
||
const available = (trip.available_tags ?? []).filter((t) => t !== tag);
|
||
const overlap = (trip.overlap_tags ?? []).filter((t) => t !== tag);
|
||
await c2api.updateTripTags(trip.trip_id, available, overlap);
|
||
setTrip((prev) => prev ? { ...prev, available_tags: available, overlap_tags: overlap } : prev);
|
||
}
|
||
|
||
async function handleToggleVisibility() {
|
||
if (!trip) return;
|
||
const next = trip.visibility === "private" ? "public" : "private";
|
||
await c2api.setTripVisibility(trip.trip_id, next);
|
||
setTrip((prev) => prev ? { ...prev, visibility: next } : prev);
|
||
}
|
||
|
||
async function handleInvite() {
|
||
const discordId = inviteInput.trim();
|
||
if (!trip || !discordId) return;
|
||
if ((trip.invited_discord_ids ?? []).includes(discordId)) { setInviteInput(""); return; }
|
||
await c2api.inviteToTrip(trip.trip_id, discordId);
|
||
setTrip((prev) => prev ? { ...prev, invited_discord_ids: [...(prev.invited_discord_ids ?? []), discordId] } : prev);
|
||
setInviteInput("");
|
||
}
|
||
|
||
async function handleRevokeInvite(discordId: string) {
|
||
if (!trip) return;
|
||
await c2api.revokeInvite(trip.trip_id, discordId);
|
||
setTrip((prev) => prev ? { ...prev, invited_discord_ids: (prev.invited_discord_ids ?? []).filter((id) => id !== discordId) } : prev);
|
||
}
|
||
|
||
async function handleToggleOverlap(tag: string) {
|
||
if (!trip) return;
|
||
const current = trip.overlap_tags ?? [];
|
||
const overlap = current.includes(tag) ? current.filter((t) => t !== tag) : [...current, tag];
|
||
await c2api.updateTripTags(trip.trip_id, trip.available_tags ?? [], overlap);
|
||
setTrip((prev) => prev ? { ...prev, overlap_tags: overlap } : prev);
|
||
}
|
||
|
||
if (loading) return <p className="text-gray-500 text-sm font-mono">Loading…</p>;
|
||
if (!trip) return <p className="text-gray-500 text-sm font-mono">Trip not found.</p>;
|
||
|
||
const days = dateRange(trip.start_date, trip.end_date);
|
||
const attendees = Object.values(trip.attendees ?? {});
|
||
const dayEvents = trip.events.filter((e) => e.date === selectedDay);
|
||
const hasConflict = (day: string) =>
|
||
detectConflicts(trip.events.filter((e) => e.date === day), trip.overlap_tags ?? []).size > 0;
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Back + header */}
|
||
<div className="space-y-4">
|
||
<button
|
||
onClick={() => router.push("/trips")}
|
||
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
|
||
>
|
||
← Trips
|
||
</button>
|
||
|
||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||
<div className="space-y-1">
|
||
<h1 className="text-white text-2xl font-bold">{trip.name}</h1>
|
||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-400">
|
||
<span>{trip.location}</span>
|
||
{trip.maps_link && (
|
||
<a href={trip.maps_link} target="_blank" rel="noopener noreferrer"
|
||
className="text-indigo-400 hover:text-indigo-300 text-xs transition-colors">
|
||
Maps
|
||
</a>
|
||
)}
|
||
<span className="text-gray-700">•</span>
|
||
<span className="font-mono text-xs">{fmtDate(trip.start_date)} — {fmtDate(trip.end_date)}</span>
|
||
{attendees.length > 0 && (
|
||
<>
|
||
<span className="text-gray-700">•</span>
|
||
<span className="text-xs">{attendees.join(", ")}</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
{/* Visibility badge */}
|
||
{trip.visibility === "private" && (
|
||
<span className="text-xs font-mono text-amber-500 border border-amber-800/50 rounded-full px-2 py-0.5">
|
||
🔒 private
|
||
</span>
|
||
)}
|
||
{isAdmin && (
|
||
<>
|
||
<button
|
||
onClick={() => setShowAdd(true)}
|
||
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm rounded-lg px-4 py-2 transition-colors"
|
||
>
|
||
+ Add Event
|
||
</button>
|
||
<button
|
||
onClick={handleToggleVisibility}
|
||
className="text-xs text-gray-500 hover:text-gray-300 transition-colors border border-gray-700 rounded-lg px-3 py-2"
|
||
>
|
||
{trip.visibility === "private" ? "Make public" : "Make private"}
|
||
</button>
|
||
<button
|
||
onClick={handleDeleteTrip}
|
||
className="text-xs text-red-500 hover:text-red-400 transition-colors"
|
||
>
|
||
Delete trip
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tag manager */}
|
||
{(isAdmin || (trip.available_tags ?? []).length > 0) && (
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{(trip.available_tags ?? []).map((tag) => {
|
||
const isOverlap = (trip.overlap_tags ?? []).includes(tag);
|
||
return (
|
||
<span key={tag} className={`inline-flex items-center gap-1 text-xs rounded-full px-2.5 py-0.5 border ${tagColor(tag, trip.available_tags ?? [])}`}>
|
||
{tag}
|
||
{isAdmin && (
|
||
<>
|
||
<button
|
||
onClick={() => handleToggleOverlap(tag)}
|
||
title={isOverlap ? "Allows overlap (click to disable)" : "Click to allow overlap"}
|
||
className={`leading-none transition-colors ${isOverlap ? "opacity-100" : "opacity-30 hover:opacity-70"}`}
|
||
>
|
||
≋
|
||
</button>
|
||
<button onClick={() => handleRemoveTag(tag)} className="hover:text-white transition-colors leading-none opacity-60 hover:opacity-100">×</button>
|
||
</>
|
||
)}
|
||
</span>
|
||
);
|
||
})}
|
||
{isAdmin && (
|
||
<div className="flex items-center gap-1">
|
||
<input
|
||
value={tagInput}
|
||
onChange={(e) => setTagInput(e.target.value)}
|
||
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleAddTag())}
|
||
placeholder="Add tag…"
|
||
className="bg-transparent border border-gray-700 rounded-full px-2.5 py-0.5 text-xs text-gray-400 placeholder-gray-600 focus:outline-none focus:border-gray-500 w-24"
|
||
/>
|
||
<button onClick={handleAddTag} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">+</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Invite management — admin only, only when private */}
|
||
{isAdmin && trip.visibility === "private" && (
|
||
<div className="space-y-2">
|
||
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono">Invited</p>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{(trip.invited_discord_ids ?? []).length === 0 && (
|
||
<span className="text-xs text-gray-600">No invites yet</span>
|
||
)}
|
||
{(trip.invited_discord_ids ?? []).map((discordId) => (
|
||
<span key={discordId} className="inline-flex items-center gap-1 text-xs bg-gray-800 border border-gray-700 rounded-full px-2.5 py-0.5 text-gray-300">
|
||
<span className="font-mono">{discordId}</span>
|
||
<button
|
||
onClick={() => handleRevokeInvite(discordId)}
|
||
className="opacity-60 hover:opacity-100 hover:text-red-400 transition-colors leading-none"
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))}
|
||
<div className="flex items-center gap-1">
|
||
<input
|
||
value={inviteInput}
|
||
onChange={(e) => setInviteInput(e.target.value)}
|
||
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleInvite())}
|
||
placeholder="Discord user ID…"
|
||
className="bg-transparent border border-gray-700 rounded-full px-2.5 py-0.5 text-xs text-gray-400 placeholder-gray-600 focus:outline-none focus:border-gray-500 w-36"
|
||
/>
|
||
<button onClick={handleInvite} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">+</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Two-column layout */}
|
||
<div className="flex gap-5 items-start">
|
||
{/* Left: Itinerary */}
|
||
<div className="flex-1 min-w-0 space-y-5">
|
||
{/* Day tabs */}
|
||
<div className="flex gap-1 flex-wrap">
|
||
{days.map((day) => {
|
||
const { short, date } = fmtDayTab(day);
|
||
const count = trip.events.filter((e) => e.date === day).length;
|
||
const conflict = hasConflict(day);
|
||
return (
|
||
<button
|
||
key={day}
|
||
onClick={() => setSelectedDay(day)}
|
||
className={`flex flex-col items-center px-3 py-2 rounded-xl text-xs transition-colors relative ${
|
||
day === selectedDay
|
||
? "bg-indigo-600 text-white"
|
||
: "bg-gray-900 border border-gray-800 text-gray-400 hover:border-gray-700 hover:text-gray-200"
|
||
}`}
|
||
>
|
||
<span className="font-mono font-bold">{short}</span>
|
||
<span className="font-mono">{date}</span>
|
||
{count > 0 && (
|
||
<span className={`text-xs mt-0.5 ${day === selectedDay ? "text-indigo-200" : "text-gray-600"}`}>
|
||
{count}
|
||
</span>
|
||
)}
|
||
{conflict && (
|
||
<span className="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full" />
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Selected day */}
|
||
<div className="space-y-4">
|
||
<p className="text-sm font-mono text-gray-400 uppercase tracking-wider">
|
||
{fmtDayHeading(selectedDay)}
|
||
</p>
|
||
<DayTimeline
|
||
events={dayEvents}
|
||
isAdmin={isAdmin}
|
||
onDelete={handleDeleteEvent}
|
||
onEdit={setEditEvent}
|
||
driveSegments={driveTimes[selectedDay] ?? []}
|
||
availableTags={trip.available_tags ?? []}
|
||
overlapTags={trip.overlap_tags ?? []}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right: AI Assistant — sticky */}
|
||
<div className="w-80 xl:w-96 shrink-0 sticky top-20 h-[calc(100vh-240px)] flex flex-col bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||
<AssistantPanel trip={trip} onAddEvent={handleAssistantAddEvent} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Add event modal */}
|
||
{showAdd && (
|
||
<AddEventModal
|
||
trip={trip}
|
||
onClose={() => setShowAdd(false)}
|
||
onAdd={handleAddEvent}
|
||
prefill={selectedDay ? { date: selectedDay } : undefined}
|
||
/>
|
||
)}
|
||
|
||
{/* Edit event modal */}
|
||
{editEvent && (
|
||
<AddEventModal
|
||
trip={trip}
|
||
onClose={() => setEditEvent(null)}
|
||
onAdd={handleUpdateEvent}
|
||
prefill={editEvent}
|
||
editEventId={editEvent.event_id}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|