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
11 changes: 10 additions & 1 deletion apps/web/src/components/a4-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export function A4Preview() {
const perRow = useSettingsStore((state) => state.polaroidsPerRow)
const showCutMarks = useSettingsStore((state) => state.showCutMarks)
const showCaptions = useSettingsStore((state) => state.showCaptions)
const showCameraLine = useSettingsStore((state) => state.showCameraLine)
const fontStack = captionFontStack(captionFontId)
const paper = paperSize(paperSizeId)

Expand All @@ -26,7 +27,14 @@ export function A4Preview() {
const handleExport = async () => {
setIsExporting(true)
try {
await downloadSheetPdf(photos, perRow, showCutMarks, showCaptions, paper)
await downloadSheetPdf(
photos,
perRow,
showCutMarks,
showCaptions,
showCameraLine,
paper,
)
} finally {
setIsExporting(false)
}
Expand Down Expand Up @@ -85,6 +93,7 @@ export function A4Preview() {
fontStack={fontStack}
showCutMarks={showCutMarks}
showCaptions={showCaptions}
showCameraLine={showCameraLine}
/>
</div>
))}
Expand Down
40 changes: 40 additions & 0 deletions apps/web/src/components/caption-controls.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { DATE_FORMATS, type DateFormat } from '@/lib/date'
import { cn } from '@/lib/utils'
import { usePhotoStore } from '@/stores/photo-store'
import {
Expand All @@ -10,15 +11,25 @@ export function CaptionControls() {
const setCaptionLocation = useSettingsStore(
(state) => state.setCaptionLocation,
)
const dateFormat = useSettingsStore((state) => state.dateFormat)
const setDateFormat = useSettingsStore((state) => state.setDateFormat)
const showCameraLine = useSettingsStore((state) => state.showCameraLine)
const setShowCameraLine = useSettingsStore((state) => state.setShowCameraLine)
const showCaptions = useSettingsStore((state) => state.showCaptions)
const setShowCaptions = useSettingsStore((state) => state.setShowCaptions)
const applyLocationMode = usePhotoStore((state) => state.applyLocationMode)
const applyDateFormat = usePhotoStore((state) => state.applyDateFormat)

const selectLocation = (location: CaptionLocation) => {
setCaptionLocation(location)
applyLocationMode(location)
}

const selectDateFormat = (format: DateFormat) => {
setDateFormat(format)
applyDateFormat(format)
}

return (
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-1.5">
Expand All @@ -43,6 +54,35 @@ export function CaptionControls() {
))}
</div>
</div>
<div className="flex items-center gap-1.5">
<label
htmlFor="date-format"
className="text-muted-foreground text-xs font-medium"
>
Date
</label>
<select
id="date-format"
value={dateFormat}
onChange={(event) => selectDateFormat(event.target.value as DateFormat)}
className="border-input bg-background rounded-md border px-2 py-1 text-xs"
>
{DATE_FORMATS.map((format) => (
<option key={format.id} value={format.id}>
{format.label}
</option>
))}
</select>
</div>
<label className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
<input
type="checkbox"
checked={showCameraLine}
onChange={(event) => setShowCameraLine(event.target.checked)}
className="accent-primary"
/>
Camera info
</label>
<label className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
<input
type="checkbox"
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/components/polaroid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function Polaroid({ photo }: { photo: Photo }) {
const framePadding = useSettingsStore((state) => state.framePadding)
const captionFontId = useSettingsStore((state) => state.captionFontId)
const showCaptions = useSettingsStore((state) => state.showCaptions)
const showCameraLine = useSettingsStore((state) => state.showCameraLine)
const fontFamily = captionFontStack(captionFontId)

return (
Expand Down Expand Up @@ -109,6 +110,14 @@ export function Polaroid({ photo }: { photo: Photo }) {
fontFamily={fontFamily}
className="text-sm text-neutral-500"
/>
{showCameraLine && photo.cameraLine && (
<span
className="mt-0.5 max-w-full truncate text-[10px] text-neutral-400"
style={{ fontFamily }}
>
{photo.cameraLine}
</span>
)}
</>
)}
</div>
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/sheet-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export function SheetPage({
fontStack,
showCutMarks,
showCaptions,
showCameraLine,
}: {
photos: Photo[]
width: number
Expand All @@ -19,6 +20,7 @@ export function SheetPage({
fontStack: string
showCutMarks: boolean
showCaptions: boolean
showCameraLine: boolean
}) {
const layout = sheetLayout(perRow, paper)
const mmToPx = width / paper.widthMm
Expand Down Expand Up @@ -52,6 +54,7 @@ export function SheetPage({
width={rect.width * mmToPx}
fontStack={fontStack}
showCaptions={showCaptions}
showCameraLine={showCameraLine}
/>
</div>
)
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/components/sheet-polaroid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ export function SheetPolaroid({
width,
fontStack,
showCaptions,
showCameraLine,
}: {
photo: Photo
width: number
fontStack: string
showCaptions: boolean
showCameraLine: boolean
}) {
const pad = width * POLAROID.framePad
const imageSize = width - pad * 2
Expand Down Expand Up @@ -73,6 +75,18 @@ export function SheetPolaroid({
>
{photo.captionBottom}
</span>
{showCameraLine && photo.cameraLine && (
<span
className="max-w-full truncate"
style={{
fontSize: width * 0.045,
lineHeight: 1.2,
color: '#a3a3a3',
}}
>
{photo.cameraLine}
</span>
)}
</>
)}
</div>
Expand Down
40 changes: 34 additions & 6 deletions apps/web/src/lib/date.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,37 @@
const captionDateFormat = new Intl.DateTimeFormat(undefined, {
month: 'long',
year: 'numeric',
})
export type DateFormat = 'monthYear' | 'fullDate' | 'year'

export const DATE_FORMATS: { id: DateFormat; label: string }[] = [
{ id: 'monthYear', label: 'Month Year' },
{ id: 'fullDate', label: 'Full date' },
{ id: 'year', label: 'Year' },
]

export const DEFAULT_DATE_FORMAT: DateFormat = 'monthYear'

const formatters: Record<DateFormat, Intl.DateTimeFormat> = {
monthYear: new Intl.DateTimeFormat(undefined, {
month: 'long',
year: 'numeric',
}),
fullDate: new Intl.DateTimeFormat(undefined, {
day: 'numeric',
month: 'long',
year: 'numeric',
}),
year: new Intl.DateTimeFormat(undefined, { year: 'numeric' }),
}

/** Formats a capture date for a polaroid caption, e.g. "October 2008". */
export function formatCaptionDate(date: Date): string {
return captionDateFormat.format(date)
export function formatCaptionDate(
date: Date,
format: DateFormat = DEFAULT_DATE_FORMAT,
): string {
return formatters[format].format(date)
}

/** True if `text` is any auto-generated date string for this capture time. */
export function isAutoDate(text: string, takenAt: number | undefined): boolean {
if (!text || takenAt == null) return false
const date = new Date(takenAt)
return DATE_FORMATS.some(({ id }) => formatCaptionDate(date, id) === text)
}
50 changes: 48 additions & 2 deletions apps/web/src/lib/exif.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,65 @@ export interface PhotoExif {
latitude?: number
longitude?: number
takenAt?: Date
/** A one-line camera/lens/exposure summary, when available. */
cameraLine?: string
}

/** Reads GPS + capture date from a photo's EXIF. All client-side. */
const CAMERA_TAGS = [
'Make',
'Model',
'LensModel',
'FNumber',
'ExposureTime',
'ISO',
'FocalLength',
]

function formatExposureTime(seconds: number): string {
if (seconds >= 1) return `${seconds}s`
return `1/${Math.round(1 / seconds)}s`
}

/** Builds e.g. "Nikon D7000 · 35mm · f/2.8 1/200s ISO400". */
function formatCameraLine(meta: Record<string, unknown>): string | undefined {
const make = typeof meta.Make === 'string' ? meta.Make.trim() : ''
const model = typeof meta.Model === 'string' ? meta.Model.trim() : ''
// Camera bodies often repeat the make inside the model ("NIKON D7000").
const body = model.startsWith(make) ? model : [make, model].join(' ').trim()

const parts: string[] = []
if (body) parts.push(body)
if (typeof meta.LensModel === 'string' && meta.LensModel.trim())
parts.push(meta.LensModel.trim())
if (typeof meta.FocalLength === 'number')
parts.push(`${Math.round(meta.FocalLength)}mm`)

const exposure: string[] = []
if (typeof meta.FNumber === 'number') exposure.push(`f/${meta.FNumber}`)
if (typeof meta.ExposureTime === 'number')
exposure.push(formatExposureTime(meta.ExposureTime))
if (typeof meta.ISO === 'number') exposure.push(`ISO${meta.ISO}`)
if (exposure.length) parts.push(exposure.join(' '))

return parts.length ? parts.join(' · ') : undefined
}

/**
* Reads GPS, capture date and camera info 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),
exifr
.parse(file, ['DateTimeOriginal', 'CreateDate', ...CAMERA_TAGS])
.catch(() => null),
])

const takenAt = meta?.DateTimeOriginal ?? meta?.CreateDate
return {
latitude: gps?.latitude,
longitude: gps?.longitude,
takenAt: takenAt instanceof Date ? takenAt : undefined,
cameraLine: meta ? formatCameraLine(meta) : undefined,
}
}
24 changes: 24 additions & 0 deletions apps/web/src/lib/pdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export async function buildSheetPdf(
perRow: number,
cutMarks: boolean,
showCaptions: boolean,
showCameraLine: boolean,
paper: PaperSize,
): Promise<Uint8Array> {
const doc = await PDFDocument.create()
Expand All @@ -70,6 +71,16 @@ export async function buildSheetPdf(
const margin = layout.marginMm * PT_PER_MM
const centerX = (text: string, size: number, cx: number) =>
cx - font.widthOfTextAtSize(text, size) / 2
const fitText = (text: string, size: number, maxWidth: number) => {
if (font.widthOfTextAtSize(text, size) <= maxWidth) return text
let trimmed = text
while (
trimmed.length > 1 &&
font.widthOfTextAtSize(`${trimmed}…`, size) > maxWidth
)
trimmed = trimmed.slice(0, -1)
return `${trimmed}…`
}

for (let start = 0; start < photos.length; start += layout.capacity) {
const page = doc.addPage([pageW, pageH])
Expand Down Expand Up @@ -156,6 +167,17 @@ export async function buildSheetPdf(
color: rgb(0.45, 0.45, 0.45),
})
}
if (showCaptions && showCameraLine && photo.cameraLine) {
const camSize = w * 0.045
const line = fitText(photo.cameraLine, camSize, w - pad * 2)
page.drawText(line, {
x: centerX(line, camSize, cx),
y: capY - topSize - botSize - 2 - camSize - 1.5,
size: camSize,
font,
color: rgb(0.64, 0.64, 0.64),
})
}
}
}

Expand All @@ -167,6 +189,7 @@ export async function downloadSheetPdf(
perRow: number,
cutMarks: boolean,
showCaptions: boolean,
showCameraLine: boolean,
paper: PaperSize,
filename = 'polaroids.pdf',
): Promise<void> {
Expand All @@ -175,6 +198,7 @@ export async function downloadSheetPdf(
perRow,
cutMarks,
showCaptions,
showCameraLine,
paper,
)
const blob = new Blob([bytes as BlobPart], { type: 'application/pdf' })
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/lib/photos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ export interface Photo {
captionBottom: string
/** Place resolved from EXIF GPS — kept so the city/country toggle can switch. */
place?: { city: string; country: string }
/** Capture time (epoch ms) from EXIF — kept so the date format can switch. */
takenAt?: number
/** Camera/lens/exposure summary from EXIF, shown as an optional caption line. */
cameraLine?: string
/** How the photo is framed inside its window (pan + zoom). */
crop: Crop
/** Shape of the photo window (square / portrait / landscape). */
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/lib/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ interface PhotoMeta {
captionTop: string
captionBottom: string
place?: { city: string; country: string }
takenAt?: number
cameraLine?: string
crop?: Crop
orientation?: Orientation
/** Position on the sheet; metas are sorted by this on load. */
Expand Down Expand Up @@ -101,6 +103,8 @@ export async function loadSession(): Promise<{
captionTop: meta.captionTop,
captionBottom: meta.captionBottom,
place: meta.place,
takenAt: meta.takenAt,
cameraLine: meta.cameraLine,
crop: meta.crop ?? DEFAULT_CROP,
orientation: meta.orientation ?? DEFAULT_ORIENTATION,
enriching: false,
Expand Down Expand Up @@ -141,6 +145,8 @@ export async function savePhotos(photos: Photo[]): Promise<void> {
captionTop: photo.captionTop,
captionBottom: photo.captionBottom,
place: photo.place,
takenAt: photo.takenAt,
cameraLine: photo.cameraLine,
crop: photo.crop,
orientation: photo.orientation,
order,
Expand Down
Loading
Loading