- /api/img: add garments/ to ALLOWED_PREFIXES so garment images proxy correctly - garments upload: resolve Android empty MIME type from file extension; return /api/img proxy URLs instead of raw pub-*.r2.dev (blocked by Cloudflare Bot Mgmt) - garments route + [id] route: toDto() now builds /api/img?key= proxy URLs - date-ist.ts: add toUTCDate() helper -- strings without Z/offset treated as UTC, preventing browser local-time misinterpretation; used in fmtTime, fmtDate, dateIST - LogModal: add editTime to SmartDefault; pre-fill time picker (custom preset) when editing an existing log instead of defaulting to now - activity page: pass editTime: log.loggedAt in handleEdit so LogModal pre-fills - wardrobe/add: explicit Camera and Gallery buttons via separate hidden inputs (one with capture=environment for direct camera, one without for media picker) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
493 lines
18 KiB
TypeScript
493 lines
18 KiB
TypeScript
"use client";
|
||
|
||
import { useRef, useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import Image from "next/image";
|
||
import { useFamily } from "@/app/FamilyProvider";
|
||
import { Button } from "@/components/ui";
|
||
import { GARMENT_CATEGORIES, GARMENT_SIZE_ORDER } from "@/db/schema/wardrobe";
|
||
|
||
type Step = "capture" | "form";
|
||
|
||
interface VisionResult {
|
||
name: string;
|
||
category: string;
|
||
colors: string[];
|
||
seasons: string[];
|
||
occasion_tags: string[];
|
||
}
|
||
|
||
const SEASON_OPTIONS = ["summer", "monsoon", "winter"];
|
||
const OCCASION_OPTIONS = ["everyday", "daycare", "festive", "photoshoot"];
|
||
const ACQUIRED_OPTIONS = ["bought", "gift", "handmedown"];
|
||
|
||
const CATEGORY_LABELS: Record<string, string> = {
|
||
onesie: "Onesie", top: "Top", bottom: "Bottom", dress: "Dress",
|
||
outerwear: "Jacket", sleepwear: "Sleepwear", accessory: "Accessory",
|
||
};
|
||
|
||
const CATEGORY_COLORS: Record<string, string> = {
|
||
onesie: "bg-pink-100 text-pink-700", top: "bg-blue-100 text-blue-700",
|
||
bottom: "bg-indigo-100 text-indigo-700", dress: "bg-purple-100 text-purple-700",
|
||
outerwear: "bg-teal-100 text-teal-700", sleepwear: "bg-amber-100 text-amber-700",
|
||
accessory: "bg-rose-100 text-rose-700",
|
||
};
|
||
|
||
function ChipRow<T extends string>({
|
||
label, options, selected, onChange, required, optionLabel,
|
||
}: {
|
||
label: string;
|
||
options: readonly T[];
|
||
selected: T[];
|
||
onChange: (v: T[]) => void;
|
||
required?: boolean;
|
||
optionLabel?: (v: T) => string;
|
||
}) {
|
||
const toggle = (v: T) => {
|
||
if (selected.includes(v)) onChange(selected.filter(x => x !== v));
|
||
else onChange([...selected, v]);
|
||
};
|
||
return (
|
||
<div className="mb-4">
|
||
<p className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-2">
|
||
{label}{required && <span className="text-red-500 ml-0.5">*</span>}
|
||
</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
{options.map(o => (
|
||
<button
|
||
key={o}
|
||
type="button"
|
||
onClick={() => toggle(o)}
|
||
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-all ${
|
||
selected.includes(o)
|
||
? "bg-rose-400 text-white shadow-sm scale-105"
|
||
: "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"
|
||
}`}
|
||
>
|
||
{optionLabel ? optionLabel(o) : o}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SingleChipRow<T extends string>({
|
||
label, options, selected, onChange, required, optionLabel,
|
||
}: {
|
||
label: string;
|
||
options: readonly T[];
|
||
selected: T | null;
|
||
onChange: (v: T) => void;
|
||
required?: boolean;
|
||
optionLabel?: (v: T) => string;
|
||
}) {
|
||
return (
|
||
<div className="mb-4">
|
||
<p className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-2">
|
||
{label}{required && <span className="text-red-500 ml-0.5">*</span>}
|
||
</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
{options.map(o => (
|
||
<button
|
||
key={o}
|
||
type="button"
|
||
onClick={() => onChange(o)}
|
||
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-all ${
|
||
selected === o
|
||
? "bg-rose-400 text-white shadow-sm scale-105"
|
||
: "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"
|
||
}`}
|
||
>
|
||
{optionLabel ? optionLabel(o) : o}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function AddGarmentPage() {
|
||
const { childId } = useFamily();
|
||
const router = useRouter();
|
||
const fileRef = useRef<HTMLInputElement>(null); // gallery picker
|
||
const cameraRef = useRef<HTMLInputElement>(null); // camera capture
|
||
|
||
const [step, setStep] = useState<Step>("capture");
|
||
const [preview, setPreview] = useState<string | null>(null);
|
||
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
|
||
const [imageKey, setImageKey] = useState("");
|
||
const [thumbKey, setThumbKey] = useState("");
|
||
const [error, setError] = useState("");
|
||
const [saving, setSaving] = useState(false);
|
||
const [uploading, setUploading] = useState(false); // photo still uploading to R2
|
||
const [visionPending, setVisionPending] = useState(false); // AI analysis running in bg
|
||
|
||
// Form fields
|
||
const [name, setName] = useState("");
|
||
const [category, setCategory] = useState<string | null>(null);
|
||
const [sizeLabel, setSizeLabel] = useState<string | null>(null);
|
||
const [colors, setColors] = useState<string[]>([]);
|
||
const [colorInput, setColorInput] = useState("");
|
||
const [seasons, setSeasons] = useState<string[]>([]);
|
||
const [occasionTags, setOccasionTags] = useState<string[]>([]);
|
||
const [acquiredVia, setAcquiredVia] = useState<string | null>(null);
|
||
const [giftFrom, setGiftFrom] = useState("");
|
||
const [visionMetadata, setVisionMetadata] = useState<VisionResult | null>(null);
|
||
|
||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
setError("");
|
||
|
||
// 1. Show local preview immediately and jump straight to the form.
|
||
// The user can start filling in size/occasions while the upload runs.
|
||
const reader = new FileReader();
|
||
reader.onload = ev => setPreview(ev.target?.result as string);
|
||
reader.readAsDataURL(file);
|
||
setStep("form");
|
||
setUploading(true);
|
||
setVisionPending(false);
|
||
|
||
// 2. Upload to R2
|
||
const form = new FormData();
|
||
form.append("file", file);
|
||
let ik = "", tk = "", tu = "";
|
||
try {
|
||
const uploadRes = await fetch("/api/garments/upload", { method: "POST", body: form });
|
||
if (!uploadRes.ok) throw new Error((await uploadRes.json()).error);
|
||
const uploadData = await uploadRes.json();
|
||
ik = uploadData.imageKey;
|
||
tk = uploadData.thumbKey;
|
||
tu = uploadData.thumbUrl;
|
||
setImageKey(ik);
|
||
setThumbKey(tk);
|
||
setThumbUrl(tu);
|
||
} catch (err) {
|
||
setError(`Upload failed: ${err}`);
|
||
setStep("capture");
|
||
setUploading(false);
|
||
return;
|
||
}
|
||
setUploading(false);
|
||
|
||
// 3. Fire vision AI in the background — do NOT await, form stays interactive.
|
||
setVisionPending(true);
|
||
fetch("/api/garments/tag", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ imageKey: ik }),
|
||
})
|
||
.then(r => r.ok ? r.json() : null)
|
||
.then((vision: VisionResult | null) => {
|
||
if (vision) {
|
||
setVisionMetadata(vision);
|
||
// Only pre-fill fields the user hasn't touched yet
|
||
setName(n => n || vision.name || "");
|
||
setCategory(c => c || vision.category || null);
|
||
setColors(c => c.length ? c : (vision.colors || []));
|
||
setSeasons(s => s.length ? s : (vision.seasons || []));
|
||
setOccasionTags(t => t.length ? t : (vision.occasion_tags || []));
|
||
}
|
||
})
|
||
.catch(() => {/* vision failure is non-fatal */})
|
||
.finally(() => setVisionPending(false));
|
||
};
|
||
|
||
const addColor = () => {
|
||
const c = colorInput.trim().toLowerCase();
|
||
if (c && !colors.includes(c)) setColors([...colors, c]);
|
||
setColorInput("");
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
if (!childId || !category || !sizeLabel) return;
|
||
setSaving(true);
|
||
setError("");
|
||
try {
|
||
const res = await fetch("/api/garments", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
childId,
|
||
name: name || null,
|
||
category,
|
||
sizeLabel,
|
||
colors,
|
||
seasons,
|
||
occasionTags,
|
||
imageKey,
|
||
thumbKey,
|
||
acquiredVia: acquiredVia || null,
|
||
giftFrom: giftFrom || null,
|
||
visionMetadata,
|
||
}),
|
||
});
|
||
if (!res.ok) throw new Error((await res.json()).error);
|
||
handleAddAnother();
|
||
} catch (err) {
|
||
setError(`Save failed: ${err}`);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleAddAnother = () => {
|
||
setStep("capture");
|
||
setPreview(null);
|
||
setThumbUrl(null);
|
||
setImageKey("");
|
||
setThumbKey("");
|
||
setName("");
|
||
setCategory(null);
|
||
setSizeLabel(null);
|
||
setColors([]);
|
||
setColorInput("");
|
||
setSeasons([]);
|
||
setOccasionTags([]);
|
||
setAcquiredVia(null);
|
||
setGiftFrom("");
|
||
setVisionMetadata(null);
|
||
setError("");
|
||
setUploading(false);
|
||
setVisionPending(false);
|
||
if (fileRef.current) fileRef.current.value = "";
|
||
if (cameraRef.current) cameraRef.current.value = "";
|
||
};
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-pink-50 via-purple-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-24">
|
||
{/* Header */}
|
||
<div className="flex items-center gap-3 p-4">
|
||
<button onClick={() => router.back()} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl">←</button>
|
||
<h1 className="text-xl font-bold">Add Garment</h1>
|
||
{step === "form" && visionPending && (
|
||
<span className="ml-auto text-xs text-amber-500 bg-amber-50 dark:bg-amber-900/20 px-2 py-1 rounded-full shadow-sm animate-pulse">
|
||
🔍 AI tagging…
|
||
</span>
|
||
)}
|
||
{step === "form" && !visionPending && visionMetadata && (
|
||
<span className="ml-auto text-xs text-gray-400 bg-white dark:bg-gray-800 px-2 py-1 rounded-full shadow-sm">
|
||
✨ AI pre-filled
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="mx-4 mb-3 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 rounded-xl text-sm text-red-700 dark:text-red-300">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{/* Step 1: Capture */}
|
||
{step === "capture" && (
|
||
<div className="mx-4 mt-4">
|
||
<div className="aspect-square rounded-3xl border-4 border-dashed border-rose-200 dark:border-gray-600 flex flex-col items-center justify-center gap-4 bg-white dark:bg-gray-800">
|
||
<span className="text-6xl">👚</span>
|
||
<div className="text-center px-4">
|
||
<p className="font-semibold text-gray-700 dark:text-gray-200">Add a garment photo</p>
|
||
<p className="text-sm text-gray-400 mt-1">Tip: flat-lay on a light surface for best AI tagging</p>
|
||
</div>
|
||
<div className="flex gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => cameraRef.current?.click()}
|
||
className="flex items-center gap-2 px-5 py-2.5 bg-rose-400 text-white rounded-full text-sm font-medium shadow-sm active:scale-95 transition-transform"
|
||
>
|
||
📷 Camera
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => fileRef.current?.click()}
|
||
className="flex items-center gap-2 px-5 py-2.5 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 rounded-full text-sm font-medium shadow-sm active:scale-95 transition-transform"
|
||
>
|
||
🖼 Gallery
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{/* Gallery input — no capture attribute, Android shows full media picker */}
|
||
<input
|
||
ref={fileRef}
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={handleFileChange}
|
||
/>
|
||
{/* Camera input — capture="environment" opens rear camera directly */}
|
||
<input
|
||
ref={cameraRef}
|
||
type="file"
|
||
accept="image/*"
|
||
capture="environment"
|
||
className="hidden"
|
||
onChange={handleFileChange}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Step 2: Form (shown immediately after photo selected) */}
|
||
{step === "form" && (
|
||
<div className="mx-4 mt-2 space-y-2">
|
||
{/* Thumbnail — shows upload spinner until R2 upload is done */}
|
||
<div className="flex gap-4 items-start mb-4">
|
||
{(thumbUrl || preview) && (
|
||
<div className="relative w-24 h-24 rounded-2xl overflow-hidden shadow-md flex-shrink-0">
|
||
<img src={thumbUrl || preview!} alt="garment" className="w-full h-full object-cover" />
|
||
{uploading && (
|
||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||
<div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
<div className="flex-1">
|
||
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300">Name</label>
|
||
<input
|
||
value={name}
|
||
onChange={e => setName(e.target.value)}
|
||
placeholder="e.g. Striped blue onesie"
|
||
className="mt-1 w-full px-3 py-2 rounded-xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 text-sm focus:outline-none focus:ring-2 focus:ring-rose-300"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Category */}
|
||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
|
||
<SingleChipRow
|
||
label="Category"
|
||
options={GARMENT_CATEGORIES}
|
||
selected={category as typeof GARMENT_CATEGORIES[number] | null}
|
||
onChange={v => setCategory(v)}
|
||
required
|
||
optionLabel={v => CATEGORY_LABELS[v] || v}
|
||
/>
|
||
</div>
|
||
|
||
{/* Size — required, not pre-filled */}
|
||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
|
||
<SingleChipRow
|
||
label="Size"
|
||
options={GARMENT_SIZE_ORDER}
|
||
selected={sizeLabel as typeof GARMENT_SIZE_ORDER[number] | null}
|
||
onChange={v => setSizeLabel(v)}
|
||
required
|
||
/>
|
||
{!sizeLabel && (
|
||
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2">
|
||
⚠ Size must be set manually — vision cannot guess it
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Colors */}
|
||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
|
||
<p className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-2">Colors</p>
|
||
<div className="flex flex-wrap gap-2 mb-3">
|
||
{colors.map(c => (
|
||
<button
|
||
key={c}
|
||
type="button"
|
||
onClick={() => setColors(colors.filter(x => x !== c))}
|
||
className="px-3 py-1 rounded-full text-sm bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 flex items-center gap-1"
|
||
>
|
||
{c} ×
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<input
|
||
value={colorInput}
|
||
onChange={e => setColorInput(e.target.value)}
|
||
onKeyDown={e => e.key === "Enter" && addColor()}
|
||
placeholder="Add color…"
|
||
className="flex-1 px-3 py-1.5 rounded-xl bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-sm focus:outline-none"
|
||
/>
|
||
<button type="button" onClick={addColor} className="px-3 py-1.5 bg-rose-400 text-white rounded-xl text-sm">Add</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Seasons */}
|
||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
|
||
<ChipRow
|
||
label="Seasons"
|
||
options={SEASON_OPTIONS as readonly string[]}
|
||
selected={seasons}
|
||
onChange={setSeasons}
|
||
/>
|
||
</div>
|
||
|
||
{/* Occasions */}
|
||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
|
||
<ChipRow
|
||
label="Occasions"
|
||
options={OCCASION_OPTIONS as readonly string[]}
|
||
selected={occasionTags}
|
||
onChange={setOccasionTags}
|
||
/>
|
||
</div>
|
||
|
||
{/* Optional fields */}
|
||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
|
||
<p className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-3">Optional</p>
|
||
<div className="mb-3">
|
||
<p className="text-xs text-gray-500 mb-1">Acquired via</p>
|
||
<div className="flex gap-2">
|
||
{ACQUIRED_OPTIONS.map(o => (
|
||
<button
|
||
key={o}
|
||
type="button"
|
||
onClick={() => setAcquiredVia(acquiredVia === o ? null : o)}
|
||
className={`px-3 py-1 rounded-full text-xs font-medium transition-all ${
|
||
acquiredVia === o
|
||
? "bg-indigo-400 text-white"
|
||
: "bg-gray-100 dark:bg-gray-700 text-gray-500"
|
||
}`}
|
||
>
|
||
{o}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
{acquiredVia === "gift" && (
|
||
<div>
|
||
<p className="text-xs text-gray-500 mb-1">Gift from</p>
|
||
<input
|
||
value={giftFrom}
|
||
onChange={e => setGiftFrom(e.target.value)}
|
||
placeholder="e.g. Nani"
|
||
className="w-full px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-sm focus:outline-none"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Action buttons */}
|
||
<div className="pt-2 space-y-3">
|
||
<Button
|
||
fullWidth
|
||
onClick={handleSave}
|
||
loading={saving}
|
||
disabled={!category || !sizeLabel || !childId || uploading}
|
||
size="lg"
|
||
>
|
||
{uploading ? "Uploading photo…" : "Save Garment"}
|
||
</Button>
|
||
{uploading && (
|
||
<p className="text-center text-sm text-gray-400">
|
||
Photo uploading — you can pick size & tags while you wait
|
||
</p>
|
||
)}
|
||
{!uploading && !sizeLabel && (
|
||
<p className="text-center text-sm text-amber-600 dark:text-amber-400">
|
||
Please select a size to save
|
||
</p>
|
||
)}
|
||
<Button fullWidth variant="secondary" onClick={handleAddAnother} size="lg">
|
||
+ Add Another
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|