diff --git a/backend/src/modules/pets/dto/reorder-photos.dto.ts b/backend/src/modules/pets/dto/reorder-photos.dto.ts new file mode 100644 index 00000000..ef214a15 --- /dev/null +++ b/backend/src/modules/pets/dto/reorder-photos.dto.ts @@ -0,0 +1,7 @@ +import { IsArray, IsUUID } from 'class-validator'; + +export class ReorderPhotosDto { + @IsArray() + @IsUUID('4', { each: true }) + photoIds: string[]; +} diff --git a/backend/src/modules/pets/entities/pet-photo.entity.ts b/backend/src/modules/pets/entities/pet-photo.entity.ts index 12b7d3a0..e8bc84bc 100644 --- a/backend/src/modules/pets/entities/pet-photo.entity.ts +++ b/backend/src/modules/pets/entities/pet-photo.entity.ts @@ -24,9 +24,36 @@ export class PetPhoto { @Column() photoUrl: string; + @Column({ nullable: true }) + thumbnailUrl: string; + + @Column({ nullable: true }) + storageKey: string; + + @Column({ nullable: true }) + thumbnailStorageKey: string; + @Column({ default: false }) isPrimary: boolean; + @Column({ type: 'int', default: 0 }) + displayOrder: number; + + @Column({ nullable: true }) + mimeType: string; + + @Column({ type: 'int', nullable: true }) + fileSize: number; + + @Column({ type: 'int', nullable: true }) + width: number; + + @Column({ type: 'int', nullable: true }) + height: number; + + @Column({ nullable: true }) + originalFilename: string; + @Column('jsonb', { nullable: true }) facialRecognitionData: Record; diff --git a/backend/src/modules/pets/pet-photos.controller.ts b/backend/src/modules/pets/pet-photos.controller.ts new file mode 100644 index 00000000..42701a46 --- /dev/null +++ b/backend/src/modules/pets/pet-photos.controller.ts @@ -0,0 +1,74 @@ +import { + Controller, + Get, + Post, + Patch, + Put, + Delete, + Param, + Body, + HttpCode, + HttpStatus, + UseInterceptors, + UploadedFiles, + ParseUUIDPipe, + BadRequestException, +} from '@nestjs/common'; +import { FilesInterceptor } from '@nestjs/platform-express'; +import { PetPhotosService } from './pet-photos.service'; +import { ReorderPhotosDto } from './dto/reorder-photos.dto'; +import { PetPhoto } from './entities/pet-photo.entity'; + +@Controller('pets/:petId/photos') +export class PetPhotosController { + constructor(private readonly petPhotosService: PetPhotosService) {} + + @Get() + getPhotos( + @Param('petId', ParseUUIDPipe) petId: string, + ): Promise { + return this.petPhotosService.getPhotos(petId); + } + + @Post() + @HttpCode(HttpStatus.CREATED) + @UseInterceptors( + FilesInterceptor('photos', 10, { + limits: { fileSize: 10 * 1024 * 1024 }, + }), + ) + uploadPhotos( + @Param('petId', ParseUUIDPipe) petId: string, + @UploadedFiles() files: Express.Multer.File[], + ): Promise { + if (!files || files.length === 0) { + throw new BadRequestException('At least one photo file is required'); + } + return this.petPhotosService.uploadPhotos(petId, files); + } + + @Patch(':photoId/primary') + setPrimary( + @Param('petId', ParseUUIDPipe) petId: string, + @Param('photoId', ParseUUIDPipe) photoId: string, + ): Promise { + return this.petPhotosService.setPrimary(petId, photoId); + } + + @Put('reorder') + reorderPhotos( + @Param('petId', ParseUUIDPipe) petId: string, + @Body() reorderDto: ReorderPhotosDto, + ): Promise { + return this.petPhotosService.reorderPhotos(petId, reorderDto.photoIds); + } + + @Delete(':photoId') + @HttpCode(HttpStatus.NO_CONTENT) + deletePhoto( + @Param('petId', ParseUUIDPipe) petId: string, + @Param('photoId', ParseUUIDPipe) photoId: string, + ): Promise { + return this.petPhotosService.deletePhoto(petId, photoId); + } +} diff --git a/backend/src/modules/pets/pet-photos.service.ts b/backend/src/modules/pets/pet-photos.service.ts new file mode 100644 index 00000000..92b44b79 --- /dev/null +++ b/backend/src/modules/pets/pet-photos.service.ts @@ -0,0 +1,254 @@ +import { + Injectable, + Logger, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In } from 'typeorm'; +import { PetPhoto } from './entities/pet-photo.entity'; +import { Pet } from './entities/pet.entity'; +import { StorageService } from '../storage/storage.service'; +import { ImageProcessingService } from '../processing/services/image-processing.service'; + +const MAX_PHOTOS_PER_PET = 10; +const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp']; + +@Injectable() +export class PetPhotosService { + private readonly logger = new Logger(PetPhotosService.name); + + constructor( + @InjectRepository(PetPhoto) + private readonly photoRepository: Repository, + @InjectRepository(Pet) + private readonly petRepository: Repository, + private readonly storageService: StorageService, + private readonly imageProcessingService: ImageProcessingService, + ) {} + + async getPhotos(petId: string): Promise { + await this.ensurePetExists(petId); + return this.photoRepository.find({ + where: { petId }, + order: { displayOrder: 'ASC', createdAt: 'ASC' }, + }); + } + + async uploadPhotos( + petId: string, + files: Express.Multer.File[], + ): Promise { + await this.ensurePetExists(petId); + + const existingCount = await this.photoRepository.count({ where: { petId } }); + if (existingCount + files.length > MAX_PHOTOS_PER_PET) { + throw new BadRequestException( + `Cannot exceed ${MAX_PHOTOS_PER_PET} photos per pet. ` + + `Currently ${existingCount}, trying to add ${files.length}.`, + ); + } + + for (const file of files) { + if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) { + throw new BadRequestException( + `Invalid file type: ${file.mimetype}. Allowed: ${ALLOWED_MIME_TYPES.join(', ')}`, + ); + } + } + + const hasPrimary = await this.photoRepository.findOne({ + where: { petId, isPrimary: true }, + }); + + const uploadedPhotos: PetPhoto[] = []; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + + const processed = await this.imageProcessingService.processImage( + file.buffer, + { + stripMetadata: true, + compress: true, + generateThumbnail: true, + }, + ); + + const compressedBuffer = processed.compressed?.buffer ?? file.buffer; + const thumbnailBuffer = processed.thumbnail?.buffer; + + const storageKey = this.storageService.generateKey({ + prefix: 'uploads', + petId, + variant: 'photos', + filename: file.originalname, + }); + + const thumbnailStorageKey = this.storageService.generateKey({ + prefix: 'uploads', + petId, + variant: 'thumbnails', + filename: file.originalname, + }); + + const [uploadResult, thumbnailResult] = await Promise.all([ + this.storageService.upload({ + key: storageKey, + body: compressedBuffer, + contentType: file.mimetype, + metadata: { petId, originalName: file.originalname }, + }), + thumbnailBuffer + ? this.storageService.upload({ + key: thumbnailStorageKey, + body: thumbnailBuffer, + contentType: 'image/jpeg', + metadata: { petId, variant: 'thumbnail' }, + }) + : Promise.resolve(null), + ]); + + const photoUrl = + this.storageService.getPublicUrl(storageKey) || + (await this.storageService.getSignedUrl({ + key: storageKey, + operation: 'get', + expiresIn: 604800, // 7 days + })); + + const thumbnailUrl = thumbnailResult + ? this.storageService.getPublicUrl(thumbnailStorageKey) || + (await this.storageService.getSignedUrl({ + key: thumbnailStorageKey, + operation: 'get', + expiresIn: 604800, + })) + : null; + + const photo = this.photoRepository.create({ + petId, + photoUrl, + thumbnailUrl: thumbnailUrl ?? photoUrl, + storageKey, + thumbnailStorageKey: thumbnailResult ? thumbnailStorageKey : null, + isPrimary: !hasPrimary && i === 0, + displayOrder: existingCount + i, + mimeType: file.mimetype, + fileSize: uploadResult.size, + width: processed.compressed?.width ?? processed.original.width, + height: processed.compressed?.height ?? processed.original.height, + originalFilename: file.originalname, + }); + + const saved = await this.photoRepository.save(photo); + uploadedPhotos.push(saved); + + this.logger.log( + `Uploaded photo ${saved.id} for pet ${petId} (${file.originalname})`, + ); + } + + return uploadedPhotos; + } + + async setPrimary(petId: string, photoId: string): Promise { + await this.ensurePetExists(petId); + + const photo = await this.photoRepository.findOne({ + where: { id: photoId, petId }, + }); + if (!photo) { + throw new NotFoundException( + `Photo ${photoId} not found for pet ${petId}`, + ); + } + + await this.photoRepository.update({ petId }, { isPrimary: false }); + photo.isPrimary = true; + return this.photoRepository.save(photo); + } + + async reorderPhotos(petId: string, photoIds: string[]): Promise { + await this.ensurePetExists(petId); + + const photos = await this.photoRepository.find({ + where: { petId, id: In(photoIds) }, + }); + + if (photos.length !== photoIds.length) { + throw new BadRequestException( + 'Some photo IDs do not belong to this pet', + ); + } + + const updates = photoIds.map((id, index) => + this.photoRepository.update({ id, petId }, { displayOrder: index }), + ); + await Promise.all(updates); + + return this.getPhotos(petId); + } + + async deletePhoto(petId: string, photoId: string): Promise { + await this.ensurePetExists(petId); + + const photo = await this.photoRepository.findOne({ + where: { id: photoId, petId }, + }); + if (!photo) { + throw new NotFoundException( + `Photo ${photoId} not found for pet ${petId}`, + ); + } + + const deletePromises: Promise[] = []; + if (photo.storageKey) { + deletePromises.push( + this.storageService.delete({ key: photo.storageKey }), + ); + } + if (photo.thumbnailStorageKey) { + deletePromises.push( + this.storageService.delete({ key: photo.thumbnailStorageKey }), + ); + } + + await Promise.allSettled(deletePromises); + const wasPrimary = photo.isPrimary; + await this.photoRepository.remove(photo); + + if (wasPrimary) { + const nextPhoto = await this.photoRepository.findOne({ + where: { petId }, + order: { displayOrder: 'ASC' }, + }); + if (nextPhoto) { + nextPhoto.isPrimary = true; + await this.photoRepository.save(nextPhoto); + } + } + + await this.reindexDisplayOrder(petId); + this.logger.log(`Deleted photo ${photoId} from pet ${petId}`); + } + + private async reindexDisplayOrder(petId: string): Promise { + const photos = await this.photoRepository.find({ + where: { petId }, + order: { displayOrder: 'ASC', createdAt: 'ASC' }, + }); + + const updates = photos.map((photo, index) => + this.photoRepository.update(photo.id, { displayOrder: index }), + ); + await Promise.all(updates); + } + + private async ensurePetExists(petId: string): Promise { + const pet = await this.petRepository.findOne({ where: { id: petId } }); + if (!pet) { + throw new NotFoundException(`Pet with ID ${petId} not found`); + } + } +} diff --git a/backend/src/modules/pets/pets.module.ts b/backend/src/modules/pets/pets.module.ts index da9dbc28..95de35d1 100644 --- a/backend/src/modules/pets/pets.module.ts +++ b/backend/src/modules/pets/pets.module.ts @@ -1,27 +1,33 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { MulterModule } from '@nestjs/platform-express'; import { Pet } from './entities/pet.entity'; import { Breed } from './entities/breed.entity'; import { PetPhoto } from './entities/pet-photo.entity'; import { PetShare } from './entities/pet-share.entity'; import { PetsService } from './pets.service'; +import { PetPhotosService } from './pet-photos.service'; import { BreedsService } from './breeds.service'; import { PetsController } from './pets.controller'; +import { PetPhotosController } from './pet-photos.controller'; import { BreedsController } from './breeds.controller'; import { BreedsSeeder } from './seeds/breeds.seed'; +import { ProcessingModule } from '../processing/processing.module'; import { LostPetsModule } from '../lost-pets/lost-pets.module'; import { AuthModule } from '../../auth/auth.module'; import { UsersModule } from '../users/users.module'; @Module({ imports: [ - TypeOrmModule.forFeature([Pet, Breed, PetPhoto, PetShare]), + TypeOrmModule.forFeature([Pet, Breed, PetPhoto]), + MulterModule.register({ storage: require('multer').memoryStorage() }), + ProcessingModule, forwardRef(() => LostPetsModule), AuthModule, UsersModule, ], - controllers: [PetsController, BreedsController], - providers: [PetsService, BreedsService, BreedsSeeder], - exports: [PetsService, BreedsService], + controllers: [PetsController, PetPhotosController, BreedsController], + providers: [PetsService, PetPhotosService, BreedsService, BreedsSeeder], + exports: [PetsService, PetPhotosService, BreedsService], }) export class PetsModule {} diff --git a/package-lock.json b/package-lock.json index 8b069b89..7c55e36f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,11 @@ "name": "pet-medical-tracka", "version": "0.1.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@stellar/stellar-sdk": "^14.4.3", + "browser-image-compression": "^2.0.2", "lucide-react": "^0.563.0", "next": "^15.3.5", "qrcode.react": "^4.2.0", @@ -41,6 +45,60 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.4.tgz", @@ -1962,6 +2020,15 @@ "node": ">=8" } }, + "node_modules/browser-image-compression": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/browser-image-compression/-/browser-image-compression-2.0.2.tgz", + "integrity": "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==", + "license": "MIT", + "dependencies": { + "uzip": "0.20201231.0" + } + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -6133,6 +6200,12 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/uzip": { + "version": "0.20201231.0", + "resolved": "https://registry.npmjs.org/uzip/-/uzip-0.20201231.0.tgz", + "integrity": "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==", + "license": "MIT" + }, "node_modules/victory-vendor": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", diff --git a/package.json b/package.json index 6d5a9529..67b2b3ef 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,11 @@ "lint": "next lint" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@stellar/stellar-sdk": "^14.4.3", + "browser-image-compression": "^2.0.2", "lucide-react": "^0.563.0", "next": "^15.3.5", "qrcode.react": "^4.2.0", diff --git a/src/components/PetPhotos/PetPhotos.module.css b/src/components/PetPhotos/PetPhotos.module.css new file mode 100644 index 00000000..d42e01ab --- /dev/null +++ b/src/components/PetPhotos/PetPhotos.module.css @@ -0,0 +1,402 @@ +.manager { + width: 100%; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.title { + margin: 0; + font-size: 18px; + font-weight: 600; + color: #1e293b; +} + +.photoCount { + font-size: 13px; + color: #64748b; + font-weight: 400; +} + +.errorBanner { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + margin-bottom: 16px; + border-radius: 8px; + background-color: #fef2f2; + border: 1px solid #fecaca; + color: #dc2626; + font-size: 14px; +} + +.errorBanner button { + margin-left: auto; + background: none; + border: none; + cursor: pointer; + color: #dc2626; + font-size: 18px; + line-height: 1; + padding: 0; +} + +/* Uploader */ +.uploaderSection { + margin-bottom: 24px; +} + +.dropzone { + border: 2px dashed #cbd5e1; + border-radius: 12px; + padding: 32px; + text-align: center; + cursor: pointer; + transition: all 0.2s ease; + background-color: #f8fafc; +} + +.dropzone:hover { + border-color: #3b82f6; + background-color: #eff6ff; +} + +.dropzoneDragging { + border-color: #10b981; + background-color: #ecfdf5; +} + +.dropzoneDisabled { + opacity: 0.6; + cursor: not-allowed; + pointer-events: none; +} + +.hiddenInput { + display: none; +} + +.dropzoneContent { + pointer-events: none; +} + +.uploadIcon { + width: 40px; + height: 40px; + margin: 0 auto 12px; + color: #94a3b8; +} + +.dropzoneText { + margin: 0 0 4px; + font-size: 15px; + font-weight: 600; + color: #1e293b; +} + +.dropzoneSubtext { + margin: 0; + font-size: 13px; + color: #64748b; +} + +/* Preview Grid */ +.previewGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 12px; + margin-top: 16px; +} + +.previewItem { + position: relative; + border-radius: 8px; + overflow: hidden; + aspect-ratio: 1; + background: #f1f5f9; +} + +.previewImage { + width: 100%; + height: 100%; + object-fit: cover; +} + +.removePreview { + position: absolute; + top: 4px; + right: 4px; + width: 22px; + height: 22px; + border-radius: 50%; + border: none; + background: rgba(0, 0, 0, 0.6); + color: white; + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + padding: 0; +} + +.uploadActions { + display: flex; + align-items: center; + gap: 12px; + margin-top: 16px; +} + +.uploadButton { + padding: 8px 20px; + border-radius: 8px; + border: none; + background: #3b82f6; + color: white; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.uploadButton:hover { + background: #2563eb; +} + +.uploadButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.cancelButton { + padding: 8px 20px; + border-radius: 8px; + border: 1px solid #e2e8f0; + background: white; + color: #64748b; + font-size: 14px; + cursor: pointer; + transition: all 0.2s; +} + +.cancelButton:hover { + border-color: #cbd5e1; + color: #475569; +} + +/* Progress Bar */ +.progressContainer { + margin-top: 16px; +} + +.progressBar { + height: 6px; + background: #e2e8f0; + border-radius: 3px; + overflow: hidden; +} + +.progressFill { + height: 100%; + background: #3b82f6; + border-radius: 3px; + transition: width 0.3s ease; +} + +.progressText { + margin-top: 4px; + font-size: 12px; + color: #64748b; + text-align: right; +} + +/* Gallery */ +.gallerySection { + margin-top: 8px; +} + +.galleryGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 16px; +} + +/* Photo Card */ +.photoCard { + position: relative; + border-radius: 12px; + overflow: hidden; + background: white; + border: 2px solid #e2e8f0; + transition: all 0.2s ease; + user-select: none; +} + +.photoCard:hover { + border-color: #cbd5e1; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + +.photoCardPrimary { + border-color: #3b82f6; +} + +.photoCardDragging { + opacity: 0.5; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + z-index: 10; +} + +.cardImageWrapper { + position: relative; + aspect-ratio: 1; + overflow: hidden; + background: #f1f5f9; +} + +.cardImage { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.2s; +} + +.photoCard:hover .cardImage { + transform: scale(1.03); +} + +.primaryBadge { + position: absolute; + top: 8px; + left: 8px; + padding: 2px 8px; + border-radius: 4px; + background: #3b82f6; + color: white; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; +} + +.dragHandle { + position: absolute; + top: 8px; + right: 8px; + width: 28px; + height: 28px; + border-radius: 6px; + background: rgba(0, 0, 0, 0.45); + color: white; + display: flex; + align-items: center; + justify-content: center; + cursor: grab; + opacity: 0; + transition: opacity 0.2s; +} + +.photoCard:hover .dragHandle { + opacity: 1; +} + +.cardActions { + display: flex; + border-top: 1px solid #f1f5f9; +} + +.cardActionBtn { + flex: 1; + padding: 8px 4px; + border: none; + background: none; + cursor: pointer; + font-size: 12px; + color: #64748b; + transition: all 0.15s; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; +} + +.cardActionBtn:hover { + background: #f8fafc; + color: #1e293b; +} + +.cardActionBtnDanger:hover { + background: #fef2f2; + color: #dc2626; +} + +.cardActionBtn + .cardActionBtn { + border-left: 1px solid #f1f5f9; +} + +/* Empty State */ +.emptyState { + text-align: center; + padding: 48px 24px; + color: #94a3b8; +} + +.emptyIcon { + width: 56px; + height: 56px; + margin: 0 auto 12px; +} + +.emptyText { + margin: 0 0 4px; + font-size: 15px; + font-weight: 500; + color: #64748b; +} + +.emptySubtext { + margin: 0; + font-size: 13px; +} + +/* Loading */ +.loadingContainer { + display: flex; + justify-content: center; + align-items: center; + padding: 48px; +} + +.spinner { + width: 32px; + height: 32px; + border: 3px solid #e2e8f0; + border-top-color: #3b82f6; + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Responsive */ +@media (max-width: 640px) { + .galleryGrid { + grid-template-columns: repeat(2, 1fr); + gap: 10px; + } + + .previewGrid { + grid-template-columns: repeat(3, 1fr); + } + + .dropzone { + padding: 24px 16px; + } +} diff --git a/src/components/PetPhotos/PetPhotosManager.tsx b/src/components/PetPhotos/PetPhotosManager.tsx new file mode 100644 index 00000000..ef54c697 --- /dev/null +++ b/src/components/PetPhotos/PetPhotosManager.tsx @@ -0,0 +1,149 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { petPhotosAPI, type PetPhoto } from '@/lib/api/petPhotosAPI'; +import { PhotoUploader } from './PhotoUploader'; +import { PhotoGallery } from './PhotoGallery'; +import styles from './PetPhotos.module.css'; + +const MAX_PHOTOS = 10; + +interface PetPhotosManagerProps { + petId: string; +} + +export const PetPhotosManager: React.FC = ({ petId }) => { + const [photos, setPhotos] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [error, setError] = useState(null); + + const fetchPhotos = useCallback(async () => { + try { + setIsLoading(true); + const data = await petPhotosAPI.getPhotos(petId); + setPhotos(data); + setError(null); + } catch (err: any) { + setError(err.response?.data?.message || 'Failed to load photos'); + } finally { + setIsLoading(false); + } + }, [petId]); + + useEffect(() => { + fetchPhotos(); + }, [fetchPhotos]); + + const handleUpload = async (files: File[]) => { + try { + setIsUploading(true); + setUploadProgress(0); + setError(null); + + const uploaded = await petPhotosAPI.uploadPhotos(petId, files, (progress) => { + setUploadProgress(progress); + }); + + setPhotos((prev) => [...prev, ...uploaded]); + } catch (err: any) { + setError(err.response?.data?.message || 'Failed to upload photos'); + } finally { + setIsUploading(false); + setUploadProgress(0); + } + }; + + const handleSetPrimary = async (photoId: string) => { + try { + setError(null); + await petPhotosAPI.setPrimary(petId, photoId); + setPhotos((prev) => + prev.map((p) => ({ + ...p, + isPrimary: p.id === photoId, + })), + ); + } catch (err: any) { + setError(err.response?.data?.message || 'Failed to set primary photo'); + } + }; + + const handleDelete = async (photoId: string) => { + try { + setError(null); + await petPhotosAPI.deletePhoto(petId, photoId); + setPhotos((prev) => { + const remaining = prev.filter((p) => p.id !== photoId); + const deleted = prev.find((p) => p.id === photoId); + if (deleted?.isPrimary && remaining.length > 0) { + remaining[0] = { ...remaining[0], isPrimary: true }; + } + return remaining; + }); + } catch (err: any) { + setError(err.response?.data?.message || 'Failed to delete photo'); + } + }; + + const handleReorder = async (photoIds: string[]) => { + const previousPhotos = [...photos]; + + const reordered = photoIds + .map((id) => photos.find((p) => p.id === id)) + .filter(Boolean) as PetPhoto[]; + setPhotos(reordered); + + try { + setError(null); + await petPhotosAPI.reorderPhotos(petId, photoIds); + } catch (err: any) { + setPhotos(previousPhotos); + setError(err.response?.data?.message || 'Failed to reorder photos'); + } + }; + + if (isLoading) { + return ( +
+
+
+
+
+ ); + } + + return ( +
+
+

+ Photos{' '} + + ({photos.length}/{MAX_PHOTOS}) + +

+
+ + {error && ( +
+ {error} + +
+ )} + + + + +
+ ); +}; diff --git a/src/components/PetPhotos/PhotoCard.tsx b/src/components/PetPhotos/PhotoCard.tsx new file mode 100644 index 00000000..5f76d853 --- /dev/null +++ b/src/components/PetPhotos/PhotoCard.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { Star, Trash2, GripVertical } from 'lucide-react'; +import type { PetPhoto } from '@/lib/api/petPhotosAPI'; +import styles from './PetPhotos.module.css'; + +interface PhotoCardProps { + photo: PetPhoto; + onSetPrimary: (photoId: string) => void; + onDelete: (photoId: string) => void; +} + +export const PhotoCard: React.FC = ({ + photo, + onSetPrimary, + onDelete, +}) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: photo.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+
+ {photo.originalFilename + {photo.isPrimary && ( + Primary + )} +
+ +
+
+ +
+ {!photo.isPrimary && ( + + )} + +
+
+ ); +}; diff --git a/src/components/PetPhotos/PhotoGallery.tsx b/src/components/PetPhotos/PhotoGallery.tsx new file mode 100644 index 00000000..1e8579f7 --- /dev/null +++ b/src/components/PetPhotos/PhotoGallery.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core'; +import { + SortableContext, + sortableKeyboardCoordinates, + rectSortingStrategy, +} from '@dnd-kit/sortable'; +import { ImageIcon } from 'lucide-react'; +import { PhotoCard } from './PhotoCard'; +import type { PetPhoto } from '@/lib/api/petPhotosAPI'; +import styles from './PetPhotos.module.css'; + +interface PhotoGalleryProps { + photos: PetPhoto[]; + onSetPrimary: (photoId: string) => void; + onDelete: (photoId: string) => void; + onReorder: (photoIds: string[]) => void; +} + +export const PhotoGallery: React.FC = ({ + photos, + onSetPrimary, + onDelete, + onReorder, +}) => { + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + + const oldIndex = photos.findIndex((p) => p.id === active.id); + const newIndex = photos.findIndex((p) => p.id === over.id); + + if (oldIndex === -1 || newIndex === -1) return; + + const reordered = [...photos]; + const [moved] = reordered.splice(oldIndex, 1); + reordered.splice(newIndex, 0, moved); + + onReorder(reordered.map((p) => p.id)); + }; + + if (photos.length === 0) { + return ( +
+ +

No photos yet

+

+ Upload photos to create a gallery for your pet +

+
+ ); + } + + return ( +
+ + p.id)} + strategy={rectSortingStrategy} + > +
+ {photos.map((photo) => ( + + ))} +
+
+
+
+ ); +}; diff --git a/src/components/PetPhotos/PhotoUploader.tsx b/src/components/PetPhotos/PhotoUploader.tsx new file mode 100644 index 00000000..303e4e9b --- /dev/null +++ b/src/components/PetPhotos/PhotoUploader.tsx @@ -0,0 +1,226 @@ +import React, { useState, useRef, useCallback } from 'react'; +import { Upload } from 'lucide-react'; +import imageCompression from 'browser-image-compression'; +import styles from './PetPhotos.module.css'; + +const MAX_FILE_SIZE_MB = 10; +const COMPRESSION_MAX_SIZE_MB = 2; +const COMPRESSION_MAX_DIMENSION = 1920; +const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']; + +interface PhotoUploaderProps { + currentCount: number; + maxPhotos: number; + isUploading: boolean; + uploadProgress: number; + onUpload: (files: File[]) => void; +} + +interface PreviewFile { + file: File; + previewUrl: string; +} + +export const PhotoUploader: React.FC = ({ + currentCount, + maxPhotos, + isUploading, + uploadProgress, + onUpload, +}) => { + const [isDragging, setIsDragging] = useState(false); + const [stagedFiles, setStagedFiles] = useState([]); + const [isCompressing, setIsCompressing] = useState(false); + const fileInputRef = useRef(null); + + const remainingSlots = maxPhotos - currentCount; + + const compressFile = async (file: File): Promise => { + if (!file.type.startsWith('image/')) return file; + + try { + const compressed = await imageCompression(file, { + maxSizeMB: COMPRESSION_MAX_SIZE_MB, + maxWidthOrHeight: COMPRESSION_MAX_DIMENSION, + useWebWorker: true, + preserveExif: false, + }); + return new File([compressed], file.name, { type: compressed.type }); + } catch { + return file; + } + }; + + const processFiles = useCallback( + async (rawFiles: FileList | File[]) => { + const fileArray = Array.from(rawFiles); + + const valid = fileArray.filter((f) => { + if (!ALLOWED_TYPES.includes(f.type)) return false; + if (f.size > MAX_FILE_SIZE_MB * 1024 * 1024) return false; + return true; + }); + + const slotsAvailable = remainingSlots - stagedFiles.length; + const toProcess = valid.slice(0, Math.max(0, slotsAvailable)); + if (toProcess.length === 0) return; + + setIsCompressing(true); + try { + const compressed = await Promise.all(toProcess.map(compressFile)); + + const previews: PreviewFile[] = compressed.map((file) => ({ + file, + previewUrl: URL.createObjectURL(file), + })); + + setStagedFiles((prev) => [...prev, ...previews]); + } finally { + setIsCompressing(false); + } + }, + [remainingSlots, stagedFiles.length], + ); + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + if (e.dataTransfer.files.length > 0) { + processFiles(e.dataTransfer.files); + } + }; + + const handleInputChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files.length > 0) { + processFiles(e.target.files); + } + if (fileInputRef.current) fileInputRef.current.value = ''; + }; + + const handleClickDropzone = () => { + if (!isUploading && !isCompressing) { + fileInputRef.current?.click(); + } + }; + + const removeStaged = (index: number) => { + setStagedFiles((prev) => { + const removed = prev[index]; + URL.revokeObjectURL(removed.previewUrl); + return prev.filter((_, i) => i !== index); + }); + }; + + const handleUpload = () => { + if (stagedFiles.length === 0) return; + onUpload(stagedFiles.map((p) => p.file)); + stagedFiles.forEach((p) => URL.revokeObjectURL(p.previewUrl)); + setStagedFiles([]); + }; + + const handleCancel = () => { + stagedFiles.forEach((p) => URL.revokeObjectURL(p.previewUrl)); + setStagedFiles([]); + }; + + const disabled = isUploading || remainingSlots <= 0; + + return ( +
+
+ +
+ +

+ {isCompressing + ? 'Compressing images...' + : isDragging + ? 'Drop your photos here' + : 'Drag & drop photos or click to browse'} +

+

+ JPEG, PNG, WebP — up to {MAX_FILE_SIZE_MB}MB each —{' '} + {remainingSlots - stagedFiles.length} slot + {remainingSlots - stagedFiles.length !== 1 ? 's' : ''} remaining +

+
+
+ + {stagedFiles.length > 0 && ( + <> +
+ {stagedFiles.map((pf, i) => ( +
+ {pf.file.name} + +
+ ))} +
+ +
+ + +
+ + )} + + {isUploading && ( +
+
+
+
+

{uploadProgress}%

+
+ )} +
+ ); +}; diff --git a/src/components/PetPhotos/index.ts b/src/components/PetPhotos/index.ts new file mode 100644 index 00000000..5068ef77 --- /dev/null +++ b/src/components/PetPhotos/index.ts @@ -0,0 +1,4 @@ +export { PetPhotosManager } from './PetPhotosManager'; +export { PhotoUploader } from './PhotoUploader'; +export { PhotoGallery } from './PhotoGallery'; +export { PhotoCard } from './PhotoCard'; diff --git a/src/lib/api/petPhotosAPI.ts b/src/lib/api/petPhotosAPI.ts new file mode 100644 index 00000000..0a42f1e2 --- /dev/null +++ b/src/lib/api/petPhotosAPI.ts @@ -0,0 +1,83 @@ +import axios, { AxiosInstance, AxiosProgressEvent } from 'axios'; + +const API_BASE_URL = + process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api'; + +export interface PetPhoto { + id: string; + petId: string; + photoUrl: string; + thumbnailUrl: string; + isPrimary: boolean; + displayOrder: number; + mimeType: string; + fileSize: number; + width: number; + height: number; + originalFilename: string; + createdAt: string; + updatedAt: string; +} + +class PetPhotosAPI { + private api: AxiosInstance; + + constructor() { + this.api = axios.create({ + baseURL: API_BASE_URL, + withCredentials: true, + }); + + this.api.interceptors.request.use((config) => { + const token = localStorage.getItem('authToken'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }); + } + + async getPhotos(petId: string): Promise { + const response = await this.api.get(`/pets/${petId}/photos`); + return response.data; + } + + async uploadPhotos( + petId: string, + files: File[], + onProgress?: (progress: number) => void, + ): Promise { + const formData = new FormData(); + files.forEach((file) => formData.append('photos', file)); + + const response = await this.api.post(`/pets/${petId}/photos`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + onUploadProgress: (event: AxiosProgressEvent) => { + if (onProgress && event.total) { + onProgress(Math.round((event.loaded * 100) / event.total)); + } + }, + }); + return response.data; + } + + async setPrimary(petId: string, photoId: string): Promise { + const response = await this.api.patch( + `/pets/${petId}/photos/${photoId}/primary`, + ); + return response.data; + } + + async reorderPhotos(petId: string, photoIds: string[]): Promise { + const response = await this.api.put(`/pets/${petId}/photos/reorder`, { + photoIds, + }); + return response.data; + } + + async deletePhoto(petId: string, photoId: string): Promise { + await this.api.delete(`/pets/${petId}/photos/${photoId}`); + } +} + +export const petPhotosAPI = new PetPhotosAPI(); diff --git a/src/pages/pets/[id].tsx b/src/pages/pets/[id].tsx new file mode 100644 index 00000000..938e2e31 --- /dev/null +++ b/src/pages/pets/[id].tsx @@ -0,0 +1,212 @@ +import React, { useState, useEffect } from 'react'; +import { useRouter } from 'next/router'; +import { ArrowLeft, PawPrint } from 'lucide-react'; +import { PetPhotosManager } from '@/components/PetPhotos'; +import styles from '@/styles/pages/PetDetailPage.module.css'; + +interface Pet { + id: string; + name: string; + species: string; + breed?: { name: string }; + gender: string; + dateOfBirth: string; + weight: number | null; + color: string | null; + microchipNumber: string | null; + specialNeeds: string | null; + photos: Array<{ + id: string; + photoUrl: string; + thumbnailUrl: string; + isPrimary: boolean; + }>; +} + +const API_BASE_URL = + process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api'; + +export default function PetDetailPage() { + const router = useRouter(); + const { id } = router.query; + + const [pet, setPet] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!id || typeof id !== 'string') return; + + const fetchPet = async () => { + try { + setIsLoading(true); + const token = localStorage.getItem('authToken'); + const res = await fetch(`${API_BASE_URL}/pets/${id}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + + if (!res.ok) { + throw new Error( + res.status === 404 ? 'Pet not found' : 'Failed to load pet', + ); + } + + const data = await res.json(); + setPet(data); + } catch (err: any) { + setError(err.message); + } finally { + setIsLoading(false); + } + }; + + fetchPet(); + }, [id]); + + if (isLoading) { + return ( +
+
+
+ Loading pet details... +
+
+ ); + } + + if (error || !pet) { + return ( +
+
+

Something went wrong

+

{error || 'Pet not found'}

+
+
+ ); + } + + const primaryPhoto = pet.photos?.find((p) => p.isPrimary); + const age = getAge(pet.dateOfBirth); + + return ( +
+
+ + +
+ {primaryPhoto ? ( + {pet.name} + ) : ( +
+ +
+ )} +
+

{pet.name}

+
+ + {capitalize(pet.species)} + + {pet.breed && ( + {pet.breed.name} + )} + {age && {age}} +
+
+
+ + {/* Pet Photos Section */} +
+ +
+ + {/* Pet Details Section */} +
+

Details

+
+
+ Species + + {capitalize(pet.species)} + +
+ {pet.breed && ( +
+ Breed + {pet.breed.name} +
+ )} +
+ Gender + + {capitalize(pet.gender)} + +
+
+ Date of Birth + + {new Date(pet.dateOfBirth).toLocaleDateString()} + +
+ {pet.weight && ( +
+ Weight + {pet.weight} kg +
+ )} + {pet.color && ( +
+ Color + {pet.color} +
+ )} + {pet.microchipNumber && ( +
+ Microchip + + {pet.microchipNumber} + +
+ )} + {pet.specialNeeds && ( +
+ Special Needs + {pet.specialNeeds} +
+ )} +
+
+
+
+ ); +} + +function capitalize(str: string): string { + if (!str) return ''; + return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); +} + +function getAge(dateOfBirth: string): string | null { + const dob = new Date(dateOfBirth); + if (isNaN(dob.getTime())) return null; + + const now = new Date(); + let years = now.getFullYear() - dob.getFullYear(); + let months = now.getMonth() - dob.getMonth(); + + if (months < 0 || (months === 0 && now.getDate() < dob.getDate())) { + years--; + months += 12; + } + + if (years > 0) return `${years} yr${years !== 1 ? 's' : ''} old`; + if (months > 0) return `${months} mo${months !== 1 ? 's' : ''} old`; + return 'Newborn'; +} diff --git a/src/styles/pages/PetDetailPage.module.css b/src/styles/pages/PetDetailPage.module.css new file mode 100644 index 00000000..6aaa49c2 --- /dev/null +++ b/src/styles/pages/PetDetailPage.module.css @@ -0,0 +1,185 @@ +.container { + min-height: 100vh; + background-color: #f3f4f6; + padding: 32px 16px; +} + +.content { + max-width: 800px; + margin: 0 auto; +} + +.backButton { + display: inline-flex; + align-items: center; + gap: 6px; + margin-bottom: 20px; + padding: 6px 12px; + border: none; + border-radius: 6px; + background: white; + color: #64748b; + font-size: 14px; + cursor: pointer; + transition: all 0.15s; +} + +.backButton:hover { + color: #1e293b; + background: #f1f5f9; +} + +.petHeader { + display: flex; + align-items: center; + gap: 20px; + padding: 24px; + background: white; + border-radius: 12px; + margin-bottom: 24px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); +} + +.petAvatar { + width: 80px; + height: 80px; + border-radius: 50%; + object-fit: cover; + border: 3px solid #e2e8f0; + background: #f1f5f9; + display: flex; + align-items: center; + justify-content: center; + color: #94a3b8; + font-size: 32px; + flex-shrink: 0; +} + +.petInfo h1 { + margin: 0 0 4px; + font-size: 24px; + font-weight: 700; + color: #1e293b; +} + +.petMeta { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.metaItem { + font-size: 14px; + color: #64748b; +} + +.section { + background: white; + border-radius: 12px; + padding: 24px; + margin-bottom: 24px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); +} + +.sectionTitle { + margin: 0 0 16px; + font-size: 18px; + font-weight: 600; + color: #1e293b; + padding-bottom: 12px; + border-bottom: 1px solid #f1f5f9; +} + +.detailGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.detailItem { + display: flex; + flex-direction: column; + gap: 2px; +} + +.detailLabel { + font-size: 12px; + font-weight: 500; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.detailValue { + font-size: 15px; + color: #1e293b; +} + +.loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 80px 24px; + color: #64748b; +} + +.spinner { + width: 32px; + height: 32px; + border: 3px solid #e2e8f0; + border-top-color: #3b82f6; + border-radius: 50%; + animation: spin 0.7s linear infinite; + margin-bottom: 12px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.error { + max-width: 600px; + margin: 40px auto; + padding: 24px; + text-align: center; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 12px; + color: #dc2626; +} + +.error h2 { + margin: 0 0 8px; + font-size: 18px; +} + +.error p { + margin: 0; + font-size: 14px; +} + +@media (max-width: 640px) { + .container { + padding: 16px 12px; + } + + .petHeader { + flex-direction: column; + text-align: center; + padding: 20px 16px; + } + + .petMeta { + justify-content: center; + } + + .detailGrid { + grid-template-columns: 1fr; + } + + .section { + padding: 16px; + } +}