From a3379e6ce47aaf0328e35f328f5381f6c18d88b1 Mon Sep 17 00:00:00 2001 From: dmystical-coder Date: Sat, 21 Feb 2026 11:38:35 +0100 Subject: [PATCH 1/5] feat(deps): add drag-and-drop and image compression dependencies Add @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities for drag-to-reorder photo gallery, and browser-image-compression for client-side image optimization before upload. Co-authored-by: Cursor --- package-lock.json | 73 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 4 +++ 2 files changed, 77 insertions(+) diff --git a/package-lock.json b/package-lock.json index 02bdbfdb..616c6913 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", "react": "^19.0.0", @@ -40,6 +44,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", @@ -1964,6 +2022,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", @@ -6135,6 +6202,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 7e12ad39..603a3afe 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", "react": "^19.0.0", From 0073046eb79874fa0144605206c6f990ff8f600c Mon Sep 17 00:00:00 2001 From: dmystical-coder Date: Sat, 21 Feb 2026 11:38:45 +0100 Subject: [PATCH 2/5] feat(pets): extend PetPhoto entity with photo management fields Add thumbnailUrl, storageKey, thumbnailStorageKey, displayOrder, mimeType, fileSize, width, height, and originalFilename columns to support photo ordering, thumbnail references, and S3 storage key tracking. Co-authored-by: Cursor --- .../modules/pets/entities/pet-photo.entity.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) 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; From d5ff4c62413e32fd47fc703b0c924fa538a72473 Mon Sep 17 00:00:00 2001 From: dmystical-coder Date: Sat, 21 Feb 2026 11:39:01 +0100 Subject: [PATCH 3/5] feat(pets): add pet photo management API endpoints Implement PetPhotosService and PetPhotosController with endpoints for: - POST /pets/:id/photos (multi-file upload with S3 storage) - GET /pets/:id/photos (list ordered photos) - PATCH /pets/:id/photos/:photoId/primary (set primary photo) - PUT /pets/:id/photos/reorder (drag-to-reorder support) - DELETE /pets/:id/photos/:photoId (S3 cleanup + auto-promote primary) Integrates with existing StorageService for S3 uploads and ImageProcessingService for server-side compression and thumbnail generation. Enforces a 10-photo-per-pet limit. Co-authored-by: Cursor --- .../modules/pets/dto/reorder-photos.dto.ts | 7 + .../src/modules/pets/pet-photos.controller.ts | 74 +++++ .../src/modules/pets/pet-photos.service.ts | 254 ++++++++++++++++++ backend/src/modules/pets/pets.module.ts | 16 +- 4 files changed, 347 insertions(+), 4 deletions(-) create mode 100644 backend/src/modules/pets/dto/reorder-photos.dto.ts create mode 100644 backend/src/modules/pets/pet-photos.controller.ts create mode 100644 backend/src/modules/pets/pet-photos.service.ts 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/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 5a663c18..e3496fc6 100644 --- a/backend/src/modules/pets/pets.module.ts +++ b/backend/src/modules/pets/pets.module.ts @@ -1,18 +1,26 @@ import { Module } 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 { 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'; @Module({ - imports: [TypeOrmModule.forFeature([Pet, Breed, PetPhoto])], - controllers: [PetsController, BreedsController], - providers: [PetsService, BreedsService, BreedsSeeder], - exports: [PetsService, BreedsService], + imports: [ + TypeOrmModule.forFeature([Pet, Breed, PetPhoto]), + MulterModule.register({ storage: require('multer').memoryStorage() }), + ProcessingModule, + ], + controllers: [PetsController, PetPhotosController, BreedsController], + providers: [PetsService, PetPhotosService, BreedsService, BreedsSeeder], + exports: [PetsService, PetPhotosService, BreedsService], }) export class PetsModule {} From 42cc30ea50f48dabcc2c9e6ddc9ebe7086ed6430 Mon Sep 17 00:00:00 2001 From: dmystical-coder Date: Sat, 21 Feb 2026 11:39:12 +0100 Subject: [PATCH 4/5] feat(ui): add pet photo upload and gallery components Create PetPhotos component suite: - PhotoUploader: drag-and-drop multi-file upload with client-side compression via browser-image-compression and staged file previews - PhotoGallery: sortable grid using @dnd-kit for drag-to-reorder - PhotoCard: individual photo card with set-primary and delete actions - PetPhotosManager: orchestrator handling state, API calls, optimistic updates, error handling, and upload progress tracking Add petPhotosAPI client with upload progress callback support. Co-authored-by: Cursor --- src/components/PetPhotos/PetPhotos.module.css | 402 ++++++++++++++++++ src/components/PetPhotos/PetPhotosManager.tsx | 149 +++++++ src/components/PetPhotos/PhotoCard.tsx | 80 ++++ src/components/PetPhotos/PhotoGallery.tsx | 96 +++++ src/components/PetPhotos/PhotoUploader.tsx | 226 ++++++++++ src/components/PetPhotos/index.ts | 4 + src/lib/api/petPhotosAPI.ts | 83 ++++ 7 files changed, 1040 insertions(+) create mode 100644 src/components/PetPhotos/PetPhotos.module.css create mode 100644 src/components/PetPhotos/PetPhotosManager.tsx create mode 100644 src/components/PetPhotos/PhotoCard.tsx create mode 100644 src/components/PetPhotos/PhotoGallery.tsx create mode 100644 src/components/PetPhotos/PhotoUploader.tsx create mode 100644 src/components/PetPhotos/index.ts create mode 100644 src/lib/api/petPhotosAPI.ts 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(); From aed06a8adb148c74c0943dfc4d8979a1de6ba81f Mon Sep 17 00:00:00 2001 From: dmystical-coder Date: Sat, 21 Feb 2026 11:39:24 +0100 Subject: [PATCH 5/5] feat(pages): add pet detail page with embedded photo manager Create /pets/[id] dynamic page that displays pet information and embeds the PetPhotosManager component directly in context. Includes pet header with primary photo avatar, details grid, responsive layout, and back navigation. Co-authored-by: Cursor --- src/pages/pets/[id].tsx | 212 ++++++++++++++++++++++ src/styles/pages/PetDetailPage.module.css | 185 +++++++++++++++++++ 2 files changed, 397 insertions(+) create mode 100644 src/pages/pets/[id].tsx create mode 100644 src/styles/pages/PetDetailPage.module.css 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; + } +}