From c27e3ba1c6b96112fa8578a8472c760b988c0e85 Mon Sep 17 00:00:00 2001 From: ankitsejwal Date: Wed, 1 Jul 2026 23:46:53 +0530 Subject: [PATCH 1/2] Add mobile liquid-glass floating actions and top-edge page controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the mobile FAB and options pill with two liquid-glass floating buttons — Customize on its own, and an Add + Export ("PDF") pair. The glass surface uses an SVG displacement-map filter for real rim refraction in Chromium, falling back to a frosted blur elsewhere, and is tinted to the theme surface so icons stay legible over both the white sheet and the dark page. Move the per-page number badge and frame-shape toggle so they straddle the sheet's top edge and stay always visible (no hover), which makes the shape control reachable on touch. Give both a dark-mode glass treatment. Header: let the subheading span full width and add a coral dot after the Polaroid wordmark. Swap the project Save/Open icons for a floppy and an open-folder, which fit those actions better than up/down arrows. --- apps/web/src/components/a4-preview.tsx | 102 ++++++------- apps/web/src/components/add-photos-fab.tsx | 34 ----- apps/web/src/components/mobile-bottom-bar.tsx | 121 ++++++++++++++++ apps/web/src/components/mobile-options.tsx | 55 ------- apps/web/src/components/options-panel.tsx | 74 +++------- apps/web/src/components/project-controls.tsx | 6 +- apps/web/src/components/ui/liquid-glass.tsx | 135 ++++++++++++++++++ apps/web/src/hooks/use-export-pdf.ts | 77 ++++++++++ apps/web/src/routes/index.tsx | 28 ++-- 9 files changed, 410 insertions(+), 222 deletions(-) delete mode 100644 apps/web/src/components/add-photos-fab.tsx create mode 100644 apps/web/src/components/mobile-bottom-bar.tsx delete mode 100644 apps/web/src/components/mobile-options.tsx create mode 100644 apps/web/src/components/ui/liquid-glass.tsx create mode 100644 apps/web/src/hooks/use-export-pdf.ts diff --git a/apps/web/src/components/a4-preview.tsx b/apps/web/src/components/a4-preview.tsx index 38b1b5b..9fa5def 100644 --- a/apps/web/src/components/a4-preview.tsx +++ b/apps/web/src/components/a4-preview.tsx @@ -81,7 +81,7 @@ export function A4Preview() { return (
{/* Pinned to the screen, so the selected frame's tools never shift the page. */} -
+
{pageLabel(page) && ( -
+
)} @@ -131,16 +131,19 @@ 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)} - /> + {pageLabel(page) && ( +
+
+ )} + {/* Straddles the sheet's top edge (≈half above it) so the per-page + frame-shape control is always reachable — including on touch, + where there's no hover to reveal it. */} +
+ setPageShape(page, shape)} + />
))} @@ -151,17 +154,13 @@ 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 +168,32 @@ 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..6329074 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,19 +19,19 @@ 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 && } @@ -55,8 +54,9 @@ function Home() {
- {hasPhotos && } - + {/* Clears the fixed mobile bottom bar so nothing hides behind it. */} +
+
) } From fe9694c6e166c3a73c9d215217f44af9f4304be9 Mon Sep 17 00:00:00 2001 From: ankitsejwal Date: Thu, 2 Jul 2026 00:24:04 +0530 Subject: [PATCH 2/2] Float per-page controls above the sheet Move the page number and frame-shape toggle out of the page and into a row centered in the gap above each sheet, so the sheet's top edge still lines up with the side columns while these sit above that line. The page number is now plain text (no pill), and the shape toggle is a smaller, rounder, more translucent control whose icons take the darker resting colour. Add top margin so the first page's row clears the header. --- apps/web/src/components/a4-preview.tsx | 41 ++++++++++---------------- apps/web/src/routes/index.tsx | 2 +- 2 files changed, 17 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/a4-preview.tsx b/apps/web/src/components/a4-preview.tsx index 9fa5def..db01a58 100644 --- a/apps/web/src/components/a4-preview.tsx +++ b/apps/web/src/components/a4-preview.tsx @@ -86,7 +86,7 @@ export function A4Preview() {
{sheetFormat === "strip" ? stripPages.map((stripPhotos, page) => ( @@ -105,8 +105,10 @@ export function A4Preview() { editable /> {pageLabel(page) && ( -
- +
+ + {pageLabel(page)} +
)}
@@ -131,15 +133,13 @@ export function A4Preview() { showCameraLine={showCameraLine} editable /> - {pageLabel(page) && ( -
- -
- )} - {/* Straddles the sheet's top edge (≈half above it) so the per-page - frame-shape control is always reachable — including on touch, - where there's no hover to reveal it. */} -
+ {/* 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)} @@ -152,14 +152,6 @@ export function A4Preview() { ); } -function PageBadge({ label }: { label: string }) { - return ( - - {label} - - ); -} - /** Always-visible glassy segmented control for this page's frame shape. */ function PageShapeToggle({ value, @@ -169,7 +161,7 @@ function PageShapeToggle({ onChange: (shape: Orientation) => void; }) { return ( -
+
{FRAME_SHAPES.map(({ id, label }) => { const Icon = SHAPE_ICONS[id]; const active = value === id; @@ -182,12 +174,11 @@ function PageShapeToggle({ aria-pressed={active} onClick={() => onChange(id)} className={cn( - "flex size-6 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-black/5 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-white/10 dark:hover:text-neutral-100", - active && - "bg-black/10 text-neutral-900 dark:bg-white/15 dark:text-neutral-100", + "flex size-5 items-center justify-center rounded-full text-neutral-900 transition-colors hover:bg-black/5 dark:text-neutral-100 dark:hover:bg-white/10", + active && "bg-black/[0.08] dark:bg-white/10", )} > - + {label} frames on this page diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 6329074..d6528ab 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -36,7 +36,7 @@ function Home() { {hasPhotos && } -
+