Wardrobe: gallery picker + non-blocking vision AI; add /api/time endpoint

Wardrobe add page:
- Remove capture="environment" so Android shows the Camera/Gallery chooser
  instead of opening the camera directly
- Vision AI no longer blocks the UI: photo preview + form appear instantly
  after selecting an image; upload spinner shows on the thumbnail while R2
  upload runs; vision AI fires in the background and fills in tags when done
  without interrupting the user (they can pick size/occasions in parallel)
- "AI tagging…" pulsing badge in header while vision runs; " AI pre-filled"
  badge when done; form fields are only overwritten if the user hasn't already
  typed/selected something (functional state updater with prev-value guard)
- Save button is disabled (with "Uploading photo…" label) until R2 upload
  completes — prevents saving a garment with no imageKey

/api/time endpoint (GET, no auth):
- Returns { utc, istDate, istTime, ist, offsetMinutes } for the current server
  time in Asia/Kolkata so the app can verify server clock and surface IST time
  reliably (can be called from browser console at /api/time)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-28 23:31:18 +05:30
parent cfb0f4b2eb
commit e99a874309
3 changed files with 118 additions and 63 deletions

File diff suppressed because one or more lines are too long

View file

@ -7,7 +7,7 @@ import { useFamily } from "@/app/FamilyProvider";
import { Button } from "@/components/ui";
import { GARMENT_CATEGORIES, GARMENT_SIZE_ORDER } from "@/db/schema/wardrobe";
type Step = "capture" | "tagging" | "form";
type Step = "capture" | "form";
interface VisionResult {
name: string;
@ -119,6 +119,8 @@ export default function AddGarmentPage() {
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("");
@ -136,53 +138,59 @@ export default function AddGarmentPage() {
const file = e.target.files?.[0];
if (!file) return;
setError("");
setStep("tagging");
// Show local preview immediately
// 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);
// Upload to R2
// 2. Upload to R2
const form = new FormData();
form.append("file", file);
let uploadRes;
let ik = "", tk = "", tu = "";
try {
uploadRes = await fetch("/api/garments/upload", { method: "POST", body: form });
const uploadRes = await fetch("/api/garments/upload", { method: "POST", body: form });
if (!uploadRes.ok) throw new Error((await uploadRes.json()).error);
} catch (err) {
setError(`Upload failed: ${err}`);
setStep("capture");
return;
}
const { imageKey: ik, thumbKey: tk, thumbUrl: tu } = await uploadRes.json();
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);
// Vision tagging
let vision: VisionResult = { name: "", category: "top", colors: [], seasons: [], occasion_tags: [] };
try {
const tagRes = await fetch("/api/garments/tag", {
// 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 }),
});
if (tagRes.ok) {
const tagData = await tagRes.json();
vision = tagData;
}
} catch {
// Vision failure is non-fatal — proceed with empty defaults
}
})
.then(r => r.ok ? r.json() : null)
.then((vision: VisionResult | null) => {
if (vision) {
setVisionMetadata(vision);
setName(vision.name || "");
setCategory(vision.category || null);
setColors(vision.colors || []);
setSeasons(vision.seasons || []);
setOccasionTags(vision.occasion_tags || []);
setStep("form");
// 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 = () => {
@ -240,6 +248,8 @@ export default function AddGarmentPage() {
setGiftFrom("");
setVisionMetadata(null);
setError("");
setUploading(false);
setVisionPending(false);
if (fileRef.current) fileRef.current.value = "";
};
@ -249,9 +259,14 @@ export default function AddGarmentPage() {
<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" && (
{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">
Vision pre-filled
AI pre-filled
</span>
)}
</div>
@ -278,45 +293,30 @@ export default function AddGarmentPage() {
📷 Add Photo
</span>
</div>
{/* No capture="environment" so Android shows "Camera / Gallery" chooser */}
<input
ref={fileRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={handleFileChange}
/>
</div>
)}
{/* Step 2: Tagging spinner */}
{step === "tagging" && (
<div className="mx-4 mt-4 flex flex-col items-center gap-6">
{preview && (
<div className="w-48 h-48 rounded-2xl overflow-hidden shadow-lg">
<img src={preview} alt="preview" className="w-full h-full object-cover" />
</div>
)}
<div className="text-center">
<div className="flex gap-1 justify-center mb-3">
{[0, 150, 300].map(d => (
<span key={d} className="w-2.5 h-2.5 bg-rose-400 rounded-full animate-bounce" style={{ animationDelay: `${d}ms` }} />
))}
</div>
<p className="font-semibold text-gray-700 dark:text-gray-200">Analysing garment</p>
<p className="text-sm text-gray-400">Vision is detecting category & colors</p>
</div>
</div>
)}
{/* Step 3: Form */}
{/* Step 2: Form (shown immediately after photo selected) */}
{step === "form" && (
<div className="mx-4 mt-2 space-y-2">
{/* Thumbnail */}
{/* Thumbnail — shows upload spinner until R2 upload is done */}
<div className="flex gap-4 items-start mb-4">
{(thumbUrl || preview) && (
<div className="w-24 h-24 rounded-2xl overflow-hidden shadow-md flex-shrink-0">
<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">
@ -446,12 +446,17 @@ export default function AddGarmentPage() {
fullWidth
onClick={handleSave}
loading={saving}
disabled={!category || !sizeLabel || !childId}
disabled={!category || !sizeLabel || !childId || uploading}
size="lg"
>
Save Garment
{uploading ? "Uploading photo…" : "Save Garment"}
</Button>
{!sizeLabel && (
{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>

50
src/app/api/time/route.ts Normal file
View file

@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
const TZ = "Asia/Kolkata";
/**
* GET /api/time
* Returns the server's current time in IST. The app can call this to verify
* that the user's device clock matches the server, and to use a trusted
* timestamp source for log entries if needed.
*
* No auth required time is not sensitive.
*/
export async function GET() {
const now = new Date();
const istDate = now.toLocaleDateString("sv-SE", { timeZone: TZ }); // YYYY-MM-DD
const istTime = now.toLocaleTimeString("en-IN", {
timeZone: TZ,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
const istLabel = now.toLocaleString("en-IN", {
timeZone: TZ,
weekday: "short",
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: true,
});
return NextResponse.json(
{
utc: now.toISOString(), // e.g. "2026-05-28T09:00:00.000Z"
istDate, // e.g. "2026-05-28"
istTime, // e.g. "14:30:00"
ist: istLabel, // e.g. "Wed, 28 May 2026, 02:30 PM"
offsetMinutes: 330, // IST = UTC +5:30
},
{
headers: {
// Do not cache — always return live server time
"Cache-Control": "no-store",
},
}
);
}