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 7e28c84..5ecfc76 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, @@ -38,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); @@ -78,6 +96,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 +116,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); @@ -139,97 +165,104 @@ 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 +300,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 +369,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 +493,18 @@ function Field({ children, }: { label: string; - htmlFor: string; + htmlFor?: string; children: ReactNode; }) { return (
- + {htmlFor ? ( + + ) : ( + {label} + )} {children}
); 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/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/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/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: [] }), })) 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