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
-
- ) : (
-
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
+
+ ) : (
+

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);