diff --git a/apps/web/src/components/frame-controls.tsx b/apps/web/src/components/frame-controls.tsx new file mode 100644 index 0000000..7b44640 --- /dev/null +++ b/apps/web/src/components/frame-controls.tsx @@ -0,0 +1,31 @@ +import { + MAX_FRAME_PADDING, + MIN_FRAME_PADDING, + useSettingsStore, +} from '@/stores/settings-store' + +export function FrameControls() { + const framePadding = useSettingsStore((state) => state.framePadding) + const setFramePadding = useSettingsStore((state) => state.setFramePadding) + + return ( +
+ + setFramePadding(Number(event.target.value))} + className="accent-primary w-32" + /> +
+ ) +} diff --git a/apps/web/src/components/photo-grid.tsx b/apps/web/src/components/photo-grid.tsx index 64ef2ec..f5bd4e8 100644 --- a/apps/web/src/components/photo-grid.tsx +++ b/apps/web/src/components/photo-grid.tsx @@ -1,8 +1,9 @@ -import { useState } from 'react' -import { ImageOff, X } from 'lucide-react' +import { X } from 'lucide-react' +import { FrameControls } from '@/components/frame-controls' +import { Polaroid } from '@/components/polaroid' import { Button } from '@/components/ui/button' -import { type Photo, formatBytes } from '@/lib/photos' +import { type Photo } from '@/lib/photos' import { usePhotoStore } from '@/stores/photo-store' export function PhotoGrid() { @@ -12,16 +13,19 @@ export function PhotoGrid() { if (photos.length === 0) return null return ( -
-
+
+

{photos.length} {photos.length === 1 ? 'photo' : 'photos'}

- +
+ + +
-
    +
      {photos.map((photo) => ( ))} @@ -32,38 +36,20 @@ export function PhotoGrid() { function PhotoTile({ photo }: { photo: Photo }) { const remove = usePhotoStore((state) => state.remove) - const [failed, setFailed] = useState(false) return ( -
    • - {failed ? ( -
      - - Preview unavailable -
      - ) : ( - {photo.name} setFailed(true)} - className="h-full w-full object-cover" - /> - )} +
    • + -
      -

      {photo.name}

      -

      {formatBytes(photo.size)}

      -
    • ) } diff --git a/apps/web/src/components/polaroid.tsx b/apps/web/src/components/polaroid.tsx new file mode 100644 index 0000000..03f7518 --- /dev/null +++ b/apps/web/src/components/polaroid.tsx @@ -0,0 +1,82 @@ +import { useState } from 'react' +import { ImageOff } from 'lucide-react' + +import { cn } from '@/lib/utils' +import { type Photo } from '@/lib/photos' +import { usePhotoStore } from '@/stores/photo-store' +import { useSettingsStore } from '@/stores/settings-store' + +export function Polaroid({ photo }: { photo: Photo }) { + const setCaption = usePhotoStore((state) => state.setCaption) + const framePadding = useSettingsStore((state) => state.framePadding) + const [failed, setFailed] = useState(false) + + return ( +
      +
      + {failed ? ( +
      + + Preview unavailable +
      + ) : ( + {photo.name} setFailed(true)} + className="h-full w-full object-cover" + /> + )} +
      +
      + setCaption(photo.id, 'captionTop', value)} + placeholder="Add a caption" + className="text-lg leading-tight" + /> + setCaption(photo.id, 'captionBottom', value)} + className="text-sm text-neutral-500" + /> +
      +
      + ) +} + +function CaptionInput({ + value, + onChange, + placeholder, + className, +}: { + value: string + onChange: (value: string) => void + placeholder?: string + className?: string +}) { + return ( + onChange(event.target.value)} + placeholder={placeholder} + aria-label="Polaroid caption" + className={cn( + 'font-handwriting w-full border-0 bg-transparent text-center text-neutral-800 placeholder:text-neutral-300 focus:outline-none', + className, + )} + /> + ) +} diff --git a/apps/web/src/lib/photos.ts b/apps/web/src/lib/photos.ts index e8be143..a0aae43 100644 --- a/apps/web/src/lib/photos.ts +++ b/apps/web/src/lib/photos.ts @@ -5,8 +5,13 @@ export interface Photo { url: string name: string size: number + /** Polaroid caption lines (auto-filled from EXIF later; editable). */ + captionTop: string + captionBottom: string } +export type CaptionField = 'captionTop' | 'captionBottom' + const IMAGE_EXTENSIONS = /\.(jpe?g|png|webp|heic|heif|avif|gif|bmp|tiff?)$/i export function isImageFile(file: File): boolean { @@ -20,6 +25,8 @@ export function createPhoto(file: File): Photo { url: URL.createObjectURL(file), name: file.name, size: file.size, + captionTop: '', + captionBottom: '', } } diff --git a/apps/web/src/stores/photo-store.ts b/apps/web/src/stores/photo-store.ts index ad31b4d..c43a6db 100644 --- a/apps/web/src/stores/photo-store.ts +++ b/apps/web/src/stores/photo-store.ts @@ -1,11 +1,17 @@ import { create } from 'zustand' -import { type Photo, createPhoto, isImageFile } from '@/lib/photos' +import { + type CaptionField, + type Photo, + createPhoto, + isImageFile, +} from '@/lib/photos' interface PhotoState { photos: Photo[] /** Adds image files to the collection. Returns how many were accepted. */ addFiles: (files: File[]) => number + setCaption: (id: string, field: CaptionField, value: string) => void remove: (id: string) => void clear: () => void } @@ -19,6 +25,12 @@ export const usePhotoStore = create((set) => ({ } return accepted.length }, + setCaption: (id, field, value) => + set((state) => ({ + photos: state.photos.map((photo) => + photo.id === id ? { ...photo, [field]: value } : photo, + ), + })), remove: (id) => set((state) => { const target = state.photos.find((photo) => photo.id === id) diff --git a/apps/web/src/stores/settings-store.ts b/apps/web/src/stores/settings-store.ts new file mode 100644 index 0000000..23d3290 --- /dev/null +++ b/apps/web/src/stores/settings-store.ts @@ -0,0 +1,15 @@ +import { create } from 'zustand' + +export const MIN_FRAME_PADDING = 6 +export const MAX_FRAME_PADDING = 28 + +interface SettingsState { + /** White polaroid border width in px (sides/top); the bottom is thicker. */ + framePadding: number + setFramePadding: (px: number) => void +} + +export const useSettingsStore = create((set) => ({ + framePadding: 14, + setFramePadding: (px) => set({ framePadding: px }), +})) diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 269edc5..4f93859 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -42,6 +42,7 @@ } @theme inline { + --font-handwriting: "Caveat", "Segoe Print", "Bradley Hand", cursive; --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius);