Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions nextjs-typescript-starter/app/components/FriendsTripsList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"use client";

import Link from "next/link";
import type { Trip } from "app/components/TripCarousel";
import type { Share } from "app/sharesStore";
import type { Friend } from "app/friendsStore";

export default function FriendsTripsList({
friends,
trips,
shares,
compact = false,
onUninvite,
}: {
friends: Friend[];
trips: Trip[];
shares: Share[];
compact?: boolean;
onUninvite?: (friendId: string, tripId: string) => void;
}) {
const tripTitle = (id: string) => trips.find((t) => t.id === id)?.name ?? `Trip ${id}`;

// Build friend -> [tripIds] map using only trips that exist
const byFriend = new Map<string, { friendId: string; friendName: string; tripIds: string[] }>();
for (const f of friends) byFriend.set(f.id, { friendId: f.id, friendName: f.name, tripIds: [] });
for (const s of shares) {
if (!byFriend.has(s.friendId)) continue;
if (trips.some((t) => t.id === s.tripId)) {
byFriend.get(s.friendId)!.tripIds.push(s.tripId);
}
}
const groups = Array.from(byFriend.values()).filter((g) => g.tripIds.length > 0);

if (groups.length === 0) {
return <div className={compact ? "text-[12px] text-stone-400" : "text-sm text-stone-400"}>No shared trips yet</div>;
}

return (
<div className={compact ? "space-y-1" : "space-y-2"}>
{groups.map((g) => (
<div key={g.friendId} className="px-1">
<div className={compact ? "px-2 py-1 text-[11px] uppercase tracking-wide text-stone-400/80" : "px-2 py-1 text-xs uppercase tracking-wide text-stone-400/90"}>
{g.friendName}
</div>
<ul className={compact ? "ml-2 border-l border-stone-800/70 pl-2 space-y-0.5" : "ml-2 border-l border-stone-800/70 pl-2 space-y-1"}>
{g.tripIds.map((tid) => (
<li key={tid} className="flex items-center justify-between gap-2">
<Link href={`/trips/${tid}`} className={compact ? "block px-3 py-1 rounded text-sm text-stone-200 hover:bg-stone-800" : "block px-3 py-1.5 rounded text-sm text-stone-200 hover:bg-stone-800"}>
{tripTitle(tid)}
</Link>
{onUninvite && (
<button
onClick={() => onUninvite(g.friendId, tid)}
className={compact ? "text-[11px] px-2 py-0.5 rounded border border-stone-700 hover:bg-stone-800" : "text-xs px-2 py-1 rounded border border-stone-700 hover:bg-stone-800"}
>
Uninvite
</button>
)}
</li>
))}
</ul>
</div>
))}
</div>
);
}
38 changes: 27 additions & 11 deletions nextjs-typescript-starter/app/components/InviteDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useEffect, useId, useMemo, useState } from 'react';
import { useEffect, useId, useState } from 'react';
import type { Trip } from 'app/components/TripCarousel';
import type { AccessLevel } from 'app/sharesStore';

type Props = {
Expand All @@ -10,29 +11,40 @@ type Props = {
onInvited?: (result: { tripId: string; access: AccessLevel }) => void;
};

const sampleTrips = [
{ id: '1', title: 'Cancún Trip 🌴' },
{ id: '2', title: 'NYC Weekend 🗽' },
{ id: '3', title: 'Banff Ski Trip ⛷️' },
];

export default function InviteDialog({ open, onClose, friend, onInvited }: Props) {
const [tripId, setTripId] = useState<string>('');
const [access, setAccess] = useState<AccessLevel>('view');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [trips, setTrips] = useState<Trip[]>([]);
const [tripsLoading, setTripsLoading] = useState(false);
const titleId = useId();

useEffect(() => {
if (open) {
setTripId('');
setAccess('view');
setError(null);
// Load real trips every time the dialog opens
let abort = false;
(async () => {
setTripsLoading(true);
try {
const res = await fetch('/api/trips', { cache: 'no-store' });
const data = await res.json();
if (!abort) setTrips(Array.isArray(data.trips) ? data.trips : []);
} catch {
if (!abort) setTrips([]);
} finally {
if (!abort) setTripsLoading(false);
}
})();
return () => {
abort = true;
};
}
}, [open]);

const trips = sampleTrips;

if (!open) return null;

async function handleInvite() {
Expand Down Expand Up @@ -78,8 +90,12 @@ export default function InviteDialog({ open, onClose, friend, onInvited }: Props
className="w-full rounded-md bg-stone-800 border border-stone-700 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-400"
>
<option value="" disabled>Select a trip</option>
{trips.map((t) => (
<option key={t.id} value={t.id}>{t.title}</option>
{tripsLoading && <option value="" disabled>Loading…</option>}
{!tripsLoading && trips.length === 0 && (
<option value="" disabled>No trips yet</option>
)}
{!tripsLoading && trips.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
Expand Down
57 changes: 18 additions & 39 deletions nextjs-typescript-starter/app/components/SideNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useEffect, useMemo, useState } from "react";
import type { Trip } from "app/components/TripCarousel";
import type { Share } from "app/sharesStore";
import type { Friend } from "app/friendsStore";
import FriendsTripsList from "./FriendsTripsList";

function NavLink({ href, children, onClick }: { href: string; children: React.ReactNode; onClick?: () => void }) {
const pathname = usePathname();
Expand Down Expand Up @@ -47,6 +48,15 @@ export default function SideNav({ open, onClose }: { open: boolean; onClose: ()
};
}, []);
const [trips, setTrips] = useState<Trip[]>([]);
const reloadTrips = async () => {
try {
const res = await fetch('/api/trips', { cache: 'no-store' });
const data = await res.json();
setTrips(Array.isArray(data.trips) ? data.trips : []);
} catch {
setTrips([]);
}
};
useEffect(() => {
let abort = false;
(async () => {
Expand All @@ -58,7 +68,9 @@ export default function SideNav({ open, onClose }: { open: boolean; onClose: ()
if (!abort) setTrips([]);
}
})();
return () => { abort = true; };
const onTripsUpdated = () => { if (!abort) void reloadTrips(); };
window.addEventListener('trips:updated', onTripsUpdated as EventListener);
return () => { abort = true; window.removeEventListener('trips:updated', onTripsUpdated as EventListener); };
}, []);

const [shares, setShares] = useState<Share[]>([]);
Expand All @@ -82,18 +94,7 @@ export default function SideNav({ open, onClose }: { open: boolean; onClose: ()
return () => { abort = true; };
}, []);

const tripTitle = (id: string) => trips.find(t => t.id === id)?.name ?? `Trip ${id}`;
const friendsWithShares = useMemo(() => {
const byFriend = new Map<string, { friendId: string; friendName: string; tripIds: string[] }>();
for (const f of friends) byFriend.set(f.id, { friendId: f.id, friendName: f.name, tripIds: [] });
for (const s of shares) {
if (!byFriend.has(s.friendId)) continue;
if (trips.some(t => t.id === s.tripId)) {
byFriend.get(s.friendId)!.tripIds.push(s.tripId);
}
}
return Array.from(byFriend.values()).filter(g => g.tripIds.length > 0);
}, [friends, shares, trips]);
const hasShares = useMemo(() => shares.some(s => trips.some(t => t.id === s.tripId)), [shares, trips]);

return (
<div>
Expand All @@ -105,20 +106,9 @@ export default function SideNav({ open, onClose }: { open: boolean; onClose: ()
<NavLink href="/trips">Trips</NavLink>
<NavLink href="/friends">Friends</NavLink>
{/* Friends -> trips grouping */}
{friendsWithShares.length > 0 && (
{hasShares && (
<div className="mt-2 space-y-1">
{friendsWithShares.map((g) => (
<div key={g.friendId} className="px-1">
<div className="px-2 py-1 text-[11px] uppercase tracking-wide text-stone-400/80">{g.friendName}</div>
<ul className="ml-2 border-l border-stone-800/70 pl-2 space-y-0.5">
{g.tripIds.map((tid) => (
<li key={tid}>
<NavLink href={`/trips/${tid}`}>{tripTitle(tid)}</NavLink>
</li>
))}
</ul>
</div>
))}
<FriendsTripsList friends={friends} trips={trips} shares={shares} compact />
</div>
)}
</nav>
Expand Down Expand Up @@ -147,20 +137,9 @@ export default function SideNav({ open, onClose }: { open: boolean; onClose: ()
<NavLink href="/trips" onClick={onClose}>Trips</NavLink>
<NavLink href="/friends" onClick={onClose}>Friends</NavLink>
{/* Friends -> trips grouping */}
{friendsWithShares.length > 0 && (
{hasShares && (
<div className="mt-2 space-y-1">
{friendsWithShares.map((g) => (
<div key={g.friendId} className="px-1">
<div className="px-2 py-1 text-[11px] uppercase tracking-wide text-stone-400/80">{g.friendName}</div>
<ul className="ml-2 border-l border-stone-800/70 pl-2 space-y-0.5">
{g.tripIds.map((tid) => (
<li key={tid}>
<NavLink href={`/trips/${tid}`} onClick={onClose}>{tripTitle(tid)}</NavLink>
</li>
))}
</ul>
</div>
))}
<FriendsTripsList friends={friends} trips={trips} shares={shares} compact />
</div>
)}
</nav>
Expand Down
13 changes: 12 additions & 1 deletion nextjs-typescript-starter/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,18 @@ export default function DashboardPage() {
if (!abort) setTripsLoading(false);
}
})();
return () => { abort = true; };
const onTripsUpdated = async () => {
if (abort) return;
try {
const res = await fetch('/api/trips', { cache: 'no-store' });
const data = await res.json();
if (!abort) setTrips(Array.isArray(data.trips) ? data.trips : []);
} catch {
if (!abort) setTrips([]);
}
};
window.addEventListener('trips:updated', onTripsUpdated as EventListener);
return () => { abort = true; window.removeEventListener('trips:updated', onTripsUpdated as EventListener); };
}, []);

type ViewMode = "carousel" | "grid" | "list";
Expand Down
55 changes: 8 additions & 47 deletions nextjs-typescript-starter/app/friends/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import SideNavShell from "app/components/SideNavShell";
import type { Share } from "app/sharesStore";
import type { Trip } from "app/components/TripCarousel";
import type { Friend } from "app/friendsStore";
import FriendsTripsList from "app/components/FriendsTripsList";

export default function FriendsPage() {
// Live friends list from API (can add more via search+add)
Expand Down Expand Up @@ -225,53 +226,13 @@ export default function FriendsPage() {
</div>
</section>

<div className="mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{friends
.filter((f) => f.name.toLowerCase().includes(search.trim().toLowerCase()))
.map((f) => {
const fShares = shares.filter(s => s.friendId === f.id);
// Deduplicate and keep only trip ids that exist
const uniqueTripIds = Array.from(
new Set(fShares.map((s) => s.tripId).filter((tid) => trips.some((t) => t.id === tid)))
);
return (
<section key={f.id} className="rounded-lg border border-stone-800 bg-stone-900/60 p-4">
<h2 className="text-base font-semibold text-white">{f.name}</h2>
<ul className="mt-3 space-y-1">
{uniqueTripIds.length > 0 ? (
uniqueTripIds.map((tid) => {
const sh = fShares.find((s) => s.tripId === tid);
const access = sh?.access ?? 'view';
return (
<li key={tid} className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 flex-1">
<span className="truncate text-sm text-stone-200">{tripTitle(tid)}</span>
<span className="text-[11px] uppercase tracking-wide text-stone-400">{access}</span>
</div>
<div className="flex items-center gap-2">
<Link
href={`/trips/${tid}`}
className="text-xs px-2 py-1 rounded bg-stone-800 border border-stone-700 hover:bg-stone-700"
>
View
</Link>
<button
onClick={() => void uninvite(f.id, tid)}
className="text-xs px-2 py-1 rounded border border-stone-700 hover:bg-stone-800"
>
Uninvite
</button>
</div>
</li>
);
})
) : (
<li className="text-sm text-stone-400">No shared trips yet</li>
)}
</ul>
</section>
);
})}
<div className="mt-6">
<FriendsTripsList
friends={friends.filter((f) => f.name.toLowerCase().includes(search.trim().toLowerCase()))}
trips={trips}
shares={shares}
onUninvite={(fid, tid) => void uninvite(fid, tid)}
/>
</div>

{loading && (
Expand Down
Loading