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 { 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
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