diff --git a/apps/web/src/components/a4-preview.tsx b/apps/web/src/components/a4-preview.tsx index 38b1b5b..db01a58 100644 --- a/apps/web/src/components/a4-preview.tsx +++ b/apps/web/src/components/a4-preview.tsx @@ -81,12 +81,12 @@ export function A4Preview() { return (
{/* Pinned to the screen, so the selected frame's tools never shift the page. */} -
+
{sheetFormat === "strip" ? stripPages.map((stripPhotos, page) => ( @@ -105,8 +105,10 @@ export function A4Preview() { editable /> {pageLabel(page) && ( -
- +
+ + {pageLabel(page)} +
)}
@@ -131,16 +133,17 @@ export function A4Preview() { showCameraLine={showCameraLine} editable /> - {/* Screen-only controls float over the sheet's top margin so the - sheet's top edge stays level with both sidebars. */} -
- {pageLabel(page) ? : } -
- setPageShape(page, shape)} - /> -
+ {/* Page number + frame-shape control sit just above the sheet, + out of the page, so the sheet's top edge stays aligned with + the side columns while these float above that line. */} +
+ + {pageLabel(page)} + + setPageShape(page, shape)} + />
))} @@ -149,19 +152,7 @@ export function A4Preview() { ); } -function PageBadge({ label }: { label: string }) { - return ( - - {label} - - ); -} - -/** - * Collapses to a single glassy icon of the current shape so it stays out of the - * way of the sheet's margin guide; reveals the full selector on hover or focus - * (focus covers touch, where there's no hover). - */ +/** Always-visible glassy segmented control for this page's frame shape. */ function PageShapeToggle({ value, onChange, @@ -169,51 +160,31 @@ function PageShapeToggle({ value: Orientation; onChange: (shape: Orientation) => void; }) { - const CurrentIcon = SHAPE_ICONS[value]; - const glass = - "rounded-md bg-white/40 ring-1 ring-black/5 backdrop-blur-md"; return ( -
- -
- {FRAME_SHAPES.map(({ id, label }) => { - const Icon = SHAPE_ICONS[id]; - const active = value === id; - return ( - - - - - {label} frames on this page - - ); - })} -
+
+ {FRAME_SHAPES.map(({ id, label }) => { + const Icon = SHAPE_ICONS[id]; + const active = value === id; + return ( + + + + + {label} frames on this page + + ); + })}
); } diff --git a/apps/web/src/components/add-photos-fab.tsx b/apps/web/src/components/add-photos-fab.tsx deleted file mode 100644 index b1770cd..0000000 --- a/apps/web/src/components/add-photos-fab.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { ImagePlus } from "lucide-react"; - -import { useAddPhotos } from "@/hooks/use-add-photos"; -import { PHOTO_ACCEPT } from "@/lib/upload"; -import { cn } from "@/lib/utils"; - -/** Floating add-photos button — the primary add action on small screens. */ -export function AddPhotosFab({ className }: { className?: string }) { - const { inputRef, open, onChange } = useAddPhotos(); - - return ( - <> - - - - ); -} diff --git a/apps/web/src/components/mobile-bottom-bar.tsx b/apps/web/src/components/mobile-bottom-bar.tsx new file mode 100644 index 0000000..a82d1ab --- /dev/null +++ b/apps/web/src/components/mobile-bottom-bar.tsx @@ -0,0 +1,121 @@ +import { type ComponentProps, type ComponentType, useState } from "react"; +import { FileDown, ImagePlus, SlidersHorizontal, X } from "lucide-react"; + +import { OptionsPanel } from "@/components/options-panel"; +import { Button } from "@/components/ui/button"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; +import { LiquidGlass } from "@/components/ui/liquid-glass"; +import { useAddPhotos } from "@/hooks/use-add-photos"; +import { useExportPdf } from "@/hooks/use-export-pdf"; +import { PHOTO_ACCEPT } from "@/lib/upload"; +import { cn } from "@/lib/utils"; + +const SNAP_POINTS = [0.55, 1]; + +/** + * Mobile-only floating actions: two liquid-glass buttons — Customize on its own, + * and an Add + Export pair — always a tap away. Icons follow the theme foreground. + */ +export function MobileBottomBar() { + const [optionsOpen, setOptionsOpen] = useState(false); + const [snap, setSnap] = useState(SNAP_POINTS[0]); + const { inputRef, open: openPicker, onChange } = useAddPhotos(); + const { exportPdf, isExporting, canExport } = useExportPdf(); + + return ( + + +
+ + setOptionsOpen(true)} + /> + + +
+ + + void exportPdf()} + /> +
+
+
+ + + Sheet options + + + + +
+ +
+
+
+ ); +} + +function GlassIcon({ + icon: Icon, + label, + text, + accent = false, + className, + ...props +}: { + icon: ComponentType<{ className?: string }>; + label: string; + text?: string; + accent?: boolean; +} & ComponentProps<"button">) { + return ( + + ); +} diff --git a/apps/web/src/components/mobile-options.tsx b/apps/web/src/components/mobile-options.tsx deleted file mode 100644 index 585cbae..0000000 --- a/apps/web/src/components/mobile-options.tsx +++ /dev/null @@ -1,55 +0,0 @@ -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 5ecfc76..3f7911e 100644 --- a/apps/web/src/components/options-panel.tsx +++ b/apps/web/src/components/options-panel.tsx @@ -1,4 +1,4 @@ -import { type ComponentType, type ReactNode, useState } from "react"; +import { type ComponentType, type ReactNode } from "react"; import { FileDown, Minus, @@ -9,6 +9,7 @@ import { } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { useExportPdf } from "@/hooks/use-export-pdf"; import { Label } from "@/components/ui/label"; import { Select, @@ -31,9 +32,7 @@ import { paperSize, } 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> = { @@ -98,7 +97,7 @@ export function OptionsPanel({ bare = false }: { bare?: boolean } = {}) { const applyPreset = useSettingsStore((state) => state.applyPreset); - const [isExporting, setIsExporting] = useState(false); + const { exportPdf, isExporting, pageCount } = useExportPdf(); const paper = paperSize(paperSizeId); // When pages carry different shapes the dropdown reads "Mixed"; picking a @@ -116,10 +115,6 @@ export function OptionsPanel({ bare = false }: { bare?: boolean } = {}) { ? pages[0].shape : null; - const pageCount = - sheetFormat === "strip" - ? paginateStrips(photos, stripsPerRow, paper, borderWidth).length - : pages.length; const framesPerPage = perRow * rows; const selectLocation = (value: CaptionLocation) => { @@ -131,39 +126,6 @@ export function OptionsPanel({ bare = false }: { bare?: boolean } = {}) { applyDateFormat(value); }; - const handleExport = async () => { - setIsExporting(true); - try { - 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); - } - }; - return (
- + {!isExporting && photos.length > 0 && ( + + {pageCount} {pageCount === 1 ? "page" : "pages"} · {paper.label} + + )} + + )} ); } diff --git a/apps/web/src/components/project-controls.tsx b/apps/web/src/components/project-controls.tsx index 61cdbdf..56d1549 100644 --- a/apps/web/src/components/project-controls.tsx +++ b/apps/web/src/components/project-controls.tsx @@ -1,5 +1,5 @@ import { useRef, useState } from 'react' -import { Download, Upload } from 'lucide-react' +import { FolderOpen, Save } from 'lucide-react' import { Button } from '@/components/ui/button' import { @@ -68,7 +68,7 @@ export function ProjectControls() { disabled={busy} onClick={() => inputRef.current?.click()} > - + Open project @@ -83,7 +83,7 @@ export function ProjectControls() { disabled={busy || photos.length === 0} onClick={() => void handleSave()} > - + Save project diff --git a/apps/web/src/components/ui/liquid-glass.tsx b/apps/web/src/components/ui/liquid-glass.tsx new file mode 100644 index 0000000..a68e5f3 --- /dev/null +++ b/apps/web/src/components/ui/liquid-glass.tsx @@ -0,0 +1,135 @@ +import { + type ReactNode, + useEffect, + useId, + useRef, + useState, +} from "react"; + +import { cn } from "@/lib/utils"; + +/** + * A capsule-shaped displacement map: pixels are neutral (128,128) in the middle + * and pushed outward within a bezel near the rounded edge, so the backdrop + * lenses at the rim — the refraction that separates iOS 26 "Liquid Glass" from + * a flat frosted blur. Red channel = x shift, green = y shift. + */ +function buildDisplacementMap(w: number, h: number): string { + const canvas = document.createElement("canvas"); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext("2d"); + if (!ctx) return ""; + const image = ctx.createImageData(w, h); + const data = image.data; + const r = h / 2; + const bezel = Math.min(r, w / 2) * 0.85; + + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + // Distance to the capsule's centre spine → distance from the edge. + const cx = Math.min(Math.max(x, r), w - r); + const dx = x - cx; + const dy = y - r; + const dist = Math.hypot(dx, dy); + let red = 128; + let green = 128; + // Only the bezel ring *inside* the capsule lenses; everything past the + // edge (the rectangular corners) stays neutral or there are artifacts. + if (dist > r - bezel && dist <= r) { + const t = (dist - (r - bezel)) / bezel; + const magnitude = t * t; // ramp up hard at the very rim + red = 128 + (dx / dist) * magnitude * 127; + green = 128 + (dy / dist) * magnitude * 127; + } + const i = (y * w + x) * 4; + data[i] = red; + data[i + 1] = green; + data[i + 2] = 128; + data[i + 3] = 255; + } + } + ctx.putImageData(image, 0, 0); + return canvas.toDataURL(); +} + +/** + * Liquid-glass surface. In Chromium the backdrop is refracted at the rim via an + * SVG displacement filter; Safari/Firefox drop the SVG part and get the frosted + * `-webkit-backdrop-filter` blur instead. Children render crisp on top. + */ +export function LiquidGlass({ + children, + className, + scale = 42, +}: { + children: ReactNode; + className?: string; + scale?: number; +}) { + const filterId = useId().replace(/:/g, ""); + const ref = useRef(null); + const [size, setSize] = useState({ w: 0, h: 0 }); + const [map, setMap] = useState(""); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const measure = () => { + const w = Math.round(el.clientWidth); + const h = Math.round(el.clientHeight); + if (w > 0 && h > 0) setSize({ w, h }); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (size.w > 0 && size.h > 0) setMap(buildDisplacementMap(size.w, size.h)); + }, [size.w, size.h]); + + return ( +
+ {map && ( + + + + + + + + )} +
+
{children}
+
+ ); +} diff --git a/apps/web/src/hooks/use-export-pdf.ts b/apps/web/src/hooks/use-export-pdf.ts new file mode 100644 index 0000000..d50e71f --- /dev/null +++ b/apps/web/src/hooks/use-export-pdf.ts @@ -0,0 +1,77 @@ +import { useState } from "react"; + +import { paperSize } from "@/lib/layout"; +import { paginate } from "@/lib/pages"; +import { downloadSheetPdf, downloadStripPdf } from "@/lib/pdf"; +import { paginateStrips } from "@/lib/strip"; +import { usePhotoStore } from "@/stores/photo-store"; +import { useSettingsStore } from "@/stores/settings-store"; + +/** + * Export action shared by the desktop panel button and the mobile bottom bar. + * Subscribes to just what the "N pages · A4" summary needs; reads the full + * settings fresh at click time. + */ +export function useExportPdf() { + const photos = usePhotoStore((state) => state.photos); + const sheetFormat = useSettingsStore((state) => state.sheetFormat); + const paperSizeId = useSettingsStore((state) => state.paperSizeId); + const perRow = useSettingsStore((state) => state.polaroidsPerRow); + const rows = useSettingsStore((state) => state.rowsPerPage); + const stripsPerRow = useSettingsStore((state) => state.stripsPerRow); + const borderWidth = useSettingsStore((state) => state.borderWidth); + const frameShape = useSettingsStore((state) => state.frameShape); + const pageShapes = useSettingsStore((state) => state.pageShapes); + + const [isExporting, setIsExporting] = useState(false); + + const paper = paperSize(paperSizeId); + const pageCount = + sheetFormat === "strip" + ? paginateStrips(photos, stripsPerRow, paper, borderWidth).length + : paginate(photos, perRow, rows, paper, frameShape, pageShapes, borderWidth) + .length; + + const exportPdf = async () => { + setIsExporting(true); + try { + const s = useSettingsStore.getState(); + const p = paperSize(s.paperSizeId); + if (s.sheetFormat === "strip") { + await downloadStripPdf( + photos, + s.stripsPerRow, + s.showCutMarks, + p, + s.borderColor, + s.borderWidth, + ); + } else { + await downloadSheetPdf( + photos, + s.polaroidsPerRow, + s.rowsPerPage, + s.showCutMarks, + s.showCaptions, + s.showCameraLine, + p, + s.frameShape, + s.pageShapes, + s.borderColor, + s.borderWidth, + s.captionFontId, + ); + } + } finally { + setIsExporting(false); + } + }; + + return { + exportPdf, + isExporting, + pageCount, + paper, + canExport: photos.length > 0, + }; +} diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 7d14708..d6528ab 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -1,8 +1,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 { MobileBottomBar } from '@/components/mobile-bottom-bar' import { OptionsPanel } from '@/components/options-panel' import { PhotoFilmstrip } from '@/components/photo-filmstrip' import { PhotoSidebar } from '@/components/photo-sidebar' @@ -20,24 +19,24 @@ function Home() { return (
-
-
+
+

- Polaroid + Polaroid.

-

- Edit each frame on the page — everything stays on your device. -

-
-
- - +
+ + +
+

+ Edit each frame on the page — everything stays on your device. +

{hasPhotos && } -
+
- {hasPhotos && } - + {/* Clears the fixed mobile bottom bar so nothing hides behind it. */} +
+
) }