Skip to content

Conversation

@duyet
Copy link
Owner

@duyet duyet commented Oct 21, 2025

Implemented a complete photo upload system that allows hosting local files in the public folder alongside Unsplash photos, with full EXIF metadata extraction and inspection capabilities.

Changes:

  • Converted photos app from static export to SSR mode for API support
  • Added photo upload API endpoint (/api/upload) with multipart form handling
  • Implemented comprehensive EXIF metadata extraction using exifreader
  • Created local photo storage service with JSON-based metadata persistence
  • Added photo management API (GET/PATCH/DELETE /api/photos/[id])
  • Built drag-and-drop PhotoUploader component with progress tracking
  • Updated PhotoGrid to merge and display both Unsplash and local photos
  • Enhanced Lightbox to show detailed EXIF data for local photos
  • Extended type system to support unified Photo type (local + Unsplash)
  • Added image processing pipeline with Sharp (4 size variants per upload)
  • Implemented automatic page revalidation after upload
  • Created comprehensive documentation and README

Technical details:

  • Dependencies: exifreader, sharp, uuid
  • Max upload size: 50MB
  • Supported formats: JPEG, PNG, WebP
  • EXIF data: Camera, lens, settings, GPS, dates, and 20+ metadata fields
  • Storage: public/photos/ with metadata.json database
  • Image sizes: original, regular (1920px), small (800px), thumb (400px)

🤖 Generated with Claude Code

Summary by Sourcery

Enable local photo uploads alongside Unsplash images by migrating the photos app to SSR, adding API endpoints for upload and photo management, performing build-time scanning and comprehensive EXIF metadata extraction, and updating the UI to unify, display, and inspect both local and remote photos

New Features:

  • Add local photo upload API endpoint with multipart form handling and SSR support
  • Implement build-time scanning of public/photos and EXIF metadata extraction for local images
  • Introduce drag-and-drop PhotoUploader component with upload progress tracking
  • Unify and display both Unsplash and local photos in PhotoGrid with merged timeline
  • Enhance Lightbox viewer to show detailed EXIF data and file information for local uploads

Enhancements:

  • Convert photos app from static export to SSR mode to support API routes
  • Integrate Sharp-based image processing pipeline to generate multiple size variants per upload
  • Extend type definitions with a unified Photo type, DetailedExif metadata, and type guards
  • Implement automatic page revalidation after photo uploads

Build:

  • Add exifreader, sharp, and uuid dependencies with corresponding type definitions

Documentation:

  • Add comprehensive README and documentation detailing local photo upload, EXIF extraction, and build-time scanning

duyetbot and others added 2 commits October 21, 2025 18:10
… inspection

Implemented a complete photo upload system that allows hosting local files in the public folder alongside Unsplash photos, with full EXIF metadata extraction and inspection capabilities.

Changes:
- Converted photos app from static export to SSR mode for API support
- Added photo upload API endpoint (/api/upload) with multipart form handling
- Implemented comprehensive EXIF metadata extraction using exifreader
- Created local photo storage service with JSON-based metadata persistence
- Added photo management API (GET/PATCH/DELETE /api/photos/[id])
- Built drag-and-drop PhotoUploader component with progress tracking
- Updated PhotoGrid to merge and display both Unsplash and local photos
- Enhanced Lightbox to show detailed EXIF data for local photos
- Extended type system to support unified Photo type (local + Unsplash)
- Added image processing pipeline with Sharp (4 size variants per upload)
- Implemented automatic page revalidation after upload
- Created comprehensive documentation and README

Technical details:
- Dependencies: exifreader, sharp, uuid
- Max upload size: 50MB
- Supported formats: JPEG, PNG, WebP
- EXIF data: Camera, lens, settings, GPS, dates, and 20+ metadata fields
- Storage: public/photos/ with metadata.json database
- Image sizes: original, regular (1920px), small (800px), thumb (400px)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
…time upload

Changed the approach from runtime photo upload API to build-time static scanning:
- Photos are now placed directly in public/photos/ directory
- Build process scans and extracts EXIF metadata at build time
- Unified timeline combines Unsplash and local photos
- Fully static export (no server required)

Changes:
- Removed upload API endpoints (/api/upload, /api/photos/[id])
- Removed PhotoUploader drag-and-drop component
- Updated localPhotos.ts to scanLocalPhotos() build-time function
- Modified page.tsx to call scanLocalPhotos() during build
- Switched back to static export mode in next.config.js
- Updated README with build-time workflow documentation

Benefits:
- No server/API required - fully static site
- Better performance - metadata extracted once at build
- Simpler workflow - just drop photos in folder and rebuild
- Can deploy anywhere (Vercel, Netlify, S3, GitHub Pages, etc.)

Usage:
1. Place photos in apps/photos/public/photos/
2. Run yarn build
3. Photos appear with full EXIF metadata extracted

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
@vercel
Copy link

vercel bot commented Oct 21, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
home Ready Ready Preview Comment Oct 21, 2025 6:41pm
photos Error Error Oct 21, 2025 6:41pm
3 Skipped Deployments
Project Deployment Preview Comments Updated (UTC)
blog Ignored Ignored Preview Oct 21, 2025 6:41pm
cv Ignored Ignored Preview Oct 21, 2025 6:41pm
insights Ignored Ignored Preview Oct 21, 2025 6:41pm

@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Oct 21, 2025

Reviewer's Guide

This PR extends the photos app from a static Unsplash-only gallery to a hybrid SSR solution with full local photo upload support. It introduces build-time scanning and EXIF extraction services, merges Unsplash and local sources into a unified Photo type, enriches metadata formatting and UI components to display detailed EXIF and file info, and updates dependencies and docs accordingly.

Sequence diagram for build-time local photo scanning and EXIF extraction

sequenceDiagram
  participant "Build Process" as Build
  participant "File System" as FS
  participant "ExifReader Service" as ExifReader
  participant "Sharp Service" as Sharp
  participant "LocalPhoto Metadata" as Metadata

  Build->>FS: Scan public/photos directory
  FS-->>Build: List of image files
  loop For each image file
    Build->>FS: Read image file buffer
    Build->>ExifReader: Extract EXIF metadata
    ExifReader-->>Build: DetailedExif object
    Build->>Sharp: Get image dimensions
    Sharp-->>Build: width, height
    Build->>FS: Get file stats (size, mtime)
    Build->>Metadata: Compile LocalPhoto object
  end
  Build-->>Build: Array of LocalPhoto objects
Loading

Sequence diagram for merging Unsplash and local photos into unified timeline

sequenceDiagram
  participant "Build Process" as Build
  participant "Unsplash API" as Unsplash
  participant "LocalPhoto Scanner" as LocalScanner
  participant "Photo Merger" as Merger

  Build->>Unsplash: Fetch Unsplash photos
  Unsplash-->>Build: UnsplashPhoto[]
  Build->>LocalScanner: Scan local photos
  LocalScanner-->>Build: LocalPhoto[]
  Build->>Merger: Merge UnsplashPhoto[] and LocalPhoto[]
  Merger-->>Build: Unified Photo[] (sorted by date)
Loading

Class diagram for unified Photo type and EXIF metadata

classDiagram
  class UnsplashPhoto {
    <<interface>>
    +created_at: string
    +width: number
    +height: number
    +exif: Exif
    +location: Location
    +user: UnsplashUser
    +stats: Stats
    +urls: PhotoURLs
    +source: 'unsplash'
  }
  class LocalPhoto {
    <<interface>>
    +id: string
    +source: 'local'
    +filename: string
    +originalName: string
    +created_at: string
    +updated_at: string
    +width: number
    +height: number
    +size: number
    +mimeType: string
    +urls: PhotoURLs
    +description: string
    +alt_description: string
    +tags: string[]
    +exif: DetailedExif
    +location: Location
    +user: LocalUser
    +stats: Stats
    +color: string
    +blur_hash: string
  }
  class DetailedExif {
    <<interface>>
    +make: string
    +model: string
    +lensModel: string
    +exposureTime: string
    +fNumber: string|number
    +aperture: string
    +iso: number
    +focalLength: string|number
    +focalLengthIn35mm: number
    +dateTime: string
    +dateTimeOriginal: string
    +dateTimeDigitized: string
    +gps: GPS
    +orientation: number
    +width: number
    +height: number
    +colorSpace: string
    +whiteBalance: string
    +software: string
    +artist: string
    +copyright: string
    +description: string
    +userComment: string
    +exposureMode: string
    +exposureProgram: string
    +meteringMode: string
    +flash: string
    +sceneCaptureType: string
  }
  class GPS {
    <<interface>>
    +latitude: number
    +longitude: number
    +altitude: number
    +latitudeRef: string
    +longitudeRef: string
  }
  class Location {
    <<interface>>
    +name: string
    +city: string
    +country: string
    +position: GPSPosition
  }
  class GPSPosition {
    <<interface>>
    +latitude: number
    +longitude: number
  }
  class UnsplashUser {
    <<interface>>
    +name: string
    +username: string
    +links: Links
  }
  class LocalUser {
    <<interface>>
    +name: string
    +username: string
  }
  class Stats {
    <<interface>>
    +views: number
    +downloads: number
  }
  class PhotoURLs {
    <<interface>>
    +raw: string
    +full: string
    +regular: string
    +small: string
    +thumb: string
  }
  class Links {
    <<interface>>
    +html: string
  }
  UnsplashPhoto <|-- Photo
  LocalPhoto <|-- Photo
  LocalPhoto o-- DetailedExif
  DetailedExif o-- GPS
  UnsplashPhoto o-- UnsplashUser
  LocalPhoto o-- LocalUser
  UnsplashPhoto o-- Stats
  LocalPhoto o-- Stats
  UnsplashPhoto o-- PhotoURLs
  LocalPhoto o-- PhotoURLs
  UnsplashUser o-- Links
  LocalPhoto o-- Location
  UnsplashPhoto o-- Location
  Location o-- GPSPosition
Loading

File-Level Changes

Change Details Files
Enhance metadata formatting to support unified Photo type with local source
  • Introduce formatFileSize and formatDetailedExif helpers
  • Branch formatPhotoMetadata on photo.source for detailed EXIF and fileInfo
  • Update description, compact, and portfolio formatters to handle local photos and add source indicator
apps/photos/lib/MetadataFormatters.ts
Extend TypeScript definitions for DetailedExif, LocalPhoto, and unified Photo type
  • Add DetailedExif interface covering 20+ EXIF fields
  • Define LocalPhoto interface and UploadResponse
  • Create Photo union type with isUnsplashPhoto and isLocalPhoto guards
apps/photos/lib/types.ts
Implement local photo scanning, EXIF extraction, and merging logic
  • Build scanLocalPhotos using fs, sharp, and uuid to read files, extract metadata, and output LocalPhoto objects
  • Add mergePhotos and groupLocalPhotosByYear utilities
  • Update page.tsx to load Unsplash photos, scan local photos, annotate source, merge lists, and group by year
apps/photos/lib/localPhotos.ts
apps/photos/app/page.tsx
Introduce EXIF extraction service for parsing detailed metadata
  • Implement extractExifData with ExifReader to map tags into DetailedExif
  • Add formatGPSCoordinates and extractPhotoDate helpers for display and ISO conversion
apps/photos/lib/exifExtractor.ts
Adapt Lightbox components to render local photos and detailed metadata
  • Change Lightbox and LightboxControls to accept Photo and PhotoMetadata props
  • Add source indicator, fileInfo, and extended EXIF panel to InfoPanel
  • Conditionally render external Unsplash links based on source
apps/photos/components/LightboxControls.tsx
apps/photos/components/Lightbox.tsx
Update dependencies and add comprehensive README
  • Add exifreader, sharp, uuid and their type packages to dependencies
  • Create README.md detailing features, architecture, build-time scanning, and usage
apps/photos/package.json
apps/photos/README.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello @duyet, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the photography portfolio application by introducing a comprehensive local photo upload and management system. It allows users to seamlessly integrate their own images, complete with rich EXIF metadata, into the existing gallery alongside Unsplash photos. The changes include build-time processing for metadata extraction and image optimization, a unified data model for both photo sources, and an enriched Lightbox experience that displays detailed technical information for local uploads. This feature provides greater flexibility and control over the displayed content, transforming the application into a more versatile photography showcase.

Highlights

  • Local Photo Upload & Management: Introduces the ability to upload and manage local photos stored in the public/photos/ directory, alongside existing Unsplash integration.
  • Comprehensive EXIF Metadata: Implements detailed EXIF metadata extraction using exifreader for local photos, including camera, lens, shooting settings, GPS, and more.
  • Unified Photo Gallery: Merges both Unsplash and local photos into a single, chronologically sorted gallery, enhancing the user experience.
  • Detailed Lightbox Display: The Lightbox now displays extensive EXIF data and file information for local photos, differentiating them from Unsplash photos with source indicators.
  • Build-Time Processing: Local photos are scanned, EXIF data extracted, and processed (e.g., image dimensions with sharp) at build time, ensuring optimal performance for the static site.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes - here's some feedback:

  • There’s a lot of duplicated camera/settings formatting logic between the Unsplash and local code paths in MetadataFormatters.ts; consider extracting the common formatting into shared helper functions to reduce duplication.
  • scanLocalPhotos processes each file sequentially which can slow down build times—consider batching the EXIF extraction and Sharp operations with Promise.all to parallelize work.
  • The grouping-by-year logic appears both in localPhotos.ts and page.tsx; consolidating that into a single utility function would keep the code DRY and easier to maintain.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- There’s a lot of duplicated camera/settings formatting logic between the Unsplash and local code paths in MetadataFormatters.ts; consider extracting the common formatting into shared helper functions to reduce duplication.
- scanLocalPhotos processes each file sequentially which can slow down build times—consider batching the EXIF extraction and Sharp operations with Promise.all to parallelize work.
- The grouping-by-year logic appears both in localPhotos.ts and page.tsx; consolidating that into a single utility function would keep the code DRY and easier to maintain.

## Individual Comments

### Comment 1
<location> `apps/photos/lib/MetadataFormatters.ts:74-77` </location>
<code_context>
-      .filter(Boolean)
-      .join(', ')
+  if (photo.location) {
+    if (photo.source === 'local' && photo.location.position) {
+      // For local photos with GPS coordinates
+      const { latitude, longitude } = photo.location.position
+      metadata.location = `${latitude.toFixed(6)}°, ${longitude.toFixed(6)}°`
+      if (photo.location.name) {
+        metadata.location = `${photo.location.name} (${metadata.location})`
</code_context>

<issue_to_address>
**issue:** No handling for invalid or missing latitude/longitude values.

Add a check to confirm latitude and longitude are valid numbers before calling toFixed to prevent runtime errors.
</issue_to_address>

### Comment 2
<location> `apps/photos/lib/MetadataFormatters.ts:139` </location>
<code_context>
+  // File info for local photos
+  if (photo.source === 'local') {
+    metadata.fileInfo = {
+      size: formatFileSize(photo.size),
+      filename: photo.originalName,
+      mimeType: photo.mimeType,
</code_context>

<issue_to_address>
**issue:** No fallback for missing photo.size.

Default to 0 or handle undefined to prevent formatFileSize from returning NaN.
</issue_to_address>

### Comment 3
<location> `apps/photos/lib/MetadataFormatters.ts:138-141` </location>
<code_context>
+  if (photo.source === 'local') {
+    metadata.fileInfo = {
+      size: formatFileSize(photo.size),
+      filename: photo.originalName,
+      mimeType: photo.mimeType,
     }
</code_context>

<issue_to_address>
**suggestion:** Potential mismatch between filename and originalName.

If originalName is missing, fallback to filename to avoid undefined values.

```suggestion
    metadata.fileInfo = {
      size: formatFileSize(photo.size),
      filename: photo.originalName ?? photo.filename,
      mimeType: photo.mimeType,
```
</issue_to_address>

### Comment 4
<location> `apps/photos/lib/MetadataFormatters.ts:106` </location>
<code_context>
-    if (camera) {
-      metadata.exif = { camera, settings }
+  if (photo.exif) {
+    if (photo.source === 'local') {
+      // Detailed EXIF for local photos
+      const exif = photo.exif as DetailedExif
</code_context>

<issue_to_address>
**suggestion:** Inconsistent handling of focal length types.

Normalize focalLength to a consistent type before formatting to prevent issues like 'undefinedmm' or '[object Object]mm'.

```suggestion
        exif.focalLength !== undefined
          ? `${typeof exif.focalLength === 'number'
              ? exif.focalLength
              : typeof exif.focalLength === 'string'
                ? exif.focalLength
                : Array.isArray(exif.focalLength) && exif.focalLength.length
                  ? exif.focalLength[0]
                  : ''}mm`
          : undefined,
```
</issue_to_address>

### Comment 5
<location> `apps/photos/lib/types.ts:220` </location>
<code_context>
+  | LocalPhoto
+
+// Type guard to check if a photo is from Unsplash
+export function isUnsplashPhoto(photo: Photo): photo is UnsplashPhoto & { source: 'unsplash' } {
+  return photo.source === 'unsplash' || 'user' in photo && 'links' in (photo as any).user
+}
</code_context>

<issue_to_address>
**issue (bug_risk):** Type guard logic may produce false positives.

The current check may misclassify local photos with a 'user' object containing 'links' as Unsplash photos. Please refine the logic to avoid this issue.
</issue_to_address>

### Comment 6
<location> `apps/photos/lib/localPhotos.ts:84` </location>
<code_context>
+          },
+          exif,
+          location,
+          description: exif?.description || exif?.userComment,
+          stats: {
+            views: 0,
</code_context>

<issue_to_address>
**suggestion:** No fallback for missing description and userComment.

If neither field is present, description will be undefined. Set a default value to ensure consistent UI behavior.

```suggestion
          description: exif?.description || exif?.userComment || "",
```
</issue_to_address>

### Comment 7
<location> `apps/photos/lib/localPhotos.ts:162` </location>
<code_context>
+    ...localPhotos,
+  ]
+
+  // Sort by created_at date (newest first)
+  allPhotos.sort(
+    (a, b) =>
</code_context>

<issue_to_address>
**issue:** No handling for invalid created_at values.

Invalid date strings will result in NaN timestamps, potentially disrupting sort order. Please validate created_at before sorting.
</issue_to_address>

### Comment 8
<location> `apps/photos/README.md:370` </location>
<code_context>
+### Missing EXIF data?
+- Some photos may not have EXIF data
+- Photos edited in some apps may strip EXIF
+- Use a EXIF viewer to check if data exists in the file
+
+### Build errors?
</code_context>

<issue_to_address>
**issue (typo):** Change 'a EXIF viewer' to 'an EXIF viewer' for correct grammar.

Use 'an' instead of 'a' before 'EXIF' to match standard English usage.

```suggestion
- Use an EXIF viewer to check if data exists in the file
```
</issue_to_address>

### Comment 9
<location> `apps/photos/lib/MetadataFormatters.ts:73` </location>
<code_context>
-    metadata.location = [photo.location.city, photo.location.country]
-      .filter(Boolean)
-      .join(', ')
+  if (photo.location) {
+    if (photo.source === 'local' && photo.location.position) {
+      // For local photos with GPS coordinates
</code_context>

<issue_to_address>
**issue (complexity):** Consider extracting location and EXIF formatting into helper functions to reduce duplication and nested branching.

```suggestion
// New helper to collapse all location‐formatting logic in one place
function formatLocation(
  loc: Photo['location'] | undefined,
  source: Photo['source']
): string | undefined {
  if (!loc) return;
  // local GPS‐based location
  if (source === 'local' && loc.position) {
    const { latitude, longitude } = loc.position;
    const coords = `${latitude.toFixed(6)}°, ${longitude.toFixed(6)}°`;
    return loc.name ? `${loc.name} (${coords})` : coords;
  }
  // city/country for either source
  const parts = [loc.city, loc.country].filter(Boolean);
  return parts.length ? parts.join(', ') : undefined;
}

// New helper to collapse EXIF camera/settings logic
function formatExif(
  exif: UnsplashExif | DetailedExif,
  source: Photo['source']
): { camera: string; settings: string; detailedInfo?: string[] } | undefined {
  const isLocal = source === 'local';
  // common camera/model join
  const camera = [exif.make, exif.model].filter(Boolean).join(' ');
  // common settings join, but key names differ
  const settings = [
    isLocal ? exif.aperture && `f/${exif.aperture}` : exif.aperture && `f/${exif.aperture}`,
    isLocal
      ? (exif as DetailedExif).exposureTime && `${(exif as DetailedExif).exposureTime}s`
      : (exif as UnsplashExif).exposure_time && `${(exif as UnsplashExif).exposure_time}s`,
    exif.iso && `ISO ${exif.iso}`,
    isLocal ? (exif as DetailedExif).focalLength && `${(exif as DetailedExif).focalLength}mm` : exif.focal_length && `${exif.focal_length}mm`,
  ]
    .filter(Boolean)
    .join(' • ');
  if (!camera && !settings) return;

  const result: ReturnType<typeof formatExif> = { camera, settings };
  if (isLocal) {
    result.detailedInfo = formatDetailedExif(exif as DetailedExif);
  }
  return result;
}

// Then consumption in formatPhotoMetadata:
export function formatPhotoMetadata(photo: Photo): PhotoMetadata {
  const metadata: PhotoMetadata = {
    dateFormatted: formatPhotoDate(photo.created_at),
    dimensions: `${photo.width} × ${photo.height}`,
    source: photo.source,
  };

  const loc = formatLocation(photo.location, photo.source);
  if (loc) metadata.location = loc;

  if (photo.stats) {
    metadata.stats = {
      views: photo.stats.views.toLocaleString(),
      downloads: photo.stats.downloads.toLocaleString(),
    };
  }

  if (photo.exif) {
    const exifData = formatExif(photo.exif, photo.source);
    if (exifData) metadata.exif = exifData;
  }

  if (photo.source === 'local') {
    metadata.fileInfo = {
      size: formatFileSize(photo.size),
      filename: photo.originalName,
      mimeType: photo.mimeType,
    };
  }

  // ...attribution logic unchanged
  return metadata;
}

// And in formatCompactMetadata:
export function formatCompactMetadata(photo: Photo) {
  const primary: string[] = [formatPhotoDate(photo.created_at)];
  if (photo.stats) {
    primary.push(`👁 ${photo.stats.views.toLocaleString()}`, `⬇ ${photo.stats.downloads.toLocaleString()}`);
  }
  if (photo.source === 'local') primary.push('📁 Local');

  const secondary: string[] = [`${photo.width} × ${photo.height}`];
  const loc = formatLocation(photo.location, photo.source);
  if (loc) secondary.push(`📍 ${loc}`);

  return { primary, secondary };
}
```  
Extracting `formatLocation` and `formatExif` removes nested branches and duplicated code while preserving all existing behavior.
</issue_to_address>

### Comment 10
<location> `apps/photos/lib/exifExtractor.ts:19` </location>
<code_context>
+
+    const exif: DetailedExif = {}
+
+    // Camera information
+    if (tags.exif?.Make) {
+      exif.make = tags.exif.Make.description || String(tags.exif.Make.value)
</code_context>

<issue_to_address>
**issue (complexity):** Consider refactoring the repetitive EXIF extraction blocks into mapping-driven loops for maintainability and brevity.

You can collapse all of those repetitive `if (tags… ) { exif… = … }` blocks into a small mapping‐driven loop. For example:

```ts
// 1. Define a reusable mapper
type TagInfo = {
  path: string[];              // e.g. ['exif','Make']
  prop: keyof DetailedExif;    // e.g. 'make'
  transform?: (t: any)=>any;   // optional override
}

const TAG_MAPPINGS: TagInfo[] = [
  // Camera
  { path: ['exif','Make'],            prop: 'make' },
  { path: ['exif','Model'],           prop: 'model' },
  { path: ['exif','LensModel'],       prop: 'lensModel' },
  // Shooting
  { path: ['exif','ExposureTime'],    prop: 'exposureTime' },
  { path: ['exif','FNumber'],         prop: 'fNumber',        transform: t => Number(t.value) },
  { path: ['exif','ApertureValue'],   prop: 'aperture' },
  { path: ['exif','ISO'],             prop: 'iso',            transform: t => Number(t.value) },
  // …and so on for each field…
  // File dims
  { path: ['file','Image Width'],     prop: 'width',          transform: t => Number(t.value) },
  { path: ['file','Image Height'],    prop: 'height',         transform: t => Number(t.value) },
]
```

```ts
// 2. Apply it in extractExifData:
export async function extractExifData(buffer: Buffer): Promise<DetailedExif|undefined> {
  const tags = ExifReader.load(buffer, { expanded: true });
  if (!tags) return;

  const exif: DetailedExif = {};
  for (const {path, prop, transform} of TAG_MAPPINGS) {
    const tag = path.reduce((o,k) => o?.[k], tags);
    if (!tag) continue;
    const val = transform
      ? transform(tag)
      : tag.description || String(tag.value);
    exif[prop] = val;
  }

  // GPS can be done similarly:
  if (tags.gps) {
    const gpsFields: Array<{ key: keyof DetailedExif['gps'], path: string[], transform?: (v:any)=>any }> = [
      { key: 'latitude',  path: ['Latitude'],        transform: v => Number(v) },
      { key: 'longitude', path: ['Longitude'],       transform: v => Number(v) },
      { key: 'altitude',  path: ['Altitude'],        transform: v => Number(v) },
      { key: 'latitudeRef',  path: ['LatitudeRef'] },
      { key: 'longitudeRef', path: ['LongitudeRef'] },
    ];
    exif.gps = {};
    for (const {key, path, transform} of gpsFields) {
      const v = path.reduce((o,k) => o?.[k], tags.gps);
      if (v != null) exif.gps[key] = transform ? transform(v) : String(v.value ?? v);
    }
  }

  return Object.keys(exif).length ? exif : undefined;
}
```

This collapses ~70 lines of guards into two small loops, makes it trivial to add/remove fields, and keeps all existing behavior.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +74 to +77
if (photo.source === 'local' && photo.location.position) {
// For local photos with GPS coordinates
const { latitude, longitude } = photo.location.position
metadata.location = `${latitude.toFixed(6)}°, ${longitude.toFixed(6)}°`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: No handling for invalid or missing latitude/longitude values.

Add a check to confirm latitude and longitude are valid numbers before calling toFixed to prevent runtime errors.

// File info for local photos
if (photo.source === 'local') {
metadata.fileInfo = {
size: formatFileSize(photo.size),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: No fallback for missing photo.size.

Default to 0 or handle undefined to prevent formatFileSize from returning NaN.

Comment on lines +138 to +141
metadata.fileInfo = {
size: formatFileSize(photo.size),
filename: photo.originalName,
mimeType: photo.mimeType,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Potential mismatch between filename and originalName.

If originalName is missing, fallback to filename to avoid undefined values.

Suggested change
metadata.fileInfo = {
size: formatFileSize(photo.size),
filename: photo.originalName,
mimeType: photo.mimeType,
metadata.fileInfo = {
size: formatFileSize(photo.size),
filename: photo.originalName ?? photo.filename,
mimeType: photo.mimeType,

exif.aperture && `f/${exif.aperture}`,
exif.exposureTime && `${exif.exposureTime}`,
exif.iso && `ISO ${exif.iso}`,
exif.focalLength && `${exif.focalLength}mm`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Inconsistent handling of focal length types.

Normalize focalLength to a consistent type before formatting to prevent issues like 'undefinedmm' or '[object Object]mm'.

Suggested change
exif.focalLength && `${exif.focalLength}mm`,
exif.focalLength !== undefined
? `${typeof exif.focalLength === 'number'
? exif.focalLength
: typeof exif.focalLength === 'string'
? exif.focalLength
: Array.isArray(exif.focalLength) && exif.focalLength.length
? exif.focalLength[0]
: ''}mm`
: undefined,

| LocalPhoto

// Type guard to check if a photo is from Unsplash
export function isUnsplashPhoto(photo: Photo): photo is UnsplashPhoto & { source: 'unsplash' } {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Type guard logic may produce false positives.

The current check may misclassify local photos with a 'user' object containing 'links' as Unsplash photos. Please refine the logic to avoid this issue.

},
exif,
location,
description: exif?.description || exif?.userComment,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: No fallback for missing description and userComment.

If neither field is present, description will be undefined. Set a default value to ensure consistent UI behavior.

Suggested change
description: exif?.description || exif?.userComment,
description: exif?.description || exif?.userComment || "",

### Missing EXIF data?
- Some photos may not have EXIF data
- Photos edited in some apps may strip EXIF
- Use a EXIF viewer to check if data exists in the file
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (typo): Change 'a EXIF viewer' to 'an EXIF viewer' for correct grammar.

Use 'an' instead of 'a' before 'EXIF' to match standard English usage.

Suggested change
- Use a EXIF viewer to check if data exists in the file
- Use an EXIF viewer to check if data exists in the file

@cloudflare-workers-and-pages
Copy link

Deploying duyet-cv with  Cloudflare Pages  Cloudflare Pages

Latest commit: a414238
Status: ✅  Deploy successful!
Preview URL: https://2e447d59.duyet-cv.pages.dev
Branch Preview URL: https://claude-implement-photo-uploa.duyet-cv.pages.dev

View logs

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a robust system for scanning local photos at build time, extracting their EXIF metadata, and integrating them into the photo gallery alongside Unsplash images. The changes are extensive, touching everything from data fetching and type definitions to UI components and metadata formatting. The addition of a comprehensive README is also a great touch.

However, I've identified a critical security concern with the versions of several new dependencies in package.json, which appear to be incorrect and could be malicious. This needs immediate attention. Additionally, there's a significant feature gap: the documented image resizing pipeline is not implemented in the code, which will negatively impact performance. I've also found a few areas for improvement regarding date handling, code duplication, and type safety that would enhance the maintainability and correctness of this new feature.

Comment on lines +30 to +41
"exifreader": "^4.32.0",
"framer-motion": "^11.2.10",
"lucide-react": "^0.546.0",
"next": "15.5.6",
"next-themes": "^0.4.0",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-masonry-css": "^1.0.16",
"sharp": "^0.34.4",
"tailwind-merge": "^3.3.1",
"unsplash-js": "^7.0.15"
"unsplash-js": "^7.0.15",
"uuid": "^13.0.0"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The versions specified for exifreader, sharp, and uuid are incorrect and pose a potential security risk. These versions do not correspond to official releases on npm and could be typos or refer to malicious packages.

  • exifreader: You've specified ^4.32.0, but the latest version is ^4.23.0.
  • sharp: You've specified ^0.34.4, but the latest is ^0.33.4.
  • uuid: You've specified ^13.0.0, which is not a valid version range; the latest is ^9.0.1.

Please update these to their correct, stable versions to prevent security vulnerabilities and ensure your project uses the official packages.

Comment on lines +75 to +81
urls: {
raw: `/photos/${filename}`,
full: `/photos/${filename}`,
regular: `/photos/${filename}`,
small: `/photos/${filename}`,
thumb: `/photos/${filename}`,
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The urls object for local photos currently points all image sizes to the original file. This contradicts the documentation (in both the PR description and README), which states that multiple image sizes (regular, small, thumb) are generated. Serving the original large image for all contexts will negatively impact web performance, especially for thumbnails.

You should implement the image processing logic here using sharp to create the different size variants and populate the urls object with the correct paths to these resized images.

Comment on lines +31 to +40
// Group by year
photosByYear = {}
photos.forEach((photo) => {
const date = new Date(photo.created_at)
const year = date.getFullYear().toString()
if (!photosByYear[year]) {
photosByYear[year] = []
}
photosByYear[year].push(photo)
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic for grouping photos by year is implemented directly within this page component. To improve code reusability and maintainability, this logic should be extracted into a dedicated utility function, similar to how mergePhotos is handled. This would also align with the groupLocalPhotosByYear function that already exists but is currently unused.

Suggested change
// Group by year
photosByYear = {}
photos.forEach((photo) => {
const date = new Date(photo.created_at)
const year = date.getFullYear().toString()
if (!photosByYear[year]) {
photosByYear[year] = []
}
photosByYear[year].push(photo)
})
// Group by year
photosByYear = groupPhotosByYear(photos)

Comment on lines +156 to 162
} else if (photo.source === 'local') {
// Local photo uploader info
metadata.attribution = {
photographer: photo.user.name || 'Unknown',
username: photo.user.username || 'local',
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This else if block for handling local photo attribution will never be executed. The scanLocalPhotos function, which is responsible for creating LocalPhoto objects, does not populate the user property. Consequently, photo.user will always be undefined for local photos, making this dead code.

Given that the user upload feature described in the PR is not part of this changeset, this block should be removed to avoid confusion and keep the code aligned with the currently implemented features.

Comment on lines +198 to +218
export function extractPhotoDate(exif?: DetailedExif): string {
if (exif?.dateTimeOriginal) {
// Convert EXIF date format (YYYY:MM:DD HH:MM:SS) to ISO
const exifDate = exif.dateTimeOriginal.replace(/^(\d{4}):(\d{2}):(\d{2})/, '$1-$2-$3')
const date = new Date(exifDate)
if (!isNaN(date.getTime())) {
return date.toISOString()
}
}

if (exif?.dateTime) {
const exifDate = exif.dateTime.replace(/^(\d{4}):(\d{2}):(\d{2})/, '$1-$2-$3')
const date = new Date(exifDate)
if (!isNaN(date.getTime())) {
return date.toISOString()
}
}

// Fallback to current date
return new Date().toISOString()
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation of extractPhotoDate falls back to new Date().toISOString() when EXIF date information is not available. This will cause any photo without an EXIF date to be incorrectly sorted as the newest. A more reliable fallback would be to use the file's creation time (birthtime).

I recommend updating this function to accept an optional fileCreationDate parameter. You can then pass stats.birthtime from the scanLocalPhotos function to ensure more accurate chronological sorting.

export function extractPhotoDate(exif?: DetailedExif, fileCreationDate?: Date): string {
  if (exif?.dateTimeOriginal) {
    // Convert EXIF date format (YYYY:MM:DD HH:MM:SS) to ISO
    const exifDate = exif.dateTimeOriginal.replace(/^(\d{4}):(\d{2}):(\d{2})/, '$1-$2-$3');
    const date = new Date(exifDate);
    if (!isNaN(date.getTime())) {
      return date.toISOString();
    }
  }

  if (exif?.dateTime) {
    const exifDate = exif.dateTime.replace(/^(\d{4}):(\d{2}):(\d{2})/, '$1-$2-$3');
    const date = new Date(exifDate);
    if (!isNaN(date.getTime())) {
      return date.toISOString();
    }
  }

  if (fileCreationDate) {
    return fileCreationDate.toISOString();
  }

  // Fallback to current date as a last resort
  return new Date().toISOString();
}

const metadata = await image.metadata()

// Determine the creation date
const createdAt = extractPhotoDate(exif)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To complement the change in extractPhotoDate, you should pass the file's creation time (stats.birthtime) as a fallback. This will ensure more accurate sorting for images that lack EXIF date information.

Suggested change
const createdAt = extractPhotoDate(exif)
const createdAt = extractPhotoDate(exif, stats.birthtime)

Comment on lines +220 to +222
export function isUnsplashPhoto(photo: Photo): photo is UnsplashPhoto & { source: 'unsplash' } {
return photo.source === 'unsplash' || 'user' in photo && 'links' in (photo as any).user
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The isUnsplashPhoto type guard is unnecessarily complex. Since the source property is a literal type that is reliably set on both UnsplashPhoto and LocalPhoto, a simple check against photo.source === 'unsplash' is sufficient, more readable, and less brittle to future changes in the UnsplashPhoto type.

Suggested change
export function isUnsplashPhoto(photo: Photo): photo is UnsplashPhoto & { source: 'unsplash' } {
return photo.source === 'unsplash' || 'user' in photo && 'links' in (photo as any).user
}
export function isUnsplashPhoto(photo: Photo): photo is UnsplashPhoto & { source: 'unsplash' } {
return photo.source === 'unsplash';
}

@cloudflare-workers-and-pages
Copy link

Deploying duyet-photos with  Cloudflare Pages  Cloudflare Pages

Latest commit: a414238
Status:🚫  Build failed.

View logs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants