diff --git a/.gitignore b/.gitignore index 058fb7fe..deace3d6 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,5 @@ public/top10/*.json # Sentry Auth Token .sentryclirc notes.md -/public/tinymce \ No newline at end of file +/public/tinymce +/public/species-images \ No newline at end of file diff --git a/components/CropPreview.tsx b/components/CropPreview.tsx new file mode 100644 index 00000000..3cf2af48 --- /dev/null +++ b/components/CropPreview.tsx @@ -0,0 +1,85 @@ +/* eslint-disable @next/next/no-img-element */ +import React from "react"; + +type CropPreviewProps = { + x: number; + y: number; + width: number; + height: number; + imgUrl: string; +}; + +const PREVIEW_WIDTH = 120; +const ASPECT_RATIO = 3 / 4; + +const CropPreview = React.memo(({ x, y, width, height, imgUrl }: CropPreviewProps) => { + const canvasRef = React.useRef(null); + const [croppedImageUrl, setCroppedImageUrl] = React.useState(null); + + const proxyImgUrl = React.useMemo(() => getProxyImgUrl(imgUrl), [imgUrl]); + + React.useEffect(() => { + const image = new Image(); + image.src = proxyImgUrl; + + image.onload = () => { + const canvas = canvasRef.current; + if (canvas) { + const ctx = canvas.getContext("2d"); + if (ctx) { + const previewWidth = PREVIEW_WIDTH * 2; + const previewHeight = PREVIEW_WIDTH * ASPECT_RATIO * 2; + canvas.width = previewWidth; + canvas.height = previewHeight; + + // Calculate the crop area in pixels + const cropX = (x / 100) * image.width; + const cropY = (y / 100) * image.height; + const cropWidth = (width / 100) * image.width; + const cropHeight = (height / 100) * image.height; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(image, cropX, cropY, cropWidth, cropHeight, 0, 0, previewWidth, previewHeight); + + canvas.toBlob((blob) => { + if (blob) { + const croppedUrl = URL.createObjectURL(blob); + setCroppedImageUrl(croppedUrl); + } + }); + } + } + }; + }, [x, y, width, height, proxyImgUrl]); + + return ( +
+ + {croppedImageUrl && ( + Cropped Preview + )} +
+ ); +}); + +CropPreview.displayName = "CropPreview"; + +export default CropPreview; + +const getProxyImgUrl = (url: string) => { + return url + .replace("https://inaturalist-open-data.s3.amazonaws.com/", "/api/image-proxy/inat/") + .replace("https://cdn.download.ams.birds.cornell.edu/", "/api/image-proxy/ebird/") + .replace("https://upload.wikimedia.org/", "/api/image-proxy/wikipedia/") + .replace("https://live.staticflickr.com/", "/api/image-proxy/flickr/"); +}; diff --git a/components/Field.tsx b/components/Field.tsx index 1f64b1e1..13d3d901 100644 --- a/components/Field.tsx +++ b/components/Field.tsx @@ -3,14 +3,16 @@ import Help from "components/Help"; type InputProps = { label: string; help?: string; + required?: boolean; children: React.ReactNode; }; -const Field = ({ label, help, children }: InputProps) => { +const Field = ({ label, help, required, children }: InputProps) => { return (
diff --git a/components/FormError.tsx b/components/FormError.tsx index 8f5933d9..e25831c6 100644 --- a/components/FormError.tsx +++ b/components/FormError.tsx @@ -1,14 +1,20 @@ import { useFormContext } from "react-hook-form"; -import { ErrorMessage } from '@hookform/error-message'; +import { ErrorMessage } from "@hookform/error-message"; type FormErrorProps = { - name: string, - className?: string, -} + name: string; + className?: string; +}; -export default function FormError({name, className}: FormErrorProps) { - const { formState: { errors } } = useFormContext(); - return ( - {message} - )} /> -} \ No newline at end of file +export default function FormError({ name, className }: FormErrorProps) { + const { + formState: { errors }, + } = useFormContext(); + return ( + {message}} + /> + ); +} diff --git a/components/InputImageCrop.tsx b/components/InputImageCrop.tsx new file mode 100644 index 00000000..960e9f79 --- /dev/null +++ b/components/InputImageCrop.tsx @@ -0,0 +1,63 @@ +/* eslint-disable @next/next/no-img-element */ +import { useController, useFormContext } from "react-hook-form"; +import Cropper from "react-easy-crop"; +import React from "react"; +import { Crop } from "lib/types"; +import CropPreview from "components/CropPreview"; +import { useDebounce } from "hooks/useDebounce"; + +type Props = { + name: string; + url: string; + className?: string; +}; + +export default function InputImageCrop({ className, name, url }: Props) { + const { + formState: { defaultValues }, + } = useFormContext(); + const { field } = useController({ name }); + const [crop, setCrop] = React.useState({ x: 0, y: 0 }); + const [zoom, setZoom] = React.useState(1); + + const value: Crop = field.value; + const debouncedCrop = useDebounce(value?.percent, 100); + + return ( +
+
+ { + field.onChange({ + percent: { + x: croppedArea.x, + y: croppedArea.y, + width: croppedArea.width, + height: croppedArea.height, + }, + pixel: { + x: croppedAreaPixels.x, + y: croppedAreaPixels.y, + width: croppedAreaPixels.width, + height: croppedAreaPixels.height, + }, + } as Crop); + document.activeElement instanceof HTMLElement && document.activeElement.blur(); + }} + onZoomChange={setZoom} + initialCroppedAreaPercentages={defaultValues?.[name]?.percent} + /> +
+
+ +
+
+ ); +} diff --git a/components/RadioGroup.tsx b/components/RadioGroup.tsx index 3400433a..3f9ad3a0 100644 --- a/components/RadioGroup.tsx +++ b/components/RadioGroup.tsx @@ -8,9 +8,10 @@ type InputProps = { options: string[] | { label: string; value: string }[]; inline?: boolean; help?: string; + onChange?: (value: string) => void; }; -const RadioGroup = ({ name, label, options, inline, help }: InputProps) => { +const RadioGroup = ({ name, label, options, inline, help, onChange }: InputProps) => { const { register } = useFormContext(); return (
@@ -22,15 +23,27 @@ const RadioGroup = ({ name, label, options, inline, help }: InputProps) => { {options.map((option) => typeof option === "string" ? ( - ) : ( - diff --git a/components/ReactSelectStyled.tsx b/components/ReactSelectStyled.tsx index dcff7d04..4766a5a9 100644 --- a/components/ReactSelectStyled.tsx +++ b/components/ReactSelectStyled.tsx @@ -1,6 +1,19 @@ -import ReactSelect from "react-select"; +import ReactSelect, { GroupBase, Props } from "react-select"; -const ReactSelectStyled = (props: any) => { +// Implemented per https://react-select.com/typescript +type SelectBaseProps< + Option, + IsMulti extends boolean = false, + Group extends GroupBase
+
+ + + + Save + +
+
+ + + + ); +} + +export const getServerSideProps = getSecureServerSideProps(async ({ query, res }, token) => { + const { code } = query; + await connect(); + const species = await Species.findById(code); + + if (!species) return { notFound: true }; + + const cleanSpecies = JSON.parse(JSON.stringify(species)); + + return { + props: { + data: cleanSpecies, + code, + }, + }; +}); diff --git a/pages/species/index.tsx b/pages/species/index.tsx new file mode 100644 index 00000000..b4a81781 --- /dev/null +++ b/pages/species/index.tsx @@ -0,0 +1,287 @@ +/* eslint-disable @next/next/no-img-element */ +import * as React from "react"; +import Link from "next/link"; +import { GetServerSideProps } from "next"; +import { ImgSourceLabel, LicenseLabel, SpeciesT } from "lib/types"; +import Species from "models/Species"; +import AdminPage from "components/AdminPage"; +import { getSourceImgUrl } from "lib/species"; +import clsx from "clsx"; +import connect from "lib/mongo"; +import XMark from "icons/XMark"; +import Families from "data/taxon-families.json"; +import SelectBasic from "components/ReactSelectStyled"; +import { useRouter } from "next/router"; + +const PER_PAGE = 200; + +type Props = { + species: SpeciesT[]; + currentPage: number; + totalPages: number; + percentWithImg: string; + percentCropped: string; + totalCount: number; + filteredCount: number; + withoutImgCount: number; + filter: string; + family: string; + startCount: number; +}; + +export default function SpeciesList({ + species, + currentPage, + totalPages, + percentWithImg, + percentCropped, + totalCount, + filteredCount, + withoutImgCount, + filter, + family, + startCount, +}: Props) { + const router = useRouter(); + const selectedFamily = Families.find((f) => f.code === family); + + return ( + +
+

Species List

+
+

+ Images: {percentWithImg}% +

+

+ Cropped: {percentCropped}% +

+
+
+ + All ({totalCount.toLocaleString()}) + + + Without Image ({withoutImgCount.toLocaleString()}) + +
+ ({ label: `${family.name} (${family.count})`, value: family.code }))} + onChange={(selectedOption) => { + if (selectedOption) { + router.push(`/species?page=1&filter=${filter}&family=${selectedOption.value}`); + } else { + router.push(`/species?page=1&filter=${filter}`); + } + }} + value={ + selectedFamily + ? { label: `${selectedFamily.name} (${selectedFamily.count})`, value: selectedFamily.code } + : undefined + } + placeholder="Filter by family" + className="w-[260px]" + isClearable + /> +

+ Filtered count: {filteredCount.toLocaleString()} +

+
+ {species.map((species, index) => ( +
+ + {species.hasImg && (species.downloadedAt || !species.crop) ? ( +
+ {species.name} + {!species.crop && ( +
+ +
+ )} +
+ ) : ( +
+ {!species.hasImg ? "No Image" : "Pending"} +
+ )} + +
+

+ {index + 1 + startCount}.{" "} + {species.name} +

+
+ + Source: {ImgSourceLabel[species.source] || "Unknown"} + + + Author: {species.author || "None"} + + + License: {LicenseLabel[species.license] || "Unknown"} + +
+
+ + {species.hasImg ? "Edit Image" : "Add Image"} + + {!species.crop && ( + <> + + Google + + + eBird + + + + )} +
+
+
+ ))} +
+
+ {currentPage > 1 && ( + + Previous + + )} + + Page {currentPage} of {totalPages} + + {currentPage < totalPages && ( + + Next + + )} +
+
+
+ ); +} + +export const getServerSideProps: GetServerSideProps = async (context) => { + const page = Number(context.query.page) || 1; + const limit = PER_PAGE; + const skip = (page - 1) * limit; + const filter = context.query.filter || "all"; + const family = context.query.family || "all"; + + let query: any = { active: true }; + if (filter === "withoutImg") { + query = { hasImg: { $ne: true } }; + } + if (filter === "withoutCrop") { + query = { crop: { $exists: false } }; + } + if (family !== "all") { + query.familyCode = family; + } + + await connect(); + const totalCount = await Species.countDocuments({}); + const filteredCount = await Species.countDocuments(query); + const withImgCount = await Species.countDocuments({ hasImg: true }); + const croppedCount = await Species.countDocuments({ crop: { $exists: true } }); + const totalPages = Math.ceil(filteredCount / limit); + const percentWithImg = ((withImgCount / totalCount) * 100).toFixed(1); + const percentCropped = ((croppedCount / withImgCount) * 100).toFixed(1); + const startCount = (page - 1) * limit; + + const speciesRes = await Species.find(query, [ + "_id", + "name", + "source", + "sourceId", + "hasImg", + "sciName", + "iNatFileExt", + "downloadedAt", + "crop", + "license", + "author", + ]) + .sort({ order: 1 }) + .skip(skip) + .limit(limit); + + const species = JSON.parse(JSON.stringify(speciesRes)); + + return { + props: { + species, + currentPage: page, + totalPages, + percentWithImg, + percentCropped, + totalCount, + filteredCount, + withoutImgCount: totalCount - withImgCount, + filter, + family, + startCount, + }, + }; +}; diff --git a/pages/species-list.tsx b/pages/species/preview.tsx similarity index 57% rename from pages/species-list.tsx rename to pages/species/preview.tsx index 3f4dc6ff..35c1ee63 100644 --- a/pages/species-list.tsx +++ b/pages/species/preview.tsx @@ -2,11 +2,12 @@ import * as React from "react"; import Link from "next/link"; import { GetServerSideProps } from "next"; -import Title from "components/Title"; import { SpeciesT } from "lib/types"; import Species from "models/Species"; import AdminPage from "components/AdminPage"; -import { getSourceUrl } from "lib/species"; +import connect from "lib/mongo"; + +const PER_PAGE = 500; type Props = { species: SpeciesT[]; @@ -18,32 +19,21 @@ export default function SpeciesList({ species, currentPage, totalPages }: Props) return (
- Species List -

Species List

-
+

Species Thumbnail Preview

+
{species.map((species) => ( - - {species.hasImg ? ( - {species.name} - ) : ( - "No Image" - )} - + src={`/species-images/${species._id}-240.jpg`} + alt={species.name} + className="aspect-[4/3] object-cover w-[120px] rounded-md" + /> ))}
{currentPage > 1 && ( Previous @@ -54,7 +44,7 @@ export default function SpeciesList({ species, currentPage, totalPages }: Props) {currentPage < totalPages && ( Next @@ -68,16 +58,15 @@ export default function SpeciesList({ species, currentPage, totalPages }: Props) export const getServerSideProps: GetServerSideProps = async (context) => { const page = Number(context.query.page) || 1; - const limit = 100; + const limit = PER_PAGE; const skip = (page - 1) * limit; - const totalCount = await Species.countDocuments(); - const totalPages = Math.ceil(totalCount / limit); + const query = { downloadedAt: { $exists: true } }; + await connect(); + const filteredCount = await Species.countDocuments(query); + const totalPages = Math.ceil(filteredCount / limit); - const speciesRes = await Species.find({}, ["_id", "name", "source", "sourceId", "hasImg"]) - .sort({ order: 1 }) - .skip(skip) - .limit(limit); + const speciesRes = await Species.find(query, ["_id"]).sort({ order: 1 }).skip(skip).limit(limit); const species = JSON.parse(JSON.stringify(speciesRes));