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/.gitignore b/.gitignore index 1f257dfa4..710a83067 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ e2e/npm-debug.log +**/.tmp/** +**/.temp/** + 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 98b03d8a7..7f1239c02 100644 --- a/src/API/Dockerfile +++ b/src/API/Dockerfile @@ -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 diff --git a/src/API/public/species-images/species_maps-Species_Distribution_Maps__Anopheles_arabiensis.jpeg b/src/API/public/species-images/species_maps-Species_Distribution_Maps__Anopheles_arabiensis.jpeg new file mode 100644 index 000000000..ad725b648 Binary files /dev/null and b/src/API/public/species-images/species_maps-Species_Distribution_Maps__Anopheles_arabiensis.jpeg differ diff --git a/src/API/public/species-images/species_maps-Species_Distribution_Maps__Anopheles_funestus.jpeg b/src/API/public/species-images/species_maps-Species_Distribution_Maps__Anopheles_funestus.jpeg new file mode 100644 index 000000000..9d1725d5f Binary files /dev/null and b/src/API/public/species-images/species_maps-Species_Distribution_Maps__Anopheles_funestus.jpeg differ diff --git a/src/API/public/species-images/species_maps-Species_Distribution_Maps__Anopheles_gambiae.jpeg b/src/API/public/species-images/species_maps-Species_Distribution_Maps__Anopheles_gambiae.jpeg new file mode 100644 index 000000000..18cbd2156 Binary files /dev/null and b/src/API/public/species-images/species_maps-Species_Distribution_Maps__Anopheles_gambiae.jpeg differ diff --git a/src/API/public/species-images/species_maps-Species_Distribution_Maps__Anopheles_melas.jpeg b/src/API/public/species-images/species_maps-Species_Distribution_Maps__Anopheles_melas.jpeg new file mode 100644 index 000000000..75ba5f46b Binary files /dev/null and b/src/API/public/species-images/species_maps-Species_Distribution_Maps__Anopheles_melas.jpeg differ diff --git a/src/API/public/species-images/species_maps-Species_Distribution_Maps__Anopheles_merus.jpeg b/src/API/public/species-images/species_maps-Species_Distribution_Maps__Anopheles_merus.jpeg new file mode 100644 index 000000000..175114450 Binary files /dev/null and b/src/API/public/species-images/species_maps-Species_Distribution_Maps__Anopheles_merus.jpeg differ diff --git a/src/API/public/species-images/species_maps-Species_Distribution_Maps__Anopheles_moucheti.jpeg b/src/API/public/species-images/species_maps-Species_Distribution_Maps__Anopheles_moucheti.jpeg new file mode 100644 index 000000000..56719fd15 Binary files /dev/null and b/src/API/public/species-images/species_maps-Species_Distribution_Maps__Anopheles_moucheti.jpeg differ diff --git a/src/API/public/species-images/species_maps-Species_Distribution_Maps__Anopheles_nili.jpeg b/src/API/public/species-images/species_maps-Species_Distribution_Maps__Anopheles_nili.jpeg new file mode 100644 index 000000000..6e2bc6a62 Binary files /dev/null and b/src/API/public/species-images/species_maps-Species_Distribution_Maps__Anopheles_nili.jpeg differ diff --git a/src/API/public/species-images/species_maps-Species_Distribution_Maps___Anopheles_arabiensis-20260707154635708.webp b/src/API/public/species-images/species_maps-Species_Distribution_Maps___Anopheles_arabiensis-20260707154635708.webp new file mode 100644 index 000000000..1140188ec Binary files /dev/null and b/src/API/public/species-images/species_maps-Species_Distribution_Maps___Anopheles_arabiensis-20260707154635708.webp differ diff --git a/src/API/public/species-images/species_maps-Species_Distribution_Maps___Anopheles_funestus-20260707155125461.webp b/src/API/public/species-images/species_maps-Species_Distribution_Maps___Anopheles_funestus-20260707155125461.webp new file mode 100644 index 000000000..47fac192b Binary files /dev/null and b/src/API/public/species-images/species_maps-Species_Distribution_Maps___Anopheles_funestus-20260707155125461.webp differ diff --git a/src/API/public/species-images/species_maps-Species_Distribution_Maps___Anopheles_gambiae-20260707155404385.webp b/src/API/public/species-images/species_maps-Species_Distribution_Maps___Anopheles_gambiae-20260707155404385.webp new file mode 100644 index 000000000..de1874f56 Binary files /dev/null and b/src/API/public/species-images/species_maps-Species_Distribution_Maps___Anopheles_gambiae-20260707155404385.webp differ diff --git a/src/API/public/species-images/species_maps-Species_Distribution_Maps___Anopheles_melas-20260707155456516.webp b/src/API/public/species-images/species_maps-Species_Distribution_Maps___Anopheles_melas-20260707155456516.webp new file mode 100644 index 000000000..7be27085d Binary files /dev/null and b/src/API/public/species-images/species_maps-Species_Distribution_Maps___Anopheles_melas-20260707155456516.webp differ diff --git a/src/API/public/species-images/species_maps-Species_Distribution_Maps___Anopheles_merus-20260707155537808.webp b/src/API/public/species-images/species_maps-Species_Distribution_Maps___Anopheles_merus-20260707155537808.webp new file mode 100644 index 000000000..5948cae5a Binary files /dev/null and b/src/API/public/species-images/species_maps-Species_Distribution_Maps___Anopheles_merus-20260707155537808.webp differ diff --git a/src/API/public/species-images/species_maps-Species_Distribution_Maps___Anopheles_moucheti-20260707155615966.webp b/src/API/public/species-images/species_maps-Species_Distribution_Maps___Anopheles_moucheti-20260707155615966.webp new file mode 100644 index 000000000..4d3ecc0a6 Binary files /dev/null and b/src/API/public/species-images/species_maps-Species_Distribution_Maps___Anopheles_moucheti-20260707155615966.webp differ diff --git a/src/API/public/species-images/species_maps-Species_Distribution_Maps___Anopheles_nili_complex-20260707155658885.webp b/src/API/public/species-images/species_maps-Species_Distribution_Maps___Anopheles_nili_complex-20260707155658885.webp new file mode 100644 index 000000000..01b9f1010 Binary files /dev/null and b/src/API/public/species-images/species_maps-Species_Distribution_Maps___Anopheles_nili_complex-20260707155658885.webp differ diff --git a/src/API/src/db/speciesInformation/entities/speciesInformation.entity.ts b/src/API/src/db/speciesInformation/entities/speciesInformation.entity.ts index b815a903e..f32727217 100644 --- a/src/API/src/db/speciesInformation/entities/speciesInformation.entity.ts +++ b/src/API/src/db/speciesInformation/entities/speciesInformation.entity.ts @@ -17,10 +17,18 @@ export class SpeciesInformation extends BaseEntity { @Field({ nullable: false }) description: string; - @Column('varchar', { nullable: false }) - @Field({ nullable: false }) + // Original, large JPEG. Only ever needed for downloading — + // never fetched by the list query. + @Column('varchar', { nullable: true }) + @Field({ nullable: true }) speciesImage: string; + // Small WebP version, generated automatically whenever a new + // speciesImage is uploaded. This is what gets displayed everywhere. + @Column('varchar', { nullable: true }) + @Field({ nullable: true }) + previewImage: string; + @Column('varchar', { nullable: false }) @Field({ nullable: false }) distributionMapUrl: string; 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..94129cdc7 --- /dev/null +++ b/src/API/src/db/speciesInformation/speciesInformation.controller.ts @@ -0,0 +1,132 @@ +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'; +import { SpeciesInformationService } from './speciesInformation.service'; + +// CHANGED: TypeScript kept resolving sharp's type declarations as +// non-callable in this project's config, regardless of import style +// (`import sharp from`, `import * as sharp from`, and +// `import sharp = require(...)` all failed the same way). Plain +// require() sidesteps that entirely — sharp becomes typed as `any` +// and isn't type-checked at all, only used at runtime. +// eslint-disable-next-line @typescript-eslint/no-var-requires +const sharp = require('sharp'); + +@Controller('species-information') +export class SpeciesInformationController { + constructor( + private readonly azureBlobService: AzureBlobService, + private readonly speciesInformationService: SpeciesInformationService, + ) {} + + // Uploads a new species image. Two things happen here: + // 1. The original JPEG is uploaded as-is (this becomes speciesImage). + // 2. A smaller WebP copy is generated on the server and uploaded too + // (this becomes previewImage). The frontend never has to do any + // image conversion itself. + @Post('upload-image') + @UseInterceptors(FileInterceptor('file')) + async uploadImage(@UploadedFile() file: Express.Multer.File) { + if (!file) { + throw new BadRequestException('No file provided'); + } + + // Only JPEG is accepted, since that's the documented format and + // the only one we're set up to convert to WebP here. + if (file.mimetype !== 'image/jpeg') { + throw new BadRequestException('Only JPEG images are supported'); + } + + // Step 1: upload the original JPEG, unchanged + const originalResult = await this.azureBlobService.upload( + file, + 'species-images', + ); + + // Step 2: build a smaller WebP version in memory. + // - resize() caps the width so the preview is genuinely lighter + // - withoutEnlargement stops small images being blown up + // - webp({ quality: 80 }) is a solid size/quality balance + const previewBuffer = await sharp(file.buffer) + .resize({ width: 800, withoutEnlargement: true }) + .webp({ quality: 80 }) + .toBuffer(); + + // Step 3: upload that WebP buffer as its own file, in its own + // container, so it's a completely separate object from the original + const previewFile: Express.Multer.File = { + ...file, + buffer: previewBuffer, + size: previewBuffer.length, + mimetype: 'image/webp', + originalname: file.originalname.replace(/\.jpe?g$/i, '.webp'), + }; + const previewResult = await this.azureBlobService.upload( + previewFile, + 'species-images-preview', + ); + + // Store only the bare filename (no directory prefix, no host/URL) so + // it matches the format the rest of the species_information table + // uses, and what the /images/:filename route expects when it looks + // the file up under public/species-images/. + const stripDirectory = (filePath: string) => filePath.split('/').pop(); + + return { + imageUrl: stripDirectory(originalResult.filePath), + fileName: stripDirectory(originalResult.filePath), + previewImageUrl: stripDirectory(previewResult.filePath), + previewFileName: stripDirectory(previewResult.filePath), + }; + } + + // On-demand download route. The list page never loads speciesImage, + // so when someone clicks "Download" there, the frontend calls this + // route with just the species id. We look up speciesImage here and + // redirect the browser to the real file — either straight to Azure + // (if it's already a full URL) or through our own local image route + // (if it's just a bare filename sitting in the flat species-images + // folder, as with the manually-populated legacy rows). + @Get(':id/download-image') + async downloadImage(@Param('id') id: string, @Res() res: Response) { + const species = + await this.speciesInformationService.getSpeciesImageForDownload(id); + + if (!species || !species.speciesImage) { + throw new NotFoundException('Image not found'); + } + + const isFullUrl = + species.speciesImage.startsWith('http://') || + species.speciesImage.startsWith('https://'); + + const redirectTarget = isFullUrl + ? species.speciesImage + : `/vector-api/species-information/images/${species.speciesImage}`; + + return res.redirect(redirectTarget); + } + + @Get('images/:filename') + async getImage(@Param('filename') filename: string, @Res() res: Response) { + const filePath = join(process.cwd(), 'public', 'species-images', filename); + + if (!fs.existsSync(filePath)) { + throw new NotFoundException('Image not found'); + } + return res.sendFile(filePath); + } +} diff --git a/src/API/src/db/speciesInformation/speciesInformation.module.ts b/src/API/src/db/speciesInformation/speciesInformation.module.ts index d9eda15d0..b130b99ed 100644 --- a/src/API/src/db/speciesInformation/speciesInformation.module.ts +++ b/src/API/src/db/speciesInformation/speciesInformation.module.ts @@ -3,10 +3,20 @@ import { Module } from '@nestjs/common'; import { SpeciesInformation } from './entities/speciesInformation.entity'; import { SpeciesInformationService } from './speciesInformation.service'; import { SpeciesInformationResolver } from './speciesInformation.resolver'; +import { SpeciesInformationController } from './speciesInformation.controller'; +import { AzureBlobService } from 'src/db/azure-blob/azure-blob.service'; @Module({ imports: [TypeOrmModule.forFeature([SpeciesInformation])], - providers: [SpeciesInformationService, SpeciesInformationResolver], + // This was previously an empty array — meaning your REST endpoints + // (upload-image, download-image, images/:filename) were never + // actually reachable. Registering the controller here fixes that. + controllers: [SpeciesInformationController], + 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..4e4455333 100644 --- a/src/API/src/db/speciesInformation/speciesInformation.resolver.ts +++ b/src/API/src/db/speciesInformation/speciesInformation.resolver.ts @@ -37,6 +37,10 @@ export class CreateSpeciesInformationInput { @Field() speciesImage: string; + // Lets the frontend send the WebP preview URL when creating/editing + @Field({ nullable: true }) + previewImage: string; + @Field(() => [String]) citations?: string[]; @@ -83,7 +87,7 @@ export class SpeciesInformationResolver { @UseGuards(GqlAuthGuard, RolesGuard) @Roles(Role.Editor) - @Mutation(() => Boolean) // Return Boolean to indicate success or failure + @Mutation(() => Boolean) async deleteSpeciesInformation( @Args('id', { type: () => String }) id: string, ): Promise { diff --git a/src/API/src/db/speciesInformation/speciesInformation.service.ts b/src/API/src/db/speciesInformation/speciesInformation.service.ts index a1fcddb11..8f74bceb2 100644 --- a/src/API/src/db/speciesInformation/speciesInformation.service.ts +++ b/src/API/src/db/speciesInformation/speciesInformation.service.ts @@ -10,27 +10,55 @@ export class SpeciesInformationService { private speciesInformationRepository: Repository, ) {} + // Used by the EDIT page. Returns every field, including speciesImage, + // because the edit page needs the original to display + download. async speciesInformationById(id: string): Promise { return await this.speciesInformationRepository.findOne({ where: { id: id }, }); } + // Used by the LIST page. Deliberately does NOT select speciesImage — + // that's the large original JPEG, and pulling it for every row in the + // list would make the list slow to load for no benefit, since the + // list only ever displays previewImage. async allSpeciesInformation(): Promise { return await this.speciesInformationRepository.find({ + select: [ + 'id', + 'name', + 'shortDescription', + 'description', + 'previewImage', + 'distributionMapUrl', + 'citations', + 'link', + // speciesImage intentionally left out + ], order: { id: 'ASC', }, }); } + // Used ONLY by the download endpoint below. When someone on the list + // page clicks "Download," we don't have speciesImage in memory yet — + // this does a fast, narrow lookup for just that one field. + async getSpeciesImageForDownload( + id: string, + ): Promise> { + return await this.speciesInformationRepository.findOne({ + where: { id }, + select: ['id', 'name', 'speciesImage'], + }); + } + 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; } } diff --git a/src/API/src/schema.gql b/src/API/src/schema.gql index be8d775fc..85cdd456a 100644 --- a/src/API/src/schema.gql +++ b/src/API/src/schema.gql @@ -88,6 +88,7 @@ input CreateSpeciesInformationInput { id: String link: String! name: String! + previewImage: String shortDescription: String! speciesImage: String! } @@ -333,6 +334,8 @@ type Query { """recorded species data""" type RecordedSpecies { + category: String + display_name: String id: String! species: String! species_id_1: String @@ -410,8 +413,9 @@ type SpeciesInformation { id: String! link: String name: String! + previewImage: String shortDescription: String! - speciesImage: String! + speciesImage: String } """uploaded dataset""" 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/api/api.ts b/src/UI/api/api.ts index b1d4445ec..da40b1ce3 100644 --- a/src/UI/api/api.ts +++ b/src/UI/api/api.ts @@ -2,7 +2,7 @@ import axios, { AxiosError } from 'axios'; import https from 'https'; import download from 'js-file-download'; import { marked } from 'marked'; -import { DatasetFileType } from '../state/state.types'; +import { DatasetFileType, SpeciesInformation } from '../state/state.types'; import { toast } from 'react-toastify'; import { useAppDispatch } from '../state/hooks'; import { @@ -1420,3 +1420,27 @@ export const rejectReviewedDatasets = async ( ); return res.data; }; + +export const uploadSpeciesImageAuthenticated = async ( + file: File, + token: string +) => { + const formData = new window.FormData(); // Uses the browser's native FormData + formData.append('file', file); + + const config = { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'multipart/form-data', + }, + }; + + // Points directly to your active NestJS controller route! + const res = await axios.post( + `${apiUrl}species-information/upload-image`, + formData, + config + ); + + return res.data; +}; diff --git a/src/UI/api/queries.ts b/src/UI/api/queries.ts index 4913f32ed..2564d97c2 100644 --- a/src/UI/api/queries.ts +++ b/src/UI/api/queries.ts @@ -155,25 +155,62 @@ export const newSourceQuery = (source: NewSource) => { `; }; +export const updateSourceQuery = (source: NewSource) => { + const validatedSourceString = sourceStringValidation(source); + const year = new Date(source.year).getFullYear(); + return ` + mutation UpdateReference { + updateReference(num_id: ${source.num_id}, input: {author: "${validatedSourceString.author}", article_title: "${validatedSourceString.article_title}", journal_title: "${validatedSourceString.journal_title}", citation: "${validatedSourceString.citation}", year: ${year}, published: ${validatedSourceString.published}, report_type: "${validatedSourceString.report_type}", v_data: ${validatedSourceString.v_data}}) + {num_id} + } + `; +}; + +export const deleteSourceQuery = (num_id: number) => { + return ` + mutation { + deleteReference(num_id: ${num_id}) + }`; +}; export const upsertSpeciesInformationMutation = ( speciesInformation: SpeciesInformation ) => { + // FIX 1: Safely formats citations into a genuine GraphQL array literal format, e.g., [1, 2, 3] or [] + // We strip out outer double quotes by mapping array contents cleanly + const citationIds = Array.isArray(speciesInformation.citations) + ? speciesInformation.citations + .map((c) => Number(c)) + .filter((n) => !isNaN(n)) + : []; + const formattedCitations = `[${citationIds.join(',')}]`; + + const safeName = (speciesInformation.name || '').replace(/"/g, '\\"'); + const safeShortDesc = (speciesInformation.shortDescription || '').replace( + /"/g, + '\\"' + ); + const safeImg = speciesInformation.speciesImage || ''; + const safePreview = speciesInformation.previewImage || ''; + const safeLink = speciesInformation.link || ''; + return ` mutation { createEditSpeciesInformation(input: { - ${speciesInformation.id ? 'id: "' + speciesInformation.id + '"' : ''} - name: "${speciesInformation.name}" - shortDescription: "${speciesInformation.shortDescription}" - description: """${speciesInformation.description}""" - speciesImage: "${speciesInformation.speciesImage}" - citations: "${speciesInformation.citations}" - link:"${speciesInformation.link}" + ${speciesInformation.id ? `id: "${speciesInformation.id}"` : ''} + name: "${safeName}" + shortDescription: "${safeShortDesc}" + description: """${speciesInformation.description || '[]'}""" + speciesImage: "${safeImg}" + previewImage: "${safePreview}" + citations: ${formattedCitations} + link: "${safeLink}" }) { - name id + name description shortDescription speciesImage + previewImage citations link } @@ -220,6 +257,7 @@ export const speciesInformationById = (id: string) => { speciesImage citations link + previewImage } } `; @@ -233,7 +271,7 @@ export const allSpecies = () => { name shortDescription description - speciesImage + previewImage citations link } diff --git a/src/UI/components/shared/navbar.tsx b/src/UI/components/shared/navbar.tsx index cfd95ae01..9f5d19b11 100644 --- a/src/UI/components/shared/navbar.tsx +++ b/src/UI/components/shared/navbar.tsx @@ -23,6 +23,9 @@ export default function NavBar() { const feature_flags = useAppSelector((state) => state.config.feature_flags); const { user } = useUser(); + { + /*const dev ='true'; //process.env.NODE_ENV === 'development';*/ + } const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const auth = useAppSelector((state) => state.auth); @@ -48,7 +51,10 @@ export default function NavBar() { user && (roles.includes(RolesEnum.REVIEWER_MANAGER) || roles.includes(RolesEnum.REVIEWER) || - roles.includes(RolesEnum.ADMIN)) + roles.includes(RolesEnum.ADMIN) || + { + /*dev==='true'*/ + }) ) { moreOptions.push({ text: t('doi'), url: '/doi' }); } diff --git a/src/UI/components/shared/textEditor/RichTextEditor.tsx b/src/UI/components/shared/textEditor/RichTextEditor.tsx index e79b8efca..1c9d4fc95 100644 --- a/src/UI/components/shared/textEditor/RichTextEditor.tsx +++ b/src/UI/components/shared/textEditor/RichTextEditor.tsx @@ -23,6 +23,8 @@ import { Divider, Typography } from '@mui/material'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { mergeRegister } from '@lexical/utils'; +const SYNC_TAG = 'react-state-sync'; + const DescriptionWatcherPlugin = ({ updateHandler, }: { @@ -32,7 +34,14 @@ const DescriptionWatcherPlugin = ({ useEffect(() => { return mergeRegister( - editor.registerUpdateListener(({ editorState }) => { + editor.registerUpdateListener(({ editorState, tags }) => { + // Skip updates that were caused by our own programmatic sync + // (i.e. loading existingSource into the editor), so we don't + // immediately overwrite the freshly-loaded value with itself + // via a stale/empty read. + if (tags.has(SYNC_TAG)) { + return; + } editorState.read(() => { const markdown = $convertToMarkdownString(TRANSFORMERS); updateHandler(markdown); @@ -48,9 +57,12 @@ const ReactStatePlugin = ({ description }: { description: string }) => { const [editor] = useLexicalComposerContext(); useEffect(() => { - editor.update(() => { - $convertFromMarkdownString(description); - }); + editor.update( + () => { + $convertFromMarkdownString(description, TRANSFORMERS); + }, + { tag: SYNC_TAG } + ); }, [editor, description]); return
; }; diff --git a/src/UI/components/shared/textEditor/ToolbarPlugin.tsx b/src/UI/components/shared/textEditor/ToolbarPlugin.tsx index c6fbf2fc1..1eab01292 100644 --- a/src/UI/components/shared/textEditor/ToolbarPlugin.tsx +++ b/src/UI/components/shared/textEditor/ToolbarPlugin.tsx @@ -173,7 +173,7 @@ const EditorToolbar = () => { diff --git a/src/UI/components/shared/textEditor/shortTextEditor.tsx b/src/UI/components/shared/textEditor/shortTextEditor.tsx index 3dc8b3d6c..3b7a09a76 100644 --- a/src/UI/components/shared/textEditor/shortTextEditor.tsx +++ b/src/UI/components/shared/textEditor/shortTextEditor.tsx @@ -22,6 +22,8 @@ import { Typography } from '@mui/material'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { mergeRegister } from '@lexical/utils'; +const SYNC_TAG = 'react-state-sync'; + const ShortDescriptionWatcherPlugin = ({ updateHandler, }: { @@ -31,7 +33,13 @@ const ShortDescriptionWatcherPlugin = ({ useEffect(() => { return mergeRegister( - editor.registerUpdateListener(({ editorState }) => { + editor.registerUpdateListener(({ editorState, tags }) => { + // Skip updates caused by our own programmatic sync (loading the + // existing shortDescription into the editor), so we don't + // immediately overwrite the freshly-loaded value with empty. + if (tags.has(SYNC_TAG)) { + return; + } editorState.read(() => { const markdown = $convertToMarkdownString(TRANSFORMERS); updateHandler(markdown); @@ -51,9 +59,12 @@ const ShortReactStatePlugin = ({ const [editor] = useLexicalComposerContext(); useEffect(() => { - editor.update(() => { - $convertFromMarkdownString(shortDescription); - }); + editor.update( + () => { + $convertFromMarkdownString(shortDescription, TRANSFORMERS); + }, + { tag: SYNC_TAG } + ); }, [editor, shortDescription]); return
; }; diff --git a/src/UI/components/sources/source_form.tsx b/src/UI/components/sources/source_form.tsx index 84091872b..a0450cbf9 100644 --- a/src/UI/components/sources/source_form.tsx +++ b/src/UI/components/sources/source_form.tsx @@ -2,14 +2,16 @@ import { Paper, Box, Button, Typography, Switch } from '@mui/material'; import { useForm, Controller } from 'react-hook-form'; import { yupResolver } from '@hookform/resolvers/yup'; import { FormControlLabel, TextField } from '@mui/material'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import * as yup from 'yup'; import { useDispatch } from 'react-redux'; import { AppDispatch } from '../../state/store'; import { DatePicker, LocalizationProvider } from '@mui/x-date-pickers'; import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns'; import { postNewSource } from '../../state/source/actions/postNewSource'; +import { updateSource } from '../../state/source/actions/updateSource'; import { useTranslations } from 'next-intl'; +import { TextEditor } from '../shared/textEditor/RichTextEditor'; export interface NewSource { author: string; @@ -33,17 +35,29 @@ const schema = yup published: yup.boolean().required(), report_type: yup.string().required(), v_data: yup.boolean().required(), + num_id: yup.number().notRequired(), }) .required(); -export default function SourceForm() { +interface SourceFormProps { + existingSource?: NewSource | null; +} + +// Default applied to brand-new sources so `report_type` is never saved as an +// empty string. Adjust this value to whatever your team's standard/most +// common report type actually is. +const DEFAULT_REPORT_TYPE = 'Journal Article'; + +export default function SourceForm({ existingSource }: SourceFormProps) { const t = useTranslations('NewSourcePage'); + const isEditMode = !!existingSource; const { register, reset, control, handleSubmit } = useForm({ resolver: yupResolver(schema), defaultValues: { v_data: false, published: false, + report_type: DEFAULT_REPORT_TYPE, }, }); const [year, setYear] = useState(null); @@ -52,11 +66,32 @@ export default function SourceForm() { }; const dispatch = useDispatch(); + + useEffect(() => { + if (existingSource) { + console.log('SourceForm received existingSource:', existingSource); + reset(existingSource); + setYear(new Date(existingSource.year, 0, 1)); + } + }, [existingSource, reset]); + const onSubmit = async (data: NewSource) => { console.log(data); - const success = await dispatch(postNewSource(data)); - if (success) { - reset(); + if (isEditMode) { + const success = await dispatch(updateSource(data)); + if (success) { + // stays on the page with updated values + } + } else { + const success = await dispatch(postNewSource(data)); + if (success) { + reset({ + v_data: false, + published: false, + report_type: DEFAULT_REPORT_TYPE, + }); + setYear(null); + } } }; @@ -74,50 +109,61 @@ export default function SourceForm() {
onSubmit(d))}>
- {t('title')} + {isEditMode ? t('editTitle') : t('title')}
+
+ + {t('author')} + ( - + helperText={error ? t('authorHelperText') : undefined} + /> )} rules={{ required: 'Author required' }} />
-
+
+ + {t('articleTitle')} + ( - + helperText={error ? t('articleTitleHelperText') : undefined} + /> )} rules={{ required: 'Article Title required' }} /> @@ -125,22 +171,27 @@ export default function SourceForm() {
+ + {t('journalTitle')} + ( - + helperText={error ? t('journalTitleHelperText') : undefined} + /> )} rules={{ required: 'Journal Title required' }} /> @@ -276,7 +327,7 @@ export default function SourceForm() {

+ + ))} @@ -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 new file mode 100644 index 000000000..8497a68b7 --- /dev/null +++ b/src/UI/components/species/SpeciesImageViewer.tsx @@ -0,0 +1,250 @@ +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, + 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 = { + // 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; + showActions?: boolean; +}; + +export default function SpeciesImageViewer({ + 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(previewRef); + if (!imageUrl) { + return null; + } + + const resetView = () => { + setScale(1); + setPosition({ x: 0, y: 0 }); + }; + + const handleClose = () => { + resetView(); + setOpen(false); + }; + + const handleDownload = async () => { + // 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)); + 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', + }} + /> + + + + + + + + ); +} diff --git a/src/UI/components/species/speciesList.tsx b/src/UI/components/species/speciesList.tsx index 445b33809..8b462790a 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,15 +20,14 @@ 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 SpeciesImageViewer from './SpeciesImageViewer'; export default function SpeciesList(): JSX.Element { const t = useTranslations('SpeciesPage'); 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 @@ -38,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)); @@ -54,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, @@ -136,18 +133,16 @@ export default function SpeciesList(): JSX.Element { justifyContent: 'center', }} > - - Mosquito Species #1 - +
@@ -188,7 +183,7 @@ export default function SpeciesList(): JSX.Element { backgroundColor: 'red', }} variant="contained" - onClick={() => handleDeleteClick(row.id as string)} // Handle delete click + onClick={() => handleDeleteClick(row.id as string)} className="DeleteButton" > {t('buttons.deleteItem')} @@ -202,7 +197,6 @@ export default function SpeciesList(): JSX.Element { ))} - {/* Dialog Box */} { 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([]); const [species, setSpecies] = useState(''); 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(); const currentSpeciesInformation = useAppSelector( (s) => s.speciesInfo.currentInfoForEditing @@ -56,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)); @@ -100,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 { @@ -116,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 = @@ -136,7 +154,6 @@ const SpeciesInformationEditor = () => { } }, [currentSpeciesInformation, sources.items]); - // Early return AFTER all hooks if (loadingSpeciesInformation) { return (
@@ -145,11 +162,11 @@ 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 +187,17 @@ const SpeciesInformationEditor = () => { }; const handleImageUpload = async (e: React.ChangeEvent) => { - if (e.target.files && e.target.files[0].size < UPLOAD_LIMIT_IN_KB * 1024) { - const speciesImage = await toBase64(e.target.files[0]); - setSpeciesImage(speciesImage); - } else { + const file = e.target.files?.[0]; + if (!file) { + 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, }); @@ -181,6 +205,23 @@ const SpeciesInformationEditor = () => { toast.error(error, { autoClose: 5000, }); + return; + } + + try { + setUploadingImage(true); + 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.'); + console.error('Image upload error:', error); + } finally { + setUploadingImage(false); } }; @@ -193,6 +234,16 @@ const SpeciesInformationEditor = () => { return (
+ + {id ? t('speciesInformationEditor.edit') @@ -283,17 +334,19 @@ const SpeciesInformationEditor = () => { sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }} > @@ -302,24 +355,23 @@ const SpeciesInformationEditor = () => { maxSize: UPLOAD_LIMIT_IN_KB, })} - {speciesImage && ( - - Species image - + {previewImage && ( + )} - Citation + {t('speciesInformationEditor.citation')} setCitationSearch(e.target.value)} sx={{ mb: 2 }} @@ -433,12 +485,17 @@ const 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 82ed04d9f..c0ff01c47 100644 --- a/src/UI/pages/species/details.tsx +++ b/src/UI/pages/species/details.tsx @@ -1,234 +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 { 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 - - -
- Mosquito Species #1 -
- - - - {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 87f2b3203..90578e7c1 100644 --- a/src/UI/state/speciesInformation/actions/upsertSpeciesInfo.action.ts +++ b/src/UI/state/speciesInformation/actions/upsertSpeciesInfo.action.ts @@ -20,6 +20,14 @@ import { toast } from 'react-toastify'; import { getAllSpecies } from './getAllSpecies'; import { getTranslation } from '../../../utils/localization'; +const safeDecodeURIComponent = (value: string): string => { + try { + return decodeURIComponent(value); + } catch { + return value; + } +}; + const sanitiseSpeciesInformation = ( speciesInformation: SpeciesInformation ): SpeciesInformation => { @@ -28,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) ), @@ -42,6 +52,8 @@ export const unsanitiseSpeciesInformation = ( name: decodeURIComponent(speciesInformation.name), shortDescription: decodeURIComponent(speciesInformation.shortDescription), description: decodeURIComponent(speciesInformation.description), + speciesImage: safeDecodeURIComponent(speciesInformation.speciesImage), + previewImage: safeDecodeURIComponent(speciesInformation.previewImage || ''), citations: speciesInformation.citations.map((citation) => decodeURIComponent(citation) ), @@ -50,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; @@ -60,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( @@ -75,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( @@ -88,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)); } ); @@ -117,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( @@ -127,7 +156,6 @@ export const deleteSpeciesInformation = createAsyncThunk( 'ReduxActions.SpeciesInformation.errors.deleteError', { id: id } ) - //`Failed to delete species information with id ${id}` ); } } catch (e) { @@ -135,7 +163,6 @@ export const deleteSpeciesInformation = createAsyncThunk( await getTranslation( 'ReduxActions.SpeciesInformation.errors.deleteGeneralError' ) - //'Unable to delete species information' ); } dispatch(speciesInfoLoading(false)); @@ -153,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 new file mode 100644 index 000000000..11c749516 --- /dev/null +++ b/src/UI/utils/speciesImageUtils.ts @@ -0,0 +1,132 @@ +// 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://') || + imageRef.startsWith('https://') || + imageRef.startsWith('/vector-api/') + ) { + return imageRef; + } + + if (imageRef.startsWith('/')) { + return imageRef; + } + + return `/vector-api/species-information/images/${imageRef}`; +} + +export function getSpeciesImageDownloadUrl( + imageRef?: string, + speciesName?: string +): string { + const resolvedUrl = resolveSpeciesImageUrl(imageRef); + if (!resolvedUrl) { + return ''; + } + + if (resolvedUrl.startsWith('data:')) { + return resolvedUrl; + } + + if (resolvedUrl.includes('/species-information/images/')) { + const separator = resolvedUrl.includes('?') ? '&' : '?'; + return `${resolvedUrl}${separator}download=true`; + } + + 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 +): Promise { + const resolvedUrl = resolveSpeciesImageUrl(imageRef); + if (!resolvedUrl) { + return; + } + + const fileName = `${(speciesName || 'species').replace( + /\s+/g, + '_' + )}_image.png`; + + if (resolvedUrl.startsWith('data:')) { + const link = document.createElement('a'); + link.href = resolvedUrl; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + return; + } + + try { + const downloadUrl = getSpeciesImageDownloadUrl(imageRef, speciesName); + const response = await fetch(downloadUrl); + + 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}`); + } +} + +// 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",