tia/src/app/(app)/wardrobe/add/page.tsx
Mannu 3cfcbdc0ca fix: garment upload MIME/proxy, log edit time pre-fill, date-ist hardening, wardrobe camera+gallery
- /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>
2026-05-29 00:42:04 +05:30

493 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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