Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@tanstack/react-router": "^1.170.16",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"exifr": "^7.1.3",
"lucide-react": "^1.21.0",
"offline-geocode-city": "^1.0.2",
"react": "^19.2.7",
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/lib/date.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const captionDateFormat = new Intl.DateTimeFormat(undefined, {
month: 'long',
year: 'numeric',
})

/** Formats a capture date for a polaroid caption, e.g. "October 2008". */
export function formatCaptionDate(date: Date): string {
return captionDateFormat.format(date)
}
22 changes: 22 additions & 0 deletions apps/web/src/lib/exif.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import exifr from 'exifr'

export interface PhotoExif {
latitude?: number
longitude?: number
takenAt?: Date
}

/** Reads GPS + capture date from a photo's EXIF. All client-side. */
export async function readExif(file: File): Promise<PhotoExif> {
const [gps, meta] = await Promise.all([
exifr.gps(file).catch(() => null),
exifr.parse(file, ['DateTimeOriginal', 'CreateDate']).catch(() => null),
])

const takenAt = meta?.DateTimeOriginal ?? meta?.CreateDate
return {
latitude: gps?.latitude,
longitude: gps?.longitude,
takenAt: takenAt instanceof Date ? takenAt : undefined,
}
}
78 changes: 54 additions & 24 deletions apps/web/src/stores/photo-store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { create } from 'zustand'

import { formatCaptionDate } from '@/lib/date'
import { readExif } from '@/lib/exif'
import { reverseGeocode } from '@/lib/geocode'
import {
type CaptionField,
type Photo,
Expand All @@ -16,30 +19,57 @@ interface PhotoState {
clear: () => void
}

export const usePhotoStore = create<PhotoState>((set) => ({
photos: [],
addFiles: (files) => {
const accepted = files.filter(isImageFile).map(createPhoto)
if (accepted.length > 0) {
set((state) => ({ photos: [...state.photos, ...accepted] }))
export const usePhotoStore = create<PhotoState>((set) => {
// Fills empty captions from EXIF — city from GPS, date from capture time.
// Runs async after the photo appears; never overwrites a caption the user
// has already typed.
async function enrich(photo: Photo) {
const exif = await readExif(photo.file)
const next: { captionTop?: string; captionBottom?: string } = {}
if (exif.takenAt) next.captionBottom = formatCaptionDate(exif.takenAt)
if (exif.latitude != null && exif.longitude != null) {
const place = await reverseGeocode(exif.latitude, exif.longitude)
if (place) next.captionTop = place.city
}
return accepted.length
},
setCaption: (id, field, value) =>
if (!next.captionTop && !next.captionBottom) return
set((state) => ({
photos: state.photos.map((photo) =>
photo.id === id ? { ...photo, [field]: value } : photo,
photos: state.photos.map((p) =>
p.id === photo.id
? {
...p,
captionTop: p.captionTop || next.captionTop || '',
captionBottom: p.captionBottom || next.captionBottom || '',
}
: p,
),
})),
remove: (id) =>
set((state) => {
const target = state.photos.find((photo) => photo.id === id)
if (target) URL.revokeObjectURL(target.url)
return { photos: state.photos.filter((photo) => photo.id !== id) }
}),
clear: () =>
set((state) => {
state.photos.forEach((photo) => URL.revokeObjectURL(photo.url))
return { photos: [] }
}),
}))
}))
}

return {
photos: [],
addFiles: (files) => {
const accepted = files.filter(isImageFile).map(createPhoto)
if (accepted.length === 0) return 0
set((state) => ({ photos: [...state.photos, ...accepted] }))
for (const photo of accepted) void enrich(photo)
return accepted.length
},
setCaption: (id, field, value) =>
set((state) => ({
photos: state.photos.map((photo) =>
photo.id === id ? { ...photo, [field]: value } : photo,
),
})),
remove: (id) =>
set((state) => {
const target = state.photos.find((photo) => photo.id === id)
if (target) URL.revokeObjectURL(target.url)
return { photos: state.photos.filter((photo) => photo.id !== id) }
}),
clear: () =>
set((state) => {
state.photos.forEach((photo) => URL.revokeObjectURL(photo.url))
return { photos: [] }
}),
}
})
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading