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:
parent
cfb0f4b2eb
commit
e99a874309
3 changed files with 118 additions and 63 deletions
File diff suppressed because one or more lines are too long
|
|
@ -7,7 +7,7 @@ import { useFamily } from "@/app/FamilyProvider";
|
||||||
import { Button } from "@/components/ui";
|
import { Button } from "@/components/ui";
|
||||||
import { GARMENT_CATEGORIES, GARMENT_SIZE_ORDER } from "@/db/schema/wardrobe";
|
import { GARMENT_CATEGORIES, GARMENT_SIZE_ORDER } from "@/db/schema/wardrobe";
|
||||||
|
|
||||||
type Step = "capture" | "tagging" | "form";
|
type Step = "capture" | "form";
|
||||||
|
|
||||||
interface VisionResult {
|
interface VisionResult {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -119,6 +119,8 @@ export default function AddGarmentPage() {
|
||||||
const [thumbKey, setThumbKey] = useState("");
|
const [thumbKey, setThumbKey] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
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
|
// Form fields
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
|
@ -136,53 +138,59 @@ export default function AddGarmentPage() {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setError("");
|
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();
|
const reader = new FileReader();
|
||||||
reader.onload = ev => setPreview(ev.target?.result as string);
|
reader.onload = ev => setPreview(ev.target?.result as string);
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
|
setStep("form");
|
||||||
|
setUploading(true);
|
||||||
|
setVisionPending(false);
|
||||||
|
|
||||||
// Upload to R2
|
// 2. Upload to R2
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", file);
|
form.append("file", file);
|
||||||
let uploadRes;
|
let ik = "", tk = "", tu = "";
|
||||||
try {
|
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);
|
if (!uploadRes.ok) throw new Error((await uploadRes.json()).error);
|
||||||
} catch (err) {
|
const uploadData = await uploadRes.json();
|
||||||
setError(`Upload failed: ${err}`);
|
ik = uploadData.imageKey;
|
||||||
setStep("capture");
|
tk = uploadData.thumbKey;
|
||||||
return;
|
tu = uploadData.thumbUrl;
|
||||||
}
|
|
||||||
const { imageKey: ik, thumbKey: tk, thumbUrl: tu } = await uploadRes.json();
|
|
||||||
setImageKey(ik);
|
setImageKey(ik);
|
||||||
setThumbKey(tk);
|
setThumbKey(tk);
|
||||||
setThumbUrl(tu);
|
setThumbUrl(tu);
|
||||||
|
} catch (err) {
|
||||||
|
setError(`Upload failed: ${err}`);
|
||||||
|
setStep("capture");
|
||||||
|
setUploading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUploading(false);
|
||||||
|
|
||||||
// Vision tagging
|
// 3. Fire vision AI in the background — do NOT await, form stays interactive.
|
||||||
let vision: VisionResult = { name: "", category: "top", colors: [], seasons: [], occasion_tags: [] };
|
setVisionPending(true);
|
||||||
try {
|
fetch("/api/garments/tag", {
|
||||||
const tagRes = await fetch("/api/garments/tag", {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ imageKey: ik }),
|
body: JSON.stringify({ imageKey: ik }),
|
||||||
});
|
})
|
||||||
if (tagRes.ok) {
|
.then(r => r.ok ? r.json() : null)
|
||||||
const tagData = await tagRes.json();
|
.then((vision: VisionResult | null) => {
|
||||||
vision = tagData;
|
if (vision) {
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Vision failure is non-fatal — proceed with empty defaults
|
|
||||||
}
|
|
||||||
|
|
||||||
setVisionMetadata(vision);
|
setVisionMetadata(vision);
|
||||||
setName(vision.name || "");
|
// Only pre-fill fields the user hasn't touched yet
|
||||||
setCategory(vision.category || null);
|
setName(n => n || vision.name || "");
|
||||||
setColors(vision.colors || []);
|
setCategory(c => c || vision.category || null);
|
||||||
setSeasons(vision.seasons || []);
|
setColors(c => c.length ? c : (vision.colors || []));
|
||||||
setOccasionTags(vision.occasion_tags || []);
|
setSeasons(s => s.length ? s : (vision.seasons || []));
|
||||||
setStep("form");
|
setOccasionTags(t => t.length ? t : (vision.occasion_tags || []));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {/* vision failure is non-fatal */})
|
||||||
|
.finally(() => setVisionPending(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const addColor = () => {
|
const addColor = () => {
|
||||||
|
|
@ -240,6 +248,8 @@ export default function AddGarmentPage() {
|
||||||
setGiftFrom("");
|
setGiftFrom("");
|
||||||
setVisionMetadata(null);
|
setVisionMetadata(null);
|
||||||
setError("");
|
setError("");
|
||||||
|
setUploading(false);
|
||||||
|
setVisionPending(false);
|
||||||
if (fileRef.current) fileRef.current.value = "";
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -249,9 +259,14 @@ export default function AddGarmentPage() {
|
||||||
<div className="flex items-center gap-3 p-4">
|
<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>
|
<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>
|
<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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -278,45 +293,30 @@ export default function AddGarmentPage() {
|
||||||
📷 Add Photo
|
📷 Add Photo
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* No capture="environment" so Android shows "Camera / Gallery" chooser */}
|
||||||
<input
|
<input
|
||||||
ref={fileRef}
|
ref={fileRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
capture="environment"
|
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 2: Tagging spinner */}
|
{/* Step 2: Form (shown immediately after photo selected) */}
|
||||||
{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 === "form" && (
|
{step === "form" && (
|
||||||
<div className="mx-4 mt-2 space-y-2">
|
<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">
|
<div className="flex gap-4 items-start mb-4">
|
||||||
{(thumbUrl || preview) && (
|
{(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" />
|
<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>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|
@ -446,12 +446,17 @@ export default function AddGarmentPage() {
|
||||||
fullWidth
|
fullWidth
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
loading={saving}
|
loading={saving}
|
||||||
disabled={!category || !sizeLabel || !childId}
|
disabled={!category || !sizeLabel || !childId || uploading}
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
Save Garment
|
{uploading ? "Uploading photo…" : "Save Garment"}
|
||||||
</Button>
|
</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">
|
<p className="text-center text-sm text-amber-600 dark:text-amber-400">
|
||||||
Please select a size to save
|
Please select a size to save
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
50
src/app/api/time/route.ts
Normal file
50
src/app/api/time/route.ts
Normal 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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue