Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 75 additions & 38 deletions apps/web/src/components/a4-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,8 @@ export function A4Preview() {
? stripPages.map((stripPhotos, page) => (
<div
key={`strip-${stripPhotos[0]?.id ?? page}`}
className="flex flex-col gap-1"
className="relative"
>
<span className="text-muted-foreground text-xs">
{pageLabel(page)}
</span>
<StripPage
photos={stripPhotos}
width={width}
Expand All @@ -107,22 +104,18 @@ export function A4Preview() {
showCutMarks={showCutMarks}
editable
/>
{pageLabel(page) && (
<div className="absolute top-2 left-2">
<PageBadge label={pageLabel(page)} />
</div>
)}
</div>
))
: gridPages.map((slice, page) => (
<div
key={`page-${slice.photos[0]?.id ?? page}`}
className="flex flex-col gap-1"
className="relative"
>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
{pageLabel(page)}
</span>
<PageShapeToggle
value={slice.shape}
onChange={(shape) => setPageShape(page, shape)}
/>
</div>
<SheetPage
photos={slice.photos}
width={width}
Expand All @@ -138,45 +131,89 @@ 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. */}
<div className="pointer-events-none absolute inset-x-0 top-0 flex items-start justify-between gap-2 p-1">
{pageLabel(page) ? <PageBadge label={pageLabel(page)} /> : <span />}
<div className="pointer-events-auto">
<PageShapeToggle
value={slice.shape}
onChange={(shape) => setPageShape(page, shape)}
/>
</div>
</div>
</div>
))}
</div>
</section>
);
}

function PageBadge({ label }: { label: string }) {
return (
<span className="pointer-events-auto rounded-md bg-white/40 px-1.5 py-0.5 text-xs text-neutral-500 ring-1 ring-black/5 backdrop-blur-md">
{label}
</span>
);
}

/**
* 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).
*/
function PageShapeToggle({
value,
onChange,
}: {
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 (
<div className="flex gap-0.5">
{FRAME_SHAPES.map(({ id, label }) => {
const Icon = SHAPE_ICONS[id];
const active = value === id;
return (
<Tooltip key={id}>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`${label} frames on this page`}
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",
)}
>
<Icon className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>{label} frames on this page</TooltipContent>
</Tooltip>
);
})}
<div className="group/shape relative flex justify-end">
<button
type="button"
aria-label="Change page frame shape"
className={cn(
"flex size-5 items-center justify-center text-neutral-500 transition-opacity group-hover/shape:pointer-events-none group-hover/shape:opacity-0 group-focus-within/shape:pointer-events-none group-focus-within/shape:opacity-0",
glass,
)}
>
<CurrentIcon className="size-3" />
</button>
<div
className={cn(
"pointer-events-none absolute top-0 right-0 flex gap-0.5 p-0.5 opacity-0 transition-opacity group-hover/shape:pointer-events-auto group-hover/shape:opacity-100 group-focus-within/shape:pointer-events-auto group-focus-within/shape:opacity-100",
glass,
)}
>
{FRAME_SHAPES.map(({ id, label }) => {
const Icon = SHAPE_ICONS[id];
const active = value === id;
return (
<Tooltip key={id}>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`${label} frames on this page`}
aria-pressed={active}
onClick={() => onChange(id)}
className={cn(
"flex size-5 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-black/5 hover:text-neutral-900",
active && "bg-black/10 text-neutral-900",
)}
>
<Icon className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent>{label} frames on this page</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
);
}
5 changes: 3 additions & 2 deletions apps/web/src/components/options-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type ReactNode, useState } from "react";
import { Minus, Plus } from "lucide-react";
import { FileDown, Minus, Plus } from "lucide-react";

import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
Expand Down Expand Up @@ -335,8 +335,9 @@ export function OptionsPanel() {
<Button
disabled={isExporting || photos.length === 0}
onClick={() => void handleExport()}
className="w-full"
className="w-full gap-2"
>
<FileDown className="size-4" />
{isExporting ? "Preparing…" : "Export PDF"}
</Button>
</div>
Expand Down
27 changes: 17 additions & 10 deletions apps/web/src/components/photo-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@ const ACCEPT =
'image/jpeg,image/png,image/webp,image/heic,image/heif,.jpg,.jpeg,.png,.webp,.heic,.heif'

/**
* Left rail: an "Add photos" button on top, the reorderable strip below.
* Left rail: the reorderable photo strip, with an "Add photos" button pinned
* to the bottom. The button only appears once there's at least one photo —
* before that, the centre empty-state hero carries the add action.
* Dragging a file anywhere over the app reveals a square drop pad here, with
* the strip blurred behind it — and a drop is accepted wherever it lands.
*/
export function PhotoSidebar() {
const addFiles = usePhotoStore((state) => state.addFiles)
const hasPhotos = usePhotoStore((state) => state.photos.length > 0)
const inputRef = useRef<HTMLInputElement>(null)

const onFiles = useCallback((files: File[]) => addFiles(files), [addFiles])
Expand All @@ -36,17 +39,21 @@ export function PhotoSidebar() {
}}
/>

<Button
type="button"
onClick={() => inputRef.current?.click()}
className="w-full gap-2"
>
<ImagePlus className="size-4" />
Add photos
</Button>

<PhotoStrip />

{hasPhotos && (
<div className="mt-auto pt-2 lg:sticky lg:bottom-4">
<Button
type="button"
onClick={() => inputRef.current?.click()}
className="w-full gap-2 shadow-sm"
>
<ImagePlus className="size-4" />
Add photos
</Button>
</div>
)}

{dragging && (
<div className="bg-background/70 absolute inset-0 z-30 flex items-center justify-center rounded-lg backdrop-blur-sm">
<div className="border-ring text-muted-foreground flex aspect-square w-full max-w-[220px] flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed text-center">
Expand Down
Loading