From cb288b999d3b662d65a588f92583700af994661b Mon Sep 17 00:00:00 2001 From: ankitsejwal Date: Wed, 1 Jul 2026 13:36:54 +0530 Subject: [PATCH 1/3] Add SegmentedControl, sheet presets, and applyPreset action --- .../src/components/ui/segmented-control.tsx | 62 +++++++++++++++++++ apps/web/src/lib/presets.ts | 57 +++++++++++++++++ apps/web/src/stores/settings-store.ts | 3 + 3 files changed, 122 insertions(+) create mode 100644 apps/web/src/components/ui/segmented-control.tsx create mode 100644 apps/web/src/lib/presets.ts diff --git a/apps/web/src/components/ui/segmented-control.tsx b/apps/web/src/components/ui/segmented-control.tsx new file mode 100644 index 0000000..67db54c --- /dev/null +++ b/apps/web/src/components/ui/segmented-control.tsx @@ -0,0 +1,62 @@ +import { type ComponentType } from "react"; + +import { cn } from "@/lib/utils"; + +export interface SegmentedOption { + value: T; + label: string; + icon?: ComponentType<{ className?: string }>; +} + +/** iOS-style segmented toggle — one tap to switch, no dropdown. */ +export function SegmentedControl({ + options, + value, + onChange, + ariaLabel, + iconOnly = false, + className, +}: { + options: SegmentedOption[]; + value: T | null; + onChange: (value: T) => void; + ariaLabel: string; + iconOnly?: boolean; + className?: string; +}) { + return ( +
+ {options.map((option) => { + const active = option.value === value; + const Icon = option.icon; + return ( + + ); + })} +
+ ); +} diff --git a/apps/web/src/lib/presets.ts b/apps/web/src/lib/presets.ts new file mode 100644 index 0000000..23760db --- /dev/null +++ b/apps/web/src/lib/presets.ts @@ -0,0 +1,57 @@ +import { type SettingsSnapshot } from "@/stores/settings-store"; + +export interface SheetPreset { + id: string; + label: string; + settings: Partial; +} + +/** One-tap starting points that set several interacting options at once. */ +export const SHEET_PRESETS: SheetPreset[] = [ + { + id: "classic", + label: "Classic", + settings: { + sheetFormat: "grid", + frameShape: "square", + borderColor: "#ffffff", + borderWidth: 0.05, + polaroidsPerRow: 3, + rowsPerPage: 3, + }, + }, + { + id: "mini", + label: "Mini grid", + settings: { + sheetFormat: "grid", + frameShape: "square", + borderColor: "#ffffff", + borderWidth: 0.035, + polaroidsPerRow: 4, + rowsPerPage: 5, + }, + }, + { + id: "filmstrip", + label: "Filmstrip", + settings: { + sheetFormat: "strip", + stripsPerRow: 3, + borderColor: "#ffffff", + borderWidth: 0.05, + }, + }, + { + id: "bold", + label: "Bold", + settings: { + sheetFormat: "grid", + frameShape: "portrait", + borderColor: "#111111", + borderWidth: 0.08, + polaroidsPerRow: 2, + rowsPerPage: 3, + }, + }, +]; diff --git a/apps/web/src/stores/settings-store.ts b/apps/web/src/stores/settings-store.ts index 7a839bb..8700dd4 100644 --- a/apps/web/src/stores/settings-store.ts +++ b/apps/web/src/stores/settings-store.ts @@ -115,6 +115,8 @@ interface SettingsState { /** Whether captions are shown at all. */ showCaptions: boolean setShowCaptions: (show: boolean) => void + /** Apply a preset's settings in one shot, clearing per-page shape overrides. */ + applyPreset: (settings: Partial) => void } export const useSettingsStore = create((set) => ({ @@ -154,4 +156,5 @@ export const useSettingsStore = create((set) => ({ setShowCameraLine: (show) => set({ showCameraLine: show }), showCaptions: true, setShowCaptions: (show) => set({ showCaptions: show }), + applyPreset: (settings) => set({ ...settings, pageShapes: [] }), })) From 3af16ed36334c269a49939b3aa78806a10365f46 Mon Sep 17 00:00:00 2001 From: ankitsejwal Date: Wed, 1 Jul 2026 13:36:54 +0530 Subject: [PATCH 2/3] Rework the options panel content Lead with one-tap presets; put the Captions master toggle first and collapse Location/Date/Font/Camera-info when it's off; turn Format, Frame, and Thickness into segmented toggles (Frame reuses the sheet's shape icons); and show the page count + paper and frames-per-page so the output isn't a guess. --- apps/web/src/components/options-panel.tsx | 252 ++++++++++++++-------- 1 file changed, 159 insertions(+), 93 deletions(-) diff --git a/apps/web/src/components/options-panel.tsx b/apps/web/src/components/options-panel.tsx index 7e28c84..449f59e 100644 --- a/apps/web/src/components/options-panel.tsx +++ b/apps/web/src/components/options-panel.tsx @@ -1,5 +1,12 @@ -import { type ReactNode, useState } from "react"; -import { FileDown, Minus, Plus } from "lucide-react"; +import { type ComponentType, type ReactNode, useState } from "react"; +import { + FileDown, + Minus, + Plus, + RectangleHorizontal, + RectangleVertical, + Square, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; @@ -10,6 +17,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { SegmentedControl } from "@/components/ui/segmented-control"; import { Switch } from "@/components/ui/switch"; import { type Orientation } from "@/lib/crop"; import { DATE_FORMATS, type DateFormat } from "@/lib/date"; @@ -24,7 +32,15 @@ import { } from "@/lib/layout"; import { paginate } from "@/lib/pages"; import { downloadSheetPdf, downloadStripPdf } from "@/lib/pdf"; +import { SHEET_PRESETS, type SheetPreset } from "@/lib/presets"; +import { paginateStrips } from "@/lib/strip"; import { cn } from "@/lib/utils"; + +const SHAPE_ICONS: Record> = { + square: Square, + portrait: RectangleVertical, + landscape: RectangleHorizontal, +}; import { usePhotoStore } from "@/stores/photo-store"; import { type CaptionLocation, @@ -78,6 +94,8 @@ export function OptionsPanel() { const showCutMarks = useSettingsStore((state) => state.showCutMarks); const setShowCutMarks = useSettingsStore((state) => state.setShowCutMarks); + const applyPreset = useSettingsStore((state) => state.applyPreset); + const [isExporting, setIsExporting] = useState(false); const paper = paperSize(paperSizeId); @@ -96,6 +114,12 @@ export function OptionsPanel() { ? pages[0].shape : null; + const pageCount = + sheetFormat === "strip" + ? paginateStrips(photos, stripsPerRow, paper, borderWidth).length + : pages.length; + const framesPerPage = perRow * rows; + const selectLocation = (value: CaptionLocation) => { setCaptionLocation(value); applyLocationMode(value); @@ -140,96 +164,98 @@ export function OptionsPanel() { return (
+ + {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} - - ))} - - - + {showCaptions && ( + <> + + 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} + + ))} + + + + + )}
)}
- - + setSheetFormat(value as SheetFormat)} - > - Grid - Photostrip - + options={[ + { value: "grid", label: "Grid" }, + { value: "strip", label: "Photostrip" }, + ]} + /> {sheetFormat === "grid" && ( - - + setFrameShapeAll(value as Orientation)} - > - {!uniformShape && ( - - Mixed - - )} - {FRAME_SHAPES.map((option) => ( - - {option.label} - - ))} - + options={FRAME_SHAPES.map((option) => ({ + value: option.id, + label: option.label, + icon: SHAPE_ICONS[option.id], + }))} + /> )} @@ -267,18 +293,16 @@ export function OptionsPanel() { - - + setBorderWidth(Number(value))} - > - {BORDER_WIDTHS.map((option) => ( - - {option.label} - - ))} - + options={BORDER_WIDTHS.map((option) => ({ + value: String(option.ratio), + label: option.label, + }))} + /> +

+ {framesPerPage} frames per page +

) : ( @@ -335,15 +362,50 @@ export function OptionsPanel() {
); } +function Presets({ + onApply, +}: { + onApply: (settings: SheetPreset["settings"]) => void; +}) { + return ( +
+

+ Quick start +

+
+ {SHEET_PRESETS.map((preset) => ( + + ))} +
+
+ ); +} + function FieldSelect({ id, value, @@ -424,14 +486,18 @@ function Field({ children, }: { label: string; - htmlFor: string; + htmlFor?: string; children: ReactNode; }) { return (
- + {htmlFor ? ( + + ) : ( + {label} + )} {children}
); From 2b10fba71f9ec928de14b793b484ab8f834c5181 Mon Sep 17 00:00:00 2001 From: ankitsejwal Date: Wed, 1 Jul 2026 13:41:27 +0530 Subject: [PATCH 3/3] Add a mobile options bottom sheet Below lg the right rail is hidden; a Customize pill opens a peek drawer (vaul) that covers about half the screen so the canvas stays visible and updates live while you adjust. The same OptionsPanel renders inside it, bare, so every content improvement carries over. Desktop keeps the rail. --- apps/web/package.json | 1 + apps/web/src/components/mobile-options.tsx | 55 +++++++++++ apps/web/src/components/options-panel.tsx | 13 ++- apps/web/src/components/ui/drawer.tsx | 106 +++++++++++++++++++++ apps/web/src/routes/index.tsx | 4 +- pnpm-lock.yaml | 18 ++++ 6 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/mobile-options.tsx create mode 100644 apps/web/src/components/ui/drawer.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 3359cb1..8d98c2f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -34,6 +34,7 @@ "react-dom": "^19.2.7", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", + "vaul": "^1.1.2", "zustand": "^5.0.14" }, "devDependencies": { diff --git a/apps/web/src/components/mobile-options.tsx b/apps/web/src/components/mobile-options.tsx new file mode 100644 index 0000000..585cbae --- /dev/null +++ b/apps/web/src/components/mobile-options.tsx @@ -0,0 +1,55 @@ +import { useState } from "react"; +import { SlidersHorizontal, X } from "lucide-react"; + +import { OptionsPanel } from "@/components/options-panel"; +import { Button } from "@/components/ui/button"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer"; + +const SNAP_POINTS = [0.55, 1]; + +/** + * Mobile-only options. A "Customize" pill opens a peek bottom sheet that leaves + * the canvas visible above, so the sheet updates live as you adjust settings. + */ +export function MobileOptions() { + const [snap, setSnap] = useState(SNAP_POINTS[0]); + + return ( + + + + + + + Sheet options + + + + +
+ +
+
+
+ ); +} diff --git a/apps/web/src/components/options-panel.tsx b/apps/web/src/components/options-panel.tsx index 449f59e..5ecfc76 100644 --- a/apps/web/src/components/options-panel.tsx +++ b/apps/web/src/components/options-panel.tsx @@ -54,8 +54,10 @@ import { useSettingsStore, } from "@/stores/settings-store"; -/** The right-hand rail: every caption + sheet option, plus export. */ -export function OptionsPanel() { +/** Every caption + sheet option, plus export. Rendered in the desktop right + * rail and, on mobile, inside the options drawer (pass `bare` there — the + * drawer supplies its own surface). */ +export function OptionsPanel({ bare = false }: { bare?: boolean } = {}) { const photos = usePhotoStore((state) => state.photos); const applyLocationMode = usePhotoStore((state) => state.applyLocationMode); const applyDateFormat = usePhotoStore((state) => state.applyDateFormat); @@ -163,7 +165,12 @@ export function OptionsPanel() { }; return ( -
+
{sheetFormat === "grid" && ( diff --git a/apps/web/src/components/ui/drawer.tsx b/apps/web/src/components/ui/drawer.tsx new file mode 100644 index 0000000..e70910f --- /dev/null +++ b/apps/web/src/components/ui/drawer.tsx @@ -0,0 +1,106 @@ +import * as React from "react"; +import { Drawer as DrawerPrimitive } from "vaul"; + +import { cn } from "@/lib/utils"; + +function Drawer(props: React.ComponentProps) { + return ; +} + +function DrawerTrigger( + props: React.ComponentProps, +) { + return ; +} + +function DrawerClose( + props: React.ComponentProps, +) { + return ; +} + +function DrawerOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DrawerContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + +
+ {children} + + + ); +} + +function DrawerHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DrawerTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DrawerDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerOverlay, + DrawerTitle, + DrawerTrigger, +}; diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index e2f0bf8..7d14708 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -2,6 +2,7 @@ import { createFileRoute } from '@tanstack/react-router' import { A4Preview } from '@/components/a4-preview' import { AddPhotosFab } from '@/components/add-photos-fab' +import { MobileOptions } from '@/components/mobile-options' import { OptionsPanel } from '@/components/options-panel' import { PhotoFilmstrip } from '@/components/photo-filmstrip' import { PhotoSidebar } from '@/components/photo-sidebar' @@ -49,12 +50,13 @@ function Home() {
-
{hasPhotos && } + ) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c52dd77..e1e2234 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + vaul: + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) zustand: specifier: ^5.0.14 version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) @@ -3991,6 +3994,12 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + vite-plugin-pwa@1.3.0: resolution: {integrity: sha512-c5kMgN+ITrOtHXp8PAtk2uOIEea6XjP/unCGxOWWBzQ6qa65qj/awHg0wf+QF9E/2u9vh86LqxPwzEPNbM2r5A==} engines: {node: '>=16.0.0'} @@ -8079,6 +8088,15 @@ snapshots: dependencies: react: 19.2.7 + vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@radix-ui/react-dialog': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + vite-plugin-pwa@1.3.0(vite@8.1.0(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0))(workbox-build@7.4.1)(workbox-window@7.4.1): dependencies: debug: 4.4.3