diff --git a/.gitignore b/.gitignore index 67a374e..ad8bc9f 100644 --- a/.gitignore +++ b/.gitignore @@ -150,3 +150,9 @@ vite.config.ts.timestamp-* # Cloudflare Wrangler local state .wrangler/ + +# Local agent handoff / scratch docs (never tracked) +.claude/ + +# TanStack Router generated temp dir +.tanstack/ diff --git a/apps/web/package.json b/apps/web/package.json index fd6af8b..3359cb1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@fontsource-variable/fraunces": "^5.2.9", "@fontsource/indie-flower": "^5.2.7", "@fontsource/kalam": "^5.2.8", "@fontsource/montserrat": "^5.2.8", diff --git a/apps/web/src/components/a4-preview.tsx b/apps/web/src/components/a4-preview.tsx index 8aad378..fcbfdca 100644 --- a/apps/web/src/components/a4-preview.tsx +++ b/apps/web/src/components/a4-preview.tsx @@ -1,6 +1,7 @@ -import { useLayoutEffect, useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { RectangleHorizontal, RectangleVertical, Square } from "lucide-react"; +import { EmptyHero } from "@/components/empty-hero"; import { SheetInspector } from "@/components/sheet-inspector"; import { SheetPage } from "@/components/sheet-page"; import { StripPage } from "@/components/strip-page"; @@ -44,19 +45,24 @@ export function A4Preview() { const fontStack = captionFontStack(captionFontId); const paper = paperSize(paperSizeId); - const containerRef = useRef(null); + const observerRef = useRef(null); const [width, setWidth] = useState(0); - useLayoutEffect(() => { - const el = containerRef.current; + // Callback ref so the observer re-attaches whenever the sheet container + // mounts — e.g. after the empty-state hero gives way to the first page. + const setContainer = useCallback((el: HTMLDivElement | null) => { + observerRef.current?.disconnect(); if (!el) return; const update = () => setWidth(el.clientWidth); update(); - const observer = new ResizeObserver(update); - observer.observe(el); - return () => observer.disconnect(); + observerRef.current = new ResizeObserver(update); + observerRef.current.observe(el); }, []); + if (photos.length === 0) { + return ; + } + const gridPages = paginate( photos, perRow, @@ -79,7 +85,7 @@ export function A4Preview() {
{sheetFormat === "strip" diff --git a/apps/web/src/components/empty-hero.tsx b/apps/web/src/components/empty-hero.tsx new file mode 100644 index 0000000..a3f628c --- /dev/null +++ b/apps/web/src/components/empty-hero.tsx @@ -0,0 +1,83 @@ +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"; + +/** A few illustrative polaroids — pure CSS, no image assets, so it loads instantly. */ +const SAMPLES = [ + { tint: "linear-gradient(150deg, #f7c59f, #ee6c4d)", caption: "Lisbon · '24", rotate: -6 }, + { tint: "linear-gradient(150deg, #bcd4e6, #3d5a80)", caption: "Sunday market", rotate: 3 }, + { tint: "linear-gradient(150deg, #f6e7b4, #e3924f)", caption: "Golden hour", rotate: -2 }, +]; + +/** Shown in the centre column before any photos are added. */ +export function EmptyHero() { + const addFiles = usePhotoStore((state) => state.addFiles); + const inputRef = useRef(null); + + return ( +
+
+ {SAMPLES.map((sample, i) => ( +
+
+
+ {sample.caption} +
+
+ ))} +
+ +
+

+ Turn your photos into polaroids +

+

+ Drop in a few shots — captions fill themselves in from each photo's + date and place — and download a print-ready sheet to cut out at home. +

+
+ +
+ { + const files = Array.from(event.target.files ?? []); + if (files.length > 0) addFiles(files); + event.target.value = ""; + }} + /> + + + or drag photos anywhere + +
+
+ ); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index fd964d1..ff64276 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -8,6 +8,7 @@ import '@fontsource/montserrat/400.css' import '@fontsource/montserrat/500.css' import '@fontsource/montserrat/600.css' import '@fontsource/montserrat/700.css' +import '@fontsource-variable/fraunces/opsz.css' import '@fontsource/indie-flower' import '@fontsource/patrick-hand' import '@fontsource/shadows-into-light' diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 55ce00f..a922819 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -15,7 +15,9 @@ function Home() {
-

Polaroid

+

+ Polaroid +

Edit each frame on the page — everything stays on your device.

diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 70d3aeb..40650d3 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -6,50 +6,56 @@ :root { color-scheme: light; --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); + /* Warm cream canvas + coral accent (#58 visual identity). */ + --coral: oklch(0.726 0.166 24); + --coral-foreground: oklch(0.225 0.009 67); + --background: oklch(0.968 0.012 84); + --foreground: oklch(0.225 0.009 67); + --card: oklch(0.985 0.008 84); + --card-foreground: oklch(0.225 0.009 67); + --popover: oklch(0.99 0.006 84); + --popover-foreground: oklch(0.225 0.009 67); + --primary: var(--coral); + --primary-foreground: var(--coral-foreground); + --secondary: oklch(0.94 0.012 84); + --secondary-foreground: oklch(0.225 0.009 67); + --muted: oklch(0.94 0.012 84); + --muted-foreground: oklch(0.585 0.012 78); + --accent: oklch(0.93 0.014 80); + --accent-foreground: oklch(0.225 0.009 67); --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); + --border: oklch(0.9 0.012 84); + --input: oklch(0.9 0.012 84); + --ring: var(--coral); } .dark { color-scheme: dark; - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.985 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); + --coral: oklch(0.74 0.16 24); + --coral-foreground: oklch(0.18 0.008 67); + --background: oklch(0.165 0.006 60); + --foreground: oklch(0.97 0.006 85); + --card: oklch(0.215 0.006 60); + --card-foreground: oklch(0.97 0.006 85); + --popover: oklch(0.215 0.006 60); + --popover-foreground: oklch(0.97 0.006 85); + --primary: var(--coral); + --primary-foreground: var(--coral-foreground); + --secondary: oklch(0.28 0.006 60); + --secondary-foreground: oklch(0.97 0.006 85); + --muted: oklch(0.28 0.006 60); + --muted-foreground: oklch(0.72 0.01 78); + --accent: oklch(0.3 0.008 60); + --accent-foreground: oklch(0.97 0.006 85); --destructive: oklch(0.704 0.191 22.216); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); + --ring: var(--coral); } @theme inline { --font-sans: 'Montserrat', ui-sans-serif, system-ui, -apple-system, sans-serif; + --font-display: 'Fraunces Variable', Georgia, 'Times New Roman', serif; --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); @@ -61,6 +67,8 @@ --color-popover-foreground: var(--popover-foreground); --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); + --color-coral: var(--coral); + --color-coral-foreground: var(--coral-foreground); --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); --color-muted: var(--muted); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a412ba..c52dd77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@19.2.7) + '@fontsource-variable/fraunces': + specifier: ^5.2.9 + version: 5.2.9 '@fontsource/indie-flower': specifier: ^5.2.7 version: 5.2.7 @@ -1014,6 +1017,9 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@fontsource-variable/fraunces@5.2.9': + resolution: {integrity: sha512-Y6IjunlN9Ni723np+GIgAaKzCDBrPRrqNi01TZxHs5wtHYROWFM9W6yiT+/gGwSjWIRD18oX17kD/BRWekc/Lw==} + '@fontsource/indie-flower@5.2.7': resolution: {integrity: sha512-vu9yEMW3Be2TXRkw2NYMLK1C4KQOUme3SUtqSha/wGzvBgzc2llT/lQ3bzZZ4aoCeF4x9ghGV+iwW+4hVr+Yhg==} @@ -5092,6 +5098,8 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@fontsource-variable/fraunces@5.2.9': {} + '@fontsource/indie-flower@5.2.7': {} '@fontsource/kalam@5.2.8': {}