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
41 changes: 22 additions & 19 deletions apps/web/src/components/caption-controls.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DATE_FORMATS, type DateFormat } from '@/lib/date'
import { cn } from '@/lib/utils'
import { LOCATION_DETAILS } from '@/lib/geocode'
import { usePhotoStore } from '@/stores/photo-store'
import {
type CaptionLocation,
Expand Down Expand Up @@ -33,26 +33,29 @@ export function CaptionControls() {
return (
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground text-xs font-medium">
<label
htmlFor="location-detail"
className="text-muted-foreground text-xs font-medium"
>
Location
</span>
<div className="border-input flex overflow-hidden rounded-md border text-xs">
{(['city', 'country'] as const).map((location) => (
<button
key={location}
type="button"
onClick={() => selectLocation(location)}
className={cn(
'px-2 py-1 capitalize',
captionLocation === location
? 'bg-primary text-primary-foreground'
: 'bg-background hover:bg-accent',
)}
>
{location}
</button>
</label>
<select
id="location-detail"
value={captionLocation}
onChange={(event) =>
selectLocation(event.target.value as CaptionLocation)
}
className="border-input bg-background rounded-md border px-2 py-1 text-xs"
>
{LOCATION_DETAILS.map((detail) => (
<option key={detail.id} value={detail.id}>
{detail.label}
</option>
))}
</div>
<option value="neighborhood" disabled>
Neighborhood (offline N/A)
</option>
</select>
</div>
<div className="flex items-center gap-1.5">
<label
Expand Down
26 changes: 26 additions & 0 deletions apps/web/src/lib/geocode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,32 @@ export interface PlaceName {
countryIso2: string
}

export type Place = { city: string; country: string }

// How much of the resolved place to show. The offline dataset only knows city
// and country, so finer levels (neighborhood) aren't offered — see #39.
export type LocationDetail = 'country' | 'city' | 'cityCountry'

export const LOCATION_DETAILS: { id: LocationDetail; label: string }[] = [
{ id: 'country', label: 'Country' },
{ id: 'city', label: 'City' },
{ id: 'cityCountry', label: 'City, Country' },
]

export function placeLabel(place: Place, detail: LocationDetail): string {
if (detail === 'country') return place.country
if (detail === 'cityCountry') return `${place.city}, ${place.country}`
return place.city
}

/** True if `text` is any auto-generated location label for this place. */
export function isAutoLocation(text: string, place: Place): boolean {
return (
text === '' ||
LOCATION_DETAILS.some(({ id }) => placeLabel(place, id) === text)
)
}

// The cities dataset (~1.7 MB) is lazy-loaded on first use so it never bloats
// the initial bundle. Everything runs locally — coordinates never leave the
// device (see docs/decisions/0001-offline-reverse-geocoding.md).
Expand Down
12 changes: 5 additions & 7 deletions apps/web/src/stores/photo-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { create } from 'zustand'
import { type Crop, type Orientation, orientationFor } from '@/lib/crop'
import { type DateFormat, formatCaptionDate, isAutoDate } from '@/lib/date'
import { readExif } from '@/lib/exif'
import { reverseGeocode } from '@/lib/geocode'
import { isAutoLocation, placeLabel, reverseGeocode } from '@/lib/geocode'
import {
type CaptionField,
type Photo,
Expand Down Expand Up @@ -76,7 +76,7 @@ export const usePhotoStore = create<PhotoState>((set) => {
const place = await reverseGeocode(exif.latitude, exif.longitude)
if (place) {
next.place = { city: place.city, country: place.country }
next.captionTop = next.place[settings.captionLocation]
next.captionTop = placeLabel(next.place, settings.captionLocation)
}
}
if (
Expand Down Expand Up @@ -150,11 +150,9 @@ export const usePhotoStore = create<PhotoState>((set) => {
set((state) => ({
photos: state.photos.map((p) => {
if (!p.place) return p
const isAuto =
p.captionTop === '' ||
p.captionTop === p.place.city ||
p.captionTop === p.place.country
return isAuto ? { ...p, captionTop: p.place[mode] } : p
return isAutoLocation(p.captionTop, p.place)
? { ...p, captionTop: placeLabel(p.place, mode) }
: p
}),
})),
applyDateFormat: (format) =>
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/stores/settings-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { create } from 'zustand'

import { type DateFormat, DEFAULT_DATE_FORMAT } from '@/lib/date'
import { DEFAULT_CAPTION_FONT_ID } from '@/lib/fonts'
import { type LocationDetail } from '@/lib/geocode'
import { DEFAULT_PAPER_SIZE_ID } from '@/lib/layout'

export const MIN_FRAME_PADDING = 6
Expand All @@ -10,7 +11,7 @@ export const MAX_FRAME_PADDING = 28
export const MIN_PER_ROW = 2
export const MAX_PER_ROW = 5

export type CaptionLocation = 'city' | 'country'
export type CaptionLocation = LocationDetail

/** The persistable subset of settings (no action functions). */
export interface SettingsSnapshot {
Expand Down
Loading