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
34 changes: 34 additions & 0 deletions apps/web/src/components/add-photos-fab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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 (
<>
<input
ref={inputRef}
type="file"
accept={PHOTO_ACCEPT}
multiple
hidden
onChange={onChange}
/>
<button
type="button"
onClick={open}
aria-label="Add photos"
className={cn(
"bg-primary text-primary-foreground ring-background fixed right-4 bottom-4 z-40 flex size-14 items-center justify-center rounded-full shadow-2xl ring-2 transition-transform active:scale-95",
className,
)}
>
<ImagePlus className="size-6" />
</button>
</>
);
}
25 changes: 6 additions & 19 deletions apps/web/src/components/empty-hero.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import { useRef } from "react";
import { ImagePlus } from "lucide-react";

import { Button } from "@/components/ui/button";
import { usePhotoStore } from "@/stores/photo-store";

const ACCEPT =
"image/jpeg,image/png,image/webp,image/heic,image/heif,.jpg,.jpeg,.png,.webp,.heic,.heif";
import { useAddPhotos } from "@/hooks/use-add-photos";
import { PHOTO_ACCEPT } from "@/lib/upload";

/** A few illustrative polaroids — pure CSS, no image assets, so it loads instantly. */
const SAMPLES = [
Expand All @@ -16,8 +13,7 @@ const SAMPLES = [

/** Shown in the centre column before any photos are added. */
export function EmptyHero() {
const addFiles = usePhotoStore((state) => state.addFiles);
const inputRef = useRef<HTMLInputElement>(null);
const { inputRef, open, onChange } = useAddPhotos();

return (
<div className="flex min-h-[60svh] flex-col items-center justify-center gap-9 px-4 text-center">
Expand Down Expand Up @@ -56,21 +52,12 @@ export function EmptyHero() {
<input
ref={inputRef}
type="file"
accept={ACCEPT}
accept={PHOTO_ACCEPT}
multiple
hidden
onChange={(event) => {
const files = Array.from(event.target.files ?? []);
if (files.length > 0) addFiles(files);
event.target.value = "";
}}
onChange={onChange}
/>
<Button
type="button"
size="lg"
className="gap-2"
onClick={() => inputRef.current?.click()}
>
<Button type="button" size="lg" className="gap-2" onClick={open}>
<ImagePlus className="size-4" />
Add photos
</Button>
Expand Down
143 changes: 143 additions & 0 deletions apps/web/src/components/photo-filmstrip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import {
DndContext,
type DragEndEvent,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
horizontalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { ImagePlus, Loader2, X } from "lucide-react";

import { useAddPhotos } from "@/hooks/use-add-photos";
import { type Photo } from "@/lib/photos";
import { PHOTO_ACCEPT } from "@/lib/upload";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
import { usePhotoStore } from "@/stores/photo-store";

/**
* Mobile photo tray: a horizontal, sideways-scrolling row so any number of
* photos stays one thumbnail tall and never pushes the sheet down the page.
* Leads with a "+" tile; drag thumbnails to reorder, tap to select, × to remove.
*/
export function PhotoFilmstrip({ className }: { className?: string }) {
const photos = usePhotoStore((state) => state.photos);
const reorder = usePhotoStore((state) => state.reorder);
const { inputRef, open, onChange } = useAddPhotos();

const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
);

const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
reorder(String(active.id), String(over.id));
}
};

return (
<div className={cn("flex items-center gap-2 overflow-x-auto pb-1", className)}>
<input
ref={inputRef}
type="file"
accept={PHOTO_ACCEPT}
multiple
hidden
onChange={onChange}
/>
<button
type="button"
onClick={open}
aria-label="Add photos"
className="border-input text-muted-foreground hover:border-ring hover:text-foreground flex size-16 shrink-0 items-center justify-center rounded-lg border border-dashed transition-colors"
>
<ImagePlus className="size-5" />
</button>

<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={photos.map((photo) => photo.id)}
strategy={horizontalListSortingStrategy}
>
{photos.map((photo) => (
<FilmstripItem key={photo.id} photo={photo} />
))}
</SortableContext>
</DndContext>
</div>
);
}

function FilmstripItem({ photo }: { photo: Photo }) {
const remove = usePhotoStore((state) => state.remove);
const selectedId = useEditorStore((state) => state.selectedId);
const select = useEditorStore((state) => state.select);
const selected = selectedId === photo.id;

const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: photo.id });

return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn("group relative shrink-0", isDragging && "z-10 opacity-60")}
>
<button
type="button"
onClick={() => select(selected ? null : photo.id)}
{...attributes}
{...listeners}
aria-label={`Select ${photo.name}`}
className={cn(
"block size-16 touch-none overflow-hidden rounded-lg border-2 transition-colors",
selected ? "border-primary" : "border-transparent",
)}
>
<img
src={photo.url}
alt={photo.name}
className="size-full object-cover"
/>
</button>

{photo.enriching && (
<span className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-lg bg-black/30">
<Loader2 className="size-4 animate-spin text-white" />
</span>
)}

<button
type="button"
aria-label={`Remove ${photo.name}`}
onClick={() => {
if (selected) select(null);
remove(photo.id);
}}
className={cn(
"bg-background text-muted-foreground hover:text-foreground ring-border absolute -top-1 -right-1 flex size-5 items-center justify-center rounded-full opacity-0 shadow ring-1 transition-opacity group-hover:opacity-100 focus-visible:opacity-100",
selected && "opacity-100",
)}
>
<X className="size-3" />
</button>
</div>
);
}
28 changes: 12 additions & 16 deletions apps/web/src/components/photo-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,24 @@
import { useCallback, useRef } from 'react'
import { useCallback } from 'react'
import { ImagePlus } from 'lucide-react'

import { PhotoStrip } from '@/components/photo-strip'
import { Button } from '@/components/ui/button'
import { useAddPhotos } from '@/hooks/use-add-photos'
import { useWindowFileDrop } from '@/hooks/use-window-file-drop'
import { PHOTO_ACCEPT } from '@/lib/upload'
import { usePhotoStore } from '@/stores/photo-store'

const ACCEPT =
'image/jpeg,image/png,image/webp,image/heic,image/heif,.jpg,.jpeg,.png,.webp,.heic,.heif'

/**
* 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.
* Dragging a file anywhere over the app reveals a full-height 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 { inputRef, open, onChange } = useAddPhotos()

const onFiles = useCallback((files: File[]) => addFiles(files), [addFiles])
const dragging = useWindowFileDrop(onFiles)
Expand All @@ -29,14 +28,10 @@ export function PhotoSidebar() {
<input
ref={inputRef}
type="file"
accept={ACCEPT}
accept={PHOTO_ACCEPT}
multiple
hidden
onChange={(event) => {
const files = Array.from(event.target.files ?? [])
if (files.length > 0) addFiles(files)
event.target.value = ''
}}
onChange={onChange}
/>

<PhotoStrip />
Expand All @@ -45,8 +40,9 @@ export function PhotoSidebar() {
<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"
variant="outline"
onClick={open}
className="border-primary text-primary hover:bg-primary hover:text-primary-foreground w-full gap-2"
>
<ImagePlus className="size-4" />
Add photos
Expand All @@ -56,7 +52,7 @@ export function PhotoSidebar() {

{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">
<div className="border-ring text-muted-foreground flex h-full w-full flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed text-center">
<ImagePlus className="size-8" />
<p className="text-foreground text-sm font-medium">
Drop your photos…
Expand Down
26 changes: 26 additions & 0 deletions apps/web/src/hooks/use-add-photos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { type ChangeEvent, useCallback, useRef } from "react";

import { usePhotoStore } from "@/stores/photo-store";

/**
* Shared wiring for every "add photos" affordance (sidebar button, hero CTA,
* mobile FAB, filmstrip tile): a hidden file input plus an `open()` trigger.
* Render the input with {@link PHOTO_ACCEPT} and spread `inputRef`/`onChange`.
*/
export function useAddPhotos() {
const addFiles = usePhotoStore((state) => state.addFiles);
const inputRef = useRef<HTMLInputElement>(null);

const open = useCallback(() => inputRef.current?.click(), []);

const onChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []);
if (files.length > 0) addFiles(files);
event.target.value = "";
},
[addFiles],
);

return { inputRef, open, onChange };
}
3 changes: 3 additions & 0 deletions apps/web/src/lib/upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/** Image types the photo picker and drop targets accept. */
export const PHOTO_ACCEPT =
"image/jpeg,image/png,image/webp,image/heic,image/heif,.jpg,.jpeg,.png,.webp,.heic,.heif";
18 changes: 17 additions & 1 deletion apps/web/src/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import { createFileRoute } from '@tanstack/react-router'

import { A4Preview } from '@/components/a4-preview'
import { AddPhotosFab } from '@/components/add-photos-fab'
import { OptionsPanel } from '@/components/options-panel'
import { PhotoFilmstrip } from '@/components/photo-filmstrip'
import { PhotoSidebar } from '@/components/photo-sidebar'
import { ProjectControls } from '@/components/project-controls'
import { ThemeToggle } from '@/components/theme-toggle'
import { cn } from '@/lib/utils'
import { usePhotoStore } from '@/stores/photo-store'

export const Route = createFileRoute('/')({
component: Home,
})

function Home() {
const hasPhotos = usePhotoStore((state) => state.photos.length > 0)

return (
<main className="mx-auto flex min-h-svh w-full max-w-7xl flex-col gap-5 p-4 sm:p-6">
<header className="flex items-start justify-between gap-3">
Expand All @@ -28,8 +34,16 @@ function Home() {
</div>
</header>

{hasPhotos && <PhotoFilmstrip className="lg:hidden" />}

<div className="grid gap-6 lg:grid-cols-[240px_minmax(0,1fr)_300px] lg:items-start">
<aside className="flex flex-col lg:self-stretch">
<aside
className={cn(
'hidden lg:flex lg:flex-col lg:self-stretch',
hasPhotos &&
'lg:rounded-lg lg:bg-black/[0.03] lg:p-3 lg:dark:bg-white/[0.04]',
)}
>
<PhotoSidebar />
</aside>
<div className="min-w-0">
Expand All @@ -39,6 +53,8 @@ function Home() {
<OptionsPanel />
</aside>
</div>

{hasPhotos && <AddPhotosFab className="lg:hidden" />}
</main>
)
}
Loading