diff --git a/backend/package.json b/backend/package.json index 3ee4965b..b3cc015f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,6 +6,7 @@ "build": "tsc && tsc-alias", "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" }, "dependencies": { diff --git a/backend/scripts/get-feature-photo.ts b/backend/scripts/get-feature-photo.ts new file mode 100644 index 00000000..f4fb0eda --- /dev/null +++ b/backend/scripts/get-feature-photo.ts @@ -0,0 +1,108 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const USER_AGENT = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"; +const IMAGE_SIZE = 1800; +const projectRoot = path.join(fileURLToPath(import.meta.url), "../../.."); +const dataFile = path.join(projectRoot, "frontend/data/feature-photos.json"); +const imageDir = path.join(projectRoot, "frontend/public/feature-photos"); + +const LICENSES: Record = { + LICENSE5: "CC BY 4.0", + LICENSE8: "CC BY-NC 4.0", + LICENSE9: "CC BY-SA 4.0", + LICENSE10: "CC0 1.0", +}; + +type FeaturePhoto = { + sourceId: string; + photographer: string; + location: string; + date: string; + license: string; + downloadedAt: string; +}; + +type MLLocation = { + name: string; + countryName: string | null; + subnational1Name: string | null; + subnational2Name: string | null; + locality: string | null; +}; + +type MLAsset = { + assetId: number; + userDisplayName: string; + obsDtDisplay: string; + licenseId: string; + location: MLLocation; +}; + +const composeLocation = (loc: MLLocation) => + [loc.locality || loc.name, loc.subnational2Name, loc.subnational1Name, loc.countryName].filter(Boolean).join(", "); + +const downloadImage = async (assetId: string, sourceId: string) => { + const url = `https://cdn.download.ams.birds.cornell.edu/api/v2/asset/${assetId}/${IMAGE_SIZE}`; + const response = await fetch(url, { headers: { "User-Agent": USER_AGENT } }); + if (!response.ok) throw new Error(`image HTTP ${response.status}`); + fs.mkdirSync(imageDir, { recursive: true }); + fs.writeFileSync(path.join(imageDir, `${sourceId}.jpg`), Buffer.from(await response.arrayBuffer())); +}; + +const fetchPhoto = async (input: string): Promise => { + const assetId = input.trim().replace(/^ML/i, ""); + if (!/^\d+$/.test(assetId)) throw new Error(`Invalid asset ID: ${input}`); + + const url = `https://ebird.org/ml-search-api/v2/search?assetId=${assetId}&taxaLocale=en&count=1`; + const response = await fetch(url, { headers: { "User-Agent": USER_AGENT } }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const [asset]: MLAsset[] = await response.json(); + if (!asset) throw new Error("No asset found"); + + const license = LICENSES[asset.licenseId]; + if (!license) throw new Error(`Disallowed license: ${asset.licenseId}`); + + const sourceId = `ML${asset.assetId}`; + await downloadImage(assetId, sourceId); + + return { + sourceId, + photographer: asset.userDisplayName, + location: composeLocation(asset.location), + date: asset.obsDtDisplay, + license, + downloadedAt: new Date().toISOString(), + }; +}; + +const readPhotos = (): FeaturePhoto[] => + fs.existsSync(dataFile) ? JSON.parse(fs.readFileSync(dataFile, "utf8")) : []; + +const run = async () => { + const inputs = process.argv.slice(2); + if (inputs.length === 0) { + console.error("Usage: npm run get-feature-photo -- [assetId...]"); + process.exit(1); + } + + const photos = readPhotos(); + + for (const input of inputs) { + try { + const photo = await fetchPhoto(input); + const index = photos.findIndex((p) => p.sourceId === photo.sourceId); + if (index === -1) photos.push(photo); + else photos[index] = photo; + console.log(`โœ“ ${photo.sourceId} โ€” ${photo.photographer}, ${photo.location}`); + } catch (error) { + console.error(`โœ— ${input}: ${error instanceof Error ? error.message : error}`); + } + } + + fs.writeFileSync(dataFile, JSON.stringify(photos, null, 2) + "\n"); +}; + +run(); diff --git a/frontend/components/BackLink.tsx b/frontend/components/BackLink.tsx new file mode 100644 index 00000000..6dc15163 --- /dev/null +++ b/frontend/components/BackLink.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { Link } from "react-router-dom"; +import Icon from "components/Icon"; +import { cn } from "lib/utils"; + +type Props = { + to: string; + label: string; + className?: string; +}; + +export default function BackLink({ to, label, className }: Props) { + return ( + + + {label} + + ); +} diff --git a/frontend/components/Button.tsx b/frontend/components/Button.tsx index d6868484..4bba9138 100644 --- a/frontend/components/Button.tsx +++ b/frontend/components/Button.tsx @@ -11,7 +11,8 @@ const buttonVariants = cva("font-semibold rounded", { red: "bg-red-600 hover:bg-red-700 text-white", grayOutline: "border border-input hover:bg-secondary transition-colors text-secondary-foreground", primary: "bg-primary text-primary-foreground hover:bg-primary-hover transition-colors", - pillPrimary: "bg-primary text-primary-foreground hover:bg-primary-hover transition-colors rounded-full", + pillPrimary: + "bg-primary text-primary-foreground hover:bg-primary-hover transition-colors rounded-full shadow-lg shadow-primary/30", pillOutlineGray: "bg-transparent text-secondary-foreground border border-input hover:bg-gray-50 transition-colors rounded-full", pillWhite: "bg-white text-secondary-foreground hover:bg-gray-50 transition-colors rounded-full shadow-md", @@ -24,6 +25,7 @@ const buttonVariants = cva("font-semibold rounded", { size: { lg: "text-lg py-2.5 px-4.5", md: "text-md py-2 px-5", + pill: "text-sm py-3 px-6", smPill: "text-[14px] py-1.5 px-4", sm: "text-[14px] py-1.5 px-2.5", xs: "text-[12px] py-0.5 px-1.5", diff --git a/frontend/components/CreateTripHero.tsx b/frontend/components/CreateTripHero.tsx new file mode 100644 index 00000000..b4d115e4 --- /dev/null +++ b/frontend/components/CreateTripHero.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import featurePhotos from "data/feature-photos.json"; + +export default function CreateTripHero() { + const [photo] = React.useState(() => featurePhotos[Math.floor(Math.random() * featurePhotos.length)]); + + if (!photo) return null; + + return ( + + ); +} diff --git a/frontend/components/EmailChangeForm.tsx b/frontend/components/EmailChangeForm.tsx index 9a7a83dc..35ada5f8 100644 --- a/frontend/components/EmailChangeForm.tsx +++ b/frontend/components/EmailChangeForm.tsx @@ -79,7 +79,7 @@ export default function EmailChangeForm({ currentEmail }: Props) { />

We'll send a 6-digit code to your new email to confirm the change.

- @@ -100,7 +100,7 @@ export default function EmailChangeForm({ currentEmail }: Props) { />
- + {open &&
{children}
} +
+ ); +} diff --git a/frontend/components/Field.tsx b/frontend/components/Field.tsx index 3bf8694a..6e064d7f 100644 --- a/frontend/components/Field.tsx +++ b/frontend/components/Field.tsx @@ -1,23 +1,34 @@ import React from "react"; +import { Label } from "components/ui/label"; +import { formLabelClass } from "lib/formStyles"; type Props = { - label: string; - children: React.ReactNode; + label?: string; isOptional?: boolean; + rightButton?: React.ReactNode; + help?: React.ReactNode; + className?: string; + children: React.ReactNode; }; -export default function Field({ label, isOptional, children }: Props) { +export default function Field({ label, isOptional, rightButton, help, className, children }: Props) { return ( -
-