Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
6a6eddb
Add import page
rawcomposition Sep 16, 2024
07b9fc2
Initial crop implementation
rawcomposition Sep 16, 2024
c66522a
Simplify preview logic
rawcomposition Sep 16, 2024
8d51a24
Show square preview
rawcomposition Sep 16, 2024
79b7b16
Save import functionality
rawcomposition Sep 17, 2024
b9b26f2
Update image-review.tsx
rawcomposition Sep 17, 2024
fe384bb
Update import.tsx
rawcomposition Sep 18, 2024
c45ae2c
Ability to update species details
rawcomposition Sep 19, 2024
aba8bef
Small tweaks
rawcomposition Sep 19, 2024
e54f34a
Fix square preview
rawcomposition Sep 21, 2024
2971faf
Download thumbnails
rawcomposition Sep 21, 2024
2a4d24d
Various species import bug fixes
rawcomposition Sep 25, 2024
ff2ec86
Import from iNat and allow filtering
rawcomposition Sep 25, 2024
a1468d4
iNat bug fixes
rawcomposition Sep 25, 2024
6d43cdf
UI tweaks
rawcomposition Sep 25, 2024
94c7cb0
Autofocus
rawcomposition Sep 25, 2024
9e9b6c3
Improve image crop preview
rawcomposition Sep 26, 2024
3e9c653
Handle iNat varying file ext
rawcomposition Sep 27, 2024
7b88772
Bug fixes
rawcomposition Sep 27, 2024
87e1108
Switch to square crop
rawcomposition Sep 29, 2024
6562d9b
Show rounded crop preview
rawcomposition Sep 29, 2024
e405c4a
Species tweaks
rawcomposition Oct 1, 2024
9741ef6
Move generated thumbs
rawcomposition Oct 1, 2024
7ef1de2
Change aspect ratio
rawcomposition Oct 1, 2024
eebb912
Ability to reset species pictures
rawcomposition Oct 2, 2024
499ca46
General improvements to species editing
rawcomposition Oct 15, 2024
a4c7d67
Improve image rendering
rawcomposition Oct 15, 2024
3f5c58b
Indicate uncropped images
rawcomposition Oct 15, 2024
eecf68e
Implement taxon syncing
rawcomposition Oct 16, 2024
5baae54
Filter by family
rawcomposition Oct 16, 2024
6aa7e06
Update index.tsx
rawcomposition Oct 16, 2024
dea7f15
Species editing clean up
rawcomposition Oct 16, 2024
5a1c33b
Migrate wikipedia metadata
rawcomposition Oct 16, 2024
5e19d1e
Expand licenses
rawcomposition Oct 16, 2024
26e1868
Improve get source function
rawcomposition Oct 16, 2024
025845c
Bug fix
rawcomposition Oct 16, 2024
d49f4fd
Update migrate-wikipedia.ts
rawcomposition Oct 16, 2024
2e52837
Properly reset fields when changing source
rawcomposition Oct 17, 2024
ba0097c
General species improvements
rawcomposition Oct 22, 2024
d1babba
Minor bugs
rawcomposition Oct 23, 2024
556968c
Update edit.tsx
rawcomposition Oct 23, 2024
d8f0162
Add support for Flickr
rawcomposition Oct 29, 2024
6f68617
Minor tweaks
rawcomposition Oct 30, 2024
09470a4
Support iNat obs with varying file ext
rawcomposition Nov 1, 2024
cc68e9a
Minor tweaks
rawcomposition Nov 5, 2024
4c9e367
Keyboard navigation to next/prev
rawcomposition Nov 10, 2024
7905ad1
Minor species tweaks
rawcomposition Nov 23, 2024
687403e
Species tweaks
rawcomposition Nov 27, 2024
f7ce271
Species migration scripts
rawcomposition Nov 27, 2024
57aab75
General refactor
rawcomposition Dec 1, 2024
b8c5501
Remove unused files
rawcomposition Dec 1, 2024
e15dc54
Update sync-source-info.ts
rawcomposition Dec 1, 2024
92ee2da
Tweaks
rawcomposition Dec 2, 2024
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,5 @@ public/top10/*.json
# Sentry Auth Token
.sentryclirc
notes.md
/public/tinymce
/public/tinymce
/public/species-images
85 changes: 85 additions & 0 deletions components/CropPreview.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLCanvasElement>(null);
const [croppedImageUrl, setCroppedImageUrl] = React.useState<string | null>(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 (
<div>
<canvas ref={canvasRef} style={{ display: "none" }} />
{croppedImageUrl && (
<img
src={croppedImageUrl}
alt="Cropped Preview"
width={PREVIEW_WIDTH}
height={PREVIEW_WIDTH * ASPECT_RATIO}
style={{
width: PREVIEW_WIDTH,
height: PREVIEW_WIDTH * ASPECT_RATIO,
}}
className="object-cover rounded"
/>
)}
</div>
);
});

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/");
};
6 changes: 4 additions & 2 deletions components/Field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="flex-1">
<label className="text-gray-500 font-bold">
{label} {help && <Help text={help} />}
{label}
{required && <span className="text-red-600 font-normal ml-px">*</span>} {help && <Help text={help} />}
<br />
{children}
</label>
Expand Down
26 changes: 16 additions & 10 deletions components/FormError.tsx
Original file line number Diff line number Diff line change
@@ -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 <ErrorMessage errors={errors} name={name} render={({ message }) => (
<span className={`text-red-600 text-sm ${className || ""}`}>{message}</span>
)} />
}
export default function FormError({ name, className }: FormErrorProps) {
const {
formState: { errors },
} = useFormContext();
return (
<ErrorMessage
errors={errors}
name={name}
render={({ message }) => <span className={`text-red-600 font-normal text-sm ${className || ""}`}>{message}</span>}
/>
);
}
63 changes: 63 additions & 0 deletions components/InputImageCrop.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={className}>
<div className="w-full h-[420px] relative">
<Cropper
image={url}
crop={crop}
zoom={zoom}
aspect={4 / 3}
zoomSpeed={0.25}
maxZoom={5}
onCropChange={setCrop}
onCropComplete={(croppedArea, croppedAreaPixels) => {
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}
/>
</div>
<div className="flex gap-4 mt-4">
<CropPreview {...debouncedCrop} imgUrl={url} />
</div>
</div>
);
}
23 changes: 18 additions & 5 deletions components/RadioGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className={inline ? "flex gap-2 justify-between" : ""}>
Expand All @@ -22,15 +23,27 @@ const RadioGroup = ({ name, label, options, inline, help }: InputProps) => {
{options.map((option) =>
typeof option === "string" ? (
<React.Fragment key={option}>
<label className="whitespace-nowrap">
<input {...register(name)} type="radio" name={name} value={option} /> {option}
<label className="whitespace-nowrap inline-flex items-center gap-1.5">
<input
{...register(name, { onChange: (e) => onChange?.(e.target.value) })}
type="radio"
name={name}
value={option}
/>
{option}
</label>
<br />
</React.Fragment>
) : (
<React.Fragment key={option.value}>
<label className="whitespace-nowrap">
<input {...register(name)} type="radio" name={name} value={option.value} /> {option.label}
<label className="whitespace-nowrap inline-flex items-center gap-1.5">
<input
{...register(name, { onChange: (e) => onChange?.(e.target.value) })}
type="radio"
name={name}
value={option.value}
/>{" "}
{option.label}
</label>
<br />
</React.Fragment>
Expand Down
21 changes: 16 additions & 5 deletions components/ReactSelectStyled.tsx
Original file line number Diff line number Diff line change
@@ -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<Option> = GroupBase<Option>
> = Props<Option, IsMulti, Group> & {
noBorder?: boolean;
};

export default function SelectBase<
Option,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
>({ noBorder, ...props }: SelectBaseProps<Option, IsMulti, Group>) {
return (
<ReactSelect
styles={{
Expand All @@ -27,6 +40,4 @@ const ReactSelectStyled = (props: any) => {
{...props}
/>
);
};

export default ReactSelectStyled;
}
16 changes: 11 additions & 5 deletions components/Select.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
import { useFormContext, Controller } from "react-hook-form";
import ReactSelectStyled from "components/ReactSelectStyled";

type Props = {
export type SelectProps = {
name: string;
required?: boolean;
isMulti?: boolean;
label?: string | React.ReactNode;
isClearable?: boolean;
options: {
value: string;
label: string;
}[];
[x: string]: any;
onChange?: (value: any) => void;
isLoading?: boolean;
placeholder?: string;
className?: string;
instanceId?: string;
disabled?: boolean;
menuPortalTarget?: HTMLElement;
};

export default function Select({ name, required, isMulti, options, onChange: customOnChange, ...props }: Props) {
export default function Select({ name, required, isMulti, options, onChange: customOnChange, ...props }: SelectProps) {
const { control } = useFormContext();

return (
Expand Down Expand Up @@ -41,8 +49,6 @@ export default function Select({ name, required, isMulti, options, onChange: cus
options={options}
onChange={onSelect}
value={selected}
cacheOptions
defaultOptions
isMulti={isMulti}
noOptionsMessage={({ inputValue }: any) => (inputValue.length ? "No Results" : "Select...")}
{...field}
Expand Down
13 changes: 13 additions & 0 deletions components/SelectLicense.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Select, { SelectProps } from "components/Select";
import { LicenseLabel } from "lib/types";

const options = Object.entries(LicenseLabel).map(([key, label]) => ({
label,
value: key,
}));

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

export default function SelectRole(props: Props) {
return <Select {...props} options={options} />;
}
13 changes: 13 additions & 0 deletions components/SelectRole.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Select, { SelectProps } from "components/Select";
import { ImgSourceLabel } from "lib/types";

const options = Object.entries(ImgSourceLabel).map(([key, label]) => ({
label,
value: key,
}));

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

export default function SelectRole(props: Props) {
return <Select {...props} options={options} />;
}
24 changes: 24 additions & 0 deletions components/SelectiNatSourceId.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/* eslint-disable @next/next/no-img-element */
import Select, { SelectProps } from "components/Select";
import { getSourceImgUrl } from "lib/species";

type Props = Omit<SelectProps, "options"> & {
sourceIds: string[];
iNatFileExts?: string[];
};

export default function SelectiNatSourceId({ sourceIds, iNatFileExts, ...props }: Props) {
const options = sourceIds.map((id, index) => ({
label: (
<img
src={getSourceImgUrl({ source: "inat", sourceId: id, size: 75, ext: iNatFileExts?.[index] }) || ""}
width={60}
height={60}
alt={id}
/>
),
value: id.toString(),
}));

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