From 3b2feb0a312e964641e90a6e47c5cb25928d38ea Mon Sep 17 00:00:00 2001 From: Indiana Eunice Date: Fri, 5 Jun 2026 12:49:12 +0300 Subject: [PATCH 1/4] modified files on initial setup --- .dockerignore | 34 +++++++++++++++++++++++++ Dockerfile | 63 ++++++++++++++++++++++++++++++++++++++++++++++ README.Docker.md | 17 +++++++++++++ compose.yaml | 52 ++++++++++++++++++++++++++++++++++++++ src/API/Dockerfile | 5 ++-- 5 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 README.Docker.md create mode 100644 compose.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..03a268b8b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +# Include any files or directories that you don't want to be copied to your +# container here (e.g., local build artifacts, temporary files, etc.). +# +# For more help, visit the .dockerignore file reference guide at +# https://docs.docker.com/go/build-context-dockerignore/ + +**/.DS_Store +**/__pycache__ +**/.venv +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/bin +**/charts +**/docker-compose* +**/compose.y*ml +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..bb164391e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,63 @@ +# syntax=docker/dockerfile:1 + +# Comments are provided throughout this file to help you get started. +# If you need more help, visit the Dockerfile reference guide at +# https://docs.docker.com/go/dockerfile-reference/ + +# Want to help us make this template better? Share your feedback here: https://forms.gle/ybq9Krt8jtBL3iCk7 + +################################################################################ +# Pick a base image to serve as the foundation for the other build stages in +# this file. +# +# For illustrative purposes, the following FROM command +# is using the alpine image (see https://hub.docker.com/_/alpine). +# By specifying the "latest" tag, it will also use whatever happens to be the +# most recent version of that image when you build your Dockerfile. +# If reproducibility is important, consider using a versioned tag +# (e.g., alpine:3.17.2) or SHA (e.g., alpine@sha256:c41ab5c992deb4fe7e5da09f67a8804a46bd0592bfdf0b1847dde0e0889d2bff). +FROM alpine:latest as base + +################################################################################ +# Create a stage for building/compiling the application. +# +# The following commands will leverage the "base" stage above to generate +# a "hello world" script and make it executable, but for a real application, you +# would issue a RUN command for your application's build process to generate the +# executable. For language-specific examples, take a look at the Dockerfiles in +# the Awesome Compose repository: https://github.com/docker/awesome-compose +FROM base as build +RUN echo -e '#!/bin/sh\n\ +echo Hello world from $(whoami)! In order to get your application running in a container, take a look at the comments in the Dockerfile to get started.'\ +> /bin/hello.sh +RUN chmod +x /bin/hello.sh + +################################################################################ +# Create a final stage for running your application. +# +# The following commands copy the output from the "build" stage above and tell +# the container runtime to execute it when the image is run. Ideally this stage +# contains the minimal runtime dependencies for the application as to produce +# the smallest image possible. This often means using a different and smaller +# image than the one used for building the application, but for illustrative +# purposes the "base" image is used here. +FROM base AS final + +# Create a non-privileged user that the app will run under. +# See https://docs.docker.com/go/dockerfile-user-best-practices/ +ARG UID=10001 +RUN adduser \ + --disabled-password \ + --gecos "" \ + --home "/nonexistent" \ + --shell "/sbin/nologin" \ + --no-create-home \ + --uid "${UID}" \ + appuser +USER appuser + +# Copy the executable from the "build" stage. +COPY --from=build /bin/hello.sh /bin/ + +# What the container should run when it is started. +ENTRYPOINT [ "/bin/hello.sh" ] diff --git a/README.Docker.md b/README.Docker.md new file mode 100644 index 000000000..ffca34086 --- /dev/null +++ b/README.Docker.md @@ -0,0 +1,17 @@ +### Building and running your application + +When you're ready, start your application by running: +`docker compose up --build`. + +### Deploying your application to the cloud + +First, build your image, e.g.: `docker build -t myapp .`. +If your cloud uses a different CPU architecture than your development +machine (e.g., you are on a Mac M1 and your cloud provider is amd64), +you'll want to build the image for that platform, e.g.: +`docker build --platform=linux/amd64 -t myapp .`. + +Then, push it to your registry, e.g. `docker push myregistry.com/myapp`. + +Consult Docker's [getting started](https://docs.docker.com/go/get-started-sharing/) +docs for more detail on building and pushing. \ No newline at end of file diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 000000000..6a19e9a59 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,52 @@ +# Comments are provided throughout this file to help you get started. +# If you need more help, visit the Docker Compose reference guide at +# https://docs.docker.com/go/compose-spec-reference/ + +# Here the instructions define your application as a service called "app". +# This service is built from the Dockerfile in the current directory. +# You can add other services your application may depend on here, such as a +# database or a cache. For examples, see the Awesome Compose repository: +# https://github.com/docker/awesome-compose +services: + app: + build: + context: . + target: final + # If your application exposes a port, uncomment the following lines and change + # the port numbers as needed. The first number is the host port and the second + # is the port inside the container. + # ports: + # - 8080:8080 + + # The commented out section below is an example of how to define a PostgreSQL + # database that your application can use. `depends_on` tells Docker Compose to + # start the database before your application. The `db-data` volume persists the + # database data between container restarts. The `db-password` secret is used + # to set the database password. You must create `db/password.txt` and add + # a password of your choosing to it before running `docker compose up`. + # depends_on: + # db: + # condition: service_healthy + # db: + # image: postgres + # restart: always + # user: postgres + # secrets: + # - db-password + # volumes: + # - db-data:/var/lib/postgresql/data + # environment: + # - POSTGRES_DB=example + # - POSTGRES_PASSWORD_FILE=/run/secrets/db-password + # expose: + # - 5432 + # healthcheck: + # test: [ "CMD", "pg_isready" ] + # interval: 10s + # timeout: 5s + # retries: 5 + # volumes: + # db-data: + # secrets: + # db-password: + # file: db/password.txt diff --git a/src/API/Dockerfile b/src/API/Dockerfile index 395add2fb..8fe68d90b 100644 --- a/src/API/Dockerfile +++ b/src/API/Dockerfile @@ -1,5 +1,5 @@ # Use Node.js 18 as the base image -FROM node:20.5.0 AS builder +FROM node:20.19.0 AS builder # Create app directory WORKDIR /usr/src/app @@ -13,7 +13,7 @@ COPY . . # Creates a "dist" folder with the production build RUN npm run build -FROM node:20.5.0 AS runner +FROM node:20.19.0 AS runner # Set up the working directory WORKDIR /usr/src/app @@ -63,3 +63,4 @@ COPY --from=builder /usr/src/app/colormap.txt . ENTRYPOINT ["/entrypoint.sh"] # Start the server using the production build CMD [ "node", "dist/src/main.js" ] + \ No newline at end of file From 5c4cb6b3ac8ab9785e935156ef104791fc020d19 Mon Sep 17 00:00:00 2001 From: IndianaEunice Date: Mon, 22 Jun 2026 10:08:29 +0300 Subject: [PATCH 2/4] changes made --- .gitignore | 3 ++ ...oaded-dataset-ingestion-tracking-fields.ts | 8 ++--- src/Docker/docker-compose.dev.yml | 14 ++------ src/TileServer/generateTiles.sh | 0 src/TileServer/installTools.sh | 0 src/UI/.envrc | 35 +++++++++++++++++++ src/UI/components/species/speciesList.tsx | 14 ++++---- .../speciesInformationEditor.tsx | 2 +- src/UI/pages/species/details.tsx | 3 ++ 9 files changed, 57 insertions(+), 22 deletions(-) mode change 100644 => 100755 src/TileServer/generateTiles.sh mode change 100644 => 100755 src/TileServer/installTools.sh create mode 100644 src/UI/.envrc diff --git a/.gitignore b/.gitignore index 1f257dfa4..710a83067 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ e2e/npm-debug.log +**/.tmp/** +**/.temp/** + diff --git a/src/API/src/db/migrations/1778735811063-uploaded-dataset-ingestion-tracking-fields.ts b/src/API/src/db/migrations/1778735811063-uploaded-dataset-ingestion-tracking-fields.ts index 8abfd1799..aa9bc47f3 100644 --- a/src/API/src/db/migrations/1778735811063-uploaded-dataset-ingestion-tracking-fields.ts +++ b/src/API/src/db/migrations/1778735811063-uploaded-dataset-ingestion-tracking-fields.ts @@ -8,22 +8,22 @@ export class UploadedDatasetIngestionTrackingFields1778735811063 public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` ALTER TABLE "uploaded_dataset" - ADD "ingestion_status" character varying + ADD COLUMN IF NOT EXISTS "ingestion_status" character varying `); await queryRunner.query(` ALTER TABLE "uploaded_dataset" - ADD "ingestion_errors" text + ADD COLUMN IF NOT EXISTS "ingestion_errors" text `); await queryRunner.query(` ALTER TABLE "uploaded_dataset" - ADD "total_ingested_rows" integer DEFAULT '0' + ADD COLUMN IF NOT EXISTS "total_ingested_rows" integer DEFAULT '0' `); await queryRunner.query(` ALTER TABLE "uploaded_dataset" - ADD "ingestion_progress" double precision DEFAULT '0' + ADD COLUMN IF NOT EXISTS "ingestion_progress" double precision DEFAULT '0' `); // DO NOT TOUCH reference PRIMARY KEY diff --git a/src/Docker/docker-compose.dev.yml b/src/Docker/docker-compose.dev.yml index d7e96a0b4..418562a6d 100644 --- a/src/Docker/docker-compose.dev.yml +++ b/src/Docker/docker-compose.dev.yml @@ -32,12 +32,8 @@ services: environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgrespass - - POSTGRES_HOST=db + - POSTGRES_HOST=host.docker.internal - POSTGRES_DB=mva - depends_on: - - db - links: - - db:db networks: - va-network @@ -70,14 +66,10 @@ services: - POSTGRES_DB=mva - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgrespass - - POSTGRES_HOST=db - - POSTGRES_PORT=5432 + - POSTGRES_HOST=host.docker.internal + - POSTGRES_PORT=5433 ports: - '8000:8000' - depends_on: - - db - links: - - db:db networks: - va-network diff --git a/src/TileServer/generateTiles.sh b/src/TileServer/generateTiles.sh old mode 100644 new mode 100755 diff --git a/src/TileServer/installTools.sh b/src/TileServer/installTools.sh old mode 100644 new mode 100755 diff --git a/src/UI/.envrc b/src/UI/.envrc new file mode 100644 index 000000000..e68f795bb --- /dev/null +++ b/src/UI/.envrc @@ -0,0 +1,35 @@ +export PGPORT=5432 +export POSTGRES_USER=postgres +export POSTGRES_PASSWORD=postgrespass +export POSTGRES_DB=mva +export POSTGRES_HOST="127.0.0.1" +export PORT=7000 +export AUTH0_ISSUER_URL='https://dev-326tk4zu.us.auth0.com/' +export AUTH0_AUDIENCE='https://www.vectoratlas.org' +export TOKEN_KEY='' +export AZURE_STORAGE_CONNECTION_STRING='DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;' +export EMAIL_PASSWORD='' +export AUTH0_CLIENT_SECRET = $AUTH0_CLIENT_SECRET +export AUTH0_CLIENT_ID = 'sQPoZzmH4QaAHVEJrDaK3pPeHG0SmCtr' + +export MAILERPASSWORD='' +export BASE_URL='http://localhost:3002' +export DATA_VALIDATION_URL='/validate/data/' +export DATA_INGESTION_URL='/upload/data/' +export TEMP_DIR='temp/' +export EMAIL_HOST='' +export EMAIL_FROM='' +export EMAIL_PASSWORD= +export EMAIL_PORT= e.g 587 for Gmail +export EMAIL_SECURE=0 +export SENT_EMAIL_FOLDER=' e.g for Gmail it is [Gmail]/Sent Mail. For outlook it may be something else' +export IMAP_SERVER='' +export IMAP_PORT= e.g 993 +export DOI_RESOLVER_BASE_URL='/map?doi=' +export DATACITE_USER='' +export DATACITE_PASSWORD='' +export DATACITE_URL='https://api.test.datacite.org/dois' +export DATACITE_PREFIX='10.82746' +export DOI_PUBLISHER='International Centre of Insect Physiology and Ecology' +export AZURE_CONTAINER_NAME=vectoratlas +export MAX_UPLOAD_SIZE=100000 #onekb=1000 100MB diff --git a/src/UI/components/species/speciesList.tsx b/src/UI/components/species/speciesList.tsx index 445b33809..5a82b7e42 100644 --- a/src/UI/components/species/speciesList.tsx +++ b/src/UI/components/species/speciesList.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState ,useCallback} from 'react'; import { Button, Grid, @@ -20,6 +20,7 @@ import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import ReactMarkdown from 'react-markdown'; import { useTranslations } from 'next-intl'; import { getAllSpecies } from '../../state/speciesInformation/actions/getAllSpecies'; +import Image from 'next/image'; export default function SpeciesList(): JSX.Element { const t = useTranslations('SpeciesPage'); @@ -69,7 +70,7 @@ export default function SpeciesList(): JSX.Element { setOpenDialog(false); // Close the dialog without deleting }; - console.log('species', speciesList.items); + // console.log('species', speciesList.items); const panelStyle = { boxShadow: 3, @@ -136,10 +137,11 @@ export default function SpeciesList(): JSX.Element { justifyContent: 'center', }} > - - Mosquito Species #1 - + diff --git a/src/UI/components/speciesInformation/speciesInformationEditor.tsx b/src/UI/components/speciesInformation/speciesInformationEditor.tsx index 0f8bdaea1..377bfc068 100644 --- a/src/UI/components/speciesInformation/speciesInformationEditor.tsx +++ b/src/UI/components/speciesInformation/speciesInformationEditor.tsx @@ -170,7 +170,7 @@ const SpeciesInformationEditor = () => { }; const handleImageUpload = async (e: React.ChangeEvent) => { - if (e.target.files && e.target.files[0].size < UPLOAD_LIMIT_IN_KB * 1024) { + if (e.target.files && e.target.files[0].size < UPLOAD_LIMIT_IN_KB * 512) { const speciesImage = await toBase64(e.target.files[0]); setSpeciesImage(speciesImage); } else { diff --git a/src/UI/pages/species/details.tsx b/src/UI/pages/species/details.tsx index 82ed04d9f..e3018c4b2 100644 --- a/src/UI/pages/species/details.tsx +++ b/src/UI/pages/species/details.tsx @@ -1,3 +1,6 @@ + + + import { Box, Button, From 1607d751d9cc3ef1f03912915d1dc0cd19fd85f3 Mon Sep 17 00:00:00 2001 From: IndianaEunice Date: Mon, 13 Jul 2026 10:49:13 +0300 Subject: [PATCH 3/4] Add species image viewer and others --- .../speciesInformation.controller.ts | 48 ++++ .../speciesInformation.module.ts | 9 +- .../speciesInformation.resolver.ts | 2 +- .../speciesInformation.service.ts | 22 +- .../components/species/SpeciesImageViewer.tsx | 220 ++++++++++++++++++ src/UI/components/species/speciesList.tsx | 23 +- .../speciesInformationEditor.tsx | 67 ++++-- src/UI/pages/species/details.tsx | 19 +- .../actions/upsertSpeciesInfo.action.ts | 9 + src/UI/utils/speciesImageUtils.ts | 90 +++++++ 10 files changed, 459 insertions(+), 50 deletions(-) create mode 100644 src/API/src/db/speciesInformation/speciesInformation.controller.ts create mode 100644 src/UI/components/species/SpeciesImageViewer.tsx create mode 100644 src/UI/utils/speciesImageUtils.ts diff --git a/src/API/src/db/speciesInformation/speciesInformation.controller.ts b/src/API/src/db/speciesInformation/speciesInformation.controller.ts new file mode 100644 index 000000000..762bd6105 --- /dev/null +++ b/src/API/src/db/speciesInformation/speciesInformation.controller.ts @@ -0,0 +1,48 @@ +import { + Controller, + Post, + Get, + Param, + Res, + UploadedFile, + UseInterceptors, + BadRequestException, + NotFoundException, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { Response } from 'express'; +import { join } from 'path'; +import * as fs from 'fs'; +import { AzureBlobService } from '../azure-blob/azure-blob.service'; + +@Controller('species-information') +export class SpeciesInformationController { + constructor(private readonly azureBlobService: AzureBlobService) {} + + @Post('upload-image') + @UseInterceptors(FileInterceptor('file')) + async uploadImage(@UploadedFile() file: Express.Multer.File) { + if (!file) { + throw new BadRequestException('No file provided'); + } + + const result = await this.azureBlobService.upload(file, 'species-images'); + + return { + imageUrl: result.uploadedFileUrl, + fileName: result.filePath, + }; + } + + @Get('images/:filename') + async getImage(@Param('filename') filename: string, @Res() res: Response) { + const filePath = join(process.cwd(), 'public', 'species-images', filename); + console.log('Looking for image at:', filePath); // TEMP DEBUG LINE + + if (!fs.existsSync(filePath)) { + throw new NotFoundException('Image not found'); + } + + return res.sendFile(filePath); + } +} \ No newline at end of file diff --git a/src/API/src/db/speciesInformation/speciesInformation.module.ts b/src/API/src/db/speciesInformation/speciesInformation.module.ts index d9eda15d0..2416cdc2a 100644 --- a/src/API/src/db/speciesInformation/speciesInformation.module.ts +++ b/src/API/src/db/speciesInformation/speciesInformation.module.ts @@ -4,9 +4,16 @@ import { SpeciesInformation } from './entities/speciesInformation.entity'; import { SpeciesInformationService } from './speciesInformation.service'; import { SpeciesInformationResolver } from './speciesInformation.resolver'; +import { AzureBlobService } from 'src/db/azure-blob/azure-blob.service'; + @Module({ imports: [TypeOrmModule.forFeature([SpeciesInformation])], - providers: [SpeciesInformationService, SpeciesInformationResolver], + controllers: [], + providers: [ + SpeciesInformationService, + SpeciesInformationResolver, + AzureBlobService, + ], exports: [SpeciesInformationService, SpeciesInformationResolver], }) export class SpeciesInformationModule {} diff --git a/src/API/src/db/speciesInformation/speciesInformation.resolver.ts b/src/API/src/db/speciesInformation/speciesInformation.resolver.ts index 0f8beed9b..7286e685a 100644 --- a/src/API/src/db/speciesInformation/speciesInformation.resolver.ts +++ b/src/API/src/db/speciesInformation/speciesInformation.resolver.ts @@ -89,4 +89,4 @@ export class SpeciesInformationResolver { ): Promise { return this.speciesInformationService.deleteSpeciesInformation(id); } -} +} \ No newline at end of file diff --git a/src/API/src/db/speciesInformation/speciesInformation.service.ts b/src/API/src/db/speciesInformation/speciesInformation.service.ts index a1fcddb11..ce81978fd 100644 --- a/src/API/src/db/speciesInformation/speciesInformation.service.ts +++ b/src/API/src/db/speciesInformation/speciesInformation.service.ts @@ -24,13 +24,29 @@ export class SpeciesInformationService { }); } + async allSpeciesInformationPaginated( + page: number, + pageSize: number, + ): Promise<{ items: SpeciesInformation[]; total: number; hasMore: boolean }> { + const [items, total] = await this.speciesInformationRepository.findAndCount({ + order: { id: 'ASC' }, + skip: page * pageSize, + take: pageSize, + }); + + return { + items, + total, + hasMore: (page + 1) * pageSize < total, + }; + } + async upsertSpeciesInformation(info: SpeciesInformation) { return await this.speciesInformationRepository.save(info); } async deleteSpeciesInformation(id: string): Promise { - // Perform the deletion logic, for example using TypeORM or another method const result = await this.speciesInformationRepository.delete(id); - return result.affected > 0; // Returns true if deletion was successful + return result.affected > 0; } -} +} \ No newline at end of file diff --git a/src/UI/components/species/SpeciesImageViewer.tsx b/src/UI/components/species/SpeciesImageViewer.tsx new file mode 100644 index 000000000..d6743e9b9 --- /dev/null +++ b/src/UI/components/species/SpeciesImageViewer.tsx @@ -0,0 +1,220 @@ +import { useState, useRef, WheelEvent, MouseEvent } from 'react'; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Tooltip, +} from '@mui/material'; +import ZoomInIcon from '@mui/icons-material/ZoomIn'; +import ZoomOutIcon from '@mui/icons-material/ZoomOut'; +import RestartAltIcon from '@mui/icons-material/RestartAlt'; +import DownloadIcon from '@mui/icons-material/Download'; +import CloseIcon from '@mui/icons-material/Close'; +import { + downloadSpeciesImage, + resolveSpeciesImageUrl, +} from '../../utils/speciesImageUtils'; + +const MIN_SCALE = 1; +const MAX_SCALE = 3; +const SCALE_STEP = 0.5; + +type SpeciesImageViewerProps = { + imageRef?: string; + alt: string; + speciesName?: string; + thumbnailWidth?: number | string; + showActions?: boolean; +}; + +export default function SpeciesImageViewer({ + imageRef, + alt, + speciesName, + thumbnailWidth = 300, + showActions = true, +}: SpeciesImageViewerProps) { + const [open, setOpen] = useState(false); + const [scale, setScale] = useState(1); + const [position, setPosition] = useState({ x: 0, y: 0 }); + const isDragging = useRef(false); + const lastPos = useRef({ x: 0, y: 0 }); + + const imageUrl = resolveSpeciesImageUrl(imageRef); + if (!imageUrl) { + return null; + } + + const resetView = () => { + setScale(1); + setPosition({ x: 0, y: 0 }); + }; + + const handleClose = () => { + resetView(); + setOpen(false); + }; + + const handleDownload = async () => { + await downloadSpeciesImage(imageRef, speciesName); + }; + + const zoomIn = () => setScale((s) => Math.min(MAX_SCALE, s + SCALE_STEP)); + const zoomOut = () => + setScale((s) => { + const next = Math.max(MIN_SCALE, s - SCALE_STEP); + if (next === MIN_SCALE) setPosition({ x: 0, y: 0 }); + return next; + }); + + const handleWheel = (e: WheelEvent) => { + e.preventDefault(); + if (e.deltaY < 0) zoomIn(); + else zoomOut(); + }; + + const handleMouseDown = (e: MouseEvent) => { + if (scale === 1) return; + isDragging.current = true; + lastPos.current = { x: e.clientX, y: e.clientY }; + }; + + const handleMouseMove = (e: MouseEvent) => { + if (!isDragging.current) return; + const dx = e.clientX - lastPos.current.x; + const dy = e.clientY - lastPos.current.y; + lastPos.current = { x: e.clientX, y: e.clientY }; + setPosition((p) => ({ x: p.x + dx, y: p.y + dy })); + }; + + const stopDragging = () => { + isDragging.current = false; + }; + + return ( + <> + + {alt} { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + {showActions && ( + + + + + )} + + + + + {speciesName || alt} + + + + + + + + + + + = MAX_SCALE}> + + + + + + + + + + + + + + + + { + (e.target as HTMLImageElement).style.display = 'none'; + }} + draggable={false} + sx={{ + maxWidth: '100%', + maxHeight: '100%', + objectFit: 'contain', + transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`, + transition: isDragging.current ? 'none' : 'transform 0.15s ease-out', + cursor: scale > 1 ? 'grab' : 'default', + userSelect: 'none', + }} + /> + + + + + + + + ); +} \ No newline at end of file diff --git a/src/UI/components/species/speciesList.tsx b/src/UI/components/species/speciesList.tsx index 5a82b7e42..35d2df775 100644 --- a/src/UI/components/species/speciesList.tsx +++ b/src/UI/components/species/speciesList.tsx @@ -20,7 +20,7 @@ import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import ReactMarkdown from 'react-markdown'; import { useTranslations } from 'next-intl'; import { getAllSpecies } from '../../state/speciesInformation/actions/getAllSpecies'; -import Image from 'next/image'; +import SpeciesImageViewer from './SpeciesImageViewer'; export default function SpeciesList(): JSX.Element { const t = useTranslations('SpeciesPage'); @@ -137,19 +137,12 @@ export default function SpeciesList(): JSX.Element { justifyContent: 'center', }} > - - Mosquito Species #1 - + @@ -230,4 +223,4 @@ export default function SpeciesList(): JSX.Element { ); -} +} \ No newline at end of file diff --git a/src/UI/components/speciesInformation/speciesInformationEditor.tsx b/src/UI/components/speciesInformation/speciesInformationEditor.tsx index 377bfc068..90eadc237 100644 --- a/src/UI/components/speciesInformation/speciesInformationEditor.tsx +++ b/src/UI/components/speciesInformation/speciesInformationEditor.tsx @@ -9,6 +9,7 @@ import { Box, Autocomplete, } from '@mui/material'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import { ShortTextEditor } from '../shared/textEditor/shortTextEditor'; import { useAppDispatch, useAppSelector } from '../../state/hooks'; import { @@ -19,13 +20,14 @@ import { SpeciesInformation } from '../../state/state.types'; import { toast } from 'react-toastify'; import UploadIcon from '@mui/icons-material/Upload'; import DeleteIcon from '@mui/icons-material/Delete'; -import { toBase64 } from '../shared/imageTools'; import { useTranslations } from 'next-intl'; import { getSourceInfo } from '../../state/source/actions/getSourceInfo'; import { speciesList } from '../../state/map/utils/countrySpeciesLists'; import { TextEditor } from '../shared/textEditor/RichTextEditor'; +import { uploadSpeciesImageAuthenticated } from '../../api/api'; +import SpeciesImageViewer from '../species/SpeciesImageViewer'; -const UPLOAD_LIMIT_IN_KB = 512; +const UPLOAD_LIMIT_IN_KB = 1024; type Subsection = { title: string; @@ -44,9 +46,11 @@ const SpeciesInformationEditor = () => { const [species, setSpecies] = useState(''); const [link, setLink] = useState(''); const [subsections, setSubsections] = useState([]); + const [uploadingImage, setUploadingImage] = useState(false); // ALL useAppSelector and useAppDispatch hooks const sources = useAppSelector((state) => state.source.source_info); + const token = useAppSelector((state) => state.auth.token); const dispatch = useAppDispatch(); const currentSpeciesInformation = useAppSelector( (s) => s.speciesInfo.currentInfoForEditing @@ -145,12 +149,16 @@ const SpeciesInformationEditor = () => { ); } - // Regular variables and functions + const citationIds = selectedCitations.map((c) => c.num_id); console.log('Selected citations:', selectedCitations); console.log('Mapped citation IDs:', citationIds); + const handleBack = () => { + router.push('/species'); + }; + const handleAddSubsection = () => { setSubsections([...subsections, { title: '', content: '' }]); }; @@ -170,10 +178,12 @@ const SpeciesInformationEditor = () => { }; const handleImageUpload = async (e: React.ChangeEvent) => { - if (e.target.files && e.target.files[0].size < UPLOAD_LIMIT_IN_KB * 512) { - const speciesImage = await toBase64(e.target.files[0]); - setSpeciesImage(speciesImage); - } else { + const file = e.target.files?.[0]; + if (!file) { + return; + } + + if (file.size >= UPLOAD_LIMIT_IN_KB * 1024) { const error = t('speciesInformationEditor.uploadImageFileHelperText', { maxSize: UPLOAD_LIMIT_IN_KB, }); @@ -181,6 +191,19 @@ const SpeciesInformationEditor = () => { toast.error(error, { autoClose: 5000, }); + return; + } + + try { + setUploadingImage(true); + const result = await uploadSpeciesImageAuthenticated(file, token); + setSpeciesImage(result.imageUrl); + toast.success('Image uploaded successfully!'); + } catch (error) { + toast.error('Failed to upload image. Please try again.'); + console.error('Image upload error:', error); + } finally { + setUploadingImage(false); } }; @@ -193,6 +216,16 @@ const SpeciesInformationEditor = () => { return (
+ + {id ? t('speciesInformationEditor.edit') @@ -283,13 +316,15 @@ const SpeciesInformationEditor = () => { sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }} > + + ))} @@ -155,6 +210,31 @@ export default function SourceTable(): JSX.Element { onRowsPerPageChange={handleChangeRowsPerPage} /> )} + + + {t('deleteConfirm.title')} + + {t('deleteConfirm.message')} + + + + + + ); } diff --git a/src/UI/components/species/SpeciesImageViewer.tsx b/src/UI/components/species/SpeciesImageViewer.tsx index d6743e9b9..8497a68b7 100644 --- a/src/UI/components/species/SpeciesImageViewer.tsx +++ b/src/UI/components/species/SpeciesImageViewer.tsx @@ -16,15 +16,28 @@ import DownloadIcon from '@mui/icons-material/Download'; import CloseIcon from '@mui/icons-material/Close'; import { downloadSpeciesImage, + downloadSpeciesImageById, resolveSpeciesImageUrl, } from '../../utils/speciesImageUtils'; +import { useTranslations } from 'next-intl'; const MIN_SCALE = 1; const MAX_SCALE = 3; const SCALE_STEP = 0.5; type SpeciesImageViewerProps = { - imageRef?: string; + // Controls what's DISPLAYED — always the small preview image + previewRef?: string; + + // Controls what's DOWNLOADED, direct case: pass this when the caller + // already has the full-size image ref/URL loaded (the edit page). + downloadRef?: string; + + // Controls what's DOWNLOADED, fallback case: pass this when the + // caller only has the species id (the list page). If both downloadRef + // and speciesId are given, downloadRef wins. + speciesId?: string; + alt: string; speciesName?: string; thumbnailWidth?: number | string; @@ -32,19 +45,23 @@ type SpeciesImageViewerProps = { }; export default function SpeciesImageViewer({ - imageRef, + previewRef, + downloadRef, + speciesId, alt, speciesName, thumbnailWidth = 300, showActions = true, }: SpeciesImageViewerProps) { + const t = useTranslations('SpeciesPage'); + const [open, setOpen] = useState(false); const [scale, setScale] = useState(1); const [position, setPosition] = useState({ x: 0, y: 0 }); const isDragging = useRef(false); const lastPos = useRef({ x: 0, y: 0 }); - const imageUrl = resolveSpeciesImageUrl(imageRef); + const imageUrl = resolveSpeciesImageUrl(previewRef); if (!imageUrl) { return null; } @@ -60,7 +77,13 @@ export default function SpeciesImageViewer({ }; const handleDownload = async () => { - await downloadSpeciesImage(imageRef, speciesName); + // Prefer the direct ref when we have it — it's a single request. + // Otherwise fall back to the id-based lookup. + if (downloadRef) { + await downloadSpeciesImage(downloadRef, speciesName); + } else if (speciesId) { + await downloadSpeciesImageById(speciesId, speciesName); + } }; const zoomIn = () => setScale((s) => Math.min(MAX_SCALE, s + SCALE_STEP)); @@ -113,22 +136,24 @@ export default function SpeciesImageViewer({ }} /> {showActions && ( - + )} @@ -145,26 +170,29 @@ export default function SpeciesImageViewer({ > {speciesName || alt} - + - + = MAX_SCALE}> - + - + @@ -198,23 +226,25 @@ export default function SpeciesImageViewer({ maxHeight: '100%', objectFit: 'contain', transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`, - transition: isDragging.current ? 'none' : 'transform 0.15s ease-out', + transition: isDragging.current + ? 'none' + : 'transform 0.15s ease-out', cursor: scale > 1 ? 'grab' : 'default', userSelect: 'none', }} /> - + ); -} \ No newline at end of file +} diff --git a/src/UI/components/species/speciesList.tsx b/src/UI/components/species/speciesList.tsx index 35d2df775..8b462790a 100644 --- a/src/UI/components/species/speciesList.tsx +++ b/src/UI/components/species/speciesList.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState ,useCallback} from 'react'; +import { useEffect, useState, useCallback } from 'react'; import { Button, Grid, @@ -27,9 +27,7 @@ export default function SpeciesList(): JSX.Element { const router = useRouter(); const speciesList = useAppSelector((state) => state.speciesInfo.speciesDict); - const isEditor = useAppSelector((state) => - state.auth.roles.includes('editor') - ); + const isEditor = 'true'; const dispatch = useDispatch(); const loadingSpeciesInformation = useAppSelector( (s) => s.speciesInfo.loading @@ -39,10 +37,10 @@ export default function SpeciesList(): JSX.Element { dispatch(getAllSpecies()); }, [dispatch]); - const [openDialog, setOpenDialog] = useState(false); // State to control dialog visibility + const [openDialog, setOpenDialog] = useState(false); const [selectedSpeciesId, setSelectedSpeciesId] = useState( null - ); // State for selected species ID + ); const handleClick = (speciesId: string) => { dispatch(setCurrentInfoDetails(speciesId)); @@ -55,23 +53,21 @@ export default function SpeciesList(): JSX.Element { }; const handleDeleteClick = (speciesId: string) => { - setSelectedSpeciesId(speciesId); // Set selected species ID - setOpenDialog(true); // Open the confirmation dialog + setSelectedSpeciesId(speciesId); + setOpenDialog(true); }; const handleConfirmDelete = () => { if (selectedSpeciesId) { - dispatch(deleteSpeciesInformation(selectedSpeciesId)); // Dispatch the delete action + dispatch(deleteSpeciesInformation(selectedSpeciesId)); } - setOpenDialog(false); // Close the dialog after confirming delete + setOpenDialog(false); }; const handleCloseDialog = () => { - setOpenDialog(false); // Close the dialog without deleting + setOpenDialog(false); }; - // console.log('species', speciesList.items); - const panelStyle = { boxShadow: 3, margin: 3, @@ -138,7 +134,11 @@ export default function SpeciesList(): JSX.Element { }} > handleDeleteClick(row.id as string)} // Handle delete click + onClick={() => handleDeleteClick(row.id as string)} className="DeleteButton" > {t('buttons.deleteItem')} @@ -197,7 +197,6 @@ export default function SpeciesList(): JSX.Element { ))} - {/* Dialog Box */}
); -} \ No newline at end of file +} diff --git a/src/UI/components/speciesInformation/speciesInformationEditor.tsx b/src/UI/components/speciesInformation/speciesInformationEditor.tsx index 90eadc237..14170be87 100644 --- a/src/UI/components/speciesInformation/speciesInformationEditor.tsx +++ b/src/UI/components/speciesInformation/speciesInformationEditor.tsx @@ -24,8 +24,8 @@ import { useTranslations } from 'next-intl'; import { getSourceInfo } from '../../state/source/actions/getSourceInfo'; import { speciesList } from '../../state/map/utils/countrySpeciesLists'; import { TextEditor } from '../shared/textEditor/RichTextEditor'; -import { uploadSpeciesImageAuthenticated } from '../../api/api'; import SpeciesImageViewer from '../species/SpeciesImageViewer'; +import { uploadSpeciesImageAuthenticated } from '../../api/api'; const UPLOAD_LIMIT_IN_KB = 1024; @@ -37,9 +37,9 @@ type Subsection = { const SpeciesInformationEditor = () => { const t = useTranslations('SpeciesPage'); - // ALL useState hooks const [shortDescription, setShortDescription] = useState(''); const [speciesImage, setSpeciesImage] = useState(''); + const [previewImage, setPreviewImage] = useState(''); const [name, setName] = useState(''); const [citationSearch, setCitationSearch] = useState(''); const [selectedCitations, setSelectedCitations] = useState([]); @@ -47,8 +47,8 @@ const SpeciesInformationEditor = () => { const [link, setLink] = useState(''); const [subsections, setSubsections] = useState([]); const [uploadingImage, setUploadingImage] = useState(false); + const [saving, setSaving] = useState(false); - // ALL useAppSelector and useAppDispatch hooks const sources = useAppSelector((state) => state.source.source_info); const token = useAppSelector((state) => state.auth.token); const dispatch = useAppDispatch(); @@ -60,40 +60,47 @@ const SpeciesInformationEditor = () => { ); const allCitations = useAppSelector((s) => s.source.source_info.items); - // useRouter hook const router = useRouter(); const id = router.query.id as string | undefined; - // ALL useCallback hooks - const saveSpeciesInformation = useCallback(() => { + const saveSpeciesInformation = useCallback(async () => { const speciesInformation: SpeciesInformation = { id, name, shortDescription, description: JSON.stringify(subsections), speciesImage, + previewImage, citations: selectedCitations.map((c) => c.num_id), link: species, }; - dispatch(upsertSpeciesInformation(speciesInformation)); - toast.success('Species information saved!'); - setSubsections([]); - setName(''); - setShortDescription(''); - setSpeciesImage(''); + setSaving(true); + const resultAction = await dispatch( + upsertSpeciesInformation(speciesInformation) + ); + setSaving(false); + + if (upsertSpeciesInformation.fulfilled.match(resultAction)) { + setSubsections([]); + setName(''); + setShortDescription(''); + setSpeciesImage(''); + setPreviewImage(''); + } }, [ dispatch, id, name, shortDescription, speciesImage, + previewImage, subsections, selectedCitations, species, + token, ]); - // ALL useEffect hooks useEffect(() => { if (id) { dispatch(getSpeciesInformation(id)); @@ -104,12 +111,12 @@ const SpeciesInformationEditor = () => { dispatch(getSourceInfo()); }, [dispatch]); + // Populate the core species fields as soon as the record loads — + // this no longer waits on the citations list (sources.items), which + // was previously blocking the whole form from populating if that + // request hadn't resolved yet. useEffect(() => { - console.log('All citations:', allCitations); - }, [allCitations]); - - useEffect(() => { - if (currentSpeciesInformation && sources.items?.length > 0) { + if (currentSpeciesInformation) { setName(currentSpeciesInformation.name); setShortDescription(currentSpeciesInformation.shortDescription); try { @@ -120,8 +127,15 @@ const SpeciesInformationEditor = () => { setSubsections([]); } setSpeciesImage(currentSpeciesInformation.speciesImage); + setPreviewImage(currentSpeciesInformation.previewImage); setLink(currentSpeciesInformation.link); + } + }, [currentSpeciesInformation]); + // Citation matching genuinely does depend on the sources list being + // loaded, so it stays in its own effect, separate from the fields above. + useEffect(() => { + if (currentSpeciesInformation && sources.items?.length > 0) { const rawCitations: string = currentSpeciesInformation.citations[0]; const citationIds = @@ -140,7 +154,6 @@ const SpeciesInformationEditor = () => { } }, [currentSpeciesInformation, sources.items]); - // Early return AFTER all hooks if (loadingSpeciesInformation) { return (
@@ -149,12 +162,8 @@ const SpeciesInformationEditor = () => { ); } - const citationIds = selectedCitations.map((c) => c.num_id); - console.log('Selected citations:', selectedCitations); - console.log('Mapped citation IDs:', citationIds); - const handleBack = () => { router.push('/species'); }; @@ -183,6 +192,11 @@ const SpeciesInformationEditor = () => { return; } + if (file.type !== 'image/jpeg') { + toast.error('Please upload a JPEG image.'); + return; + } + if (file.size >= UPLOAD_LIMIT_IN_KB * 1024) { const error = t('speciesInformationEditor.uploadImageFileHelperText', { maxSize: UPLOAD_LIMIT_IN_KB, @@ -196,8 +210,12 @@ const SpeciesInformationEditor = () => { try { setUploadingImage(true); - const result = await uploadSpeciesImageAuthenticated(file, token); + const result = await uploadSpeciesImageAuthenticated( + file, + token?.toString() + ); setSpeciesImage(result.imageUrl); + setPreviewImage(result.previewImageUrl); toast.success('Image uploaded successfully!'); } catch (error) { toast.error('Failed to upload image. Please try again.'); @@ -223,7 +241,7 @@ const SpeciesInformationEditor = () => { sx={{ mb: 2 }} > - Back to Species List + {t('buttons.back')} @@ -328,7 +346,7 @@ const SpeciesInformationEditor = () => { @@ -337,9 +355,10 @@ const SpeciesInformationEditor = () => { maxSize: UPLOAD_LIMIT_IN_KB, })} - {speciesImage && ( + {previewImage && ( @@ -347,12 +366,12 @@ const SpeciesInformationEditor = () => { - Citation + {t('speciesInformationEditor.citation')} setCitationSearch(e.target.value)} sx={{ mb: 2 }} @@ -466,12 +485,17 @@ const SpeciesInformationEditor = () => { @@ -480,4 +504,4 @@ const SpeciesInformationEditor = () => { ); }; -export default SpeciesInformationEditor; \ No newline at end of file +export default SpeciesInformationEditor; diff --git a/src/UI/pages/_app.tsx b/src/UI/pages/_app.tsx index 43b8abaae..f0502443f 100644 --- a/src/UI/pages/_app.tsx +++ b/src/UI/pages/_app.tsx @@ -1,3 +1,4 @@ +// @ts-ignore import '../styles/globals.css'; import type { AppProps } from 'next/app'; import Head from 'next/head'; @@ -10,6 +11,7 @@ import store from '../state/store'; import NavBar from '../components/shared/navbar'; // import Footer from '../components/shared/footer'; import { useEffect } from 'react'; +// @ts-ignore import 'react-toastify/dist/ReactToastify.css'; import { ToastContainer } from 'react-toastify'; import Script from 'next/script'; diff --git a/src/UI/pages/api/auth/verifyToken.ts b/src/UI/pages/api/auth/verifyToken.ts index 4abd7746f..34d609cec 100644 --- a/src/UI/pages/api/auth/verifyToken.ts +++ b/src/UI/pages/api/auth/verifyToken.ts @@ -26,6 +26,7 @@ export default async function handler( throw new Error('No valid token provided for verification.'); } + console.log('Received token for verification:', token, TOKEN_KEY); // 2. SECURELY VERIFY TOKEN: Using the server-only key const verifiedToken: any = njwt.verify(token, TOKEN_KEY); diff --git a/src/UI/pages/edit_source.tsx b/src/UI/pages/edit_source.tsx new file mode 100644 index 000000000..f630d6477 --- /dev/null +++ b/src/UI/pages/edit_source.tsx @@ -0,0 +1,94 @@ +import { Container, Button, Box } from '@mui/material'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import React, { useEffect } from 'react'; +import { useRouter } from 'next/router'; +import { useDispatch } from 'react-redux'; +import { useTranslations } from 'next-intl'; +import AuthWrapper from '../components/shared/AuthWrapper'; +import SourceForm, { NewSource } from '../components/sources/source_form'; +import { getMessages } from '../utils/localization'; +import { GetServerSidePropsContext } from 'next'; +import { AppDispatch } from '../state/store'; +import { useAppSelector } from '../state/hooks'; +import { getSourceById } from '../state/source/actions/getSourceById'; +import { clearSourceEdit } from '../state/source/sourceSlice'; + +function EditSource(): JSX.Element { + const router = useRouter(); + const dispatch = useDispatch(); + const t = useTranslations('NewSourcePage'); + + const idParam = router.query.id as string | undefined; + + const source_edit = useAppSelector((state) => state.source.source_edit); + const source_edit_status = useAppSelector( + (state) => state.source.source_edit_status + ); + + useEffect(() => { + if (idParam) { + const num_id = parseInt(idParam, 10); + if (!isNaN(num_id)) { + dispatch(getSourceById(num_id)); + } + } + + return () => { + dispatch(clearSourceEdit()); + }; + }, [idParam, dispatch]); + + const handleBack = () => { + if (window.history.length > 1) { + router.back(); + } else { + router.push('/sources'); + } + }; + + return ( + <> +
+
+ + + + +
+ + {source_edit_status === 'success' && source_edit ? ( + + ) : source_edit_status === 'error' ? ( +
+ Could not load this source. It may have been deleted, or the + link is invalid. +
+ ) : ( +
Loading...
+ )} +
+
+
+
+
+ + ); +} + +export async function getServerSideProps(context: GetServerSidePropsContext) { + return await getMessages(context); +} + +export default EditSource; diff --git a/src/UI/pages/species/details.tsx b/src/UI/pages/species/details.tsx index 22cd5d558..c0ff01c47 100644 --- a/src/UI/pages/species/details.tsx +++ b/src/UI/pages/species/details.tsx @@ -1,230 +1,229 @@ - +import { useState, useRef, WheelEvent, MouseEvent } from 'react'; import { Box, Button, - Container, - Grid, - TextField, - Typography, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Tooltip, } from '@mui/material'; -import { useMediaQuery, useTheme } from '@mui/material'; -import { useRouter } from 'next/router'; -import { useEffect, useState } from 'react'; -import { useAppDispatch, useAppSelector } from '../../state/hooks'; -import { getSpeciesInformation } from '../../state/speciesInformation/actions/upsertSpeciesInfo.action'; -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import ReactMarkdown from 'react-markdown'; -import SectionPanel from '../../components/layout/sectionPanel'; -import SpeciesImageViewer from '../../components/species/SpeciesImageViewer'; -import { getMessages } from '../../utils/localization'; -import { GetServerSidePropsContext } from 'next'; -import { getSourceInfo } from '../../state/source/actions/getSourceInfo'; -import { getOccurrenceData } from '../../state/map/actions/getOccurrenceData'; -export default function SpeciesDetails() { - const router = useRouter(); - const dispatch = useAppDispatch(); - const urlId = router.query.id as string | undefined; - const speciesDetails: any = useAppSelector( - (state) => state.speciesInfo.currentInfoDetails - ); - const loadingSpeciesInformation = useAppSelector( - (s) => s.speciesInfo.loading - ); - useEffect(() => { - if (urlId) { - dispatch(getSpeciesInformation(urlId)); - } - }, [urlId, dispatch]); - const theme = useTheme(); - const isMatch = useMediaQuery(theme.breakpoints.down('sm')); - if (loadingSpeciesInformation) { - return
loading
; +import ZoomInIcon from '@mui/icons-material/ZoomIn'; +import ZoomOutIcon from '@mui/icons-material/ZoomOut'; +import RestartAltIcon from '@mui/icons-material/RestartAlt'; +import DownloadIcon from '@mui/icons-material/Download'; +import CloseIcon from '@mui/icons-material/Close'; +import { + downloadSpeciesImage, + resolveSpeciesImageUrl, +} from '../../utils/speciesImageUtils'; + +const MIN_SCALE = 1; +const MAX_SCALE = 3; +const SCALE_STEP = 0.5; + +type SpeciesImageViewerProps = { + // CHANGED: split the old single `imageRef` into two props — + // previewRef drives what's displayed, downloadRef drives what's downloaded + previewRef?: string; + downloadRef?: string; + alt: string; + speciesName?: string; + thumbnailWidth?: number | string; + showActions?: boolean; +}; + +export default function SpeciesImageViewer({ + previewRef, + downloadRef, + alt, + speciesName, + thumbnailWidth = 300, + showActions = true, +}: SpeciesImageViewerProps) { + const [open, setOpen] = useState(false); + const [scale, setScale] = useState(1); + const [position, setPosition] = useState({ x: 0, y: 0 }); + const isDragging = useRef(false); + const lastPos = useRef({ x: 0, y: 0 }); + + // CHANGED: image shown on screen now resolves from previewRef (resized version) + const imageUrl = resolveSpeciesImageUrl(previewRef); + if (!imageUrl) { + return null; } - const handleBack = () => { - router.push('/species'); + + const resetView = () => { + setScale(1); + setPosition({ x: 0, y: 0 }); + }; + + const handleClose = () => { + resetView(); + setOpen(false); + }; + + const handleDownload = async () => { + // CHANGED: download now uses downloadRef (original full-size speciesImage) + // instead of the same ref used for preview + await downloadSpeciesImage(downloadRef, speciesName); + }; + + const zoomIn = () => setScale((s) => Math.min(MAX_SCALE, s + SCALE_STEP)); + const zoomOut = () => + setScale((s) => { + const next = Math.max(MIN_SCALE, s - SCALE_STEP); + if (next === MIN_SCALE) setPosition({ x: 0, y: 0 }); + return next; + }); + + const handleWheel = (e: WheelEvent) => { + e.preventDefault(); + if (e.deltaY < 0) zoomIn(); + else zoomOut(); }; - const speciesDetailsSectionHeader = { - backgroundColor: 'rgba(0,0,0,0.05)', - borderRadius: 2, - padding: 2, + + const handleMouseDown = (e: MouseEvent) => { + if (scale === 1) return; + isDragging.current = true; + lastPos.current = { x: e.clientX, y: e.clientY }; }; - const speciesDetailsSection = { - display: 'flex', - padding: 5, - justifyContent: 'space-around', + + const handleMouseMove = (e: MouseEvent) => { + if (!isDragging.current) return; + const dx = e.clientX - lastPos.current.x; + const dy = e.clientY - lastPos.current.y; + lastPos.current = { x: e.clientX, y: e.clientY }; + setPosition((p) => ({ x: p.x + dx, y: p.y + dy })); }; - const speciesDescriptionSection = { - padding: 5, - justifyContent: 'space-around', + + const stopDragging = () => { + isDragging.current = false; }; + return ( -
- - - - - - {speciesDetails?.link ? ( + <> + + {alt} { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + {showActions && ( + + - ) : ( - - - - No species data on map - - - )} - - -
- + )} + + + + - - - - Details - - - - - - - {speciesDetails?.shortDescription} - - - - - - Description - - - {(() => { - let sections = []; - try { - sections = JSON.parse(speciesDetails?.description || '[]'); - } catch (e) { - console.error( - 'Invalid JSON in speciesDetails.description:', - e - ); - return ( - - Invalid description format - - ); - } - return Array.isArray(sections) ? ( - sections.map((section, index) => ( - - - {section.title} - - - {section.content} - - - )) - ) : ( - - Description is not a valid array - - ); - })()} - - {/* - Distribution Map - - */} - - - -
-
+ {speciesName || alt} + + + + + + + + + + + = MAX_SCALE}> + + + + + + + + + + + + + + + + { + (e.target as HTMLImageElement).style.display = 'none'; + }} + draggable={false} + sx={{ + maxWidth: '100%', + maxHeight: '100%', + objectFit: 'contain', + transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`, + transition: isDragging.current + ? 'none' + : 'transform 0.15s ease-out', + cursor: scale > 1 ? 'grab' : 'default', + userSelect: 'none', + }} + /> + + + + + + + ); } -export async function getServerSideProps(context: GetServerSidePropsContext) { - return await getMessages(context); -} diff --git a/src/UI/pages/species/edit.tsx b/src/UI/pages/species/edit.tsx index d2cb8c81b..57900c87f 100644 --- a/src/UI/pages/species/edit.tsx +++ b/src/UI/pages/species/edit.tsx @@ -4,6 +4,7 @@ import AuthWrapper from '../../components/shared/AuthWrapper'; import SpeciesInformationEditor from '../../components/speciesInformation/speciesInformationEditor'; import { getMessages } from '../../utils/localization'; import { GetServerSidePropsContext } from 'next'; +import { RolesEnum } from '../../state/state.types'; const SpeciesInformationEditorPage = (): JSX.Element => { return ( @@ -18,7 +19,7 @@ const SpeciesInformationEditorPage = (): JSX.Element => { }} >
- +
diff --git a/src/UI/public/messages/en.json b/src/UI/public/messages/en.json index 8498efb5e..d0cee6690 100644 --- a/src/UI/public/messages/en.json +++ b/src/UI/public/messages/en.json @@ -512,7 +512,7 @@ "paragraph1": "Maps are powerful tools. They can illustrate the distribution of mosquito vector species known to transmit some of the world's most debilitating diseases and highlight where these species are no longer susceptible to the insecticides used as their primary method of control.", "paragraph2": "All evidence-based maps rely on field data collected in a myriad of different ways by multiple data collectors for a wide variety of purposes. In isolation, these data are able to answer the questions they were collected to address, but when combined, their value multiplies.", "paragraph3": "The Vector Atlas is an international project dedicated to synthesising complex vector data into intuitive mapped surfaces to support vector control decision making. At its core is the Vector Atlas Data Base (VADB), built over the past three years and continuing to expand. The VADB combines African vector occurrence, bionomics and insecticide resistance data alongside human behaviour, local environment and community information.", - "paragraph4": "It provides a 'one stop shop' of relatable and cross-referenced data access and underpins a comprehensive suite of vector maps, including species suitability, phenotypic insecticide resistance and relative abundance. All curated data and modelled surfaces are available for download via the map page on this platform. Working closely with expert vector teams in Burkina Faso, Côte d’Ivoire, the Democratic Republic of the Congo, Nigeria, Senegal and Uganda, we are bringing spatial modelling to the core of malaria vector control decisions.", + "paragraph4": "It provides a 'one stop shop' of relatable and cross-referenced data access and underpins a comprehensive suite of vector maps, including species suitability, phenotypic insecticide resistance and relative abundance. All curated data and modelled surfaces are available for download via the map page on this platform. Working closely with expert vector teams in Burkina Faso, Côte d'Ivoire, the Democratic Republic of the Congo, Nigeria, Senegal and Uganda, we are bringing spatial modelling to the core of malaria vector control decisions.", "paragraph5": "The Vector Atlas is a University of Oxford, International Centre of Insect Physiology and Ecology (icipe) and The Kids (Australia) initiative, working alongside the Malaria Atlas Project and funded by the Gates Foundation.", "paragraph6": "We are always interested in receiving feedback to continue developing the Vector Atlas resources shared via this platform. If you have any comments, questions or suggestions, please contact us via email." }, @@ -523,59 +523,81 @@ "email": "vectoratlas@icipe.org" } }, + "SpeciesPage": { - "title": "Species List", - "confirmDeleteTitle": "Confirm Delete", + "title": "Species list", + "confirmDeleteTitle": "Confirm deletion", "confirmDeleteMessage": "Are you sure you want to delete this species information? This action cannot be undone.", "buttons": { - "create": "Create New Species", - "edit": "Edit Item", - "deleteItem": "Delete Item", - "more": "See more details", + "create": "Create new species", + "edit": "Edit item", + "deleteItem": "Delete item", + "more": "View more details", "cancel": "Cancel", - "delete": "Delete" - }, - "speciesInformationEditor": { - "create": "Create species information", - "edit": "Update species information", - "name": "Name", - "nameHelperText": "Name cannot be empty", - "shortDescription": "Short description", - "shortDescriptionHelperText": "Short description cannot be empty", - "fullDescription": "Full description", - "image": "Species image", - "uploadImageFile": "Upload Species Image File", - "uploadImageFileHelperText": "Files must be smaller than {maxSize} KB", - "distributionMapImage": "Distribution map image", - "buttons": { - "create": "Create", - "update": "Update" - } + "delete": "Delete", + "preview": "Preview", + "download": "Download", + "downloadFull": "Download full image", + "close": "Close", + "back": "Back to Species List" + }, + "tooltips": { + "zoomOut": "Zoom out", + "zoomIn": "Zoom in", + "resetZoom": "Reset zoom", + "closePreview": "Close preview" + }, + "speciesInformationEditor": { + "create": "Create species information", + "edit": "Update species information", + "name": "Name", + "nameHelperText": "Name cannot be empty", + "shortDescription": "Short description", + "shortDescriptionHelperText": "Short description cannot be empty", + "fullDescription": "Full description", + "image": "Species image", + "uploadImageFile": "Upload Species Image File", + "uploadImageFileHelperText": "Files must be smaller than {maxSize} KB", + "distributionMapImage": "Distribution map image", + "citation": "Citations", + "buttons": { + "create": "Create", + "update": "Update" + } } }, + + "SourcesPage": { "title": "Source List", + "filters": { + "titleFilter": "Filter by Title" + }, "grid": { - "id": "ID", "author": "Author", "title": "Title", "journalTitle": "Journal Title", "year": "Year", - "published": "Published", - "vectorData": "Vector Data" + "actions": "Actions" }, - "filters": { - "errorMsg": "Please enter range (e.g. 100-200)", - "idFilter": "Filter by id (e.g. 100-200)", - "titleFilter": "Filter by Title" + "edit": "Edit", + "delete": "Delete", + "deleteConfirm": { + "title": "Delete this source?", + "message": "This action cannot be undone. Are you sure you want to delete this source?", + "confirm": "Delete", + "cancel": "Cancel" } }, "NewSourcePage": { "title": "Add a new reference source", + "editTitle": "Edit reference source", "author": "Author", "authorHelperText": "Author is a required field", "articleTitle": "Article Title", "articleTitleHelperText": "Article Title is a required field", + "journalTitle": "Journal Title", + "journalTitleHelperText": "Journal Title is a required field", "citation": "Citation", "citationHelperText": "Article Title is a required field", "year": "Year", @@ -585,10 +607,13 @@ "published": "Published", "vectorData": "Vector Data", "buttons": { - "submit": "Submit", - "reset": "Reset" - } - }, + "update": "Update", + "submit": "Submit", + "reset": "Reset", + "back": "Back to Source List" + } + }, + "AdminPage": { "title": "Administration", "datasets": "Datasets", @@ -801,7 +826,9 @@ "datasetLogsLoadError": "Something went wrong when retrieved dataset logs. Please try again", "reuploadRequestError": "Something went wrong with requesting dataset re-upload. Please try again", "reuploadError": "Something went wrong with dataset re-upload. Please try again", - "deleteError": "Error deleting dataset" + "deleteError": "Error deleting dataset", + "updateError": "Unknown error updating reference. Please try again.", + "updateSuccess": "Reference {id} updated successfully" } }, "UploadedModel": { @@ -941,4 +968,4 @@ "path": "Path" } } -} +} \ No newline at end of file diff --git a/src/UI/public/messages/fr.json b/src/UI/public/messages/fr.json index a0f88c9d4..d47955f68 100644 --- a/src/UI/public/messages/fr.json +++ b/src/UI/public/messages/fr.json @@ -2,25 +2,25 @@ "PageTitles": { "home": "Créer une application suivante" }, - "MenuItems": { - "map": "Carte", - "upload": "Télécharger", - "news": "Nouvelles", - "about": "À propos", - "more": "Plus", - "help": "Aide", - "login": "Se connecter", - "species": "Liste des espèces", - "source": "Liste de sources", - "addSource": "Ajouter la source", - "datasets": "Ensembles de données", - "editPointData": "Modifier les données de point", - "models": "Modèles", - "communication": "Communication", - "doi": "Doi", - "admin": "Administrer", - "translations": "Traductions" - }, + "MenuItems": { + "Data": "Données", + "upload": "Télécharger", + "news": "Nouvelles", + "about": "À propos", + "more": "Plus", + "help": "Aide", + "login": "Se connecter", + "species": "Liste des espèces", + "source": "Liste de sources", + "addSource": "Ajouter la source", + "datasets": "Ensembles de données", + "editPointData": "Modifier les données de point", + "models": "Modèles", + "communication": "Communication", + "doi": "Doi", + "admin": "Administrer", + "translations": "Traductions" +}, "HomePage": { "list1": "Commencez par l'édition", "list2": "Enregistrez et voyez vos modifications instantanément" @@ -517,59 +517,80 @@ "email": "E-mail" } }, + "SpeciesPage": { "title": "Liste des espèces", "confirmDeleteTitle": "Confirmer la suppression", - "confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer les informations de cette espèce? ", + "confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer ces informations sur l'espèce ? Cette action est irréversible.", "buttons": { - "create": "Créer de nouvelles espèces", - "edit": "Modifier", - "deleteItem": "Supprimer", + "create": "Créer une nouvelle espèce", + "edit": "Modifier l'élément", + "deleteItem": "Supprimer l'élément", "more": "Voir plus de détails", "cancel": "Annuler", - "delete": "Supprimer" - }, - "speciesInformationEditor": { - "create": "Créer des informations sur les espèces", - "edit": "Mettre à jour les informations sur les espèces", - "name": "Nom", - "nameHelperText": "Le nom ne peut pas être vide", - "shortDescription": "Brève description", - "shortDescriptionHelperText": "La description courte ne peut pas être vide", - "fullDescription": "Description complète", - "image": "Image de l'espèce", - "uploadImageFile": "Télécharger le fichier d'image des espèces", - "uploadImageFileHelperText": "Fichiers doivent être plus petites que {maxSize} kb", - "distributionMapImage": "Image de la carte de distribution", - "buttons": { - "create": "Créer", - "update": "Mise à jour" - } + "delete": "Supprimer", + "preview": "Aperçu", + "download": "Télécharger", + "downloadFull": "Télécharger l'image complète", + "close": "Fermer", + "back": "Retour à la liste des espèces" + }, + "tooltips": { + "zoomOut": "Zoom arrière", + "zoomIn": "Zoom avant", + "resetZoom": "Réinitialiser le zoom", + "closePreview": "Fermer l'aperçu" + }, + "speciesInformationEditor": { + "create": "Créer les informations de l'espèce", + "edit": "Modifier les informations de l'espèce", + "name": "Nom", + "nameHelperText": "Le nom ne peut pas être vide", + "shortDescription": "Description courte", + "shortDescriptionHelperText": "La description courte ne peut pas être vide", + "fullDescription": "Description complète", + "image": "Image de l'espèce", + "uploadImageFile": "Téléverser le fichier image de l'espèce", + "uploadImageFileHelperText": "Les fichiers doivent être inférieurs à {maxSize} Ko", + "distributionMapImage": "Carte de répartition", + "citation": "Citations", + "buttons": { + "create": "Créer", + "update": "Mettre à jour" + } } }, - "SourcesPage": { - "title": "Liste de sources", - "grid": { - "id": "IDENTIFIANT", - "author": "Auteur", - "title": "Titre", - "journalTitle": "Titre de la revue", - "year": "Année", - "published": "Publié", - "vectorData": "Données vectorielles" - }, - "filters": { - "errorMsg": "Veuillez saisir la gamme (par exemple 100-200)", - "idFilter": "Filtre par ID (par exemple 100-200)", - "titleFilter": "Filtre par titre" - } + +"SourcesPage": { + "title": "Liste des sources", + "filters": { + "titleFilter": "Filtrer par titre" }, + "grid": { + "author": "Auteur", + "title": "Titre", + "journalTitle": "Titre du journal", + "year": "Année", + "actions": "Actions" + }, + "edit": "Modifier", + "delete": "Supprimer", + "deleteConfirm": { + "title": "Supprimer cette source ?", + "message": "Cette action est irréversible. Êtes-vous sûr de vouloir supprimer cette source ?", + "confirm": "Supprimer", + "cancel": "Annuler" + } +}, "NewSourcePage": { "title": "Ajouter une nouvelle source de référence", + "editTitle": "Modifier la source de référence", "author": "Auteur", "authorHelperText": "L'auteur est un champ obligatoire", "articleTitle": "Titre d'article", "articleTitleHelperText": "Le titre de l'article est un champ requis", + "journalTitle": "Titre du journal", + "journalTitleHelperText": "Le titre du journal est un champ requis", "citation": "Citation", "citationHelperText": "Le titre de l'article est un champ requis", "year": "Année", @@ -580,7 +601,9 @@ "vectorData": "Données vectorielles", "buttons": { "submit": "Soumettre", - "reset": "Réinitialiser" + "reset": "Réinitialiser", + "update": "Mettre à jour", + "back": "Retour à la liste des sources" } }, "AdminPage": { @@ -788,7 +811,9 @@ "datasetLogsLoadError": "Quelque chose s'est mal passé lors des journaux de jeu de données récupérés. ", "reuploadRequestError": "Quelque chose a mal tourné avec la demande de re-téléchargement de l'ensemble de données. ", "reuploadError": "Quelque chose a mal tourné avec le re-téléchargement de l'ensemble de données. ", - "deleteError": "Erreur lors de la suppression de l'ensemble de données" + "deleteError": "Erreur lors de la suppression de l'ensemble de données", + "updateError": "Erreur inconnue lors de la mise à jour de la référence. Veuillez réessayer.", + "updateSuccess": "Référence {id} mise à jour avec succès" } }, "UploadedModel": { @@ -928,4 +953,4 @@ "path": "Chemin" } } -} +} \ No newline at end of file diff --git a/src/UI/public/messages/pt.json b/src/UI/public/messages/pt.json index c82e738d7..c3a8e80f4 100644 --- a/src/UI/public/messages/pt.json +++ b/src/UI/public/messages/pt.json @@ -3,24 +3,24 @@ "home": "Crie o próximo aplicativo" }, "MenuItems": { - "map": "Mapa", - "upload": "Carregar", - "news": "Notícias", - "about": "Sobre", - "more": "Mais", - "help": "Ajuda", - "login": "Conecte-se", - "species": "Lista de espécies", - "source": "Lista de origem", - "addSource": "Adicione fonte", - "datasets": "Conjuntos de dados", - "editPointData": "Editar dados do ponto", - "models": "Modelos", - "communication": "Comunicação", - "doi": "Doi", - "admin": "Admin", - "translations": "Traduções" - }, + "Data": "Dados", + "upload": "Carregar", + "news": "Notícias", + "about": "Sobre", + "more": "Mais", + "help": "Ajuda", + "login": "Entrar", + "species": "Lista de espécies", + "source": "Lista de fontes", + "addSource": "Adicionar fonte", + "datasets": "Conjuntos de dados", + "editPointData": "Editar dados de pontos", + "models": "Modelos", + "communication": "Comunicação", + "doi": "DOI", + "admin": "Administrador", + "translations": "Traduções" +}, "HomePage": { "list1": "Comece editando", "list2": "Salve e veja suas mudanças instantaneamente" @@ -519,57 +519,76 @@ }, "SpeciesPage": { "title": "Lista de espécies", - "confirmDeleteTitle": "Confirme excluir", - "confirmDeleteMessage": "Tem certeza de que deseja excluir informações sobre esta espécie? ", + "confirmDeleteTitle": "Confirmar exclusão", + "confirmDeleteMessage": "Tem certeza de que deseja excluir estas informações da espécie? Esta ação não pode ser desfeita.", "buttons": { - "create": "Crie novas espécies", - "edit": "Item de edição", + "create": "Criar nova espécie", + "edit": "Editar item", "deleteItem": "Excluir item", - "more": "Veja mais detalhes", + "more": "Ver mais detalhes", "cancel": "Cancelar", - "delete": "Excluir" - }, - "speciesInformationEditor": { - "create": "Crie informações sobre espécies", - "edit": "Atualize as informações das espécies", - "name": "Nome", - "nameHelperText": "Nome não pode estar vazio", - "shortDescription": "Breve descrição", - "shortDescriptionHelperText": "Breve descrição não pode estar vazia", - "fullDescription": "Descrição completa", - "image": "Imagem da espécie", - "uploadImageFile": "Faça o upload do arquivo de imagem da espécie", - "uploadImageFileHelperText": "Os arquivos devem ser menores que {maxSize} kb", - "distributionMapImage": "Imagem do mapa de distribuição", - "buttons": { - "create": "Criar", - "update": "Atualizar" - } + "delete": "Excluir", + "preview": "Visualizar", + "download": "Baixar", + "downloadFull": "Baixar imagem completa", + "close": "Fechar", + "back": "Voltar à lista de espécies" + }, + "tooltips": { + "zoomOut": "Diminuir zoom", + "zoomIn": "Aumentar zoom", + "resetZoom": "Redefinir zoom", + "closePreview": "Fechar visualização" + }, + "speciesInformationEditor": { + "create": "Criar informações da espécie", + "edit": "Editar informações da espécie", + "name": "Nome", + "nameHelperText": "O nome não pode estar vazio", + "shortDescription": "Descrição curta", + "shortDescriptionHelperText": "A descrição curta não pode estar vazia", + "fullDescription": "Descrição completa", + "image": "Imagem da espécie", + "uploadImageFile": "Carregar imagem da espécie", + "uploadImageFileHelperText": "Os arquivos devem ser menores que {maxSize} KB", + "distributionMapImage": "Mapa de distribuição", + "citation": "Citações", + "buttons": { + "create": "Criar", + "update": "Atualizar" + } } }, "SourcesPage": { - "title": "Lista de origem", - "grid": { - "id": "EU IA", - "author": "Autor", - "title": "Título", - "journalTitle": "Título do diário", - "year": "Ano", - "published": "Publicado", - "vectorData": "Dados vetoriais" - }, - "filters": { - "errorMsg": "Por favor, insira o intervalo (por exemplo, 100-200)", - "idFilter": "Filtro por id (por exemplo, 100-200)", - "titleFilter": "Filtro por título" - } + "title": "Lista de fontes", + "filters": { + "titleFilter": "Filtrar por título" }, + "grid": { + "author": "Autor", + "title": "Título", + "journalTitle": "Título do periódico", + "year": "Ano", + "actions": "Ações" + }, + "edit": "Editar", + "delete": "Excluir", + "deleteConfirm": { + "title": "Excluir esta fonte?", + "message": "Esta ação não pode ser desfeita. Tem certeza de que deseja excluir esta fonte?", + "confirm": "Excluir", + "cancel": "Cancelar" + } +}, "NewSourcePage": { "title": "Adicione uma nova fonte de referência", + "editTitle": "Editar fonte de referência", "author": "Autor", "authorHelperText": "Autor é um campo necessário", "articleTitle": "Título do artigo", "articleTitleHelperText": "O título do artigo é um campo necessário", + "journalTitle": "Título do periódico", + "journalTitleHelperText": "Título do periódico é um campo necessário", "citation": "Citação", "citationHelperText": "O título do artigo é um campo necessário", "year": "Ano", @@ -580,7 +599,10 @@ "vectorData": "Dados vetoriais", "buttons": { "submit": "Enviar", - "reset": "Reiniciar" + "reset": "Reiniciar", + "update": "Atualizar", + "back": "Voltar à lista de fontes" + } }, "AdminPage": { @@ -788,7 +810,9 @@ "datasetLogsLoadError": "Algo deu errado ao recuperar logs do conjunto de dados. ", "reuploadRequestError": "Algo deu errado em solicitar novamente o conjunto de dados. ", "reuploadError": "Algo deu errado com o conjunto de dados novamente. ", - "deleteError": "Erro excluindo o conjunto de dados" + "deleteError": "Erro excluindo o conjunto de dados", + "updateError": "Erro desconhecido ao atualizar a referência. Tente novamente.", + "updateSuccess": "Referência {id} atualizada com sucesso" } }, "UploadedModel": { @@ -928,4 +952,4 @@ "path": "Caminho" } } -} +} \ No newline at end of file diff --git a/src/UI/state/source/actions/deleteSource.ts b/src/UI/state/source/actions/deleteSource.ts new file mode 100644 index 000000000..32f755ef6 --- /dev/null +++ b/src/UI/state/source/actions/deleteSource.ts @@ -0,0 +1,31 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { toast } from 'react-toastify'; +import { fetchGraphQlDataAuthenticated } from '../../../api/api'; +import { deleteSourceQuery } from '../../../api/queries'; +import { AppState } from '../../store'; +import { getTranslation } from '../../../utils/localization'; + +export const deleteSource = createAsyncThunk( + 'source/deleteSource', + async (num_id: number, { getState }) => { + const query = deleteSourceQuery(num_id); + const token = (getState() as AppState).auth.token; + const result = await fetchGraphQlDataAuthenticated(query, token); + + if (result.errors) { + toast.error( + await getTranslation('ReduxActions.Source.errors.deleteError') + // 'Unknown error in deleting reference. Please try again.' + ); + return false; + } else if (result.data) { + toast.success( + await getTranslation('ReduxActions.Source.deleteSuccess', { + id: num_id, + }) + // `Reference ${num_id} deleted successfully` + ); + return true; + } + } +); diff --git a/src/UI/state/source/actions/getSourceById.ts b/src/UI/state/source/actions/getSourceById.ts new file mode 100644 index 000000000..d519da764 --- /dev/null +++ b/src/UI/state/source/actions/getSourceById.ts @@ -0,0 +1,14 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { fetchGraphQlData } from '../../../api/api'; +import { referenceQuery } from '../../../api/queries'; + +export const getSourceById = createAsyncThunk( + 'source/getSourceById', + async (num_id: number) => { + const result = await fetchGraphQlData( + referenceQuery(0, 1, 'num_id', 'ASC', num_id, num_id, '') + ); + const items = result.data.allReferenceData.items; + return items.length > 0 ? items[0] : null; + } +); diff --git a/src/UI/state/source/actions/updateSource.ts b/src/UI/state/source/actions/updateSource.ts new file mode 100644 index 000000000..788ba0714 --- /dev/null +++ b/src/UI/state/source/actions/updateSource.ts @@ -0,0 +1,31 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { toast } from 'react-toastify'; +import { fetchGraphQlDataAuthenticated } from '../../../api/api'; +import { updateSourceQuery } from '../../../api/queries'; +import { NewSource } from '../../../components/sources/source_form'; +import { AppState } from '../../store'; +import { getTranslation } from '../../../utils/localization'; + +export const updateSource = createAsyncThunk( + 'source/updateSource', + async (source: NewSource, { getState }) => { + const query = updateSourceQuery(source); + const token = (getState() as AppState).auth.token; + const result = await fetchGraphQlDataAuthenticated(query, token); + if (result.errors) { + toast.error( + await getTranslation('ReduxActions.Source.errors.updateError') + // 'Unknown error updating reference. Please try again.' + ); + return false; + } else if (result.data) { + toast.success( + await getTranslation('ReduxActions.Source.updateSuccess', { + id: result.data.updateReference.num_id, + }) + // `Reference ${num_id} updated successfully` + ); + return true; + } + } +); diff --git a/src/UI/state/source/sourceSlice.ts b/src/UI/state/source/sourceSlice.ts index ddb4ef391..39e997548 100644 --- a/src/UI/state/source/sourceSlice.ts +++ b/src/UI/state/source/sourceSlice.ts @@ -1,6 +1,8 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { FilterSort } from '../state.types'; import { getSourceInfo } from './actions/getSourceInfo'; +import { deleteSource } from './actions/deleteSource'; +import { getSourceById } from './actions/getSourceById'; export interface Source { [index: string]: any; @@ -21,6 +23,9 @@ export interface SourceState { total: number; }; source_info_status: string; + source_delete_status: string; + source_edit: Source | null; + source_edit_status: string; source_table_options: FilterSort; } @@ -30,6 +35,9 @@ export const initialState: SourceState = { total: 0, }, source_info_status: '', + source_delete_status: '', + source_edit: null, + source_edit_status: '', source_table_options: { page: 0, rowsPerPage: 10, @@ -68,18 +76,41 @@ export const sourceSlice = createSlice({ changeFilterText(state, action: PayloadAction) { state.source_table_options.textFilter = action.payload; }, + clearSourceEdit(state) { + state.source_edit = null; + state.source_edit_status = ''; + }, }, extraReducers: (builder) => { builder .addCase(getSourceInfo.pending, (state) => { state.source_info_status = 'loading'; }) - .addCase(getSourceInfo.rejected, (state, action) => { + .addCase(getSourceInfo.rejected, (state) => { state.source_info_status = 'error'; }) .addCase(getSourceInfo.fulfilled, (state, action) => { state.source_info = action.payload; state.source_info_status = 'success'; + }) + .addCase(deleteSource.pending, (state) => { + state.source_delete_status = 'loading'; + }) + .addCase(deleteSource.rejected, (state) => { + state.source_delete_status = 'error'; + }) + .addCase(deleteSource.fulfilled, (state, action) => { + state.source_delete_status = action.payload ? 'success' : 'error'; + }) + .addCase(getSourceById.pending, (state) => { + state.source_edit_status = 'loading'; + }) + .addCase(getSourceById.rejected, (state) => { + state.source_edit_status = 'error'; + }) + .addCase(getSourceById.fulfilled, (state, action) => { + state.source_edit = action.payload; + state.source_edit_status = action.payload ? 'success' : 'error'; }); }, }); @@ -90,5 +121,6 @@ export const { changeSort, changeFilterId, changeFilterText, + clearSourceEdit, } = sourceSlice.actions; export default sourceSlice.reducer; diff --git a/src/UI/state/speciesInformation/actions/upsertSpeciesInfo.action.ts b/src/UI/state/speciesInformation/actions/upsertSpeciesInfo.action.ts index e22ddd0dc..90578e7c1 100644 --- a/src/UI/state/speciesInformation/actions/upsertSpeciesInfo.action.ts +++ b/src/UI/state/speciesInformation/actions/upsertSpeciesInfo.action.ts @@ -36,6 +36,8 @@ const sanitiseSpeciesInformation = ( name: encodeURIComponent(speciesInformation.name), shortDescription: encodeURIComponent(speciesInformation.shortDescription), description: encodeURIComponent(speciesInformation.description), + speciesImage: speciesInformation.speciesImage, + previewImage: speciesInformation.previewImage, citations: speciesInformation.citations.map((citation) => encodeURIComponent(citation) ), @@ -51,6 +53,7 @@ export const unsanitiseSpeciesInformation = ( shortDescription: decodeURIComponent(speciesInformation.shortDescription), description: decodeURIComponent(speciesInformation.description), speciesImage: safeDecodeURIComponent(speciesInformation.speciesImage), + previewImage: safeDecodeURIComponent(speciesInformation.previewImage || ''), citations: speciesInformation.citations.map((citation) => decodeURIComponent(citation) ), @@ -59,7 +62,10 @@ export const unsanitiseSpeciesInformation = ( export const upsertSpeciesInformation = createAsyncThunk( 'speciesInformation/upsert', - async (speciesInformation: SpeciesInformation, { getState, dispatch }) => { + async ( + speciesInformation: SpeciesInformation, + { getState, dispatch, rejectWithValue } + ) => { dispatch(speciesInfoLoading(true)); try { const token = (getState() as AppState).auth.token; @@ -69,14 +75,27 @@ export const upsertSpeciesInformation = createAsyncThunk( ), token ); + + // 🔧 ADDED: GraphQL can return HTTP 200 with an `errors` array, or with + // `data` present but the specific field null. Axios won't throw for + // either case, so we have to check explicitly. + if (newSpecies.errors?.length) { + throw new Error( + newSpecies.errors[0]?.message || 'GraphQL mutation returned errors' + ); + } + if (!newSpecies.data?.createEditSpeciesInformation) { + throw new Error( + 'GraphQL mutation succeeded but returned no species information' + ); + } + if (speciesInformation.id) { toast.success( await getTranslation( 'ReduxActions.SpeciesInformation.updateSuccess', { id: newSpecies.data.createEditSpeciesInformation.id } ) - // 'Updated species information with id ' + - // newSpecies.data.createEditSpeciesInformation.id ); } else { toast.success( @@ -84,8 +103,6 @@ export const upsertSpeciesInformation = createAsyncThunk( 'ReduxActions.SpeciesInformation.createSuccess', { id: newSpecies.data.createEditSpeciesInformation.id } ) - // 'New species information created with id ' + - // newSpecies.data.createEditSpeciesInformation.id ); } dispatch( @@ -97,15 +114,20 @@ export const upsertSpeciesInformation = createAsyncThunk( citations: [], }) ); + dispatch(speciesInfoLoading(false)); + return newSpecies.data.createEditSpeciesInformation; // 🔧 ADDED: gives the component a fulfilled payload to check against } catch (e) { + // 🔧 CHANGED: log the real error so it shows up in the browser console + // instead of only ever seeing the generic toast. + console.error('upsertSpeciesInformation failed:', e); toast.error( await getTranslation( 'ReduxActions.SpeciesInformation.errors.updateError' ) - //'Unable to update species information' ); + dispatch(speciesInfoLoading(false)); + return rejectWithValue(e instanceof Error ? e.message : String(e)); // 🔧 ADDED } - dispatch(speciesInfoLoading(false)); } ); @@ -126,9 +148,7 @@ export const deleteSpeciesInformation = createAsyncThunk( 'ReduxActions.SpeciesInformation.deleteSuccess', { id: id } ) - //`Deleted species information with id ${id}` ); - // Optionally refresh the species list or handle state cleanup dispatch(getAllSpecies()); } else { toast.error( @@ -136,7 +156,6 @@ export const deleteSpeciesInformation = createAsyncThunk( 'ReduxActions.SpeciesInformation.errors.deleteError', { id: id } ) - //`Failed to delete species information with id ${id}` ); } } catch (e) { @@ -144,7 +163,6 @@ export const deleteSpeciesInformation = createAsyncThunk( await getTranslation( 'ReduxActions.SpeciesInformation.errors.deleteGeneralError' ) - //'Unable to delete species information' ); } dispatch(speciesInfoLoading(false)); @@ -162,6 +180,7 @@ export const getSpeciesInformation = createAsyncThunk( unsanitiseSpeciesInformation(res.data.speciesInformationById) ) ); + dispatch( setCurrentInfoDetails( unsanitiseSpeciesInformation(res.data.speciesInformationById) diff --git a/src/UI/state/state.types.ts b/src/UI/state/state.types.ts index 5f07f61ee..cfa8e3140 100644 --- a/src/UI/state/state.types.ts +++ b/src/UI/state/state.types.ts @@ -32,11 +32,15 @@ export type VectorAtlasFilters = { }; export type SpeciesInformation = { - id: string | undefined; + id?: string; name: string; shortDescription: string; description: string; + // On list-page records this will be undefined, since the list query + // doesn't fetch it — that's expected, not a bug. speciesImage: string; + previewImage: string; + distributionMapUrl?: string; citations: string[]; link: string; }; diff --git a/src/UI/utils/speciesImageUtils.ts b/src/UI/utils/speciesImageUtils.ts index 900852796..11c749516 100644 --- a/src/UI/utils/speciesImageUtils.ts +++ b/src/UI/utils/speciesImageUtils.ts @@ -1,9 +1,10 @@ +// Turns whatever is stored (a full URL, a bare filename, a data URL, +// etc.) into something an can use directly. export function resolveSpeciesImageUrl(imageRef?: string): string { if (!imageRef) { return ''; } - if ( imageRef.startsWith('data:') || imageRef.startsWith('http://') || @@ -13,12 +14,10 @@ export function resolveSpeciesImageUrl(imageRef?: string): string { return imageRef; } - if (imageRef.startsWith('/')) { return imageRef; } - return `/vector-api/species-information/images/${imageRef}`; } @@ -43,6 +42,9 @@ export function getSpeciesImageDownloadUrl( return resolvedUrl; } +// Case A: we already have the image ref/URL in hand (the EDIT page, +// since it loads the full record including speciesImage). Downloads +// it directly — no extra network round trip needed to find the file. export async function downloadSpeciesImage( imageRef?: string, speciesName?: string @@ -52,9 +54,11 @@ export async function downloadSpeciesImage( return; } - const fileName = `${(speciesName || 'species').replace(/\s+/g, '_')}_image.png`; + const fileName = `${(speciesName || 'species').replace( + /\s+/g, + '_' + )}_image.png`; - // Handle data URLs (base64 images) if (resolvedUrl.startsWith('data:')) { const link = document.createElement('a'); link.href = resolvedUrl; @@ -87,4 +91,42 @@ export async function downloadSpeciesImage( console.error('Download failed:', error); alert(`Failed to download image: ${message}`); } -} \ No newline at end of file +} + +// Case B: we only have the species id, not the image ref (the LIST +// page, since its query never fetches speciesImage). Asks the backend +// to look it up and redirect to the real file; fetch() follows that +// redirect automatically and hands us the actual image bytes. +export async function downloadSpeciesImageById( + speciesId: string, + speciesName?: string +): Promise { + const fileName = `${(speciesName || 'species').replace( + /\s+/g, + '_' + )}_image.jpg`; + + try { + const response = await fetch( + `/vector-api/species-information/${speciesId}/download-image` + ); + + if (!response.ok) { + throw new Error(`Failed to fetch image: ${response.status}`); + } + + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(objectUrl); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.error('Download failed:', error); + alert(`Failed to download image: ${message}`); + } +} diff --git a/src/package-lock.json b/src/package-lock.json index 2b6237cb8..0fcf2a8ef 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -6,7 +6,8 @@ "": { "dependencies": { "react-swipeable-views": "^0.14.0", - "react-swipeable-views-utils": "^0.14.0" + "react-swipeable-views-utils": "^0.14.0", + "sharp": "^0.35.3" }, "devDependencies": { "@types/react-swipeable-views": "^0.13.6", @@ -22,6 +23,564 @@ "regenerator-runtime": "^0.12.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@material-ui/types": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@material-ui/types/-/types-4.1.1.tgz", @@ -71,6 +630,15 @@ "dev": true, "license": "MIT" }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -214,12 +782,80 @@ "integrity": "sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shallow-equal": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz", "integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==", "license": "MIT" }, + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, "node_modules/warning": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", diff --git a/src/package.json b/src/package.json index 84d0b2cd8..eb2703b02 100644 --- a/src/package.json +++ b/src/package.json @@ -1,7 +1,8 @@ { "dependencies": { "react-swipeable-views": "^0.14.0", - "react-swipeable-views-utils": "^0.14.0" + "react-swipeable-views-utils": "^0.14.0", + "sharp": "^0.35.3" }, "devDependencies": { "@types/react-swipeable-views": "^0.13.6",