Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b436560
Unify accent colors on primary token and tokenize ui primitives
rawcomposition Jul 2, 2026
e7e91f9
Nest trip pages under a shared TripLayout route
rawcomposition Jul 2, 2026
faafc8b
Add Share modal with trip privacy toggle
rawcomposition Jul 3, 2026
13b605d
Add missing ui primitives (card, badge, tooltip, textarea, alert, ske…
rawcomposition Jul 3, 2026
e353e5b
Migrate legacy twins to ui primitives, drop @headlessui
rawcomposition Jul 3, 2026
fc420f9
Replace hand-rolled spinners with ui/spinner
rawcomposition Jul 3, 2026
2a25764
Redesign itinerary: day cards, drag-to-reorder, inline dates, print view
rawcomposition Jul 3, 2026
8ae525f
Show travel times on printed itineraries
rawcomposition Jul 3, 2026
17f9c57
Add audience-aware trip Overview page
rawcomposition Jul 3, 2026
3d7078b
Polish Overview: frequency-sorted teaser, pluralized stats, adaptive …
rawcomposition Jul 3, 2026
e99e922
Fix logged-out 404 handling; add ESLint guardrail for raw palette cla…
rawcomposition Jul 3, 2026
b2d525d
Replace Google Places with Photon in PlaceSearch; drop Google Maps JS…
rawcomposition Jul 3, 2026
25b5325
Add SearchInput, FilterChip, SegmentedControl, Stat building blocks; …
rawcomposition Jul 3, 2026
6c7d77f
Rebuild SelectDropdown on ui/dropdown-menu radio items
rawcomposition Jul 3, 2026
94127af
Auth pages + UtilityPage design pass: modern card dialect, semantic t…
rawcomposition Jul 3, 2026
8868a49
Design pass: trips list + contact to semantic tokens; ui/select on co…
rawcomposition Jul 3, 2026
e8797b1
Add trip documents (presigned R2 uploads); fix trip 404 refetch loop
rawcomposition Jul 3, 2026
89ab03e
Add DESIGN.md codifying the design language
rawcomposition Jul 3, 2026
f1bf3a6
Rebuild Overview as the trip's home page
rawcomposition Jul 3, 2026
9400951
Polish pass on Overview and modals from review feedback
rawcomposition Jul 3, 2026
bbbd687
Add custom-area targets: draw on map, live H3 hex preview
rawcomposition Jul 3, 2026
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
56 changes: 56 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
@@ -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).
3 changes: 2 additions & 1 deletion backend/lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
19 changes: 18 additions & 1 deletion backend/lib/storage.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -23,6 +24,22 @@ export function imageUrl(fileName?: string | null): string | null {
return `${publicUrl}/${fileName}`;
}

export async function createUploadUrl(key: string, contentType: string): Promise<string | null> {
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<void> {
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<string | null> {
if (!client) {
console.warn("S3 storage not configured, skipping image upload");
Expand Down
9 changes: 9 additions & 0 deletions backend/models/Trip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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 },
Expand Down
26 changes: 26 additions & 0 deletions backend/models/TripDocument.ts
Original file line number Diff line number Diff line change
@@ -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<keyof Omit<TripDocument, "createdAt" | "updatedAt" | "url">, 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<TripDocument>) || model<TripDocument>("TripDocument", TripDocumentSchema);

export default TripDocumentModel;
4 changes: 3 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
172 changes: 172 additions & 0 deletions backend/routes/trips/[tripId]/documents.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
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<TripDocumentUploadUrlInput>();
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<TripDocumentCreateInput>();
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<TripDocumentUpdateInput>();
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;
Loading
Loading