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
31 changes: 31 additions & 0 deletions apps/web/src/components/frame-controls.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center gap-2">
<label
htmlFor="frame-size"
className="text-muted-foreground text-xs font-medium whitespace-nowrap"
>
Frame size
</label>
<input
id="frame-size"
type="range"
min={MIN_FRAME_PADDING}
max={MAX_FRAME_PADDING}
step={1}
value={framePadding}
onChange={(event) => setFramePadding(Number(event.target.value))}
className="accent-primary w-32"
/>
</div>
)
}
46 changes: 16 additions & 30 deletions apps/web/src/components/photo-grid.tsx
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -12,16 +13,19 @@ export function PhotoGrid() {
if (photos.length === 0) return null

return (
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<section className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-sm font-medium">
{photos.length} {photos.length === 1 ? 'photo' : 'photos'}
</h2>
<Button variant="ghost" size="sm" onClick={clear}>
Clear all
</Button>
<div className="flex items-center gap-4">
<FrameControls />
<Button variant="ghost" size="sm" onClick={clear}>
Clear all
</Button>
</div>
</div>
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{photos.map((photo) => (
<PhotoTile key={photo.id} photo={photo} />
))}
Expand All @@ -32,38 +36,20 @@ export function PhotoGrid() {

function PhotoTile({ photo }: { photo: Photo }) {
const remove = usePhotoStore((state) => state.remove)
const [failed, setFailed] = useState(false)

return (
<li className="group bg-muted relative aspect-square overflow-hidden rounded-md border">
{failed ? (
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-1 p-2 text-center">
<ImageOff className="size-5" />
<span className="text-[10px] leading-tight">Preview unavailable</span>
</div>
) : (
<img
src={photo.url}
alt={photo.name}
loading="lazy"
onError={() => setFailed(true)}
className="h-full w-full object-cover"
/>
)}
<li className="group relative">
<Polaroid photo={photo} />
<Button
type="button"
variant="secondary"
size="icon"
aria-label={`Remove ${photo.name}`}
onClick={() => remove(photo.id)}
className="absolute top-1 right-1 size-7 opacity-0 shadow-sm transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
className="absolute top-1.5 right-1.5 size-7 opacity-0 shadow-sm transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
>
<X />
</Button>
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-1.5">
<p className="truncate text-[10px] text-white">{photo.name}</p>
<p className="text-[10px] text-white/70">{formatBytes(photo.size)}</p>
</div>
</li>
)
}
82 changes: 82 additions & 0 deletions apps/web/src/components/polaroid.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className="flex flex-col bg-white shadow-md"
style={{ padding: framePadding, paddingBottom: 0 }}
>
<div className="aspect-square overflow-hidden bg-neutral-200">
{failed ? (
<div className="flex h-full flex-col items-center justify-center gap-1 text-neutral-400">
<ImageOff className="size-6" />
<span className="text-[10px]">Preview unavailable</span>
</div>
) : (
<img
src={photo.url}
alt={photo.name}
loading="lazy"
onError={() => setFailed(true)}
className="h-full w-full object-cover"
/>
)}
</div>
<div
className="flex flex-col items-center"
style={{
paddingTop: framePadding * 0.75,
paddingBottom: framePadding * 1.5,
}}
>
<CaptionInput
value={photo.captionTop}
onChange={(value) => setCaption(photo.id, 'captionTop', value)}
placeholder="Add a caption"
className="text-lg leading-tight"
/>
<CaptionInput
value={photo.captionBottom}
onChange={(value) => setCaption(photo.id, 'captionBottom', value)}
className="text-sm text-neutral-500"
/>
</div>
</div>
)
}

function CaptionInput({
value,
onChange,
placeholder,
className,
}: {
value: string
onChange: (value: string) => void
placeholder?: string
className?: string
}) {
return (
<input
type="text"
value={value}
onChange={(event) => 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,
)}
/>
)
}
7 changes: 7 additions & 0 deletions apps/web/src/lib/photos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -20,6 +25,8 @@ export function createPhoto(file: File): Photo {
url: URL.createObjectURL(file),
name: file.name,
size: file.size,
captionTop: '',
captionBottom: '',
}
}

Expand Down
14 changes: 13 additions & 1 deletion apps/web/src/stores/photo-store.ts
Original file line number Diff line number Diff line change
@@ -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
}
Expand All @@ -19,6 +25,12 @@ export const usePhotoStore = create<PhotoState>((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)
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/stores/settings-store.ts
Original file line number Diff line number Diff line change
@@ -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<SettingsState>((set) => ({
framePadding: 14,
setFramePadding: (px) => set({ framePadding: px }),
}))
1 change: 1 addition & 0 deletions apps/web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading