diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..f03f94e1 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,56 @@ +# BirdPlan design language + +The rules the app's modern surfaces already follow. New UI must follow them; legacy surfaces adopt them when touched. ESLint enforces the token rule in `pages/` (see `eslint.config.js` — remove a page from the grandfather list when you modernize it). + +## Tokens, never raw palette + +Use semantic tokens for every color. No `gray-*`, `slate-*`, or `bg-white` in new code. + +| Use | Token class | +| --- | --- | +| Page canvas | `bg-background` | +| Surfaces (cards, popups, inputs) | `bg-card` | +| Primary text / values | `text-foreground` | +| Body / secondary text | `text-secondary-foreground` | +| Labels, captions, icons | `text-muted-foreground` | +| Subtle fills (segmented tracks, hovers) | `bg-muted`, `hover:bg-muted/50` | +| Borders | `border` (defaults to `border-border`); softer: `border-border/60`, `divide-border/60` | +| The one accent | `primary` family (`bg-primary`, `text-primary`, `border-primary/30`, `bg-primary/10`) | +| Links | `text-link` | +| Destructive / success | `destructive`, `success` families | + +Exceptions: status tints on chips/alerts (amber = starred/warning, emerald = mutual/success) and white text over photos/gradients (`text-white`, `bg-gradient-to-t from-black/70`). + +## Surfaces + +- **Card**: `rounded-xl border bg-card shadow-xs` — this is `ui/card`'s default; don't hand-roll it. Hero/feature cards may use `rounded-2xl`. +- **Popups/menus**: `rounded-xl border bg-card shadow-lg` (or use `ui/dropdown-menu` / `ui/select`, which handle it). +- Shadows stop at `shadow-xs` for resting surfaces, `shadow-lg` for floating ones. No `shadow-sm` cards, no ring hacks. +- Card padding: `p-5` via `CardHeader`/`CardContent`; standalone stat-style cards `p-4`. + +## Type scale + +- Page title: `text-3xl font-bold tracking-tight text-foreground` (`components/Heading`, with optional `hat` eyebrow). +- Section/card title: `CardTitle`; widget eyebrow: `components/WidgetHeader` (`text-xs font-bold tracking-widest uppercase`). +- Eyebrow/label style: `text-[11px] font-bold uppercase tracking-wide text-muted-foreground`. +- Body `text-sm`; captions `text-xs text-muted-foreground`; numbers get `tabular-nums`. + +## Controls + +Reach for the building block before writing markup: + +- `ui/*` primitives: button, input (tall `default` for auth/marketing, `sm` for app forms), textarea, select, dropdown-menu, dialog, sheet, tooltip, badge, alert, skeleton, spinner, checkbox, switch, tabs, card. +- `SearchInput` — pill search with icon (toolbars). +- `FilterChip` — pill toggle, `tone` = primary | amber | emerald, `active` prop. +- `SegmentedControl` — inline option switcher on a `bg-muted` track. +- `SelectDropdown` — labeled value-picker pill ("Sort: Best"). +- `EmptyState` for all empty/none states; `Spinner` for all loading (never hand-rolled `animate-spin`). +- Toolbars: h-9 pills with `gap-2`–`gap-3`, search left, filters right, `TargetsOptionsDropdown`-style kebab last. + +## Icons & misc + +- One icon system: **lucide-react** (`size-4` inline, `size-[18px]` map buttons). `components/Icon` only for glyphs lucide lacks; shrink it over time. +- `cn()` from `lib/utils` — never import `clsx` directly. +- Rounding: `rounded-full` for pills/chips, `rounded-md`/`rounded-lg` inside cards, `rounded-xl`+ for surfaces. +- Print: chrome gets `print:hidden`; content pages should stay printable (see itinerary). +- Modals via `ModalProvider`; confirm destructive actions (`confirm()` is the current norm). diff --git a/backend/lib/db.ts b/backend/lib/db.ts index 7b78b428..94ae9f05 100644 --- a/backend/lib/db.ts +++ b/backend/lib/db.ts @@ -7,6 +7,7 @@ import OtpCode from "models/OtpCode.js"; import MagicLink from "models/MagicLink.js"; import RateLimit from "models/RateLimit.js"; import Log from "models/Log.js"; +import TripDocument from "models/TripDocument.js"; import mongoose from "mongoose"; let isConnected = false; @@ -48,4 +49,4 @@ export async function connect() { } } -export { Trip, User, Participant, IntegrationToken, Session, OtpCode, MagicLink, RateLimit, Log }; +export { Trip, User, Participant, IntegrationToken, Session, OtpCode, MagicLink, RateLimit, Log, TripDocument }; diff --git a/backend/lib/storage.ts b/backend/lib/storage.ts index add57816..13731d4e 100644 --- a/backend/lib/storage.ts +++ b/backend/lib/storage.ts @@ -1,4 +1,5 @@ -import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; +import { S3Client, PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { nanoId } from "lib/utils.js"; const { S3_KEY_ID, S3_SECRET, S3_ENDPOINT, S3_BUCKET, S3_PUBLIC_URL } = process.env; @@ -23,6 +24,22 @@ export function imageUrl(fileName?: string | null): string | null { return `${publicUrl}/${fileName}`; } +export async function createUploadUrl(key: string, contentType: string): Promise { + if (!client) return null; + return getSignedUrl(client, new PutObjectCommand({ Bucket: S3_BUCKET!, Key: key, ContentType: contentType }), { + expiresIn: 600, + }); +} + +export async function deleteFromStorage(key: string): Promise { + if (!client) return; + await client.send(new DeleteObjectCommand({ Bucket: S3_BUCKET!, Key: key })); +} + +export function buildTripImageUrl(bounds: { minX: number; minY: number; maxX: number; maxY: number }): string { + return `https://api.mapbox.com/styles/v1/mapbox/outdoors-v11/static/[${bounds.minX},${bounds.minY},${bounds.maxX},${bounds.maxY}]/1280x640@2x?access_token=${process.env.MAPBOX_SERVER_KEY}&padding=128`; +} + export async function uploadMapboxImageToStorage(mapboxImageUrl: string): Promise { if (!client) { console.warn("S3 storage not configured, skipping image upload"); diff --git a/backend/models/Trip.ts b/backend/models/Trip.ts index a4f92ab8..d11724ff 100644 --- a/backend/models/Trip.ts +++ b/backend/models/Trip.ts @@ -21,6 +21,7 @@ const fields: Record< ownerName: String, isPublic: { type: Boolean, default: true }, name: { type: String, required: true }, + description: String, region: { type: String, required: true }, bounds: { minX: { type: Number, required: true }, @@ -91,6 +92,14 @@ const fields: Record< startMonth: { type: Number, required: true }, endMonth: { type: Number, required: true }, imgUrl: { type: String, default: null }, + customArea: { + type: { + _id: false, + polygon: [[Number]], + cells: [String], + }, + default: null, + }, targetStars: [{ type: String, default: [] }], targetNotes: { type: Map, of: String, default: {} }, shareCode: { type: String, unique: true, sparse: true }, diff --git a/backend/models/TripDocument.ts b/backend/models/TripDocument.ts new file mode 100644 index 00000000..ab235ef0 --- /dev/null +++ b/backend/models/TripDocument.ts @@ -0,0 +1,26 @@ +import type { TripDocument } from "@birdplan/shared"; +import mongoose, { Schema, model, Model } from "mongoose"; +import { nanoId } from "lib/utils.js"; + +const fields: Record, any> = { + _id: { type: String, default: () => nanoId() }, + tripId: { type: String, required: true }, + name: { type: String, required: true }, + key: { type: String, required: true }, + size: { type: Number, required: true }, + mimeType: { type: String, required: true }, + category: { type: String, default: null }, + visibility: { type: String, default: "trip" }, + uploadedBy: { type: String, required: true }, +}; + +const TripDocumentSchema = new Schema(fields, { + timestamps: true, +}); + +TripDocumentSchema.index({ tripId: 1, createdAt: 1 }); + +const TripDocumentModel = + (mongoose.models.TripDocument as Model) || model("TripDocument", TripDocumentSchema); + +export default TripDocumentModel; diff --git a/backend/package.json b/backend/package.json index b3cc015f..5fb4ebac 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,10 +7,12 @@ "start": "node --require dotenv/config dist/backend/index.js", "get-avicommons": "tsx --require dotenv/config scripts/get-avicommons.ts", "get-feature-photo": "tsx scripts/get-feature-photo.ts", - "tz-sync-regions": "tsx --require dotenv/config scripts/tz-sync-regions.ts" + "tz-sync-regions": "tsx --require dotenv/config scripts/tz-sync-regions.ts", + "backfill-trip-images": "tsx --require dotenv/config scripts/backfill-trip-images.ts" }, "dependencies": { "@aws-sdk/client-s3": "^3.1075.0", + "@aws-sdk/s3-request-presigner": "^3.1079.0", "@hono/node-server": "^1.14.4", "@maphubs/tokml": "^0.6.1", "axios": "^1.9.0", diff --git a/backend/routes/trips/[tripId]/documents.ts b/backend/routes/trips/[tripId]/documents.ts new file mode 100644 index 00000000..2b8a9a22 --- /dev/null +++ b/backend/routes/trips/[tripId]/documents.ts @@ -0,0 +1,172 @@ +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { authenticate, authenticateOptional, nanoId, sanitizeFileName } from "lib/utils.js"; +import { connect, Trip, TripDocument } from "lib/db.js"; +import { isTripEditor, isEditorInRoster, loadActiveRoster } from "lib/participants.js"; +import { createUploadUrl, deleteFromStorage, imageUrl } from "lib/storage.js"; +import type { + TripDocumentUploadUrlInput, + TripDocumentCreateInput, + TripDocumentUpdateInput, + TripDocumentCategory, + TripDocumentVisibility, +} from "@birdplan/shared"; + +const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024; +const MAX_DOCUMENTS_PER_TRIP = 50; + +const CATEGORIES: TripDocumentCategory[] = ["flights", "lodging", "transport", "permits", "maps", "other"]; +const VISIBILITIES: TripDocumentVisibility[] = ["private", "trip", "public"]; + +const documents = new Hono(); + +const requireEditor = async (c: any): Promise => { + const session = await authenticate(c); + const tripId = c.req.param("tripId"); + if (!tripId) throw new HTTPException(400, { message: "Trip ID is required" }); + + await connect(); + const [trip, isEditor] = await Promise.all([Trip.exists({ _id: tripId }), isTripEditor(tripId, session.userId)]); + if (!trip) throw new HTTPException(404, { message: "Trip not found" }); + if (!isEditor) throw new HTTPException(403, { message: "Forbidden" }); + + return session.userId; +}; + +const validateName = (name: unknown) => { + if (typeof name !== "string" || !name.trim() || name.length > 200) { + throw new HTTPException(400, { message: "Invalid file name" }); + } +}; + +const validateMeta = (name: unknown, size: unknown, mimeType: unknown) => { + validateName(name); + if (typeof size !== "number" || !Number.isFinite(size) || size <= 0) { + throw new HTTPException(400, { message: "Invalid file size" }); + } + if (size > MAX_DOCUMENT_BYTES) { + throw new HTTPException(400, { message: "Files can be up to 10 MB" }); + } + if (typeof mimeType !== "string" || !/^[\w.+-]+\/[\w.+-]+$/.test(mimeType)) { + throw new HTTPException(400, { message: "Invalid file type" }); + } +}; + +const findVisibleDocument = async (tripId: string, documentId: string, userId: string) => { + const doc = await TripDocument.findOne({ _id: documentId, tripId }).lean(); + if (!doc || (doc.visibility === "private" && doc.uploadedBy !== userId)) { + throw new HTTPException(404, { message: "Document not found" }); + } + return doc; +}; + +documents.get("/", async (c) => { + const session = await authenticateOptional(c); + const tripId = c.req.param("tripId"); + if (!tripId) throw new HTTPException(400, { message: "Trip ID is required" }); + + await connect(); + const [trip, roster] = await Promise.all([Trip.findById(tripId).lean(), loadActiveRoster(tripId)]); + if (!trip) throw new HTTPException(404, { message: "Trip not found" }); + + const isEditor = isEditorInRoster(roster, session?.userId); + if (!isEditor && !trip.isPublic) throw new HTTPException(403, { message: "Forbidden" }); + + const filter = isEditor + ? { tripId, $or: [{ visibility: { $ne: "private" } }, { uploadedBy: session?.userId }] } + : { tripId, visibility: "public" }; + + const docs = await TripDocument.find(filter).sort({ createdAt: 1 }).lean(); + return c.json(docs.map((doc) => ({ ...doc, url: imageUrl(doc.key) }))); +}); + +documents.post("/upload-url", async (c) => { + const tripId = c.req.param("tripId"); + await requireEditor(c); + + const data = await c.req.json(); + validateMeta(data.name, data.size, data.mimeType); + + const dot = data.name.lastIndexOf("."); + const base = dot > 0 ? data.name.slice(0, dot) : data.name; + const ext = dot > 0 ? data.name.slice(dot + 1).toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 10) : ""; + const safeBase = sanitizeFileName(base).replace(/ /g, "-").toLowerCase() || "file"; + const key = `docs/${tripId}/${nanoId()}/${safeBase}${ext ? `.${ext}` : ""}`; + + const uploadUrl = await createUploadUrl(key, data.mimeType); + if (!uploadUrl) throw new HTTPException(500, { message: "File storage is not configured" }); + + return c.json({ key, uploadUrl }); +}); + +documents.post("/", async (c) => { + const tripId = c.req.param("tripId"); + const userId = await requireEditor(c); + + const data = await c.req.json(); + validateMeta(data.name, data.size, data.mimeType); + if (typeof data.key !== "string" || !data.key.startsWith(`docs/${tripId}/`)) { + throw new HTTPException(400, { message: "Invalid document key" }); + } + + const count = await TripDocument.countDocuments({ tripId }); + if (count >= MAX_DOCUMENTS_PER_TRIP) { + throw new HTTPException(400, { message: `Trips can have up to ${MAX_DOCUMENTS_PER_TRIP} documents` }); + } + + const doc = await TripDocument.create({ + tripId, + name: data.name.trim(), + key: data.key, + size: data.size, + mimeType: data.mimeType, + uploadedBy: userId, + }); + + return c.json({ ...doc.toObject(), url: imageUrl(doc.key) }); +}); + +documents.patch("/:documentId", async (c) => { + const tripId = c.req.param("tripId"); + const documentId = c.req.param("documentId"); + const userId = await requireEditor(c); + + const data = await c.req.json(); + validateName(data.name); + const category = data.category || null; + if (category !== null && !CATEGORIES.includes(category)) { + throw new HTTPException(400, { message: "Invalid category" }); + } + if (!VISIBILITIES.includes(data.visibility)) { + throw new HTTPException(400, { message: "Invalid visibility" }); + } + + await findVisibleDocument(tripId!, documentId, userId); + const doc = await TripDocument.findOneAndUpdate( + { _id: documentId, tripId }, + { name: data.name.trim(), category, visibility: data.visibility }, + { new: true } + ).lean(); + if (!doc) throw new HTTPException(404, { message: "Document not found" }); + + return c.json({ ...doc, url: imageUrl(doc.key) }); +}); + +documents.delete("/:documentId", async (c) => { + const tripId = c.req.param("tripId"); + const documentId = c.req.param("documentId"); + const userId = await requireEditor(c); + + const doc = await findVisibleDocument(tripId!, documentId, userId); + + await TripDocument.deleteOne({ _id: documentId }); + try { + await deleteFromStorage(doc.key); + } catch (error) { + console.error("Failed to delete document from storage", doc.key, error); + } + + return c.json({}); +}); + +export default documents; diff --git a/backend/routes/trips/[tripId]/index.ts b/backend/routes/trips/[tripId]/index.ts index c7bdd7ed..0b63dbde 100644 --- a/backend/routes/trips/[tripId]/index.ts +++ b/backend/routes/trips/[tripId]/index.ts @@ -12,7 +12,7 @@ import { isDuplicateKeyError, validateTripDates, } from "lib/utils.js"; -import { connect, Trip, Participant, User, IntegrationToken } from "lib/db.js"; +import { connect, Trip, Participant, User, IntegrationToken, TripDocument } from "lib/db.js"; import { isTripEditor, isEditorInRoster, @@ -20,14 +20,15 @@ import { loadUsersById, resolveTripLifelist, } from "lib/participants.js"; -import { uploadMapboxImageToStorage, imageUrl } from "lib/storage.js"; +import { uploadMapboxImageToStorage, imageUrl, deleteFromStorage } from "lib/storage.js"; import { OPENBIRDING_API_URL, SHARE_CODE_TTL_MINUTES } from "lib/config.js"; -import type { TripUpdateInput, OpenBirdingLocationResponse } from "@birdplan/shared"; +import type { TripUpdateInput, TripDatesInput, TripCustomArea, OpenBirdingLocationResponse } from "@birdplan/shared"; import targetStars from "./targets.js"; import markers from "./markers.js"; import hotspots from "./hotspots.js"; import itinerary from "./itinerary.js"; import participants from "./participants.js"; +import documents from "./documents.js"; // @ts-ignore - no type definitions available import tokml from "@maphubs/tokml"; @@ -38,6 +39,7 @@ trip.route("/markers", markers); trip.route("/hotspots", hotspots); trip.route("/itinerary", itinerary); trip.route("/participants", participants); +trip.route("/documents", documents); trip.get("/", async (c) => { const session = await authenticateOptional(c); @@ -121,6 +123,154 @@ trip.patch("/", async (c) => { return c.json({}); }); +trip.patch("/privacy", async (c) => { + const session = await authenticate(c); + + const tripId: string | undefined = c.req.param("tripId"); + + if (!tripId) { + throw new HTTPException(400, { message: "Trip ID is required" }); + } + + const { isPublic } = await c.req.json<{ isPublic: boolean }>(); + if (typeof isPublic !== "boolean") { + throw new HTTPException(400, { message: "isPublic must be a boolean" }); + } + + await connect(); + const trip = await Trip.findById(tripId).lean(); + if (!trip) { + throw new HTTPException(404, { message: "Trip not found" }); + } + if (!(await isTripEditor(tripId, session.userId))) { + throw new HTTPException(403, { message: "Forbidden" }); + } + + await Trip.updateOne({ _id: tripId }, { isPublic }); + + return c.json({}); +}); + +trip.patch("/custom-area", async (c) => { + const session = await authenticate(c); + + const tripId: string | undefined = c.req.param("tripId"); + + if (!tripId) { + throw new HTTPException(400, { message: "Trip ID is required" }); + } + + const { customArea } = await c.req.json<{ customArea: TripCustomArea | null }>(); + if (customArea !== null) { + const isLngLat = (point: unknown): point is [number, number] => + Array.isArray(point) && + point.length === 2 && + typeof point[0] === "number" && + typeof point[1] === "number" && + point[0] >= -180 && + point[0] <= 180 && + point[1] >= -90 && + point[1] <= 90; + const isH3Index = (cell: unknown): cell is string => typeof cell === "string" && /^[0-9a-f]{15}$/.test(cell); + const isValid = + !!customArea && + typeof customArea === "object" && + Array.isArray(customArea.polygon) && + customArea.polygon.length >= 3 && + customArea.polygon.length <= 500 && + customArea.polygon.every(isLngLat) && + Array.isArray(customArea.cells) && + customArea.cells.length >= 1 && + customArea.cells.length <= 3000 && + customArea.cells.every(isH3Index); + if (!isValid) { + throw new HTTPException(400, { + message: "customArea must be null or contain a polygon of [lng, lat] points and 1-3000 H3 cell indexes", + }); + } + } + + await connect(); + const trip = await Trip.findById(tripId).lean(); + if (!trip) { + throw new HTTPException(404, { message: "Trip not found" }); + } + if (!(await isTripEditor(tripId, session.userId))) { + throw new HTTPException(403, { message: "Forbidden" }); + } + + await Trip.updateOne( + { _id: tripId }, + customArea === null + ? { $unset: { customArea: 1 } } + : { customArea: { polygon: customArea.polygon, cells: customArea.cells } } + ); + + return c.json({}); +}); + +trip.patch("/description", async (c) => { + const session = await authenticate(c); + + const tripId: string | undefined = c.req.param("tripId"); + + if (!tripId) { + throw new HTTPException(400, { message: "Trip ID is required" }); + } + + const { description } = await c.req.json<{ description: string }>(); + if (typeof description !== "string" || description.length > 5000) { + throw new HTTPException(400, { message: "description must be a string of at most 5000 characters" }); + } + + await connect(); + const trip = await Trip.findById(tripId).lean(); + if (!trip) { + throw new HTTPException(404, { message: "Trip not found" }); + } + if (!(await isTripEditor(tripId, session.userId))) { + throw new HTTPException(403, { message: "Forbidden" }); + } + + await Trip.updateOne({ _id: tripId }, { description: description.trim() }); + + return c.json({}); +}); + +trip.patch("/dates", async (c) => { + const session = await authenticate(c); + + const tripId: string | undefined = c.req.param("tripId"); + + if (!tripId) { + throw new HTTPException(400, { message: "Trip ID is required" }); + } + + const { startDate, endDate } = await c.req.json(); + const isDate = (value: unknown) => typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value); + if (!isDate(startDate) || !isDate(endDate)) { + throw new HTTPException(400, { message: "startDate and endDate must be YYYY-MM-DD dates" }); + } + if (endDate < startDate) { + throw new HTTPException(400, { message: "endDate must be on or after startDate" }); + } + + await connect(); + const trip = await Trip.findById(tripId).lean(); + if (!trip) { + throw new HTTPException(404, { message: "Trip not found" }); + } + if (!(await isTripEditor(tripId, session.userId))) { + throw new HTTPException(403, { message: "Forbidden" }); + } + + const startMonth = Number(startDate.slice(5, 7)); + const endMonth = Number(endDate.slice(5, 7)); + await Trip.updateOne({ _id: tripId }, { startDate, endDate, startMonth, endMonth }); + + return c.json({}); +}); + trip.delete("/", async (c) => { const session = await authenticate(c); @@ -139,12 +289,23 @@ trip.delete("/", async (c) => { throw new HTTPException(403, { message: "Forbidden" }); } + const documents = await TripDocument.find({ tripId }).select("key").lean(); + await Promise.all([ Trip.deleteOne({ _id: tripId }), Participant.deleteMany({ tripId }), IntegrationToken.deleteMany({ tripId }), + TripDocument.deleteMany({ tripId }), ]); + await Promise.all( + documents.map((doc) => + deleteFromStorage(doc.key).catch((error) => + console.error("Failed to delete document from storage", doc.key, error) + ) + ) + ); + return c.json({}); }); diff --git a/backend/routes/trips/[tripId]/itinerary.ts b/backend/routes/trips/[tripId]/itinerary.ts index 7a8d6d0f..abb822b9 100644 --- a/backend/routes/trips/[tripId]/itinerary.ts +++ b/backend/routes/trips/[tripId]/itinerary.ts @@ -8,6 +8,7 @@ import type { ItineraryDayInput, ItineraryNotesInput, MoveLocationInput, + ReorderLocationsInput, RemoveLocationInput, AddLocationInput, CalcTravelTimeInput, @@ -97,6 +98,51 @@ itinerary.patch("/:dayId/move-location", async (c) => { return c.json({}); }); +itinerary.patch("/:dayId/reorder-locations", async (c) => { + const session = await authenticate(c); + + const tripId = c.req.param("tripId"); + const dayId = c.req.param("dayId"); + if (!tripId) throw new HTTPException(400, { message: "Trip ID is required" }); + if (!dayId) throw new HTTPException(400, { message: "Day ID is required" }); + + const data = await c.req.json(); + + await connect(); + const [trip, isEditor] = await Promise.all([ + Trip.findById(tripId).lean(), + isTripEditor(tripId, session.userId), + ]); + if (!trip) throw new HTTPException(404, { message: "Trip not found" }); + if (!isEditor) throw new HTTPException(403, { message: "Forbidden" }); + + const day = trip.itinerary?.find((it) => it.id === dayId); + if (!day) throw new HTTPException(404, { message: "Day not found" }); + + const currentIds = (day.locations || []).map((it) => it.id); + const isSameSet = + data.ids?.length === currentIds.length && currentIds.every((id) => data.ids.includes(id)); + if (!isSameSet) throw new HTTPException(400, { message: "ids must match the day's locations" }); + + const updatedDay = { + ...day, + locations: data.ids.map((id) => day.locations.find((it) => it.id === id)!), + }; + + const updatedDayWithTravel = await updateDayTravelTimes(trip as any, updatedDay as any); + + await Trip.updateOne( + { _id: tripId, "itinerary.id": dayId }, + { + $set: { + "itinerary.$.locations": updatedDayWithTravel.locations || [], + }, + } + ); + + return c.json({}); +}); + itinerary.patch("/:dayId/notes", async (c) => { const session = await authenticate(c); diff --git a/backend/routes/trips/index.ts b/backend/routes/trips/index.ts index 9490c562..e1802e39 100644 --- a/backend/routes/trips/index.ts +++ b/backend/routes/trips/index.ts @@ -4,7 +4,7 @@ import { rateLimiter } from "hono-rate-limiter"; import trip from "./[tripId]/index.js"; import { authenticate, getBounds, validateTripDates } from "lib/utils.js"; import { connect, Trip, Participant, IntegrationToken, User } from "lib/db.js"; -import { uploadMapboxImageToStorage, imageUrl } from "lib/storage.js"; +import { uploadMapboxImageToStorage, buildTripImageUrl, imageUrl } from "lib/storage.js"; import { SHARE_CODE_TTL_MINUTES } from "lib/config.js"; import type { TripInput, ParticipantView, TripStats, TripListItem, TripListPage } from "@birdplan/shared"; @@ -257,8 +257,7 @@ trips.post("/", async (c) => { throw new HTTPException(500, { message: "Failed to fetch region info" }); } - const mapboxImgUrl = `https://api.mapbox.com/styles/v1/mapbox/outdoors-v11/static/[${bounds?.minX},${bounds?.minY},${bounds?.maxX},${bounds?.maxY}]/300x185@2x?access_token=${process.env.MAPBOX_SERVER_KEY}&padding=30`; - const imgUrl = await uploadMapboxImageToStorage(mapboxImgUrl); + const imgUrl = await uploadMapboxImageToStorage(buildTripImageUrl(bounds)); await connect(); const user = await User.findOne({ _id: session.userId }).select("name").lean(); diff --git a/backend/scripts/backfill-trip-images.ts b/backend/scripts/backfill-trip-images.ts new file mode 100644 index 00000000..748d96d3 --- /dev/null +++ b/backend/scripts/backfill-trip-images.ts @@ -0,0 +1,39 @@ +import { connect, Trip } from "../lib/db.js"; +import { buildTripImageUrl, uploadMapboxImageToStorage, deleteFromStorage } from "../lib/storage.js"; + +const isDryRun = !process.argv.includes("--apply"); + +await connect(); +const trips = await Trip.find({ "bounds.minX": { $exists: true } }) + .select("name bounds imgUrl") + .lean(); + +console.log(`${trips.length} trips with bounds${isDryRun ? " (dry run — pass --apply to write)" : ""}`); + +let updated = 0; +let failed = 0; + +for (const trip of trips) { + const label = `${trip._id} (${trip.name})`; + if (isDryRun) { + console.log(`would regenerate ${label}`); + continue; + } + + const newKey = await uploadMapboxImageToStorage(buildTripImageUrl(trip.bounds)); + if (!newKey) { + failed++; + console.error(`failed ${label}`); + continue; + } + + await Trip.updateOne({ _id: trip._id }, { imgUrl: newKey }); + if (trip.imgUrl) { + await deleteFromStorage(trip.imgUrl).catch((error) => console.error(`old image cleanup failed ${label}`, error)); + } + updated++; + console.log(`regenerated ${label}`); +} + +console.log(isDryRun ? "dry run complete" : `done: ${updated} updated, ${failed} failed`); +process.exit(0); diff --git a/frontend/RootLayout.tsx b/frontend/RootLayout.tsx index 83577429..37b32702 100644 --- a/frontend/RootLayout.tsx +++ b/frontend/RootLayout.tsx @@ -1,16 +1,17 @@ import { Outlet } from "react-router-dom"; import { Toaster } from "react-hot-toast"; import { ModalRoot } from "components/Modal"; +import { TooltipProvider } from "components/ui/tooltip"; import { useClearSelectedSpeciesOnNavigate } from "hooks/useTrip"; export default function RootLayout() { useClearSelectedSpeciesOnNavigate(); return ( - <> + - + ); } diff --git a/frontend/components/AcceptError.tsx b/frontend/components/AcceptError.tsx index 86272bbe..5c56513e 100644 --- a/frontend/components/AcceptError.tsx +++ b/frontend/components/AcceptError.tsx @@ -1,5 +1,5 @@ import React from "react"; -import Alert from "components/Alert"; +import { Alert } from "components/ui/alert"; import { Button } from "components/ui/button"; type Props = { @@ -14,7 +14,7 @@ export default function AcceptError({ title, message, onRetry, retrying, childre return (

{title}

- + {message || "Something went wrong."}
diff --git a/frontend/components/Alert.tsx b/frontend/components/Alert.tsx deleted file mode 100644 index 23150d60..00000000 --- a/frontend/components/Alert.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { clsx } from "clsx"; - -type Props = { - children: React.ReactNode; - className?: string; - style: "warning" | "error" | "info" | "gray"; -}; - -export default function Alert({ children, className, style }: Props) { - const styleMap = { - warning: "bg-amber-100 text-amber-800", - error: "bg-red-100 text-red-800", - info: "bg-sky-100 text-sky-800", - gray: "bg-neutral-100 text-neutral-800", - }; - return ( -
- {children} -
- ); -} diff --git a/frontend/components/AreaDraw.tsx b/frontend/components/AreaDraw.tsx new file mode 100644 index 00000000..031dc071 --- /dev/null +++ b/frontend/components/AreaDraw.tsx @@ -0,0 +1,238 @@ +import React from "react"; +import { Marker, Source, Layer, useMap } from "react-map-gl"; +import toast from "react-hot-toast"; +import { Button } from "components/ui/button"; +import { Spinner } from "components/ui/spinner"; +import { useTrip } from "hooks/useTrip"; +import useTripMutation from "hooks/useTripMutation"; +import { TripCustomArea } from "@birdplan/shared"; +import { cn } from "lib/utils"; + +const H3_RESOLUTION = 6; +const MAX_CELLS = 3000; + +type LngLat = [number, number]; + +async function cellsForPolygon(polygon: LngLat[]): Promise { + const { polygonToCells } = await import("h3-js"); + return polygonToCells( + polygon.map(([lng, lat]) => [lat, lng]), + H3_RESOLUTION + ); +} + +async function cellsToGeojson(cells: string[]) { + const { cellsToMultiPolygon } = await import("h3-js"); + return { + type: "Feature" as const, + properties: {}, + geometry: { type: "MultiPolygon" as const, coordinates: cellsToMultiPolygon(cells, true) }, + }; +} + +export function CustomAreaLayer({ area }: { area: TripCustomArea }) { + const [hexGeojson, setHexGeojson] = React.useState(null); + const cellsKey = area.cells.join(","); + + React.useEffect(() => { + let cancelled = false; + cellsToGeojson(area.cells).then((geojson) => { + if (!cancelled) setHexGeojson(geojson); + }); + return () => { + cancelled = true; + }; + }, [cellsKey]); + + if (!hexGeojson) return null; + + return ( + + + + + ); +} + +type Props = { + onExit: () => void; +}; + +export default function AreaDraw({ onExit }: Props) { + const { current: map } = useMap(); + const { trip } = useTrip(); + const [vertices, setVertices] = React.useState([]); + const [closed, setClosed] = React.useState(false); + const [cells, setCells] = React.useState([]); + const [hexGeojson, setHexGeojson] = React.useState(null); + const [isComputing, setIsComputing] = React.useState(false); + + const saveMutation = useTripMutation<{ customArea: TripCustomArea }>({ + url: `/trips/${trip?._id}/custom-area`, + method: "PATCH", + updateCache: (old, input) => ({ ...old, customArea: input.customArea }), + }); + + React.useEffect(() => { + if (!map) return; + const handleClick = (e: any) => { + if (closed) return; + setVertices((prev) => [...prev, [e.lngLat.lng, e.lngLat.lat]]); + }; + map.on("click", handleClick); + map.getMap().doubleClickZoom.disable(); + return () => { + map.off("click", handleClick); + map.getMap().doubleClickZoom.enable(); + }; + }, [map, closed]); + + React.useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onExit(); + }; + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [onExit]); + + const verticesKey = vertices.map((v) => v.join(":")).join(","); + React.useEffect(() => { + let cancelled = false; + const timeout = setTimeout(async () => { + if (vertices.length < 3) { + setCells([]); + setHexGeojson(null); + setIsComputing(false); + return; + } + setIsComputing(true); + const nextCells = await cellsForPolygon(vertices); + if (cancelled) return; + setCells(nextCells); + setHexGeojson(nextCells.length && nextCells.length <= MAX_CELLS ? await cellsToGeojson(nextCells) : null); + setIsComputing(false); + }, 250); + return () => { + cancelled = true; + clearTimeout(timeout); + }; + }, [verticesKey]); + + const closeRing = (e: React.MouseEvent) => { + e.stopPropagation(); + if (vertices.length >= 3) setClosed(true); + }; + + const useMapView = () => { + if (!map) return; + const b = map.getBounds(); + if (!b) return; + setVertices([ + [b.getWest(), b.getSouth()], + [b.getEast(), b.getSouth()], + [b.getEast(), b.getNorth()], + [b.getWest(), b.getNorth()], + ]); + setClosed(true); + }; + + const handleSave = () => { + if (!cells.length) return toast.error("This area has no bird data — try a different spot"); + if (cells.length > MAX_CELLS) return toast.error("Area too large — use an eBird region for areas this big"); + saveMutation.mutate( + { customArea: { polygon: vertices, cells } }, + { + onSuccess: () => { + toast.success("Custom targets area saved"); + onExit(); + }, + } + ); + }; + + const tooBig = cells.length > MAX_CELLS; + const lineGeojson = { + type: "Feature" as const, + properties: {}, + geometry: { + type: "LineString" as const, + coordinates: closed && vertices.length >= 3 ? [...vertices, vertices[0]] : vertices, + }, + }; + + return ( + <> + {hexGeojson && ( + + + + + )} + {vertices.length > 0 && ( + + + + )} + {vertices.map(([lng, lat], index) => ( + + + )} + {closed && ( + + )} + {isComputing ? ( + + Calculating cells... + + ) : ( + !!cells.length && ( + + {cells.length.toLocaleString()} cells{tooBig && ` (max ${MAX_CELLS.toLocaleString()})`} + + ) + )} + +
+
+ + + ); +} diff --git a/frontend/components/AuthForm.tsx b/frontend/components/AuthForm.tsx index 57c169a1..b8c728f5 100644 --- a/frontend/components/AuthForm.tsx +++ b/frontend/components/AuthForm.tsx @@ -1,9 +1,9 @@ import React from "react"; import { Link, useNavigate } from "react-router-dom"; -import Input from "components/Input"; +import { Input } from "components/ui/input"; import Field from "components/Field"; import { Button } from "components/ui/button"; -import Alert from "components/Alert"; +import { Alert } from "components/ui/alert"; import useRequestCode from "hooks/useRequestCode"; import useVerifyCode from "hooks/useVerifyCode"; import useReportNoCode from "hooks/useReportNoCode"; @@ -85,11 +85,11 @@ export default function AuthForm({ heading, message, email: initialEmail, lockEm return ( <> - {heading &&

{heading}

} - {message &&

{message}

} + {heading &&

{heading}

} + {message &&

{message}

} {error && ( - + {error} )} @@ -122,7 +122,7 @@ export default function AuthForm({ heading, message, email: initialEmail, lockEm value={code} onChange={(e: React.ChangeEvent) => setCode(e.target.value.replace(/\D/g, ""))} /> - We sent a code to your inbox + We sent a code to your inbox )} @@ -140,9 +140,9 @@ export default function AuthForm({ heading, message, email: initialEmail, lockEm {step === "code" && (
{showHelp ? ( -
+

- We sent a code to {email}.{" "} + We sent a code to {email}.{" "} @@ -152,7 +152,7 @@ export default function AuthForm({ heading, message, email: initialEmail, lockEm

  • Check your spam or junk folder.
  • {cooldown > 0 ? ( - Resend in {cooldown}s + Resend in {cooldown}s ) : (
  • {canEdit && ( - )} diff --git a/frontend/components/Heading.tsx b/frontend/components/Heading.tsx index 77db3ebd..c03358e0 100644 --- a/frontend/components/Heading.tsx +++ b/frontend/components/Heading.tsx @@ -16,11 +16,11 @@ export default function Heading({ title, hat, subtitle, icon, iconClassName, cla return (
    {hat &&

    {hat}

    } -

    - {icon && } +

    + {icon && } {title}

    - {subtitle &&

    {subtitle}

    } + {subtitle &&

    {subtitle}

    }

    ); } diff --git a/frontend/components/HotspotTargets.tsx b/frontend/components/HotspotTargets.tsx index 7a62f3c4..a90b17ce 100644 --- a/frontend/components/HotspotTargets.tsx +++ b/frontend/components/HotspotTargets.tsx @@ -1,13 +1,14 @@ import React from "react"; import { useTrip } from "hooks/useTrip"; import Icon from "components/Icon"; +import { Spinner } from "components/ui/spinner"; import { Button } from "components/ui/button"; import HotspotTargetRow from "components/HotspotTargetRow"; import SelectDropdown from "components/SelectDropdown"; import useTargetView from "hooks/useTargetView"; import useMutualTargets from "hooks/useMutualTargets"; import TargetViewToggle from "components/TargetViewToggle"; -import Alert from "components/Alert"; +import { Alert } from "components/ui/alert"; import { HOTSPOT_TARGET_CUTOFF } from "lib/config"; import useLocationTargets from "hooks/useLocationTargets"; import { computeFrequency, getMonthRange } from "lib/targets"; @@ -45,8 +46,8 @@ export default function HotspotTargets({ hotspotId, onSpeciesClick }: Props) { if (isLoading) { return ( - - + + Loading targets... ); @@ -54,7 +55,7 @@ export default function HotspotTargets({ hotspotId, onSpeciesClick }: Props) { if (isError) { return ( - + Failed to load targets
    )} {!sortedItems?.length && ( - + No targets found > {HOTSPOT_TARGET_CUTOFF}% )} diff --git a/frontend/components/Input.tsx b/frontend/components/Input.tsx deleted file mode 100644 index 964e6071..00000000 --- a/frontend/components/Input.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from "react"; - -type Props = { - type?: string; - className?: string; - autoFocus?: boolean; - isTextarea?: boolean; - [key: string]: any; -}; - -const Input = React.forwardRef(({ type = "text", isTextarea, className, autoFocus, ...props }: Props, ref: any) => { - const thisRef = React.useRef(null); - React.useEffect(() => { - if (autoFocus) { - setTimeout(() => { - (ref?.current || thisRef.current)?.focus(); - }, 50); - } - }, [autoFocus, ref]); - - const Tag = isTextarea ? "textarea" : "input"; - - return ( - - ); -}); - -Input.displayName = "Input"; - -export default Input; diff --git a/frontend/components/InputNotes.tsx b/frontend/components/InputNotes.tsx index 9ee42a51..8f015823 100644 --- a/frontend/components/InputNotes.tsx +++ b/frontend/components/InputNotes.tsx @@ -31,7 +31,7 @@ export default function InputNotes({ value, onBlur }: Props) {
    setNotes(e.target.value)} onBlur={(e) => onBlur(e.target.value)} @@ -41,7 +41,7 @@ export default function InputNotes({ value, onBlur }: Props) { />
    ) : ( -
    {notes || "No notes"}
    +
    {notes || "No notes"}
    )}
    {showToggleBtn && ( diff --git a/frontend/components/InputNotesSimple.tsx b/frontend/components/InputNotesSimple.tsx index a9084cf4..14fa55b6 100644 --- a/frontend/components/InputNotesSimple.tsx +++ b/frontend/components/InputNotesSimple.tsx @@ -27,7 +27,7 @@ export default function InputNotesSimple({ value, onBlur, className, canEdit, sh {inEditMode ? ( setNotes(e.target.value)} onBlur={(e) => handleBlur(e.target.value)} @@ -36,7 +36,7 @@ export default function InputNotesSimple({ value, onBlur, className, canEdit, sh ref={notsRef} /> ) : ( -
    {notes || ""}
    +
    {notes || ""}
    )}
    {!inEditMode && canEdit && ( diff --git a/frontend/components/ItineraryDay.tsx b/frontend/components/ItineraryDay.tsx index 7c30cfd4..881ca506 100644 --- a/frontend/components/ItineraryDay.tsx +++ b/frontend/components/ItineraryDay.tsx @@ -1,5 +1,6 @@ import React from "react"; import { Button } from "components/ui/button"; +import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "components/ui/card"; import { useTrip } from "hooks/useTrip"; import dayjs from "dayjs"; import { useModal } from "stores/modals"; @@ -7,10 +8,16 @@ import MarkerWithIcon from "components/MarkerWithIcon"; import TravelTime from "components/TravelTime"; import InputNotesSimple from "components/InputNotesSimple"; import Icon from "components/Icon"; +import { GripVertical, Plus, Trash2, X } from "lucide-react"; import useTripMutation from "hooks/useTripMutation"; import { useMutationState } from "@tanstack/react-query"; import { Day } from "@birdplan/shared"; -import { removeInvalidTravelData, moveLocation } from "lib/itinerary"; +import { removeInvalidTravelData } from "lib/itinerary"; +import { cn } from "lib/utils"; +import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; +import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; type PropsT = { day: Day; @@ -21,6 +28,7 @@ type PropsT = { export default function ItineraryDay({ day, dayIndex, isEditing }: PropsT) { const { trip, isFetching: isFetchingTrip } = useTrip(); const { open } = useModal(); + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })); const isAddingLocation = useMutationState({ filters: { mutationKey: [`/trips/${trip?._id}/itinerary/${day.id}/add-location`] }, @@ -55,8 +63,8 @@ export default function ItineraryDay({ day, dayIndex, isEditing }: PropsT) { }), }); - const moveLocationMutation = useTripMutation<{ id: string; direction: "up" | "down" }>({ - url: `/trips/${trip?._id}/itinerary/${day.id}/move-location`, + const reorderMutation = useTripMutation<{ ids: string[] }>({ + url: `/trips/${trip?._id}/itinerary/${day.id}/reorder-locations`, method: "PATCH", updateCache: (old, input) => ({ ...old, @@ -65,7 +73,9 @@ export default function ItineraryDay({ day, dayIndex, isEditing }: PropsT) { it.id === day.id ? { ...it, - locations: removeInvalidTravelData(moveLocation(it.locations, input.id, input.direction)), + locations: removeInvalidTravelData( + input.ids.flatMap((id) => it.locations?.find((loc) => loc.id === id) || []) + ), } : it ) || [], @@ -86,8 +96,15 @@ export default function ItineraryDay({ day, dayIndex, isEditing }: PropsT) { removeDayMutation.mutate({}); }; + const handleDragEnd = ({ active, over }: DragEndEvent) => { + if (!over || active.id === over.id) return; + const ids = locations.map((it) => it.id); + const newIds = arrayMove(ids, ids.indexOf(String(active.id)), ids.indexOf(String(over.id))); + reorderMutation.mutate({ ids: newIds }); + }; + const isLoading = - moveLocationMutation.isPending || + reorderMutation.isPending || removeLocationMutation.isPending || removeDayMutation.isPending || isCalculatingTravelTime || @@ -98,110 +115,169 @@ export default function ItineraryDay({ day, dayIndex, isEditing }: PropsT) { const { notes, locations } = day; return ( -
    -
    -
    -

    Day {dayIndex + 1}

    - {date} -
    + + + Day {dayIndex + 1} + {date && {date}} + {isEditing && ( + + + + )} + + setNotesMutation.mutate({ notes: value })} - className="mt-1 mb-4" + className={cn(!!locations?.length && "mb-4")} canEdit={isEditing} /> -
    - {!!locations?.length && ( -
      - {locations?.map(({ locationId, type, id }, index) => { - const location = - trip?.hotspots?.find((h) => h.id === locationId) || trip?.markers?.find((m) => m.id === locationId); - - return ( - - {index !== 0 && ( -
    • - -
    • - )} -
    • -
      - type === "hotspot" - ? open("hotspot", { hotspot: location }) - : open("viewMarker", { markerId: location.id }) - : undefined - } - > - {location ? ( - - ) : ( - - )} - -
      {location?.name || "Unknown Location"}
      - {location?.notes && ( - {location.notes} - )} -
      -
      - {isEditing && ( -
      - {index !== locations.length - 1 && ( - - )} + {!!locations?.length && ( + + it.id)} + strategy={verticalListSortingStrategy} + disabled={!isEditing} + > +
        + {locations.map(({ locationId, type, id }, index) => { + const location = + trip?.hotspots?.find((h) => h.id === locationId) || trip?.markers?.find((m) => m.id === locationId); + + return ( + {index !== 0 && ( - +
      • + +
      • )} - -
      - )} -
    • -
      - ); - })} -
    - )} - {isEditing && ( -
    - + )} +
    + type === "hotspot" + ? open("hotspot", { hotspot: location }) + : open("viewMarker", { markerId: location.id }) + : undefined + } + > + {location ? ( + + ) : ( + + )} + + + {location?.name || "Unknown Location"} + + {location?.notes && ( + + {location.notes} + + )} + +
    + {isEditing && ( + + )} + + )} + + + ); + })} + + + + )} + {isEditing && ( + - -
    + )} + + + ); +} + +type RowRenderProps = { + handleProps: Record; + setActivatorNodeRef: (el: HTMLElement | null) => void; +}; + +function SortableLocationRow({ + id, + disabled, + children, +}: { + id: string; + disabled: boolean; + children: (props: RowRenderProps) => React.ReactNode; +}) { + const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({ + id, + disabled, + }); + + return ( +
  • + > + {children({ handleProps: { ...attributes, ...listeners }, setActivatorNodeRef })} +
  • ); } diff --git a/frontend/components/LifelistCard.tsx b/frontend/components/LifelistCard.tsx index b143c081..cab8ce7f 100644 --- a/frontend/components/LifelistCard.tsx +++ b/frontend/components/LifelistCard.tsx @@ -2,6 +2,7 @@ import React from "react"; import toast from "react-hot-toast"; import { Button } from "components/ui/button"; import Icon from "components/Icon"; +import { Spinner } from "components/ui/spinner"; import { parseLifelistCsv } from "lib/lifelistCsv"; type Props = { @@ -39,7 +40,7 @@ export default function LifelistCard({ label, count, onImport, onRemove, disable disabled ? "bg-gray-400" : "bg-green-600" }`} > - + {disabled ? : }

    {label}

    diff --git a/frontend/components/LifelistUpload.tsx b/frontend/components/LifelistUpload.tsx index 905719a3..b1c4b171 100644 --- a/frontend/components/LifelistUpload.tsx +++ b/frontend/components/LifelistUpload.tsx @@ -1,6 +1,7 @@ import React from "react"; import toast from "react-hot-toast"; import Icon from "components/Icon"; +import { Spinner } from "components/ui/spinner"; import { parseLifelistCsv } from "lib/lifelistCsv"; type Props = { @@ -29,7 +30,7 @@ export default function LifelistUpload({ onImport, isPending, buttonLabel, varia return ( ); @@ -63,7 +64,7 @@ export default function LifelistUpload({ onImport, isPending, buttonLabel, varia onChange={handleFileUpload} /> - + {isPending ? : } {isPending ? ( Importing… diff --git a/frontend/components/Mapbox.tsx b/frontend/components/Mapbox.tsx index 6f655dca..6f4e0aad 100644 --- a/frontend/components/Mapbox.tsx +++ b/frontend/components/Mapbox.tsx @@ -16,9 +16,11 @@ type Props = { hotspotLayer?: any; obsLayer?: any; addingMarker?: boolean; + drawing?: boolean; showSatellite?: boolean; onHotspotClick?: (id: string) => void; onDisableAddingMarker?: () => void; + children?: React.ReactNode; }; export default function Mapbox({ @@ -29,8 +31,10 @@ export default function Mapbox({ hotspotLayer, obsLayer, addingMarker, + drawing, showSatellite, onDisableAddingMarker, + children, }: Props) { const { open, close } = useModal(); const { selectedMarkerId, halo } = useTrip(); @@ -108,7 +112,7 @@ export default function Mapbox({ if (!lat || !lng) return null; return ( -
    +
    { + if (drawing) return; if (addingMarker) { const lat = Math.round(e.lngLat.lat * 1000000) / 1000000; const lng = Math.round(e.lngLat.lng * 1000000) / 1000000; @@ -199,6 +204,7 @@ export default function Mapbox({ )} + {children} {halo && (
    diff --git a/frontend/components/Modal.tsx b/frontend/components/Modal.tsx index 1bb00622..fda0749c 100644 --- a/frontend/components/Modal.tsx +++ b/frontend/components/Modal.tsx @@ -21,6 +21,9 @@ import AddParticipant from "modals/AddParticipant"; import InviteAsEditor from "modals/InviteAsEditor"; import ManageLifelist from "modals/ManageLifelist"; import GenerateMagicLink from "modals/GenerateMagicLink"; +import Share from "modals/Share"; +import TripNotes from "modals/TripNotes"; +import EditDocument from "modals/EditDocument"; type ModalConfig = { Component: React.ComponentType; @@ -41,6 +44,9 @@ const modals: Record = { inviteAsEditor: { Component: InviteAsEditor }, manageLifelist: { Component: ManageLifelist }, generateMagicLink: { Component: GenerateMagicLink }, + share: { Component: Share }, + tripNotes: { Component: TripNotes }, + editDocument: { Component: EditDocument }, }; const ModalRoot = () => { diff --git a/frontend/components/MonthlyFrequencyChart.tsx b/frontend/components/MonthlyFrequencyChart.tsx index ec53b461..89050fa5 100644 --- a/frontend/components/MonthlyFrequencyChart.tsx +++ b/frontend/components/MonthlyFrequencyChart.tsx @@ -66,7 +66,7 @@ export default function MonthlyFrequencyChart({ className={clsx( "w-full h-full transition-colors", isMini ? "rounded-[1px]" : "rounded-md", - inRange ? (isHover ? "bg-sky-700" : "bg-sky-600") : isHover ? "bg-gray-600" : "bg-gray-300" + inRange ? (isHover ? "bg-primary-hover" : "bg-primary") : isHover ? "bg-gray-600" : "bg-gray-300" )} /> {isHover && ( @@ -85,7 +85,7 @@ export default function MonthlyFrequencyChart({
    {MONTH_INITIALS[i]} diff --git a/frontend/components/MutualBadge.tsx b/frontend/components/MutualBadge.tsx index 0e5a78e3..3bc6244f 100644 --- a/frontend/components/MutualBadge.tsx +++ b/frontend/components/MutualBadge.tsx @@ -1,7 +1,6 @@ -import React from "react"; -import clsx from "clsx"; +import { cn } from "lib/utils"; import Icon from "components/Icon"; -import Tooltip from "components/Tooltip"; +import { Tooltip, TooltipTrigger, TooltipContent } from "components/ui/tooltip"; const TOOLTIP = "Mutual target — everyone in your group still needs this species"; @@ -13,21 +12,26 @@ type Props = { export default function MutualBadge({ size = "sm", variant = "badge" }: Props) { const iconOnly = variant === "icon"; return ( - - - - + + + + + } + /> + {TOOLTIP} ); } diff --git a/frontend/components/ObsList.tsx b/frontend/components/ObsList.tsx index 54f4e238..57782890 100644 --- a/frontend/components/ObsList.tsx +++ b/frontend/components/ObsList.tsx @@ -1,12 +1,13 @@ import React from "react"; import Icon from "components/Icon"; +import { Spinner } from "components/ui/spinner"; import { Button } from "components/ui/button"; import { dateTimeToRelative } from "lib/helpers"; import { useTrip } from "hooks/useTrip"; import dayjs from "dayjs"; import useFetchHotspotObs from "hooks/useFetchHotspotObs"; import useFetchRecentChecklists from "hooks/useFetchRecentChecklists"; -import Alert from "components/Alert"; +import { Alert } from "components/ui/alert"; type Props = { hotspotId: string; @@ -65,7 +66,7 @@ export default function ObsList({ hotspotId, speciesCode }: Props) { {evidence === "N" && } {evidence === "P" && } - {evidence === "A" && } + {evidence === "A" && } @@ -85,14 +86,14 @@ export default function ObsList({ hotspotId, speciesCode }: Props) { )}

    {isLoading && ( - - + + Loading observations... )} {error && ( - + Failed to load observations + + ))} + + )} + {query.length >= 2 && !isFetching && !!data && !results.length && ( +

    + No places found +

    + )} +
    ); } diff --git a/frontend/components/RecentChecklistList.tsx b/frontend/components/RecentChecklistList.tsx index e058f682..6ddbdc34 100644 --- a/frontend/components/RecentChecklistList.tsx +++ b/frontend/components/RecentChecklistList.tsx @@ -9,9 +9,10 @@ import useFetchHotspotObs from "hooks/useFetchHotspotObs"; import useLocationTargets from "hooks/useLocationTargets"; import { RecentChecklist } from "lib/types"; import Icon from "components/Icon"; +import { Spinner } from "components/ui/spinner"; import ObsList from "components/ObsList"; import SelectDropdown from "components/SelectDropdown"; -import Alert from "components/Alert"; +import { Alert } from "components/ui/alert"; type Props = { hotspotId: string; @@ -46,10 +47,10 @@ export default function RecentChecklistList({ hotspotId, speciesCode, speciesNam return ( <> {speciesCode && ( -
    +
    {speciesName}
    - {isLoadingTargets && } + {isLoadingTargets && } {!isLoadingTargets && successRate !== null && ( <> {Math.round(successRate * 100)}% of {totalSamples.toLocaleString()}{" "} @@ -59,7 +60,7 @@ export default function RecentChecklistList({ hotspotId, speciesCode, speciesNam
    @@ -164,18 +165,18 @@ export default function RecentChecklistList({ hotspotId, speciesCode, speciesNam

    )} {!isLoading && !isLoadingSpecies && checklists.length === 0 && !error && ( - + No recent checklists )} {(isLoading || isLoadingSpecies) && ( - - {!reduceLoaders && } + + {!reduceLoaders && } Loading recent checklists... )} {error && ( - + Failed to load recent checklists + ))} +
    + ); +} diff --git a/frontend/components/SelectDropdown.tsx b/frontend/components/SelectDropdown.tsx index 812b5f9c..40abb303 100644 --- a/frontend/components/SelectDropdown.tsx +++ b/frontend/components/SelectDropdown.tsx @@ -1,6 +1,12 @@ -import React from "react"; -import clsx from "clsx"; +import { cn } from "lib/utils"; import Icon from "components/Icon"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, +} from "components/ui/dropdown-menu"; export type SelectOption = { value: T; @@ -26,57 +32,32 @@ export default function SelectDropdown({ align = "right", className, }: Props) { - const [open, setOpen] = React.useState(false); const current = options.find((o) => o.value === value) ?? options[0]; return ( -
    - - {open && ( - <> -
    setOpen(false)} className="fixed inset-0 z-30" /> -
    - {options.map((o) => { - const active = o.value === value; - return ( - - ); - })} -
    - - )} -
    + + + + onChange(next as T)}> + {options.map((option) => ( + + {option.label} + + ))} + + + ); } diff --git a/frontend/components/SlideOver.tsx b/frontend/components/SlideOver.tsx index 71cf133d..10b61c7b 100644 --- a/frontend/components/SlideOver.tsx +++ b/frontend/components/SlideOver.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Transition } from "@headlessui/react"; +import { cn } from "lib/utils"; import Icon from "components/Icon"; type Props = { @@ -10,29 +10,21 @@ type Props = { export default function SlideOver({ open, onClose, children }: Props) { return ( - -
    - -
    - -
    {children}
    -
    -
    -
    -
    +
    + +
    {children}
    +
    ); } diff --git a/frontend/components/SpeciesCard.tsx b/frontend/components/SpeciesCard.tsx index 2dd9a3b9..3b82e7cc 100644 --- a/frontend/components/SpeciesCard.tsx +++ b/frontend/components/SpeciesCard.tsx @@ -44,7 +44,7 @@ export default function Trip({ name, code }: Props) { Showing reports over the last 30 days.{" "} View on eBird diff --git a/frontend/components/SpeciesHero.tsx b/frontend/components/SpeciesHero.tsx index ea6084f5..57081ea3 100644 --- a/frontend/components/SpeciesHero.tsx +++ b/frontend/components/SpeciesHero.tsx @@ -8,7 +8,7 @@ import { import Icon from "components/Icon"; import { Button } from "components/ui/button"; import { Map, Star, ExternalLink, Check } from "lucide-react"; -import Card from "components/Card"; +import { Card } from "components/ui/card"; import MutualBadge from "components/MutualBadge"; import MonthlyFrequencyChart from "components/MonthlyFrequencyChart"; diff --git a/frontend/components/SpeciesHotspotList.tsx b/frontend/components/SpeciesHotspotList.tsx index 7e1eeaa9..eee4df65 100644 --- a/frontend/components/SpeciesHotspotList.tsx +++ b/frontend/components/SpeciesHotspotList.tsx @@ -1,7 +1,8 @@ import React from "react"; import clsx from "clsx"; import Icon from "components/Icon"; -import Card from "components/Card"; +import { Spinner } from "components/ui/spinner"; +import { Card } from "components/ui/card"; import SelectDropdown from "components/SelectDropdown"; import type { OpenBirdingHotspotRanking } from "@birdplan/shared"; @@ -49,7 +50,7 @@ export default function SpeciesHotspotList({
    {loading ? (
    - + Updating…
    ) : ( @@ -76,7 +77,7 @@ function SpeciesHotspotRow({ h, rank, onSelect }: { h: HotspotItem; rank: number onClick={() => onSelect(h.id)} role="button" tabIndex={0} - className="px-5 py-3.5 border-b border-gray-100 last:border-b-0 hover:bg-sky-50/60 transition-colors cursor-pointer grid gap-4 items-center grid-cols-[auto_1fr_auto] sm:grid-cols-[auto_1fr_220px_28px]" + className="px-5 py-3.5 border-b border-gray-100 last:border-b-0 hover:bg-primary/5 transition-colors cursor-pointer grid gap-4 items-center grid-cols-[auto_1fr_auto] sm:grid-cols-[auto_1fr_220px_28px]" >
    {rank}.
    diff --git a/frontend/components/SpeciesHotspotToolbar.tsx b/frontend/components/SpeciesHotspotToolbar.tsx index dad903f6..71329dde 100644 --- a/frontend/components/SpeciesHotspotToolbar.tsx +++ b/frontend/components/SpeciesHotspotToolbar.tsx @@ -1,7 +1,9 @@ import React from "react"; -import clsx from "clsx"; +import { cn } from "lib/utils"; import Icon from "components/Icon"; import SelectDropdown from "components/SelectDropdown"; +import SegmentedControl from "components/SegmentedControl"; +import FilterChip from "components/FilterChip"; export type Scope = "saved" | "all"; export type SortKey = "best" | "freq"; @@ -47,9 +49,16 @@ export default function SpeciesHotspotToolbar({ }: Props) { return (
    - + }, + { value: "all", label: "All hotspots" }, + ]} + />
    - + void }) { - const options: { value: Scope; label: string; icon?: React.ReactNode }[] = [ - { value: "saved", label: "Saved", icon: }, - { value: "all", label: "All hotspots" }, - ]; - return ( -
    - {options.map((opt) => { - const active = scope === opt.value; - return ( - - ); - })} -
    - ); -} - -function SortDropdown({ value, onChange }: { value: SortKey; onChange: (v: SortKey) => void }) { - return ; -} - function MoreFiltersMenu({ minObservations, setMinObservations, @@ -109,29 +86,20 @@ function MoreFiltersMenu({ return (
    - + {open && ( <>
    setOpen(false)} className="fixed inset-0 z-30" /> -
    +
    -
    +
    Last seen (days)
    -
    - {([ + { - const active = recentDays === opt.value; - return ( - - ); - })} -
    -
    + ]} + /> +
    Minimum observations
    -
    +
    Min - {minObservations} + {minObservations} + + )} + + + + {documents?.length ? ( +
      + {documents.map((doc) => { + const DocIcon = getDocumentIcon(doc); + const category = getDocumentCategory(doc); + const visibility = getDocumentVisibility(doc.visibility); + return ( +
    • + + + +
      + + {doc.name} + +

      + + {category && <>{category.label} · } + {formatBytes(doc.size)} + + {canEdit && ( + + · + {visibility.label} + + )} +

      +
      + {canEdit && ( + + + } + > + + + + open("editDocument", { document: doc })}> + Edit details + + } + > + Download + + handleDelete(doc)}> + Delete + + + + )} +
    • + ); + })} +
    + ) : ( +
    + +

    + Flight itineraries, lodging confirmations, permits — keep the group's paperwork in one place. +

    +
    + )} +
    + + ); +} diff --git a/frontend/components/TripLayout.tsx b/frontend/components/TripLayout.tsx new file mode 100644 index 00000000..83da7583 --- /dev/null +++ b/frontend/components/TripLayout.tsx @@ -0,0 +1,26 @@ +import { Outlet } from "react-router-dom"; +import Header from "components/Header"; +import TripNav from "components/TripNav"; +import NotFound from "components/NotFound"; +import ErrorBoundary from "components/ErrorBoundary"; +import { useTrip } from "hooks/useTrip"; +import { useUser } from "hooks/useUser"; + +export default function TripLayout() { + const { trip, is404 } = useTrip(); + const { user } = useUser(); + + if (is404) return ; + + return ( +
    +
    + +
    + + + +
    +
    + ); +} diff --git a/frontend/components/TripNav.tsx b/frontend/components/TripNav.tsx index 9e8237da..7c3dd11a 100644 --- a/frontend/components/TripNav.tsx +++ b/frontend/components/TripNav.tsx @@ -1,5 +1,4 @@ import React from "react"; -import clsx from "clsx"; import { cn } from "lib/utils"; import { useTrip } from "hooks/useTrip"; import { Link, useLocation } from "react-router-dom"; @@ -9,36 +8,35 @@ import { buttonVariants } from "components/ui/button"; import Icon from "components/Icon"; const links = [ - { name: "Map", slug: "", icon: "mapFlat" }, + { name: "Overview", slug: "", icon: "house" }, + { name: "Map", slug: "map", icon: "mapFlat" }, { name: "Targets", slug: "targets", icon: "bullseye" }, { name: "Itinerary", slug: "itinerary", icon: "calendar" }, ]; -type Props = { - active: string; - border?: boolean; -}; - -export default function TripNav({ active, border = true }: Props) { +export default function TripNav() { const { trip } = useTrip(); const { pathname } = useLocation(); const { close } = useModal(); + const active = pathname.split("/")[2] ?? ""; React.useEffect(() => { close(); }, [pathname]); return ( -
    -
    +
    +
    {links.map(({ name, slug, icon }) => ( diff --git a/frontend/components/TripOptionsDropdown.tsx b/frontend/components/TripOptionsDropdown.tsx index 912b2f99..8f166118 100644 --- a/frontend/components/TripOptionsDropdown.tsx +++ b/frontend/components/TripOptionsDropdown.tsx @@ -66,9 +66,10 @@ export default function TripOptionsDropdown({ className }: Props) { return ( } + render={
    -
    {children}
    +
    {children}