Skip to content
Merged
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
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
108 changes: 108 additions & 0 deletions backend/scripts/get-feature-photo.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<FeaturePhoto> => {
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> [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();
25 changes: 25 additions & 0 deletions frontend/components/BackLink.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Link
to={to}
className={cn(
"inline-flex items-center gap-2 text-sm font-semibold text-gray-500 transition-colors hover:text-gray-700",
className
)}
>
<Icon name="arrowLeft" className="text-xs" />
{label}
</Link>
);
}
4 changes: 3 additions & 1 deletion frontend/components/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
43 changes: 43 additions & 0 deletions frontend/components/CreateTripHero.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<aside
className="absolute inset-y-0 right-0 hidden overflow-hidden lg:block"
style={{ left: "calc(max((100vw - 80rem) / 2, 0px) + 42rem + 3rem)" }}
>
<img
src={`/feature-photos/${photo.sourceId}.jpg`}
alt=""
className="absolute inset-0 h-full w-full object-cover"
/>
<div
className="absolute inset-x-0 bottom-0 h-[42%]"
style={{ background: "linear-gradient(180deg,transparent,rgba(20,14,12,.55) 55%,rgba(13,9,8,.92))" }}
/>
<div
className="absolute inset-0"
style={{ background: "radial-gradient(120% 92% at 50% 38%,transparent 54%,rgba(0,0,0,.42))" }}
/>
<a
href={`https://media.ebird.org/asset/${photo.sourceId.replace(/^ML/, "")}`}
target="_blank"
rel="noopener noreferrer"
className="absolute bottom-5 left-5 flex max-w-[calc(100%-2.5rem)] items-center gap-2.5 rounded-[13px] bg-black/40 px-3.5 py-2.5 backdrop-blur-sm transition-colors hover:bg-black/55"
>
<span className="text-sm">📷</span>
<div className="min-w-0 leading-tight">
<div className="truncate text-[13px] font-bold text-white">{photo.location}</div>
<div className="truncate text-[11px] font-medium text-white/80">
{photo.photographer} · {photo.license}
</div>
</div>
</a>
</aside>
);
}
4 changes: 2 additions & 2 deletions frontend/components/EmailChangeForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export default function EmailChangeForm({ currentEmail }: Props) {
/>
</Field>
<p className="text-sm text-gray-600">We&apos;ll send a 6-digit code to your new email to confirm the change.</p>
<Button type="submit" color="primary" disabled={requestMutation.isPending || !isDirty}>
<Button type="submit" color="pillPrimary" size="pill" disabled={requestMutation.isPending || !isDirty}>
{requestMutation.isPending ? "Sending..." : "Send code"}
</Button>
</form>
Expand All @@ -100,7 +100,7 @@ export default function EmailChangeForm({ currentEmail }: Props) {
/>
</Field>
<div className="flex items-center gap-3">
<Button type="submit" color="primary" disabled={updateMutation.isPending || code.length < 6}>
<Button type="submit" color="pillPrimary" size="pill" disabled={updateMutation.isPending || code.length < 6}>
{updateMutation.isPending ? "Updating..." : "Update Email"}
</Button>
<button
Expand Down
28 changes: 28 additions & 0 deletions frontend/components/Expander.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import React from "react";
import Icon from "components/Icon";
import { cn } from "lib/utils";

type Props = {
label: React.ReactNode;
defaultOpen?: boolean;
className?: string;
children: React.ReactNode;
};

export default function Expander({ label, defaultOpen = false, className, children }: Props) {
const [open, setOpen] = React.useState(defaultOpen);

return (
<div className={cn("border-t border-gray-100 pt-1", className)}>
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
className="flex w-full items-center gap-2 py-3 text-[13px] font-bold text-gray-500 hover:text-gray-700"
>
<Icon name="angleDown" className={`text-xs transition-transform ${open ? "" : "-rotate-90"}`} />
{label}
</button>
{open && <div className="mt-1 flex flex-col gap-[22px]">{children}</div>}
</div>
);
}
35 changes: 23 additions & 12 deletions frontend/components/Field.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<label className="flex flex-col gap-1">
<span className="text-sm font-medium text-gray-700">
{label}
{isOptional && (
<span className="rounded-2xl bg-gray-200 text-gray-700 text-xs font-medium px-2 py-0.5 ml-2">optional</span>
<div className={className}>
{(label || rightButton) && (
<div className="mb-2 flex items-center justify-between gap-2">
{label ? (
<Label className={formLabelClass}>
{label}
{isOptional && <span className="ml-1 font-medium normal-case text-gray-400">optional</span>}
</Label>
) : (
<span />
)}
</span>
{children}
</label>
{rightButton}
</div>
)}
{children}
{help && <p className="mt-2 text-xs text-gray-500">{help}</p>}
</div>
);
}
52 changes: 52 additions & 0 deletions frontend/components/FormPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React from "react";
import { IconNameT } from "lib/icons";
import Header from "components/Header";
import Footer from "components/Footer";
import BackLink from "components/BackLink";
import Heading from "components/Heading";

type Props = {
title: string;
hat?: string;
icon?: IconNameT;
iconClassName?: string;
subtitle?: React.ReactNode;
back?: { to: string; label: string };
documentTitle?: string;
header?: React.ReactNode;
children: React.ReactNode;
};

export default function FormPage({
title,
hat,
icon,
iconClassName,
subtitle,
back,
documentTitle,
header,
children,
}: Props) {
return (
<div className="flex flex-col h-full">
{documentTitle && <title>{documentTitle}</title>}
{header ?? <Header />}
<main className="flex-1 overflow-y-auto bg-background">
<div className="mx-auto w-full max-w-2xl px-4 py-6 pb-16 sm:px-6 sm:py-8">
{back && <BackLink to={back.to} label={back.label} className="mb-5" />}
<Heading
hat={hat}
title={title}
icon={icon}
iconClassName={iconClassName}
subtitle={subtitle}
className="mb-6"
/>
{children}
</div>
</main>
<Footer />
</div>
);
}
26 changes: 26 additions & 0 deletions frontend/components/Heading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React from "react";
import Icon from "components/Icon";
import { IconNameT } from "lib/icons";
import { cn } from "lib/utils";

type Props = {
title: string;
hat?: string;
subtitle?: React.ReactNode;
icon?: IconNameT;
iconClassName?: string;
className?: string;
};

export default function Heading({ title, hat, subtitle, icon, iconClassName, className }: Props) {
return (
<div className={className}>
{hat && <p className="mb-2 text-[11px] font-bold uppercase tracking-[0.14em] text-primary">{hat}</p>}
<h1 className="flex items-center gap-2.5 text-3xl font-bold tracking-tight text-gray-800">
{icon && <Icon name={icon} className={cn("text-xl text-gray-500", iconClassName)} />}
{title}
</h1>
{subtitle && <p className="mt-1.5 text-sm text-gray-500">{subtitle}</p>}
</div>
);
}
5 changes: 3 additions & 2 deletions frontend/components/MonthSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import React from "react";
import Select, { SelectProps } from "components/ReactSelectStyled";
import { months } from "lib/helpers";
import { formSelectStyles } from "lib/formStyles";

type Props = Omit<SelectProps, "options">;

export default function MonthSelect(props: Props) {
export default function MonthSelect({ styles = formSelectStyles, ...props }: Props) {
const options = months.map((month, i) => ({ value: (i + 1).toString(), label: month }));

return <Select options={options} {...props} />;
return <Select options={options} styles={styles} {...props} />;
}
Loading
Loading