From 50035e9f5d07e18408a792441c8462fe35dd84cc Mon Sep 17 00:00:00 2001 From: IndianaEunice Date: Fri, 31 Jul 2026 11:52:53 +0300 Subject: [PATCH 1/5] rrecheck of species and source lists --- src/API/package.json | 1 + ...85228239230-ConvertSpeciesImagesToBytea.ts | 20 ++ src/API/src/db/shared/reference.resolver.ts | 37 +++ src/API/src/db/shared/reference.service.ts | 120 ++++----- .../entities/speciesInformation.entity.ts | 17 +- .../speciesInformation.controller.ts | 86 ++++++ .../speciesInformation.module.ts | 12 +- .../speciesInformation.resolver.ts | 38 ++- .../speciesInformation.service.ts | 32 ++- src/UI/api/api.ts | 26 +- src/UI/api/queries.ts | 61 ++++- .../shared/textEditor/RichTextEditor.tsx | 110 +++++--- .../shared/textEditor/ToolbarPlugin.tsx | 129 ++++++--- .../shared/textEditor/shortTextEditor.tsx | 19 +- src/UI/components/sources/source_filters.tsx | 63 ++++- src/UI/components/sources/source_form.tsx | 117 +++++--- src/UI/components/sources/source_table.tsx | 93 ++++++- .../components/species/SpeciesImageViewer.tsx | 253 ++++++++++++++++++ src/UI/components/species/speciesList.tsx | 44 ++- .../speciesInformationEditor.tsx | 147 +++++++--- src/UI/pages/_app.tsx | 2 + src/UI/pages/species/details.tsx | 38 +-- src/UI/pages/species/edit.tsx | 3 +- src/UI/public/messages/en.json | 143 ++++++---- src/UI/public/messages/fr.json | 169 +++++++----- src/UI/public/messages/pt.json | 164 +++++++----- src/UI/state/source/actions/deleteSource.ts | 31 +++ src/UI/state/source/actions/getSourceById.ts | 14 + src/UI/state/source/actions/getSourceInfo.ts | 16 +- src/UI/state/source/actions/updateSource.ts | 31 +++ src/UI/state/source/sourceSlice.ts | 45 +++- .../actions/upsertSpeciesInfo.action.ts | 50 +++- src/UI/utils/speciesImageUtils.ts | 151 +++++++++++ 33 files changed, 1746 insertions(+), 536 deletions(-) create mode 100644 src/API/src/db/migrations/1785228239230-ConvertSpeciesImagesToBytea.ts create mode 100644 src/API/src/db/speciesInformation/speciesInformation.controller.ts create mode 100644 src/UI/components/species/SpeciesImageViewer.tsx create mode 100644 src/UI/state/source/actions/deleteSource.ts create mode 100644 src/UI/state/source/actions/getSourceById.ts create mode 100644 src/UI/state/source/actions/updateSource.ts create mode 100644 src/UI/utils/speciesImageUtils.ts diff --git a/src/API/package.json b/src/API/package.json index cc4acffc3..e5607b330 100644 --- a/src/API/package.json +++ b/src/API/package.json @@ -74,6 +74,7 @@ "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^7.2.0", + "sharp": "^0.35.3", "typeorm": "^0.3.7", "uuid": "^8.3.2", "winston": "^3.17.0", diff --git a/src/API/src/db/migrations/1785228239230-ConvertSpeciesImagesToBytea.ts b/src/API/src/db/migrations/1785228239230-ConvertSpeciesImagesToBytea.ts new file mode 100644 index 000000000..758b3afe2 --- /dev/null +++ b/src/API/src/db/migrations/1785228239230-ConvertSpeciesImagesToBytea.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class ConvertSpeciesImagesToBytea1785228239230 implements MigrationInterface { + name = 'ConvertSpeciesImagesToBytea1785228239230' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "species_information" DROP COLUMN "speciesImage"`); + await queryRunner.query(`ALTER TABLE "species_information" ADD "speciesImage" bytea`); + await queryRunner.query(`ALTER TABLE "species_information" DROP COLUMN "previewImage"`); + await queryRunner.query(`ALTER TABLE "species_information" ADD "previewImage" bytea`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "species_information" DROP COLUMN "previewImage"`); + await queryRunner.query(`ALTER TABLE "species_information" ADD "previewImage" character varying`); + await queryRunner.query(`ALTER TABLE "species_information" DROP COLUMN "speciesImage"`); + await queryRunner.query(`ALTER TABLE "species_information" ADD "speciesImage" character varying`); + } + +} \ No newline at end of file diff --git a/src/API/src/db/shared/reference.resolver.ts b/src/API/src/db/shared/reference.resolver.ts index 0747e2a03..b89ea0fac 100644 --- a/src/API/src/db/shared/reference.resolver.ts +++ b/src/API/src/db/shared/reference.resolver.ts @@ -8,6 +8,7 @@ import { Resolver, ObjectType, ArgsType, + Int, } from '@nestjs/graphql'; import { ReferenceService } from './reference.service'; import { UseGuards } from '@nestjs/common'; @@ -48,6 +49,11 @@ export class CreateReferenceInput { v_data: boolean; } +// Same shape as create — an update always resends the full form, +// matching how the frontend's updateSourceQuery builds its payload. +@InputType() +export class UpdateReferenceInput extends CreateReferenceInput {} + @ObjectType() class PaginatedReferenceData extends PaginatedResponse(Reference) {} @@ -81,6 +87,14 @@ export class GetReferenceDataArgs { @Field(stringTypeResolver, { nullable: true, defaultValue: '' }) @Min(0) textFilter: string; + + // Which column textFilter searches against — matches whichever option + // the user picked in the field dropdown on the frontend. Defaults to + // the previous hardcoded behavior so any existing caller that doesn't + // pass this still works unchanged. + @Field(stringTypeResolver, { nullable: true, defaultValue: 'article_title' }) + @Min(0) + filterField: string; } @Resolver(() => Reference) @@ -103,6 +117,7 @@ export class ReferenceResolver { startId, endId, textFilter, + filterField, }: GetReferenceDataArgs, ) { const { items, total } = await this.referenceService.findReferences( @@ -113,6 +128,7 @@ export class ReferenceResolver { startId, endId, textFilter, + filterField, ); return Object.assign(new PaginatedReferenceData(), { items, @@ -141,4 +157,25 @@ export class ReferenceResolver { }; return this.referenceService.save(newRef); } + + @UseGuards(GqlAuthGuard, RolesGuard) + @Roles(Role.Uploader, Role.Editor) + @Mutation(() => Reference) + async updateReference( + @Args('num_id', { type: () => Int }) num_id: number, + @Args({ name: 'input', type: () => UpdateReferenceInput, nullable: false }) + input: UpdateReferenceInput, + ) { + const updates: Partial = { + author: decodeURIComponent(input.author), + article_title: decodeURIComponent(input.article_title), + journal_title: decodeURIComponent(input.journal_title), + citation: decodeURIComponent(input.citation), + year: input.year, + published: input.published, + report_type: decodeURIComponent(input.report_type), + v_data: input.v_data, + }; + return this.referenceService.update(num_id, updates); + } } diff --git a/src/API/src/db/shared/reference.service.ts b/src/API/src/db/shared/reference.service.ts index 0fa4d2d6a..52bed9f52 100644 --- a/src/API/src/db/shared/reference.service.ts +++ b/src/API/src/db/shared/reference.service.ts @@ -1,10 +1,14 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Reference } from './entities/reference.entity'; -import { Occurrence } from '../occurrence/entities/occurrence.entity'; -import { Dataset } from './entities/dataset.entity'; import { Repository } from 'typeorm'; +// Only these columns are ever allowed as a dynamic filter target — this +// whitelist exists specifically to prevent filterField (which ultimately +// comes from user input via the frontend dropdown) from being used to +// inject an arbitrary column/expression into the raw SQL string below. +const ALLOWED_FILTER_FIELDS = ['article_title', 'author', 'journal_title']; + @Injectable() export class ReferenceService { constructor( @@ -16,6 +20,10 @@ export class ReferenceService { return this.referenceRepository.findOne({ where: { id: id } }); } + findOneByNumId(num_id: number): Promise { + return this.referenceRepository.findOne({ where: { num_id } }); + } + findAll(): Promise { return this.referenceRepository.find(); } @@ -27,6 +35,18 @@ export class ReferenceService { return this.referenceRepository.save(reference); } + async update( + num_id: number, + updates: Partial, + ): Promise { + const existing = await this.findOneByNumId(num_id); + if (!existing) { + throw new NotFoundException(`Reference with num_id ${num_id} not found`); + } + const merged = this.referenceRepository.merge(existing, updates); + return this.referenceRepository.save(merged); + } + async findReferences( take: number, skip: number, @@ -35,96 +55,46 @@ export class ReferenceService { startId: number, endId: number, textFilter: string, + filterField = 'article_title', ): Promise<{ items: Reference[]; total: number }> { const nonStringCols = ['num_id', 'year', 'published', 'v_data']; + const orderByString = nonStringCols.includes(orderBy) + ? `reference.${orderBy}` + : `LOWER(reference.${orderBy})`; - // Build filter conditions that will be applied to both queries - const filterParams: Record = { status: 'Approved' }; + // Guard against an unexpected/invalid filterField value reaching the + // raw query string below — falls back to the original hardcoded + // column if the requested one isn't in the allowed list. + const safeFilterField = ALLOWED_FILTER_FIELDS.includes(filterField) + ? filterField + : 'article_title'; - if (startId && !isNaN(startId)) { - filterParams.startId = startId; - } - if (endId && !isNaN(endId)) { - filterParams.endId = endId; - } - if (textFilter) { - filterParams.textFilter = `%${textFilter.toLocaleLowerCase()}%`; - } + let query = this.referenceRepository.createQueryBuilder('reference'); - // ============================================ - // QUERY 1: Get paginated items - // ============================================ - let itemsQuery = this.referenceRepository - .createQueryBuilder('reference') - .innerJoin(Occurrence, 'occ', 'occ.referenceId = reference.id') - .innerJoin(Dataset, 'ds', 'ds.id = occ.datasetId') - .andWhere('ds.status = :status', filterParams) - .distinct(true); - - // Apply filters to items query if (startId && !isNaN(startId)) { - itemsQuery = itemsQuery.andWhere( - 'reference.num_id >= :startId', - filterParams, - ); - } - if (endId && !isNaN(endId)) { - itemsQuery = itemsQuery.andWhere( - 'reference.num_id <= :endId', - filterParams, - ); - } - if (textFilter) { - itemsQuery = itemsQuery.andWhere( - 'LOWER(reference.article_title) LIKE :textFilter', - filterParams, - ); - } - - // Apply ordering - if (nonStringCols.includes(orderBy)) { - itemsQuery = itemsQuery.addOrderBy(`"reference"."${orderBy}"`, order); - } else { - const lowerAlias = `lower_${orderBy}`; - itemsQuery = itemsQuery.addSelect( - `LOWER("reference"."${orderBy}")`, - lowerAlias, - ); - itemsQuery = itemsQuery.addOrderBy(lowerAlias, order); - } - - const items = await itemsQuery.skip(skip).take(take).getMany(); - - // ============================================ - // QUERY 2: Get DISTINCT count - // ============================================ - let countQuery = this.referenceRepository - .createQueryBuilder('reference') - .select('COUNT(DISTINCT reference.id)', 'count') - .innerJoin(Occurrence, 'occ2', 'occ2.referenceId = reference.id') - .innerJoin(Dataset, 'ds2', 'ds2.id = occ2.datasetId') - .andWhere('ds2.status = :status', { status: 'Approved' }); - - // Apply same filters to count query - if (startId && !isNaN(startId)) { - countQuery = countQuery.andWhere('reference.num_id >= :startId', { + query = query.andWhere('"reference"."num_id" >= :startId', { startId, }); } if (endId && !isNaN(endId)) { - countQuery = countQuery.andWhere('reference.num_id <= :endId', { endId }); + query = query.andWhere('"reference"."num_id" <= :endId', { + endId, + }); } if (textFilter) { - countQuery = countQuery.andWhere( - 'LOWER(reference.article_title) LIKE :textFilter', + query = query.andWhere( + `LOWER("reference"."${safeFilterField}") LIKE :textFilter`, { textFilter: `%${textFilter.toLocaleLowerCase()}%`, }, ); } - const countResult = await countQuery.getRawOne(); - const total = parseInt(countResult.count, 10) || 0; + const [items, total] = await query + .orderBy(orderByString, order) + .skip(skip) + .take(take) + .getManyAndCount(); return { items, total }; } diff --git a/src/API/src/db/speciesInformation/entities/speciesInformation.entity.ts b/src/API/src/db/speciesInformation/entities/speciesInformation.entity.ts index b815a903e..872d980f2 100644 --- a/src/API/src/db/speciesInformation/entities/speciesInformation.entity.ts +++ b/src/API/src/db/speciesInformation/entities/speciesInformation.entity.ts @@ -17,9 +17,20 @@ export class SpeciesInformation extends BaseEntity { @Field({ nullable: false }) description: string; - @Column('varchar', { nullable: false }) - @Field({ nullable: false }) - speciesImage: string; + // Original, full-size JPEG, stored directly as raw bytes in Postgres. + // No @Field here — exposed to GraphQL as a base64 string via a + // @ResolveField in speciesInformation.resolver.ts, since GraphQL has + // no native binary type. Only ever needed for downloading — + // still never fetched by the list query (see allSpeciesInformation's + // select list in the service, unchanged). + @Column('bytea', { nullable: true }) + speciesImage: Buffer; + + // Small WebP version, generated automatically whenever a new + // speciesImage is uploaded. This is what gets displayed everywhere. + // Same base64-via-resolver treatment as speciesImage above. + @Column('bytea', { nullable: true }) + previewImage: Buffer; @Column('varchar', { nullable: false }) @Field({ nullable: false }) 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..17cbc2072 --- /dev/null +++ b/src/API/src/db/speciesInformation/speciesInformation.controller.ts @@ -0,0 +1,86 @@ +import { + Controller, + Post, + Get, + Param, + Res, + UploadedFile, + UseInterceptors, + BadRequestException, + NotFoundException, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { Response } from 'express'; +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 speciesInformationService: SpeciesInformationService, + ) {} + + // Processes a newly-selected species image. No external storage upload + // happens here anymore — this validates the file, generates a smaller + // WebP preview, and returns both as base64 strings. The frontend holds + // these in local state and sends them along with the rest of the form + // in the createEditSpeciesInformation mutation, where they get decoded + // to raw bytes and saved directly into the species_information table's + // speciesImage / previewImage columns. + @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'); + } + + // 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(); + + return { + imageBase64: file.buffer.toString('base64'), + previewBase64: previewBuffer.toString('base64'), + }; + } + + // Download route for the list page's "Download" button. Reads the raw + // bytes straight from Postgres and sends them back as a file attachment + // — no redirect anywhere, since there's no external file to redirect to. + @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'); + } + + res.set({ + 'Content-Type': 'image/jpeg', + 'Content-Disposition': `attachment; filename="${ + species.name || 'species' + }.jpeg"`, + }); + return res.send(species.speciesImage); + } +} 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..680744936 100644 --- a/src/API/src/db/speciesInformation/speciesInformation.resolver.ts +++ b/src/API/src/db/speciesInformation/speciesInformation.resolver.ts @@ -3,7 +3,9 @@ import { Field, InputType, Mutation, + Parent, Query, + ResolveField, Resolver, } from '@nestjs/graphql'; import { SpeciesInformationService } from './speciesInformation.service'; @@ -34,9 +36,15 @@ export class CreateSpeciesInformationInput { @Field() description: string; - @Field() + // Base64-encoded image bytes (from uploadImage's imageBase64 response), + // decoded to a Buffer in createEditSpeciesInformation before saving. + @Field({ nullable: true }) speciesImage: string; + // Base64-encoded WebP preview bytes (from uploadImage's previewBase64). + @Field({ nullable: true }) + previewImage: string; + @Field(() => [String]) citations?: string[]; @@ -58,6 +66,24 @@ export class SpeciesInformationResolver { return await this.speciesInformationService.allSpeciesInformation(); } + // Converts the stored bytea Buffer to a base64 string whenever a query + // actually asks for speciesImage. List queries that don't request this + // field never trigger it — allSpeciesInformation's service method + // already excludes speciesImage from its select for exactly this reason. + @ResolveField('speciesImage', () => String, { nullable: true }) + resolveSpeciesImage(@Parent() species: SpeciesInformation): string | null { + return species.speciesImage + ? species.speciesImage.toString('base64') + : null; + } + + @ResolveField('previewImage', () => String, { nullable: true }) + resolvePreviewImage(@Parent() species: SpeciesInformation): string | null { + return species.previewImage + ? species.previewImage.toString('base64') + : null; + } + @UseGuards(GqlAuthGuard, RolesGuard) @Roles(Role.Editor) @Mutation(() => SpeciesInformation) @@ -72,6 +98,14 @@ export class SpeciesInformationResolver { const newSpeciesInformation: SpeciesInformation = { id: input.id ?? uuidv4(), ...input, + // Decode base64 strings from the frontend back into raw bytes + // for storage in the bytea columns. + speciesImage: input.speciesImage + ? Buffer.from(input.speciesImage, 'base64') + : null, + previewImage: input.previewImage + ? Buffer.from(input.previewImage, 'base64') + : null, distributionMapUrl: '', citations: input.citations ?? [], }; @@ -83,7 +117,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/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..9e0248be5 100644 --- a/src/UI/api/queries.ts +++ b/src/UI/api/queries.ts @@ -120,11 +120,12 @@ export const referenceQuery = ( order: string, startId: number | null, endId: number | null, - textFilter: string + textFilter: string, + filterField: string = 'article_title' ) => { return ` query Reference{ - allReferenceData(skip:${skip}, take:${take}, orderBy:"${orderBy}", order:"${order}", startId: ${startId}, endId: ${endId}, textFilter: "${textFilter}") { + allReferenceData(skip:${skip}, take:${take}, orderBy:"${orderBy}", order:"${order}", startId: ${startId}, endId: ${endId}, textFilter: "${textFilter}", filterField: "${filterField}") { items{author article_title journal_title @@ -155,25 +156,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 +258,7 @@ export const speciesInformationById = (id: string) => { speciesImage citations link + previewImage } } `; @@ -233,7 +272,7 @@ export const allSpecies = () => { name shortDescription description - speciesImage + previewImage citations link } diff --git a/src/UI/components/shared/textEditor/RichTextEditor.tsx b/src/UI/components/shared/textEditor/RichTextEditor.tsx index e79b8efca..f354f453c 100644 --- a/src/UI/components/shared/textEditor/RichTextEditor.tsx +++ b/src/UI/components/shared/textEditor/RichTextEditor.tsx @@ -19,10 +19,12 @@ import { $convertToMarkdownString, TRANSFORMERS, } from '@lexical/markdown'; -import { Divider, Typography } from '@mui/material'; +import { 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,10 @@ const DescriptionWatcherPlugin = ({ useEffect(() => { return mergeRegister( - editor.registerUpdateListener(({ editorState }) => { + editor.registerUpdateListener(({ editorState, tags }) => { + if (tags.has(SYNC_TAG)) { + return; + } editorState.read(() => { const markdown = $convertToMarkdownString(TRANSFORMERS); updateHandler(markdown); @@ -48,9 +53,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
; }; @@ -61,6 +69,8 @@ export const TextEditor = (props: { setDescription: (d: string) => void; error?: boolean; helperText?: string; + hideBlockTypeSelector?: boolean; + label?: string; }) => { const editorConfig = { namespace: 'speciesInformation', @@ -86,44 +96,68 @@ export const TextEditor = (props: { return ( -
- -
- - } - placeholder={''} - ErrorBoundary={LexicalErrorBoundary} - /> - - - - - - -
- {props.helperText ? ( - + {props.label ? ( + - {props.helperText} - + + {props.label} + + + ) : null} -
+
+
+ + } + placeholder={''} + ErrorBoundary={LexicalErrorBoundary} + /> + + + + + + +
+ {props.helperText ? ( + + {props.helperText} + + ) : null} +
- + +
); }; diff --git a/src/UI/components/shared/textEditor/ToolbarPlugin.tsx b/src/UI/components/shared/textEditor/ToolbarPlugin.tsx index c6fbf2fc1..54f813cc7 100644 --- a/src/UI/components/shared/textEditor/ToolbarPlugin.tsx +++ b/src/UI/components/shared/textEditor/ToolbarPlugin.tsx @@ -11,7 +11,6 @@ import { $getSelection, $isRangeSelection, FORMAT_TEXT_COMMAND, - LexicalCommand, SELECTION_CHANGE_COMMAND, TextFormatType, } from 'lexical'; @@ -37,7 +36,17 @@ import { useTranslations } from 'next-intl'; const LowPriority = 1; -const EditorToolbar = () => { +// showBlockTypeSelector controls whether the "Normal/Heading/List" dropdown +// renders. compact drops the MUI Toolbar wrapper and shrinks the B/I/U +// buttons so the whole toolbar can sit inline inside a , next to +// the field label, instead of as its own row inside the box. +const EditorToolbar = ({ + showBlockTypeSelector = true, + compact = false, +}: { + showBlockTypeSelector?: boolean; + compact?: boolean; +}) => { const t = useTranslations('RichTextEditor'); const [editor] = useLexicalComposerContext(); const [blockType, setBlockType] = useState('paragraph'); @@ -69,7 +78,6 @@ const EditorToolbar = () => { } } - // Update text format const formats = []; if (selection.hasFormat('bold')) { formats.push('bold'); @@ -137,49 +145,82 @@ const EditorToolbar = () => { editor.dispatchCommand(FORMAT_TEXT_COMMAND, format); }; + // Added: stops the browser's default mousedown behavior on each format + // button. Without this, clicking a button moves focus off the Lexical + // contentEditable *before* onClick fires, which collapses/clears the + // text selection. By the time toggleFormat() runs, there's nothing left + // to format, so the button toggles visually but bold/italic/underline + // never actually gets applied to the text. + const preventFocusLoss = (e: React.MouseEvent) => { + e.preventDefault(); + }; + + const blockTypeSelector = showBlockTypeSelector ? ( + + + + ) : null; + + const formatButtons = ( + + + + + + + + + + + + ); + + if (compact) { + return ( + + {blockTypeSelector} + {formatButtons} + + ); + } + return ( - - - - - - - - - - - - - - + {blockTypeSelector} + {formatButtons} ); }; 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_filters.tsx b/src/UI/components/sources/source_filters.tsx index 8e2245d3f..531028bc6 100644 --- a/src/UI/components/sources/source_filters.tsx +++ b/src/UI/components/sources/source_filters.tsx @@ -1,4 +1,10 @@ -import { Box, TextField } from '@mui/material'; +import { + Box, + MenuItem, + Select, + SelectChangeEvent, + TextField, +} from '@mui/material'; import { debounce } from 'lodash'; import { useCallback, useState } from 'react'; import { useAppDispatch } from '../../state/hooks'; @@ -6,6 +12,7 @@ import { getSourceInfo } from '../../state/source/actions/getSourceInfo'; import { changeFilterId, changeFilterText, + changeFilterField, } from '../../state/source/sourceSlice'; import { useTranslations } from 'next-intl'; import { useRouter } from 'next/router'; @@ -17,6 +24,14 @@ const getEndId = (range: string) => { return parseInt(range.substring(range.indexOf('-') + 1, range.length)); }; +// The value each option maps to must match a real column name that +// reference.service.ts's findReferences can filter against. +const FILTER_FIELD_OPTIONS = [ + { value: 'article_title', labelKey: 'filters.fieldTitle' }, + { value: 'author', labelKey: 'filters.fieldAuthor' }, + { value: 'journal_title', labelKey: 'filters.fieldJournalTitle' }, +]; + export default function SourceFilters(): JSX.Element { const t = useTranslations('SourcesPage'); const dispatch = useAppDispatch(); @@ -25,6 +40,7 @@ export default function SourceFilters(): JSX.Element { const hasNumIds = typeof router.query.num_ids === 'string'; const [idError, setIdError] = useState(false); + const [filterField, setFilterField] = useState('article_title'); const idHandler = useCallback( debounce((value: string) => { @@ -70,22 +86,57 @@ export default function SourceFilters(): JSX.Element { textHandler(event.target.value); }; + const handleFieldChange = (event: SelectChangeEvent) => { + const value = event.target.value; + setFilterField(value); + dispatch(changeFilterField(value)); + dispatch(getSourceInfo()); + }; + return ( + diff --git a/src/UI/components/sources/source_form.tsx b/src/UI/components/sources/source_form.tsx index 84091872b..6cca6ff3f 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; @@ -31,19 +33,28 @@ const schema = yup journal_title: yup.string().required(), year: yup.string().required(), published: yup.boolean().required(), - report_type: yup.string().required(), + report_type: yup.string().notRequired(), v_data: yup.boolean().required(), + num_id: yup.number().notRequired(), }) .required(); -export default function SourceForm() { +interface SourceFormProps { + existingSource?: NewSource | null; +} + +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({ + const { 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 +63,33 @@ export default function SourceForm() { }; const dispatch = useDispatch(); + + useEffect(() => { + if (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) { + // updateSource already shows a success/error toast internally + await dispatch(updateSource(data)); + } else { + const resultAction = await dispatch(postNewSource(data)); + // postNewSource already shows a success/error toast internally; + // we just check the real return value to decide whether to clear the form + if ( + postNewSource.fulfilled.match(resultAction) && + resultAction.payload === true + ) { + reset({ + v_data: false, + published: false, + report_type: DEFAULT_REPORT_TYPE, + }); + setYear(null); + } } }; @@ -74,50 +107,51 @@ export default function SourceForm() {
onSubmit(d))}>
- {t('title')} + {isEditMode ? t('editTitle') : t('title')}
+
( - + helperText={error ? t('authorHelperText') : undefined} + hideBlockTypeSelector + /> )} rules={{ required: 'Author required' }} />
-
+
( - + helperText={error ? t('articleTitleHelperText') : undefined} + hideBlockTypeSelector + /> )} rules={{ required: 'Article Title required' }} /> @@ -129,18 +163,18 @@ export default function SourceForm() { name="journal_title" control={control} render={({ - field: { onChange, value }, + field: { value, onChange }, fieldState: { error }, }) => ( - + helperText={error ? t('journalTitleHelperText') : undefined} + hideBlockTypeSelector + /> )} rules={{ required: 'Journal Title required' }} /> @@ -157,11 +191,11 @@ export default function SourceForm() { }) => ( + /> )} rules={{ required: 'Citation required' }} /> @@ -217,6 +251,7 @@ export default function SourceForm() { }) => ( + /> )} - rules={{ required: 'Report Type required' }} />

@@ -241,9 +274,9 @@ export default function SourceForm() { control={ onChange(e.target.checked)} color="primary" size="medium" - {...register('published')} /> } label={t('published')} @@ -263,9 +296,9 @@ export default function SourceForm() { control={ onChange(e.target.checked)} color="primary" size="medium" - {...register('v_data')} /> } label={t('vectorData')} @@ -276,7 +309,7 @@ export default function SourceForm() {

+ + + )} ))} @@ -155,6 +219,33 @@ export default function SourceTable(): JSX.Element { onRowsPerPageChange={handleChangeRowsPerPage} /> )} + + {canEdit && ( + + {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..06f97f810 --- /dev/null +++ b/src/UI/components/species/SpeciesImageViewer.tsx @@ -0,0 +1,253 @@ +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 }); + + // previewRef holds the WebP preview (generated server-side from the + // original JPEG), so it needs the correct MIME type when resolved to + // a data URI — otherwise the browser may fail to render it correctly. + const imageUrl = resolveSpeciesImageUrl(previewRef, 'image/webp'); + 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. + // downloadRef is the original full-size JPEG, not the WebP preview. + if (downloadRef) { + await downloadSpeciesImage(downloadRef, speciesName, 'image/jpeg'); + } 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..5bb5f7590 100644 --- a/src/UI/components/species/speciesList.tsx +++ b/src/UI/components/species/speciesList.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useCallback } from 'react'; import { Button, Grid, @@ -20,6 +20,7 @@ import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import ReactMarkdown from 'react-markdown'; import { useTranslations } from 'next-intl'; import { getAllSpecies } from '../../state/speciesInformation/actions/getAllSpecies'; +import SpeciesImageViewer from './SpeciesImageViewer'; export default function SpeciesList(): JSX.Element { const t = useTranslations('SpeciesPage'); @@ -38,10 +39,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 +55,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 +135,16 @@ export default function SpeciesList(): JSX.Element { justifyContent: 'center', }} > - - Mosquito Species #1 - +
@@ -188,7 +185,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 +199,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,10 @@ 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). 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 +125,15 @@ const SpeciesInformationEditor = () => { setSubsections([]); } setSpeciesImage(currentSpeciesInformation.speciesImage); + setPreviewImage(currentSpeciesInformation.previewImage); setLink(currentSpeciesInformation.link); + } + }, [currentSpeciesInformation]); + // Citation matching still depends 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 +152,6 @@ const SpeciesInformationEditor = () => { } }, [currentSpeciesInformation, sources.items]); - // Early return AFTER all hooks if (loadingSpeciesInformation) { return (
@@ -145,11 +160,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: '' }]); @@ -169,11 +184,26 @@ const SpeciesInformationEditor = () => { setSubsections(updated); }; + // Uploads a JPEG species image. The backend no longer stores this + // anywhere external (no Azure) — it just validates the file, generates + // a WebP preview, and hands back both as base64 strings. Those base64 + // strings are held here in local state and only actually persisted + // when the form is submitted via saveSpeciesInformation, which sends + // them to createEditSpeciesInformation to be decoded and saved + // directly into the species_information table's bytea columns. 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; + } + + // Only JPEG is accepted here, matching what the backend expects. + 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 +211,23 @@ const SpeciesInformationEditor = () => { toast.error(error, { autoClose: 5000, }); + return; + } + + try { + setUploadingImage(true); + const result = await uploadSpeciesImageAuthenticated( + file, + token?.toString() + ); + setSpeciesImage(result.imageBase64); + setPreviewImage(result.previewBase64); + 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 +240,16 @@ const SpeciesInformationEditor = () => { return (
+ + {id ? t('speciesInformationEditor.edit') @@ -283,17 +340,19 @@ const SpeciesInformationEditor = () => { sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }} > @@ -302,24 +361,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 +491,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/species/details.tsx b/src/UI/pages/species/details.tsx index 82ed04d9f..08b184079 100644 --- a/src/UI/pages/species/details.tsx +++ b/src/UI/pages/species/details.tsx @@ -18,6 +18,8 @@ import { getMessages } from '../../utils/localization'; import { GetServerSidePropsContext } from 'next'; import { getSourceInfo } from '../../state/source/actions/getSourceInfo'; import { getOccurrenceData } from '../../state/map/actions/getOccurrenceData'; +import SpeciesImageViewer from '../../components/species/SpeciesImageViewer'; + export default function SpeciesDetails() { const router = useRouter(); const dispatch = useAppDispatch(); @@ -133,14 +135,12 @@ export default function SpeciesDetails() {
- Mosquito Species #1
- {/* - Distribution Map - - */} 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..88a6cbcb0 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,60 +523,87 @@ "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" - } - } - }, - "SourcesPage": { - "title": "Source List", - "grid": { - "id": "ID", - "author": "Author", - "title": "Title", - "journalTitle": "Journal Title", - "year": "Year", - "published": "Published", - "vectorData": "Vector Data" - }, - "filters": { - "errorMsg": "Please enter range (e.g. 100-200)", - "idFilter": "Filter by id (e.g. 100-200)", - "titleFilter": "Filter by Title" + "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", + "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" + }, + "filters": { + "titleFilter": "Filter by Title", + "fieldTitle": "Title", + "fieldAuthor": "Author", + "fieldJournalTitle": "Journal Title", + "searchPlaceholder": "Search..." + }, + "grid": { + "author": "Author", + "title": "Title", + "journalTitle": "Journal Title", + "year": "Year", + "actions": "Actions" + } +}, "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", - "citation": "Citation", + "journalTitle": "Journal Title", + + "journalTitleHelperText": "Journal Title is a required field", + "citation": " DOI Citation", "citationHelperText": "Article Title is a required field", "year": "Year", "yearHelperText": "Year is a required field", @@ -585,10 +612,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 +831,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": { @@ -893,11 +925,14 @@ } }, "Source": { - "createSuccess": "Reference created with id {id}", - "errors": { - "createError": "Unknown error in creating new reference. Please try again.", - "duplicateSource": "Reference with title {article_title} already exists" - } + "createSuccess": "Reference created with id {id}", + "updateSuccess": "Reference {id} updated successfully", + "errors": { + "createError": "Unknown error in creating new reference. Please try again.", + "duplicateSource": "Reference with title {article_title} already exists", + "updateError": "Unknown error updating reference. Please try again." + } + }, "SpeciesInformation": { "updateSuccess": "Updated species information with id {id}", @@ -941,4 +976,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..8c4da172e 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,60 +517,85 @@ "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" - } - } - }, - "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" + "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 des sources", + "filters": { + "titleFilter": "Filtrer par titre", + "fieldTitle": "Titre", + "fieldAuthor": "Auteur", + "fieldJournalTitle": "Titre du journal", + "searchPlaceholder": "Rechercher..." + }, + "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", - "citation": "Citation", + "journalTitle": "Titre du journal", + "journalTitleHelperText": "Le titre du journal est un champ requis", + "citation": " DOI Citation", "citationHelperText": "Le titre de l'article est un champ requis", "year": "Année", "yearHelperText": "L'année est un champ obligatoire", @@ -580,7 +605,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 +815,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": { @@ -879,13 +908,15 @@ "approveError": "Quelque chose a mal tourné avec l'approbation de l'ensemble de données. " } }, - "Source": { - "createSuccess": "Référence créée avec id {id}", - "errors": { - "createError": "Erreur inconnue dans la création de nouvelles références. ", - "duplicateSource": "Référence avec le titre {article_title} existe déjà" - } - }, + "Source": { + "createSuccess": "Référence créée avec id {id}", + "updateSuccess": "Référence {id} mise à jour avec succès", + "errors": { + "createError": "Erreur inconnue dans la création de nouvelles références. ", + "duplicateSource": "Référence avec le titre {article_title} existe déjà", + "updateError": "Erreur inconnue lors de la mise à jour de la référence. Veuillez réessayer." + } +}, "SpeciesInformation": { "updateSuccess": "Informations sur les espèces mises à jour avec id {id}", "createSuccess": "Informations sur les nouvelles espèces créées avec id {id}", @@ -928,4 +959,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..972610ed0 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,58 +519,81 @@ }, "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", + "fieldTitle": "Título", + "fieldAuthor": "Autor", + "fieldJournalTitle": "Título do periódico", + "searchPlaceholder": "Pesquisar..." + }, + "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", - "citation": "Citação", + "journalTitle": "Título do periódico", + "journalTitleHelperText": "Título do periódico é um campo necessário", + "citation": " DOI Citação", "citationHelperText": "O título do artigo é um campo necessário", "year": "Ano", "yearHelperText": "Ano é um campo necessário", @@ -580,7 +603,10 @@ "vectorData": "Dados vetoriais", "buttons": { "submit": "Enviar", - "reset": "Reiniciar" + "reset": "Reiniciar", + "update": "Atualizar", + "back": "Voltar à lista de fontes" + } }, "AdminPage": { @@ -788,7 +814,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": { @@ -880,12 +908,14 @@ } }, "Source": { - "createSuccess": "Referência criada com id {id}", - "errors": { - "createError": "Erro desconhecido na criação de uma nova referência. ", - "duplicateSource": "Referência com o título {artigo_title} já existe" - } - }, + "createSuccess": "Referência criada com id {id}", + "updateSuccess": "Referência {id} atualizada com sucesso", + "errors": { + "createError": "Erro desconhecido na criação de uma nova referência. ", + "duplicateSource": "Referência com o título {article_title} já existe", + "updateError": "Erro desconhecido ao atualizar a referência. Tente novamente." + } +}, "SpeciesInformation": { "updateSuccess": "Informações sobre espécies atualizadas com id {id}", "createSuccess": "Informações de novas espécies criadas com id {id}", @@ -928,4 +958,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/getSourceInfo.ts b/src/UI/state/source/actions/getSourceInfo.ts index 8a7814c3c..872e70244 100644 --- a/src/UI/state/source/actions/getSourceInfo.ts +++ b/src/UI/state/source/actions/getSourceInfo.ts @@ -6,9 +6,16 @@ import { AppState } from '../../store'; export const getSourceInfo = createAsyncThunk( 'source/getSourceInfo', async (_, { getState }) => { - const { page, rowsPerPage, orderBy, order, startId, endId, textFilter } = ( - getState() as AppState - ).source.source_table_options; + const { + page, + rowsPerPage, + orderBy, + order, + startId, + endId, + textFilter, + filterField, + } = (getState() as AppState).source.source_table_options; const skip = page * rowsPerPage; const sourceInfo = await fetchGraphQlData( referenceQuery( @@ -18,7 +25,8 @@ export const getSourceInfo = createAsyncThunk( order.toLocaleUpperCase(), startId, endId, - textFilter + textFilter, + filterField ) ); return sourceInfo.data.allReferenceData; 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..4d479e2cc 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; @@ -9,10 +11,7 @@ export interface Source { journal_title: string; citation: string; year: number; - //published: boolean; report_type: string; - //v_data: boolean; - //num_id: number; } export interface SourceState { @@ -21,6 +20,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 +32,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, @@ -38,6 +43,10 @@ export const initialState: SourceState = { startId: 0, endId: null, textFilter: '', + // Which column the text filter searches against. Defaults to the + // previous hardcoded behavior (article_title) so nothing breaks for + // anyone not yet using the new field-picker dropdown. + filterField: 'article_title', }, }; @@ -68,18 +77,44 @@ export const sourceSlice = createSlice({ changeFilterText(state, action: PayloadAction) { state.source_table_options.textFilter = action.payload; }, + changeFilterField(state, action: PayloadAction) { + state.source_table_options.filterField = 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 +125,7 @@ export const { changeSort, changeFilterId, changeFilterText, + changeFilterField, + 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/utils/speciesImageUtils.ts b/src/UI/utils/speciesImageUtils.ts new file mode 100644 index 000000000..4d1e67dbf --- /dev/null +++ b/src/UI/utils/speciesImageUtils.ts @@ -0,0 +1,151 @@ +// Turns whatever is stored (a full URL, a bare filename, a data URL, +// raw base64 image data, etc.) into something an can use +// directly. +export function resolveSpeciesImageUrl( + imageRef?: string, + mimeType: string = 'image/jpeg' +): 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; + } + + // Raw base64 image data (no prefix at all) — this is what + // speciesImage/previewImage now contain since images are stored + // directly in the database rather than referenced by filename/URL. + // Wrap it as a data URI rather than treating it as a path fragment, + // which previously produced an unusably long URL. + const isLikelyBase64 = /^[A-Za-z0-9+/]+={0,2}$/.test(imageRef.slice(0, 100)); + if (isLikelyBase64) { + return `data:${mimeType};base64,${imageRef}`; + } + + return `/vector-api/species-information/images/${imageRef}`; +} + +export function getSpeciesImageDownloadUrl( + imageRef?: string, + speciesName?: string, + mimeType: string = 'image/jpeg' +): string { + const resolvedUrl = resolveSpeciesImageUrl(imageRef, mimeType); + 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, + mimeType: string = 'image/jpeg' +): Promise { + const resolvedUrl = resolveSpeciesImageUrl(imageRef, mimeType); + 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, + mimeType + ); + 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 streams back the actual image bytes directly. +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}`); + } +} From a89dc3ae0a135c91d8a21d0c003c7c635a11a230 Mon Sep 17 00:00:00 2001 From: IndianaEunice Date: Fri, 31 Jul 2026 12:02:19 +0300 Subject: [PATCH 2/5] Sync API package-lock.json with sharp dependency --- src/API/package-lock.json | 654 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 622 insertions(+), 32 deletions(-) diff --git a/src/API/package-lock.json b/src/API/package-lock.json index a1ed29cef..13ea06b6e 100644 --- a/src/API/package-lock.json +++ b/src/API/package-lock.json @@ -57,6 +57,7 @@ "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^7.2.0", + "sharp": "^0.35.3", "typeorm": "^0.3.7", "uuid": "^8.3.2", "winston": "^3.17.0", @@ -1015,7 +1016,6 @@ "version": "7.24.9", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.9.tgz", "integrity": "sha512-5e3FI4Q3M3Pbr21+5xJwCv6ZT6KmGkI0vw3Tozy5ODAQFTIWe37iT8Cr7Ice2Ntb+M3iSKCEWMB1MBgKrW3whg==", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.7", @@ -1684,6 +1684,16 @@ "kuler": "^2.0.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.11.tgz", @@ -2276,6 +2286,554 @@ "deprecated": "Use @eslint/object-schema instead", "dev": true }, + "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/@ioredis/commands": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", @@ -3220,7 +3778,6 @@ "version": "10.4.1", "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.1.tgz", "integrity": "sha512-4CkrDx0s4XuWqFjX8WvOFV7Y6RGJd0P2OBblkhZS7nwoctoSuW5pyEa8SWak6YHNGrHRpFb6ymm5Ai4LncwRVA==", - "peer": true, "dependencies": { "iterare": "1.2.1", "tslib": "2.6.3", @@ -3264,7 +3821,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.3.10.tgz", "integrity": "sha512-ZbQ4jovQyzHtCGCrzK5NdtW1SYO2fHSsgSY1+/9WdruYCUra+JDkWEXgZ4M3Hv480Dl3OXehAmY1wCOojeMyMQ==", "hasInstallScript": true, - "peer": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", @@ -3301,7 +3857,6 @@ "version": "12.2.0", "resolved": "https://registry.npmjs.org/@nestjs/graphql/-/graphql-12.2.0.tgz", "integrity": "sha512-du/aI+EXADxtJrHF1mAXR6RYRHuEWPNnJyHTmIOPW2Wx5qN32P7lQoHGD7TySATMl5aa47w05lPzxcasdUmpMQ==", - "peer": true, "dependencies": { "@graphql-tools/merge": "9.0.4", "@graphql-tools/schema": "10.0.4", @@ -3410,7 +3965,6 @@ "version": "10.3.10", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.3.10.tgz", "integrity": "sha512-wK2ow3CZI2KFqWeEpPmoR300OB6BcBLxARV1EiClJLCj4S1mZsoCmS0YWgpk3j1j6mo0SI8vNLi/cC2iZPEPQA==", - "peer": true, "dependencies": { "body-parser": "1.20.2", "cors": "2.8.5", @@ -4307,7 +4861,6 @@ "resolved": "https://registry.npmjs.org/@types/jest/-/jest-27.5.2.tgz", "integrity": "sha512-mpT8LJJ4CMeeahobofYWIjFo0xonRS/HfxnVEPMPFSQdGUt1uHCnoPT7Zhb+sjDU2wz0oKV0OLUR0WzrHNgfeA==", "dev": true, - "peer": true, "dependencies": { "jest-matcher-utils": "^27.0.0", "pretty-format": "^27.0.0" @@ -4362,7 +4915,6 @@ "version": "18.19.42", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.42.tgz", "integrity": "sha512-d2ZFc/3lnK2YCYhos8iaNIYu9Vfhr92nHiyJHRltXWjXUBjEE+A4I58Tdbnw4VhggSW+2j5y5gTrLs4biNnubg==", - "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -4529,7 +5081,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -4918,7 +5469,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "devOptional": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4970,7 +5520,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5601,7 +6150,6 @@ "version": "1.7.9", "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", - "peer": true, "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -5892,7 +6440,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001640", "electron-to-chromium": "^1.4.820", @@ -5991,7 +6538,6 @@ "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.71.1.tgz", "integrity": "sha512-kOBfdcsHmO6wwmIjpersoVdYQ7jkjTgky4Yop0loc7QwSdgxliSzD69U9ijZuRrkyCJwz5p5eqxeGeQkJ0YGZQ==", "license": "MIT", - "peer": true, "dependencies": { "cron-parser": "4.9.0", "ioredis": "5.10.1", @@ -6275,14 +6821,12 @@ "node_modules/class-transformer": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", - "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "peer": true + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==" }, "node_modules/class-validator": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.2.tgz", "integrity": "sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==", - "peer": true, "dependencies": { "@types/validator": "^13.11.8", "libphonenumber-js": "^1.11.1", @@ -7050,7 +7594,6 @@ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=8" } @@ -7535,7 +8078,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7964,7 +8506,6 @@ "version": "4.19.2", "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -8739,7 +9280,6 @@ "version": "16.9.0", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.9.0.tgz", "integrity": "sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==", - "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -9668,7 +10208,6 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-28.0.3.tgz", "integrity": "sha512-uS+T5J3w5xyzd1KSJCGKhCo8WTJXbNl86f5SW11wgssbandJOVLRKKUxmhdFfmKxhPeksl1hHZ0HaA8VBzp7xA==", "dev": true, - "peer": true, "dependencies": { "@jest/core": "^28.0.3", "import-local": "^3.0.2", @@ -12307,7 +12846,6 @@ "version": "6.9.16", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.16.tgz", "integrity": "sha512-psAuZdTIRN08HKVd/E8ObdV6NO7NTBY3KsC30F7M4H1OnmLCUNaS56FpYxyb26zWLSyYF9Ozch9KYHhHegsiOQ==", - "peer": true, "engines": { "node": ">=6.0.0" } @@ -12710,7 +13248,6 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/passport/-/passport-0.6.0.tgz", "integrity": "sha512-0fe+p3ZnrWRW74fe8+SvCyf4a3Pb2/h7gFkQ8yTJpAO50gDzlfjZUZTO1k5Eg9kUct22OxHLqDZoKUWRHOh9ug==", - "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -12822,7 +13359,6 @@ "version": "8.12.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.12.0.tgz", "integrity": "sha512-A+LHUSnwnxrnL/tZ+OLfqR1SxLN3c/pgDztZ47Rpbsd4jUytsTtwQo/TLPRzPJMp/1pbhYVhH9cuSZLAajNfjQ==", - "peer": true, "dependencies": { "pg-connection-string": "^2.6.4", "pg-pool": "^3.6.2", @@ -13108,7 +13644,6 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "dev": true, - "peer": true, "bin": { "prettier": "bin-prettier.js" }, @@ -13503,7 +14038,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -14126,7 +14660,6 @@ "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -14180,6 +14713,7 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -14327,6 +14861,67 @@ "sha.js": "bin.js" } }, + "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/sharp/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/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -15294,7 +15889,6 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -15470,7 +16064,6 @@ "version": "0.3.20", "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.20.tgz", "integrity": "sha512-sJ0T08dV5eoZroaq9uPKBoNcGslHBR4E4y+EBHs//SiGbblGe7IeduP/IH4ddCcj0qp3PHwDwGnuvqEAnKlq/Q==", - "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "app-root-path": "^3.1.0", @@ -15631,7 +16224,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "devOptional": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16005,7 +16597,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.82.1.tgz", "integrity": "sha512-C6uiGQJ+Gt4RyHXXYt+v9f+SN1v83x68URwgxNQ98cvH8kxiuywWGP4XeNZ1paOzZ63aY3cTciCEQJNFUljlLw==", "dev": true, - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.0", @@ -16169,7 +16760,6 @@ "version": "3.17.0", "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", - "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.2", From 616ffa8269d5abc92f46538084ec763e042a1817 Mon Sep 17 00:00:00 2001 From: IndianaEunice Date: Fri, 31 Jul 2026 12:15:11 +0300 Subject: [PATCH 3/5] Sync UI package-lock.json with package.json --- src/UI/package-lock.json | 40 ++++++---------------------------------- 1 file changed, 6 insertions(+), 34 deletions(-) diff --git a/src/UI/package-lock.json b/src/UI/package-lock.json index 16d08d5c5..6220f1a7c 100644 --- a/src/UI/package-lock.json +++ b/src/UI/package-lock.json @@ -148,7 +148,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -733,7 +732,6 @@ "version": "11.14.0", "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -793,7 +791,6 @@ "version": "11.14.1", "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1582,7 +1579,6 @@ "version": "0.6.5", "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.6.5.tgz", "integrity": "sha512-yQtiKJUsvS5oftJIj5VlNA0dKsDvzpeIQTRQCVFfcwboAnwTF9VmLVW54UlDsaiW7ZeIOTXj4BgEaFdfpPkFLA==", - "peer": true, "dependencies": { "@lexical/html": "0.6.5", "@lexical/list": "0.6.5", @@ -1767,7 +1763,6 @@ "version": "0.6.5", "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.6.5.tgz", "integrity": "sha512-PvDLxbnHCDET/9UQp1Od4R5wakc6GgTeIKPxkfMyw9eF+vr8xFELvWvOadfhcCb+ydp5IMqqsSZ7eSCl8wFODg==", - "peer": true, "peerDependencies": { "lexical": "0.6.5" } @@ -1795,7 +1790,6 @@ "version": "0.6.5", "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.6.5.tgz", "integrity": "sha512-G/PBON7SeGoKs7yYbyLNtJE7CltxuXHWfw7F9vUk0avCzoSTrBeMNkmIOhnyp8XPuT1/5hgNWP8IG7kMsgozEg==", - "peer": true, "dependencies": { "@lexical/list": "0.6.5", "@lexical/table": "0.6.5" @@ -1997,7 +1991,6 @@ "version": "5.18.0", "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.18.0.tgz", "integrity": "sha512-bbH/HaJZpFtXGvWg3TsBWG4eyt3gah3E7nCNU8GLyRjVoWcA91Vm/T+sjHfUcwgJSw9iLtucfHBoq+qW/T30aA==", - "peer": true, "dependencies": { "@babel/runtime": "^7.23.9", "@mui/core-downloads-tracker": "^5.18.0", @@ -2100,7 +2093,6 @@ "version": "5.18.0", "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.18.0.tgz", "integrity": "sha512-ojZGVcRWqWhu557cdO3pWHloIGJdzVtxs3rk0F9L+x55LsUjcMUVkEhiF7E4TMxZoF9MmIHGGs0ZX3FDLAf0Xw==", - "peer": true, "dependencies": { "@babel/runtime": "^7.23.9", "@mui/private-theming": "^5.17.1", @@ -3244,8 +3236,7 @@ "version": "18.0.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.0.1.tgz", "integrity": "sha512-CmR8+Tsy95hhwtZBKJBs0/FFq4XX7sDZHlGGf+0q+BRZfMbOTkzkj0AFAuTyXbObDIoanaBBW0+KEW+m3N16Wg==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@types/pako": { "version": "2.0.4", @@ -3288,7 +3279,6 @@ "version": "18.0.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.14.tgz", "integrity": "sha512-x4gGuASSiWmo0xjDLpm5mPb52syZHJx02VKbqUKdLmKtAwIh63XClGsiTI1K6DO5q7ox4xAsQrU+Gl3+gGXF9Q==", - "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -3312,7 +3302,6 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.0.5.tgz", "integrity": "sha512-OWPWTUrY/NIrjsAPkAk1wW9LZeIjSvkXRhclsFO8CZcZGCOg2G0YZy4ft+rOyYxy8B7ui5iZzi9OkDebZ7/QSA==", "devOptional": true, - "peer": true, "dependencies": { "@types/react": "*" } @@ -3467,7 +3456,6 @@ "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -3622,7 +3610,6 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4208,7 +4195,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -4800,7 +4786,6 @@ "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", - "peer": true, "dependencies": { "@babel/runtime": "^7.21.0" }, @@ -5379,7 +5364,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.19.0.tgz", "integrity": "sha512-SXOPj3x9VKvPe81TjjUJCYlV4oJjQw68Uek+AM0X4p+33dj2HY5bpTZOgnQHcG2eAm1mtCU9uNMnJi7exU/kYw==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", - "peer": true, "dependencies": { "@eslint/eslintrc": "^1.3.0", "@humanwhocodes/config-array": "^0.9.2", @@ -5557,7 +5541,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7353,6 +7336,7 @@ "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", "license": "MIT", + "peer": true, "funding": { "type": "GitHub Sponsors ❤", "url": "https://github.com/sponsors/dmonad" @@ -7464,7 +7448,6 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-28.1.3.tgz", "integrity": "sha512-N4GT5on8UkZgH0O5LUavMRV1EDEhNTL0KEfRmDIeZHSV7p2XgLoY9t9VDUgL6o+yfdgYHVxuz81G8oB9VG5uyA==", "dev": true, - "peer": true, "dependencies": { "@jest/core": "^28.1.3", "@jest/types": "^28.1.3", @@ -8805,8 +8788,7 @@ "node_modules/leaflet": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", - "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", - "peer": true + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==" }, "node_modules/lerc": { "version": "3.0.0", @@ -8837,14 +8819,14 @@ "node_modules/lexical": { "version": "0.6.5", "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.6.5.tgz", - "integrity": "sha512-DV4/lw/CX5XLJ2qdHurkn/KjjvMw83EJidvH7Os/ub61yoaffMQxovPvsfzyOHSZ3/V+GBFCGF2hLAaaB1s37g==", - "peer": true + "integrity": "sha512-DV4/lw/CX5XLJ2qdHurkn/KjjvMw83EJidvH7Os/ub61yoaffMQxovPvsfzyOHSZ3/V+GBFCGF2hLAaaB1s37g==" }, "node_modules/lib0": { "version": "0.2.117", "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", "license": "MIT", + "peer": true, "dependencies": { "isomorphic.js": "^0.2.4" }, @@ -9684,7 +9666,6 @@ "version": "12.3.4", "resolved": "https://registry.npmjs.org/next/-/next-12.3.4.tgz", "integrity": "sha512-VcyMJUtLZBGzLKo3oMxrEF0stxh8HwuW976pAzlHhI3t8qJ4SROjCrSh1T24bhrbjw55wfZXAbXPGwPt5FLRfQ==", - "peer": true, "dependencies": { "@next/env": "12.3.4", "@swc/helpers": "0.4.11", @@ -10338,7 +10319,6 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "dev": true, - "peer": true, "bin": { "prettier": "bin-prettier.js" }, @@ -10546,7 +10526,6 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -10587,7 +10566,6 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -10631,7 +10609,6 @@ "version": "7.66.0", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -10646,8 +10623,7 @@ "node_modules/react-is": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz", - "integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==", - "peer": true + "integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==" }, "node_modules/react-leaflet": { "version": "4.2.1", @@ -10737,7 +10713,6 @@ "version": "8.1.3", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz", "integrity": "sha512-n0ZrutD7DaX/j9VscF+uTALI3oUPa/pO4Z3soOBIjuRn/FzVu6aehhysxZCLi6y7duMf52WNZGMl7CtuK5EnRw==", - "peer": true, "dependencies": { "@babel/runtime": "^7.12.1", "@types/hoist-non-react-statics": "^3.3.1", @@ -10913,7 +10888,6 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", - "peer": true, "dependencies": { "@babel/runtime": "^7.9.2" } @@ -12097,7 +12071,6 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -12314,7 +12287,6 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" From 9a68e87e6dfd51f5f44c1ba75c328ea35f91a2fa Mon Sep 17 00:00:00 2001 From: IndianaEunice Date: Fri, 31 Jul 2026 12:34:43 +0300 Subject: [PATCH 4/5] updatexd species fix --- .../src/db/shared/reference.resolver.test.ts | 1 + src/API/src/schema.gql | 20 ++++++++++++++++--- .../speciesInformationEditor.tsx | 7 +++++-- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/API/src/db/shared/reference.resolver.test.ts b/src/API/src/db/shared/reference.resolver.test.ts index 60c71bb6c..da20a9e52 100644 --- a/src/API/src/db/shared/reference.resolver.test.ts +++ b/src/API/src/db/shared/reference.resolver.test.ts @@ -37,6 +37,7 @@ describe('Reference resolver', () => { startId: NaN, endId: 100, textFilter: 'filter', + filterField: 'author', }); expect(referenceService.findReferences).toHaveBeenCalledWith( diff --git a/src/API/src/schema.gql b/src/API/src/schema.gql index 4a4ed955a..b9ad01070 100644 --- a/src/API/src/schema.gql +++ b/src/API/src/schema.gql @@ -88,8 +88,9 @@ input CreateSpeciesInformationInput { id: String link: String! name: String! + previewImage: String shortDescription: String! - speciesImage: String! + speciesImage: String } """doi""" @@ -199,6 +200,7 @@ type Mutation { deleteSpeciesInformation(id: String!): Boolean! disableNotifications(disable: Boolean!, userId: String!): Boolean! requestRoles(email: String!, requestReason: String!, rolesRequested: [String!]!): Boolean! + updateReference(input: UpdateReferenceInput!, num_id: Int!): Reference! updateUserRoles(input: UserRoleInput!): UserRole! } @@ -309,7 +311,7 @@ type Query { allDoisByStatus(status: String!): [DOI!]! allGeoData: Bionomics! allNews: [News!]! - allReferenceData(endId: Float = null, order: String = "asc", orderBy: String = "num_id", skip: Float = 0, startId: Float = 1, take: Float = 1, textFilter: String = ""): PaginatedReferenceData! + allReferenceData(endId: Float = null, filterField: String = "article_title", order: String = "asc", orderBy: String = "num_id", skip: Float = 0, startId: Float = 1, take: Float = 1, textFilter: String = ""): PaginatedReferenceData! allSpeciesInformation: [SpeciesInformation!]! allUploadedDatasets: [UploadedDataset!] allUploadedModels: [UploadedModel!] @@ -412,8 +414,20 @@ type SpeciesInformation { id: String! link: String name: String! + previewImage: String shortDescription: String! - speciesImage: String! + speciesImage: String +} + +input UpdateReferenceInput { + article_title: String! + author: String! + citation: String! + journal_title: String! + published: Boolean! + report_type: String! + v_data: Boolean! + year: Float! } """uploaded dataset""" diff --git a/src/UI/components/speciesInformation/speciesInformationEditor.tsx b/src/UI/components/speciesInformation/speciesInformationEditor.tsx index 2d5cc803e..12b9f9a71 100644 --- a/src/UI/components/speciesInformation/speciesInformationEditor.tsx +++ b/src/UI/components/speciesInformation/speciesInformationEditor.tsx @@ -70,7 +70,6 @@ const SpeciesInformationEditor = () => { shortDescription, description: JSON.stringify(subsections), speciesImage, - previewImage, citations: selectedCitations.map((c) => c.num_id), link: species, }; @@ -125,7 +124,11 @@ const SpeciesInformationEditor = () => { setSubsections([]); } setSpeciesImage(currentSpeciesInformation.speciesImage); - setPreviewImage(currentSpeciesInformation.previewImage); + setPreviewImage( + (currentSpeciesInformation as SpeciesInformation & { + previewImage?: string; + }).previewImage || '' + ); setLink(currentSpeciesInformation.link); } }, [currentSpeciesInformation]); From d80132c05d259dfcf55cd14fd70f8ed982afd83f Mon Sep 17 00:00:00 2001 From: IndianaEunice Date: Fri, 31 Jul 2026 13:10:07 +0300 Subject: [PATCH 5/5] update fix on species-fix and sourcelist --- .../speciesInformation/speciesInformationEditor.tsx | 9 ++++++--- .../state/speciesInformation/speciesInformationSlice.ts | 1 + src/UI/state/state.types.ts | 2 ++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/UI/components/speciesInformation/speciesInformationEditor.tsx b/src/UI/components/speciesInformation/speciesInformationEditor.tsx index 12b9f9a71..e26239eb7 100644 --- a/src/UI/components/speciesInformation/speciesInformationEditor.tsx +++ b/src/UI/components/speciesInformation/speciesInformationEditor.tsx @@ -70,6 +70,7 @@ const SpeciesInformationEditor = () => { shortDescription, description: JSON.stringify(subsections), speciesImage, + previewImage, citations: selectedCitations.map((c) => c.num_id), link: species, }; @@ -125,9 +126,11 @@ const SpeciesInformationEditor = () => { } setSpeciesImage(currentSpeciesInformation.speciesImage); setPreviewImage( - (currentSpeciesInformation as SpeciesInformation & { - previewImage?: string; - }).previewImage || '' + ( + currentSpeciesInformation as SpeciesInformation & { + previewImage?: string; + } + ).previewImage || '' ); setLink(currentSpeciesInformation.link); } diff --git a/src/UI/state/speciesInformation/speciesInformationSlice.ts b/src/UI/state/speciesInformation/speciesInformationSlice.ts index 8cc27da09..a7081e76d 100644 --- a/src/UI/state/speciesInformation/speciesInformationSlice.ts +++ b/src/UI/state/speciesInformation/speciesInformationSlice.ts @@ -31,6 +31,7 @@ export const initialState: () => SpeciesInformationState = () => ({ startId: 0, endId: null, textFilter: '', + filterField: 'name', }, }); diff --git a/src/UI/state/state.types.ts b/src/UI/state/state.types.ts index 5f07f61ee..52977119c 100644 --- a/src/UI/state/state.types.ts +++ b/src/UI/state/state.types.ts @@ -37,6 +37,7 @@ export type SpeciesInformation = { shortDescription: string; description: string; speciesImage: string; + previewImage: string; citations: string[]; link: string; }; @@ -49,6 +50,7 @@ export type FilterSort = { startId: number | null; endId: number | null; textFilter: string; + filterField: string; }; export type News = {