From da932209649a623263a0b735356e5c3095290335 Mon Sep 17 00:00:00 2001 From: ankitsejwal Date: Mon, 29 Jun 2026 23:22:12 +0530 Subject: [PATCH 1/3] Add photostrip geometry and format settings Strip layout math (lib/strip.ts): four square photos stacked over a wider footer, sized to fit the page height and centred, plus pagination helpers. New sheetFormat ('grid' | 'strip') and stripsPerRow settings, persisted. No UI wiring yet. --- apps/web/src/lib/strip.ts | 118 ++++++++++++++++++++++++++ apps/web/src/stores/settings-store.ts | 19 +++++ 2 files changed, 137 insertions(+) create mode 100644 apps/web/src/lib/strip.ts diff --git a/apps/web/src/lib/strip.ts b/apps/web/src/lib/strip.ts new file mode 100644 index 0000000..5572a68 --- /dev/null +++ b/apps/web/src/lib/strip.ts @@ -0,0 +1,118 @@ +import { type PaperSize, type Rect, DEFAULT_BORDER_WIDTH } from '@/lib/layout' +import { type Photo } from '@/lib/photos' + +// Photo-booth strip geometry. A strip is a tall card holding four square photos +// stacked in a column with a thin border between them and a wider footer band +// to write on. The same crop math drives the on-screen preview and the PDF, so +// what you frame is what prints — exactly like the polaroid grid. + +export const PHOTOS_PER_STRIP = 4 + +export const STRIP = { + /** Footer band height, as a fraction of the strip width. */ + footer: 0.22, +} + +/** + * Strip height ÷ width: a top border, four square photos separated by thin + * borders, and the footer — all expressed as fractions of the strip width so + * the same number drives px (preview) and pt (PDF). + */ +export function stripAspect(border = DEFAULT_BORDER_WIDTH): number { + const photo = 1 - border * 2 // square photo height, in width units + const gaps = PHOTOS_PER_STRIP - 1 // thin borders between the photos + return border + PHOTOS_PER_STRIP * photo + gaps * border + STRIP.footer +} + +export interface StripLayout { + stripsPerRow: number + /** Max photos per sheet (strips per row × photos per strip). */ + capacityPhotos: number + stripWidthMm: number + stripHeightMm: number + marginMm: number + gapMm: number + /** Top-left position + size (mm) of the strip at `index` on the sheet. */ + rectForStrip: (index: number) => Rect + /** The four photo boxes (mm) inside a strip, given that strip's rect. */ + photoRects: (strip: Rect) => Rect[] +} + +/** + * Lays out a single row of `stripsPerRow` strips on the sheet. Strips are tall, + * so each is sized to the largest that fits both the column width and the full + * page height (usually height-bound), then the row is centred horizontally with + * any slack split evenly. + */ +export function stripLayout( + stripsPerRow: number, + paper: PaperSize, + border = DEFAULT_BORDER_WIDTH, + gapMm = 4, +): StripLayout { + const marginMm = paper.marginMm + const usableW = paper.widthMm - marginMm * 2 + const usableH = paper.heightMm - marginMm * 2 + const aspect = stripAspect(border) + const widthBudget = (usableW - gapMm * (stripsPerRow - 1)) / stripsPerRow + const stripWidthMm = Math.min(widthBudget, usableH / aspect) + const stripHeightMm = stripWidthMm * aspect + + const rowW = stripsPerRow * stripWidthMm + gapMm * (stripsPerRow - 1) + const originX = marginMm + (usableW - rowW) / 2 + const originY = marginMm + (usableH - stripHeightMm) / 2 + + const rectForStrip = (index: number): Rect => ({ + x: originX + index * (stripWidthMm + gapMm), + y: originY, + width: stripWidthMm, + height: stripHeightMm, + }) + + const photoRects = (strip: Rect): Rect[] => { + const b = strip.width * border + const size = strip.width - b * 2 // square photo edge + return Array.from({ length: PHOTOS_PER_STRIP }, (_, i) => ({ + x: strip.x + b, + y: strip.y + b + i * (size + b), + width: size, + height: size, + })) + } + + return { + stripsPerRow, + capacityPhotos: stripsPerRow * PHOTOS_PER_STRIP, + stripWidthMm, + stripHeightMm, + marginMm, + gapMm, + rectForStrip, + photoRects, + } +} + +/** Splits photos into pages of `stripsPerRow × 4` for the strip format. */ +export function paginateStrips( + photos: Photo[], + stripsPerRow: number, + paper: PaperSize, + border = DEFAULT_BORDER_WIDTH, +): Photo[][] { + if (photos.length === 0) return [[]] + const { capacityPhotos } = stripLayout(stripsPerRow, paper, border) + const pages: Photo[][] = [] + for (let i = 0; i < photos.length; i += capacityPhotos) { + pages.push(photos.slice(i, i + capacityPhotos)) + } + return pages +} + +/** Chunks one page's photos into strips of up to `PHOTOS_PER_STRIP`. */ +export function toStrips(photos: Photo[]): Photo[][] { + const strips: Photo[][] = [] + for (let i = 0; i < photos.length; i += PHOTOS_PER_STRIP) { + strips.push(photos.slice(i, i + PHOTOS_PER_STRIP)) + } + return strips +} diff --git a/apps/web/src/stores/settings-store.ts b/apps/web/src/stores/settings-store.ts index 28028fa..7a839bb 100644 --- a/apps/web/src/stores/settings-store.ts +++ b/apps/web/src/stores/settings-store.ts @@ -14,11 +14,17 @@ export const MIN_PER_ROW = 2 export const MAX_PER_ROW = 5 export const MIN_ROWS = 1 export const MAX_ROWS = 6 +export const MIN_STRIPS_PER_ROW = 2 +export const MAX_STRIPS_PER_ROW = 5 + +/** Sheet layout format: the polaroid grid or photo-booth strips. */ +export type SheetFormat = 'grid' | 'strip' export type CaptionLocation = LocationDetail /** The persistable subset of settings (no action functions). */ export interface SettingsSnapshot { + sheetFormat: SheetFormat borderColor: string borderWidth: number frameShape: Orientation @@ -27,6 +33,7 @@ export interface SettingsSnapshot { paperSizeId: string polaroidsPerRow: number rowsPerPage: number + stripsPerRow: number showCutMarks: boolean captionLocation: CaptionLocation dateFormat: DateFormat @@ -43,6 +50,7 @@ export function settingsSnapshot(state: SettingsState): SettingsSnapshot { } export const PERSISTED_SETTINGS_KEYS: (keyof SettingsSnapshot)[] = [ + 'sheetFormat', 'borderColor', 'borderWidth', 'frameShape', @@ -51,6 +59,7 @@ export const PERSISTED_SETTINGS_KEYS: (keyof SettingsSnapshot)[] = [ 'paperSizeId', 'polaroidsPerRow', 'rowsPerPage', + 'stripsPerRow', 'showCutMarks', 'captionLocation', 'dateFormat', @@ -59,6 +68,9 @@ export const PERSISTED_SETTINGS_KEYS: (keyof SettingsSnapshot)[] = [ ] interface SettingsState { + /** Sheet layout format: the polaroid grid or photo-booth strips. */ + sheetFormat: SheetFormat + setSheetFormat: (format: SheetFormat) => void /** Polaroid border colour (hex) — the card the photo sits on. */ borderColor: string setBorderColor: (hex: string) => void @@ -85,6 +97,9 @@ interface SettingsState { /** Grid rows per page; combined with perRow this fixes the frames per sheet. */ rowsPerPage: number setRowsPerPage: (count: number) => void + /** Photo-booth strips per row (strip format only). */ + stripsPerRow: number + setStripsPerRow: (count: number) => void /** Show crop/cut guides for trimming after printing. */ showCutMarks: boolean setShowCutMarks: (show: boolean) => void @@ -103,6 +118,8 @@ interface SettingsState { } export const useSettingsStore = create((set) => ({ + sheetFormat: 'grid', + setSheetFormat: (format) => set({ sheetFormat: format }), borderColor: DEFAULT_BORDER_COLOR, setBorderColor: (hex) => set({ borderColor: hex }), borderWidth: DEFAULT_BORDER_WIDTH, @@ -125,6 +142,8 @@ export const useSettingsStore = create((set) => ({ setPolaroidsPerRow: (count) => set({ polaroidsPerRow: count }), rowsPerPage: 3, setRowsPerPage: (count) => set({ rowsPerPage: count }), + stripsPerRow: 3, + setStripsPerRow: (count) => set({ stripsPerRow: count }), showCutMarks: true, setShowCutMarks: (show) => set({ showCutMarks: show }), captionLocation: 'city', From c9dda2b827cae7a72c62d66386b5aa8a71e744db Mon Sep 17 00:00:00 2001 From: ankitsejwal Date: Mon, 29 Jun 2026 23:24:21 +0530 Subject: [PATCH 2/3] Add photostrip PDF export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildStripPdf/downloadStripPdf draw a row of strips per page — a coloured card per strip with four square photos over the footer, plus cut marks — reusing the shared sRGB rasterizer. Factor the blob-download into a triggerDownload helper shared with the grid export. --- apps/web/src/lib/pdf.ts | 122 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 115 insertions(+), 7 deletions(-) diff --git a/apps/web/src/lib/pdf.ts b/apps/web/src/lib/pdf.ts index 8826f21..f9fe267 100644 --- a/apps/web/src/lib/pdf.ts +++ b/apps/web/src/lib/pdf.ts @@ -12,6 +12,7 @@ import { import { paginate } from '@/lib/pages' import { embedCaptionFont } from '@/lib/pdf-fonts' import { type Photo } from '@/lib/photos' +import { paginateStrips, stripLayout, toStrips } from '@/lib/strip' const IMAGE_DPI = 300 @@ -204,6 +205,99 @@ export async function buildSheetPdf( return doc.save() } +/** + * Builds a print-ready, sRGB PDF of photo-booth strips: a row of strips per + * page, each a coloured card holding four square photos over a footer band. + */ +export async function buildStripPdf( + photos: Photo[], + stripsPerRow: number, + cutMarks: boolean, + paper: PaperSize, + borderColor: string, + borderWidth: number, +): Promise { + const doc = await PDFDocument.create() + const pages = paginateStrips(photos, stripsPerRow, paper, borderWidth) + const layout = stripLayout(stripsPerRow, paper, borderWidth) + const pageW = paper.widthMm * PT_PER_MM + const pageH = paper.heightMm * PT_PER_MM + const margin = paper.marginMm * PT_PER_MM + const card = hexToRgb01(borderColor) + const toPx = (pt: number) => Math.round((pt / PT_PER_MM) * (IMAGE_DPI / 25.4)) + + for (const pagePhotos of pages) { + const page = doc.addPage([pageW, pageH]) + page.drawRectangle({ + x: margin, + y: margin, + width: pageW - margin * 2, + height: pageH - margin * 2, + borderColor: rgb(0.85, 0.85, 0.85), + borderWidth: 0.5, + borderDashArray: [3, 3], + }) + + const strips = toStrips(pagePhotos) + for (let s = 0; s < strips.length; s++) { + const stripRect = layout.rectForStrip(s) + const x = stripRect.x * PT_PER_MM + const yTop = stripRect.y * PT_PER_MM + const w = stripRect.width * PT_PER_MM + const h = stripRect.height * PT_PER_MM + + page.drawRectangle({ + x, + y: pageH - yTop - h, + width: w, + height: h, + color: rgb(card.r, card.g, card.b), + borderColor: rgb(0.85, 0.85, 0.85), + borderWidth: 0.5, + }) + if (cutMarks) { + for (const seg of cropMarks(stripRect)) { + page.drawLine({ + start: { x: seg.x1 * PT_PER_MM, y: pageH - seg.y1 * PT_PER_MM }, + end: { x: seg.x2 * PT_PER_MM, y: pageH - seg.y2 * PT_PER_MM }, + thickness: 0.4, + color: rgb(0.6, 0.6, 0.6), + }) + } + } + + const photoRects = layout.photoRects(stripRect) + for (let i = 0; i < strips[s].length; i++) { + const photo = strips[s][i] + const pr = photoRects[i] + const pw = pr.width * PT_PER_MM + const ph = pr.height * PT_PER_MM + const jpeg = await rasterizeJpeg(photo.url, toPx(pw), toPx(ph), photo.crop) + if (!jpeg) continue + const embedded = await doc.embedJpg(jpeg) + page.drawImage(embedded, { + x: pr.x * PT_PER_MM, + y: pageH - pr.y * PT_PER_MM - ph, + width: pw, + height: ph, + }) + } + } + } + + return doc.save() +} + +function triggerDownload(bytes: Uint8Array, filename: string): void { + const blob = new Blob([bytes as BlobPart], { type: 'application/pdf' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + link.click() + URL.revokeObjectURL(url) +} + export async function downloadSheetPdf( photos: Photo[], perRow: number, @@ -233,11 +327,25 @@ export async function downloadSheetPdf( borderWidth, captionFontId, ) - const blob = new Blob([bytes as BlobPart], { type: 'application/pdf' }) - const url = URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = url - link.download = filename - link.click() - URL.revokeObjectURL(url) + triggerDownload(bytes, filename) +} + +export async function downloadStripPdf( + photos: Photo[], + stripsPerRow: number, + cutMarks: boolean, + paper: PaperSize, + borderColor: string, + borderWidth: number, + filename = 'photostrips.pdf', +): Promise { + const bytes = await buildStripPdf( + photos, + stripsPerRow, + cutMarks, + paper, + borderColor, + borderWidth, + ) + triggerDownload(bytes, filename) } From 6029a47c4b279fbe73885c6195ab5760dfee44ab Mon Sep 17 00:00:00 2001 From: ankitsejwal Date: Mon, 29 Jun 2026 23:27:48 +0530 Subject: [PATCH 3/3] Wire photostrip preview and Format toggle StripPage renders a sheet of strips in the editor, reusing CroppedImage so each photo pans/zooms like a grid frame. A4Preview branches on sheetFormat; the right rail gains a Format select (Grid / Photostrip) and a Strips stepper, and hides the grid/caption controls that don't apply to strips. Export routes to the strip PDF in strip mode. --- apps/web/src/components/a4-preview.tsx | 201 +++++----- apps/web/src/components/options-panel.tsx | 432 +++++++++++++--------- apps/web/src/components/strip-page.tsx | 135 +++++++ 3 files changed, 501 insertions(+), 267 deletions(-) create mode 100644 apps/web/src/components/strip-page.tsx diff --git a/apps/web/src/components/a4-preview.tsx b/apps/web/src/components/a4-preview.tsx index bea8d18..8aad378 100644 --- a/apps/web/src/components/a4-preview.tsx +++ b/apps/web/src/components/a4-preview.tsx @@ -1,64 +1,76 @@ -import { useLayoutEffect, useRef, useState } from 'react' -import { - RectangleHorizontal, - RectangleVertical, - Square, -} from 'lucide-react' +import { useLayoutEffect, useRef, useState } from "react"; +import { RectangleHorizontal, RectangleVertical, Square } from "lucide-react"; -import { SheetInspector } from '@/components/sheet-inspector' -import { SheetPage } from '@/components/sheet-page' +import { SheetInspector } from "@/components/sheet-inspector"; +import { SheetPage } from "@/components/sheet-page"; +import { StripPage } from "@/components/strip-page"; import { Tooltip, TooltipContent, TooltipTrigger, -} from '@/components/ui/tooltip' -import { type Orientation } from '@/lib/crop' -import { captionFontStack } from '@/lib/fonts' -import { FRAME_SHAPES, paperSize } from '@/lib/layout' -import { paginate } from '@/lib/pages' -import { cn } from '@/lib/utils' -import { usePhotoStore } from '@/stores/photo-store' -import { useSettingsStore } from '@/stores/settings-store' +} from "@/components/ui/tooltip"; +import { type Orientation } from "@/lib/crop"; +import { captionFontStack } from "@/lib/fonts"; +import { FRAME_SHAPES, paperSize } from "@/lib/layout"; +import { paginate } from "@/lib/pages"; +import { paginateStrips } from "@/lib/strip"; +import { cn } from "@/lib/utils"; +import { usePhotoStore } from "@/stores/photo-store"; +import { useSettingsStore } from "@/stores/settings-store"; const SHAPE_ICONS: Record = { square: Square, portrait: RectangleVertical, landscape: RectangleHorizontal, -} +}; /** The centre column: the print sheet itself, with a floating frame inspector. */ export function A4Preview() { - const photos = usePhotoStore((state) => state.photos) - const captionFontId = useSettingsStore((state) => state.captionFontId) - const paperSizeId = useSettingsStore((state) => state.paperSizeId) - const frameShape = useSettingsStore((state) => state.frameShape) - const pageShapes = useSettingsStore((state) => state.pageShapes) - const setPageShape = useSettingsStore((state) => state.setPageShape) - const borderColor = useSettingsStore((state) => state.borderColor) - const borderWidth = useSettingsStore((state) => state.borderWidth) - const perRow = useSettingsStore((state) => state.polaroidsPerRow) - const rows = useSettingsStore((state) => state.rowsPerPage) - 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) + const photos = usePhotoStore((state) => state.photos); + const sheetFormat = useSettingsStore((state) => state.sheetFormat); + const stripsPerRow = useSettingsStore((state) => state.stripsPerRow); + const captionFontId = useSettingsStore((state) => state.captionFontId); + const paperSizeId = useSettingsStore((state) => state.paperSizeId); + const frameShape = useSettingsStore((state) => state.frameShape); + const pageShapes = useSettingsStore((state) => state.pageShapes); + const setPageShape = useSettingsStore((state) => state.setPageShape); + const borderColor = useSettingsStore((state) => state.borderColor); + const borderWidth = useSettingsStore((state) => state.borderWidth); + const perRow = useSettingsStore((state) => state.polaroidsPerRow); + const rows = useSettingsStore((state) => state.rowsPerPage); + 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); - const containerRef = useRef(null) - const [width, setWidth] = useState(0) + const containerRef = useRef(null); + const [width, setWidth] = useState(0); useLayoutEffect(() => { - const el = containerRef.current - if (!el) return - const update = () => setWidth(el.clientWidth) - update() - const observer = new ResizeObserver(update) - observer.observe(el) - return () => observer.disconnect() - }, []) + const el = containerRef.current; + if (!el) return; + const update = () => setWidth(el.clientWidth); + update(); + const observer = new ResizeObserver(update); + observer.observe(el); + return () => observer.disconnect(); + }, []); - const pages = paginate(photos, perRow, rows, paper, frameShape, pageShapes, borderWidth) - const pageCount = pages.length + const gridPages = paginate( + photos, + perRow, + rows, + paper, + frameShape, + pageShapes, + borderWidth, + ); + const stripPages = paginateStrips(photos, stripsPerRow, paper, borderWidth); + const pageCount = + sheetFormat === "strip" ? stripPages.length : gridPages.length; + const pageLabel = (page: number) => + pageCount > 1 ? `Page ${page + 1} of ${pageCount}` : ""; return (
@@ -70,54 +82,75 @@ export function A4Preview() { ref={containerRef} className="mx-auto flex w-full max-w-xl flex-col gap-5" > - {pages.map((slice, page) => ( -
-
- - {pageCount > 1 ? `Page ${page + 1} of ${pageCount}` : ''} - - setPageShape(page, shape)} - /> -
- -
- ))} + {sheetFormat === "strip" + ? stripPages.map((stripPhotos, page) => ( +
+ + {pageLabel(page)} + + +
+ )) + : gridPages.map((slice, page) => ( +
+
+ + {pageLabel(page)} + + setPageShape(page, shape)} + /> +
+ +
+ ))}
- ) + ); } function PageShapeToggle({ value, onChange, }: { - value: Orientation - onChange: (shape: Orientation) => void + value: Orientation; + onChange: (shape: Orientation) => void; }) { return (
{FRAME_SHAPES.map(({ id, label }) => { - const Icon = SHAPE_ICONS[id] - const active = value === id + const Icon = SHAPE_ICONS[id]; + const active = value === id; return ( @@ -127,8 +160,8 @@ function PageShapeToggle({ aria-pressed={active} onClick={() => onChange(id)} className={cn( - 'text-muted-foreground hover:bg-accent hover:text-foreground flex size-6 items-center justify-center rounded', - active && 'bg-accent text-foreground', + "text-muted-foreground hover:bg-accent hover:text-foreground flex size-6 items-center justify-center rounded", + active && "bg-accent text-foreground", )} > @@ -136,8 +169,8 @@ function PageShapeToggle({ {label} frames on this page - ) + ); })}
- ) + ); } diff --git a/apps/web/src/components/options-panel.tsx b/apps/web/src/components/options-panel.tsx index dc1fbea..ef9c6d3 100644 --- a/apps/web/src/components/options-panel.tsx +++ b/apps/web/src/components/options-panel.tsx @@ -1,189 +1,237 @@ -import { type ReactNode, useState } from 'react' -import { Minus, Plus } from 'lucide-react' +import { type ReactNode, useState } from "react"; +import { Minus, Plus } from "lucide-react"; -import { Button } from '@/components/ui/button' -import { Label } from '@/components/ui/label' +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from '@/components/ui/select' -import { Switch } from '@/components/ui/switch' -import { type Orientation } from '@/lib/crop' -import { DATE_FORMATS, type DateFormat } from '@/lib/date' -import { CAPTION_FONTS } from '@/lib/fonts' -import { LOCATION_DETAILS } from '@/lib/geocode' +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { type Orientation } from "@/lib/crop"; +import { DATE_FORMATS, type DateFormat } from "@/lib/date"; +import { CAPTION_FONTS } from "@/lib/fonts"; +import { LOCATION_DETAILS } from "@/lib/geocode"; import { BORDER_COLORS, BORDER_WIDTHS, FRAME_SHAPES, PAPER_SIZES, paperSize, -} from '@/lib/layout' -import { paginate } from '@/lib/pages' -import { downloadSheetPdf } from '@/lib/pdf' -import { cn } from '@/lib/utils' -import { usePhotoStore } from '@/stores/photo-store' +} from "@/lib/layout"; +import { paginate } from "@/lib/pages"; +import { downloadSheetPdf, downloadStripPdf } from "@/lib/pdf"; +import { cn } from "@/lib/utils"; +import { usePhotoStore } from "@/stores/photo-store"; import { type CaptionLocation, + type SheetFormat, MAX_PER_ROW, MAX_ROWS, + MAX_STRIPS_PER_ROW, MIN_PER_ROW, MIN_ROWS, + MIN_STRIPS_PER_ROW, useSettingsStore, -} from '@/stores/settings-store' +} from "@/stores/settings-store"; /** The right-hand rail: every caption + sheet option, plus export. */ export function OptionsPanel() { - const photos = usePhotoStore((state) => state.photos) - const applyLocationMode = usePhotoStore((state) => state.applyLocationMode) - const applyDateFormat = usePhotoStore((state) => state.applyDateFormat) + const photos = usePhotoStore((state) => state.photos); + const applyLocationMode = usePhotoStore((state) => state.applyLocationMode); + const applyDateFormat = usePhotoStore((state) => state.applyDateFormat); - const captionLocation = useSettingsStore((state) => state.captionLocation) - const setCaptionLocation = useSettingsStore((state) => state.setCaptionLocation) - const dateFormat = useSettingsStore((state) => state.dateFormat) - const setDateFormat = useSettingsStore((state) => state.setDateFormat) - const captionFontId = useSettingsStore((state) => state.captionFontId) - const setCaptionFont = useSettingsStore((state) => state.setCaptionFont) - 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 paperSizeId = useSettingsStore((state) => state.paperSizeId) - const setPaperSize = useSettingsStore((state) => state.setPaperSize) - const frameShape = useSettingsStore((state) => state.frameShape) - const pageShapes = useSettingsStore((state) => state.pageShapes) - const setFrameShapeAll = useSettingsStore((state) => state.setFrameShapeAll) - const borderColor = useSettingsStore((state) => state.borderColor) - const setBorderColor = useSettingsStore((state) => state.setBorderColor) - const borderWidth = useSettingsStore((state) => state.borderWidth) - const setBorderWidth = useSettingsStore((state) => state.setBorderWidth) - const perRow = useSettingsStore((state) => state.polaroidsPerRow) - const setPerRow = useSettingsStore((state) => state.setPolaroidsPerRow) - const rows = useSettingsStore((state) => state.rowsPerPage) - const setRows = useSettingsStore((state) => state.setRowsPerPage) - const showCutMarks = useSettingsStore((state) => state.showCutMarks) - const setShowCutMarks = useSettingsStore((state) => state.setShowCutMarks) + const captionLocation = useSettingsStore((state) => state.captionLocation); + const setCaptionLocation = useSettingsStore( + (state) => state.setCaptionLocation, + ); + const dateFormat = useSettingsStore((state) => state.dateFormat); + const setDateFormat = useSettingsStore((state) => state.setDateFormat); + const captionFontId = useSettingsStore((state) => state.captionFontId); + const setCaptionFont = useSettingsStore((state) => state.setCaptionFont); + 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 paperSizeId = useSettingsStore((state) => state.paperSizeId); + const setPaperSize = useSettingsStore((state) => state.setPaperSize); + const frameShape = useSettingsStore((state) => state.frameShape); + const pageShapes = useSettingsStore((state) => state.pageShapes); + const setFrameShapeAll = useSettingsStore((state) => state.setFrameShapeAll); + const borderColor = useSettingsStore((state) => state.borderColor); + const setBorderColor = useSettingsStore((state) => state.setBorderColor); + const borderWidth = useSettingsStore((state) => state.borderWidth); + const setBorderWidth = useSettingsStore((state) => state.setBorderWidth); + const sheetFormat = useSettingsStore((state) => state.sheetFormat); + const setSheetFormat = useSettingsStore((state) => state.setSheetFormat); + const perRow = useSettingsStore((state) => state.polaroidsPerRow); + const setPerRow = useSettingsStore((state) => state.setPolaroidsPerRow); + const rows = useSettingsStore((state) => state.rowsPerPage); + const setRows = useSettingsStore((state) => state.setRowsPerPage); + const stripsPerRow = useSettingsStore((state) => state.stripsPerRow); + const setStripsPerRow = useSettingsStore((state) => state.setStripsPerRow); + const showCutMarks = useSettingsStore((state) => state.showCutMarks); + const setShowCutMarks = useSettingsStore((state) => state.setShowCutMarks); - const [isExporting, setIsExporting] = useState(false) - const paper = paperSize(paperSizeId) + const [isExporting, setIsExporting] = useState(false); + const paper = paperSize(paperSizeId); // When pages carry different shapes the dropdown reads "Mixed"; picking a // shape there applies it to every page. - const pages = paginate(photos, perRow, rows, paper, frameShape, pageShapes, borderWidth) + const pages = paginate( + photos, + perRow, + rows, + paper, + frameShape, + pageShapes, + borderWidth, + ); const uniformShape = pages.every((p) => p.shape === pages[0].shape) ? pages[0].shape - : null + : null; const selectLocation = (value: CaptionLocation) => { - setCaptionLocation(value) - applyLocationMode(value) - } + setCaptionLocation(value); + applyLocationMode(value); + }; const selectDate = (value: DateFormat) => { - setDateFormat(value) - applyDateFormat(value) - } + setDateFormat(value); + applyDateFormat(value); + }; const handleExport = async () => { - setIsExporting(true) + setIsExporting(true); try { - await downloadSheetPdf( - photos, - perRow, - rows, - showCutMarks, - showCaptions, - showCameraLine, - paper, - frameShape, - pageShapes, - borderColor, - borderWidth, - captionFontId, - ) + if (sheetFormat === "strip") { + await downloadStripPdf( + photos, + stripsPerRow, + showCutMarks, + paper, + borderColor, + borderWidth, + ); + } else { + await downloadSheetPdf( + photos, + perRow, + rows, + showCutMarks, + showCaptions, + showCameraLine, + paper, + frameShape, + pageShapes, + borderColor, + borderWidth, + captionFontId, + ); + } } finally { - setIsExporting(false) + setIsExporting(false); } - } + }; return (
-
- - selectLocation(value as CaptionLocation)} - > - {LOCATION_DETAILS.map((detail) => ( - - {detail.label} - - ))} - - Neighborhood (offline N/A) - - - - - selectDate(value as DateFormat)} - > - {DATE_FORMATS.map((format) => ( - - {format.label} - - ))} - - - - - {CAPTION_FONTS.map((font) => ( - - {font.label} + {sheetFormat === "grid" && ( +
+ + selectLocation(value as CaptionLocation)} + > + {LOCATION_DETAILS.map((detail) => ( + + {detail.label} + + ))} + + Neighborhood (offline N/A) - ))} - - - - -
+
+
+ + selectDate(value as DateFormat)} + > + {DATE_FORMATS.map((format) => ( + + {format.label} + + ))} + + + + + {CAPTION_FONTS.map((font) => ( + + {font.label} + + ))} + + + + +
+ )}
- + setFrameShapeAll(value as Orientation)} + id="opt-format" + value={sheetFormat} + onChange={(value) => setSheetFormat(value as SheetFormat)} > - {!uniformShape && ( - - Mixed - - )} - {FRAME_SHAPES.map((option) => ( - - {option.label} - - ))} + Grid + Photostrip + {sheetFormat === "grid" && ( + + setFrameShapeAll(value as Orientation)} + > + {!uniformShape && ( + + Mixed + + )} + {FRAME_SHAPES.map((option) => ( + + {option.label} + + ))} + + + )} {BORDER_COLORS.map((swatch) => ( @@ -194,9 +242,9 @@ export function OptionsPanel() { aria-pressed={borderColor.toLowerCase() === swatch.hex} onClick={() => setBorderColor(swatch.hex)} className={cn( - 'size-5 rounded-full border border-black/10', + "size-5 rounded-full border border-black/10", borderColor.toLowerCase() === swatch.hex && - 'ring-primary ring-2 ring-offset-1', + "ring-primary ring-2 ring-offset-1", )} style={{ backgroundColor: swatch.hex }} /> @@ -206,7 +254,7 @@ export function OptionsPanel() { className="relative size-5 cursor-pointer overflow-hidden rounded-full border border-black/10" style={{ background: - 'conic-gradient(red, yellow, lime, cyan, blue, magenta, red)', + "conic-gradient(red, yellow, lime, cyan, blue, magenta, red)", }} > - + {PAPER_SIZES.map((size) => ( {size.label} @@ -241,24 +293,38 @@ export function OptionsPanel() { ))} - - - - - - + {sheetFormat === "grid" ? ( + <> + + + + + + + + ) : ( + + + + )} void handleExport()} className="w-full" > - {isExporting ? 'Preparing…' : 'Export PDF'} + {isExporting ? "Preparing…" : "Export PDF"}
- ) + ); } function FieldSelect({ @@ -283,10 +349,10 @@ function FieldSelect({ onChange, children, }: { - id: string - value: string - onChange: (value: string) => void - children: ReactNode + id: string; + value: string; + onChange: (value: string) => void; + children: ReactNode; }) { return ( - ) + ); } function Stepper({ @@ -305,11 +371,11 @@ function Stepper({ max, onChange, }: { - noun: string - value: number - min: number - max: number - onChange: (value: number) => void + noun: string; + value: number; + min: number; + max: number; + onChange: (value: number) => void; }) { return ( @@ -337,7 +403,7 @@ function Stepper({ - ) + ); } function Section({ title, children }: { title: string; children: ReactNode }) { @@ -348,7 +414,7 @@ function Section({ title, children }: { title: string; children: ReactNode }) { {children} - ) + ); } function Field({ @@ -356,9 +422,9 @@ function Field({ htmlFor, children, }: { - label: string - htmlFor: string - children: ReactNode + label: string; + htmlFor: string; + children: ReactNode; }) { return (
@@ -367,7 +433,7 @@ function Field({ {children}
- ) + ); } function SettingSwitch({ @@ -375,11 +441,11 @@ function SettingSwitch({ checked, onChange, }: { - label: string - checked: boolean - onChange: (value: boolean) => void + label: string; + checked: boolean; + onChange: (value: boolean) => void; }) { - const id = `opt-${label.toLowerCase().replace(/\s+/g, '-')}` + const id = `opt-${label.toLowerCase().replace(/\s+/g, "-")}`; return (
- ) + ); } diff --git a/apps/web/src/components/strip-page.tsx b/apps/web/src/components/strip-page.tsx new file mode 100644 index 0000000..aa6bc6c --- /dev/null +++ b/apps/web/src/components/strip-page.tsx @@ -0,0 +1,135 @@ +import { CroppedImage } from "@/components/cropped-image"; +import { type PaperSize, cropMarks } from "@/lib/layout"; +import { type Photo } from "@/lib/photos"; +import { stripLayout, toStrips } from "@/lib/strip"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; +import { usePhotoStore } from "@/stores/photo-store"; + +/** One print sheet of photo-booth strips: a centred row of coloured strips. */ +export function StripPage({ + photos, + width, + stripsPerRow, + paper, + borderColor, + borderWidth, + showCutMarks, + editable = false, +}: { + photos: Photo[]; + width: number; + stripsPerRow: number; + paper: PaperSize; + borderColor: string; + borderWidth: number; + showCutMarks: boolean; + editable?: boolean; +}) { + const selectedId = useEditorStore((state) => state.selectedId); + const select = useEditorStore((state) => state.select); + const setCrop = usePhotoStore((state) => state.setCrop); + const layout = stripLayout(stripsPerRow, paper, borderWidth); + const mmToPx = width / paper.widthMm; + const pageHeight = width * (paper.heightMm / paper.widthMm); + const strips = toStrips(photos); + + return ( +
select(null) : undefined} + > +
+ {width > 0 && + strips.map((stripPhotos, stripIndex) => { + const strip = layout.rectForStrip(stripIndex); + const rects = layout.photoRects(strip); + return ( +
+ {stripPhotos.map((photo, i) => { + const rect = rects[i]; + const selected = editable && selectedId === photo.id; + return ( +
{ + event.stopPropagation(); + select(photo.id); + } + : undefined + } + > + setCrop(photo.id, crop) : undefined + } + /> +
+ ); + })} +
+ ); + })} + {showCutMarks && width > 0 && ( + + {strips.flatMap((stripPhotos, stripIndex) => + cropMarks(layout.rectForStrip(stripIndex)).map((seg, segIndex) => ( + + )), + )} + + )} +
+ ); +}