From 3cfcbdc0caf40eef522ea200ae1d5d32f0edb0c0 Mon Sep 17 00:00:00 2001 From: Mannu Date: Fri, 29 May 2026 00:42:04 +0530 Subject: [PATCH] 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 --- src/app/(app)/activity/page.tsx | 6 +++- src/app/(app)/wardrobe/add/page.tsx | 45 ++++++++++++++++++++-------- src/app/api/garments/[id]/route.ts | 22 +++++++------- src/app/api/garments/route.ts | 21 ++++++------- src/app/api/garments/upload/route.ts | 24 +++++++++++---- src/app/api/img/route.ts | 2 +- src/components/LogModal.tsx | 26 ++++++++++++++-- src/lib/date-ist.ts | 25 ++++++++++++++-- 8 files changed, 124 insertions(+), 47 deletions(-) diff --git a/src/app/(app)/activity/page.tsx b/src/app/(app)/activity/page.tsx index a58ce2e..2b0dc7f 100644 --- a/src/app/(app)/activity/page.tsx +++ b/src/app/(app)/activity/page.tsx @@ -91,7 +91,11 @@ export default function ActivityPage() { const handleEdit = (log: Log) => { // Store the old log to delete after the new one is saved setPendingDeleteId({ id: log.id, type: log.type }); - setSmartDefault({ subType: log.subType ?? "", amountMl: log.amount ?? undefined } as SmartDefault); + setSmartDefault({ + subType: log.subType ?? "", + amountMl: log.amount ?? undefined, + editTime: log.loggedAt, // pre-fills the time picker with the original log time + } as SmartDefault); setModalType(log.type as ModalLogType); setSelectedLog(null); }; diff --git a/src/app/(app)/wardrobe/add/page.tsx b/src/app/(app)/wardrobe/add/page.tsx index c41a287..2edefa1 100644 --- a/src/app/(app)/wardrobe/add/page.tsx +++ b/src/app/(app)/wardrobe/add/page.tsx @@ -110,7 +110,8 @@ function SingleChipRow({ export default function AddGarmentPage() { const { childId } = useFamily(); const router = useRouter(); - const fileRef = useRef(null); + const fileRef = useRef(null); // gallery picker + const cameraRef = useRef(null); // camera capture const [step, setStep] = useState("capture"); const [preview, setPreview] = useState(null); @@ -250,7 +251,8 @@ export default function AddGarmentPage() { setError(""); setUploading(false); setVisionPending(false); - if (fileRef.current) fileRef.current.value = ""; + if (fileRef.current) fileRef.current.value = ""; + if (cameraRef.current) cameraRef.current.value = ""; }; return ( @@ -280,20 +282,30 @@ export default function AddGarmentPage() { {/* Step 1: Capture */} {step === "capture" && (
-
fileRef.current?.click()} - 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 cursor-pointer hover:border-rose-400 transition-colors bg-white dark:bg-gray-800" - > +
👚
-

Take or upload a photo

-

Tip: flat-lay on a light surface for best tagging

+

Add a garment photo

+

Tip: flat-lay on a light surface for best AI tagging

+
+
+ +
- - 📷 Add Photo -
- {/* No capture="environment" so Android shows "Camera / Gallery" chooser */} + {/* Gallery input — no capture attribute, Android shows full media picker */} + {/* Camera input — capture="environment" opens rear camera directly */} +
)} diff --git a/src/app/api/garments/[id]/route.ts b/src/app/api/garments/[id]/route.ts index a5a2d97..e4479e2 100644 --- a/src/app/api/garments/[id]/route.ts +++ b/src/app/api/garments/[id]/route.ts @@ -3,7 +3,10 @@ import { requireFamily } from "@/lib/auth"; import { sql } from "@/db"; import { GARMENT_CATEGORIES } from "@/db/schema/wardrobe"; -function toDto(g: Record, baseUrl: string) { +function toDto(g: Record) { + const appUrl = process.env.NEXT_PUBLIC_APP_URL || ""; + const ik = g.image_key as string | null; + const tk = g.thumb_key as string | null; return { id: g.id, familyId: g.family_id, @@ -14,10 +17,11 @@ function toDto(g: Record, baseUrl: string) { colors: g.colors || [], seasons: g.seasons || [], occasionTags: g.occasion_tags || [], - imageKey: g.image_key, - thumbKey: g.thumb_key, - imageUrl: `${baseUrl}/${g.image_key}`, - thumbUrl: `${baseUrl}/${g.thumb_key}`, + imageKey: ik, + thumbKey: tk, + // Proxy through /api/img — never expose raw pub-*.r2.dev URLs (blocked by Cloudflare Bot Mgmt) + imageUrl: ik ? `${appUrl}/api/img?key=${encodeURIComponent(ik)}` : null, + thumbUrl: tk ? `${appUrl}/api/img?key=${encodeURIComponent(tk)}` : null, status: g.status, acquiredVia: g.acquired_via, giftFrom: g.gift_from, @@ -27,10 +31,6 @@ function toDto(g: Record, baseUrl: string) { }; } -function getBaseUrl() { - return process.env.R2_PUBLIC_URL || `https://pub-${process.env.R2_ACCOUNT_ID}.r2.dev`; -} - // GET /api/garments/[id] export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const auth = await requireFamily(); @@ -47,7 +47,7 @@ export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: WHERE garment_id = ${id} AND family_id = ${familyId} ORDER BY worn_on DESC`; - return NextResponse.json({ success: true, item: toDto(rows[0], getBaseUrl()), wears }); + return NextResponse.json({ success: true, item: toDto(rows[0]), wears }); } // PATCH /api/garments/[id] @@ -92,7 +92,7 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id WHERE id = ${id} AND family_id = ${familyId} RETURNING *`; - return NextResponse.json({ success: true, item: toDto(rows[0], getBaseUrl()) }); + return NextResponse.json({ success: true, item: toDto(rows[0]) }); } // DELETE /api/garments/[id] diff --git a/src/app/api/garments/route.ts b/src/app/api/garments/route.ts index b0e141a..09134f2 100644 --- a/src/app/api/garments/route.ts +++ b/src/app/api/garments/route.ts @@ -70,11 +70,9 @@ export async function GET(req: NextRequest) { ORDER BY created_at DESC`; } - const baseUrl = process.env.R2_PUBLIC_URL || `https://pub-${process.env.R2_ACCOUNT_ID}.r2.dev`; - return NextResponse.json({ success: true, - items: rows.map(g => toDto(g, baseUrl)), + items: rows.map(g => toDto(g)), }); } @@ -132,11 +130,13 @@ export async function POST(req: NextRequest) { ${visionMetadata ? JSON.stringify(visionMetadata) : null}) RETURNING *`; - const baseUrl = process.env.R2_PUBLIC_URL || `https://pub-${process.env.R2_ACCOUNT_ID}.r2.dev`; - return NextResponse.json({ success: true, item: toDto(rows[0], baseUrl) }, { status: 201 }); + return NextResponse.json({ success: true, item: toDto(rows[0]) }, { status: 201 }); } -function toDto(g: Record, baseUrl: string) { +function toDto(g: Record) { + const appUrl = process.env.NEXT_PUBLIC_APP_URL || ""; + const ik = g.image_key as string | null; + const tk = g.thumb_key as string | null; return { id: g.id, familyId: g.family_id, @@ -147,10 +147,11 @@ function toDto(g: Record, baseUrl: string) { colors: g.colors || [], seasons: g.seasons || [], occasionTags: g.occasion_tags || [], - imageKey: g.image_key, - thumbKey: g.thumb_key, - imageUrl: `${baseUrl}/${g.image_key}`, - thumbUrl: `${baseUrl}/${g.thumb_key}`, + imageKey: ik, + thumbKey: tk, + // Proxy through /api/img — never expose raw pub-*.r2.dev URLs (blocked by Cloudflare Bot Mgmt) + imageUrl: ik ? `${appUrl}/api/img?key=${encodeURIComponent(ik)}` : null, + thumbUrl: tk ? `${appUrl}/api/img?key=${encodeURIComponent(tk)}` : null, status: g.status, acquiredVia: g.acquired_via, giftFrom: g.gift_from, diff --git a/src/app/api/garments/upload/route.ts b/src/app/api/garments/upload/route.ts index 7236296..c25512b 100644 --- a/src/app/api/garments/upload/route.ts +++ b/src/app/api/garments/upload/route.ts @@ -7,6 +7,17 @@ import { randomUUID } from "crypto"; const ALLOWED_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic"]; const MAX_BYTES = 8 * 1024 * 1024; // 8MB +/** Android often returns file.type="" or "application/octet-stream" — infer from extension. */ +function resolveContentType(file: File): string { + if (file.type && file.type !== "application/octet-stream") return file.type; + const ext = file.name.split(".").pop()?.toLowerCase() || ""; + const map: Record = { + jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", + webp: "image/webp", heic: "image/heic", + }; + return map[ext] || "image/jpeg"; +} + function getR2Config() { return { accountId: process.env.R2_ACCOUNT_ID!, @@ -50,7 +61,8 @@ export async function POST(req: NextRequest) { const file = formData.get("file") as File | null; if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 }); - if (!ALLOWED_TYPES.includes(file.type)) { + const contentType = resolveContentType(file); + if (!ALLOWED_TYPES.includes(contentType)) { return NextResponse.json({ error: "Unsupported file type" }, { status: 400 }); } @@ -78,7 +90,7 @@ export async function POST(req: NextRequest) { Bucket: R2.bucket, Key: imageKey, Body: originalBuffer, - ContentType: file.type, + ContentType: contentType, })), client.send(new PutObjectCommand({ Bucket: R2.bucket, @@ -88,12 +100,12 @@ export async function POST(req: NextRequest) { })), ]); - const baseUrl = R2.publicUrl || `https://pub-${R2.accountId}.r2.dev`; - + // Return proxy URLs (never raw R2 pub URLs — those are blocked by Cloudflare Bot Management) + const appUrl = process.env.NEXT_PUBLIC_APP_URL || ""; return NextResponse.json({ imageKey, thumbKey, - imageUrl: `${baseUrl}/${imageKey}`, - thumbUrl: `${baseUrl}/${thumbKey}`, + imageUrl: `${appUrl}/api/img?key=${encodeURIComponent(imageKey)}`, + thumbUrl: `${appUrl}/api/img?key=${encodeURIComponent(thumbKey)}`, }); } diff --git a/src/app/api/img/route.ts b/src/app/api/img/route.ts index 32b2986..b779de3 100644 --- a/src/app/api/img/route.ts +++ b/src/app/api/img/route.ts @@ -1,7 +1,7 @@ import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; import { NextRequest, NextResponse } from "next/server"; -const ALLOWED_PREFIXES = ["avatars/", "profiles/", "memories/", "thumbnails/", "families/"]; +const ALLOWED_PREFIXES = ["avatars/", "profiles/", "memories/", "thumbnails/", "families/", "garments/"]; export async function GET(req: NextRequest) { const key = req.nextUrl.searchParams.get("key"); diff --git a/src/components/LogModal.tsx b/src/components/LogModal.tsx index 3ccf8e0..0e32f70 100644 --- a/src/components/LogModal.tsx +++ b/src/components/LogModal.tsx @@ -10,6 +10,8 @@ export type { LogType }; export interface SmartDefault { subType: string; amountMl?: number; + /** UTC ISO string of the original log time — when set, pre-fills the time picker for editing. */ + editTime?: string; } interface Props { @@ -36,6 +38,17 @@ function localDatetimeNow() { return new Date(now.getTime() - offset).toISOString().slice(0, 16); } +/** + * Convert a UTC ISO string to a datetime-local value in the device's local timezone. + * This is the inverse of what new Date(datetimeLocalValue).toISOString() does. + * Used to pre-fill the time picker when editing an existing log. + */ +function isoToLocalDatetimeLocal(utcIso: string): string { + const date = new Date(utcIso); + const offset = date.getTimezoneOffset() * 60000; // negative for UTC+tz (e.g. IST = -330*60000) + return new Date(date.getTime() - offset).toISOString().slice(0, 16); +} + function resolveLoggedAt(preset: TimePreset, customValue: string): string { const now = new Date(); if (preset === "5") return new Date(now.getTime() - 5 * 60_000).toISOString(); @@ -59,14 +72,21 @@ export function LogModal({ type, childId, onClose, onSaved, smartDefault }: Prop const [timePreset, setTimePreset] = useState("now"); const [customTime, setCustomTime] = useState(localDatetimeNow); - // Reset fields and apply smart defaults whenever type changes + // Reset fields and apply smart defaults whenever the modal opens (type changes to non-null) useEffect(() => { if (!type) return; setSubType(smartDefault?.subType ?? DEFAULT_SUBTYPE[type]); setAmountMl(type === "feed" && smartDefault?.amountMl ? String(smartDefault.amountMl) : ""); setNotes(""); - setTimePreset("now"); - setCustomTime(localDatetimeNow()); + if (smartDefault?.editTime) { + // Editing an existing log — pre-fill the picker with its original time + setTimePreset("custom"); + setCustomTime(isoToLocalDatetimeLocal(smartDefault.editTime)); + } else { + setTimePreset("now"); + setCustomTime(localDatetimeNow()); + } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [type]); if (!type) return null; diff --git a/src/lib/date-ist.ts b/src/lib/date-ist.ts index 8959f6a..121ca62 100644 --- a/src/lib/date-ist.ts +++ b/src/lib/date-ist.ts @@ -12,6 +12,25 @@ const TZ = "Asia/Kolkata"; const LOCALE = "en-IN"; +/** + * Safely convert any timestamp to a Date, treating no-timezone strings as UTC. + * + * Why: `new Date("2024-05-28T09:00:00")` (no 'Z') is treated as *local* time by + * browsers/Node, which breaks IST display when the host is in a different tz. + * `new Date("2024-05-28T09:00:00.000Z")` is always UTC regardless of host tz. + * + * Our postgres.js custom parser already appends 'Z', so DB values are always safe. + * This helper is a safety net for values from other sources (API responses, props). + */ +function toUTCDate(isoOrDate: string | Date): Date { + if (isoOrDate instanceof Date) return isoOrDate; + const s = isoOrDate as string; + // Already has explicit timezone: trailing 'Z' or '+HH:MM' / '-HH:MM' offset + if (s.endsWith("Z") || /[+-]\d{2}:\d{2}$/.test(s)) return new Date(s); + // No timezone marker — assume UTC (postgres timestamp without time zone) + return new Date(s + "Z"); +} + /** ISO date string (YYYY-MM-DD) for today in IST — use for comparisons. */ export function todayIST(): string { // "sv-SE" locale gives YYYY-MM-DD — a clean, comparable format. @@ -20,7 +39,7 @@ export function todayIST(): string { /** ISO date string (YYYY-MM-DD) for any timestamp, evaluated in IST. */ export function dateIST(isoOrDate: string | Date): string { - return new Date(isoOrDate).toLocaleDateString("sv-SE", { timeZone: TZ }); + return toUTCDate(isoOrDate).toLocaleDateString("sv-SE", { timeZone: TZ }); } /** True if the given timestamp falls on today in IST. */ @@ -49,7 +68,7 @@ export function hourIST(): number { /** Format a timestamp as "hh:mm AM/PM" in IST. */ export function fmtTime(isoOrDate: string | Date): string { - return new Date(isoOrDate).toLocaleTimeString(LOCALE, { + return toUTCDate(isoOrDate).toLocaleTimeString(LOCALE, { timeZone: TZ, hour: "2-digit", minute: "2-digit", @@ -61,7 +80,7 @@ export function fmtDate( isoOrDate: string | Date, opts: Intl.DateTimeFormatOptions = { day: "numeric", month: "short", year: "numeric" } ): string { - return new Date(isoOrDate).toLocaleDateString(LOCALE, { timeZone: TZ, ...opts }); + return toUTCDate(isoOrDate).toLocaleDateString(LOCALE, { timeZone: TZ, ...opts }); } /**