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>
This commit is contained in:
Manohar Gupta 2026-05-29 00:42:04 +05:30
parent e99a874309
commit 3cfcbdc0ca
8 changed files with 124 additions and 47 deletions

View file

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

View file

@ -110,7 +110,8 @@ function SingleChipRow<T extends string>({
export default function AddGarmentPage() {
const { childId } = useFamily();
const router = useRouter();
const fileRef = useRef<HTMLInputElement>(null);
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);
@ -251,6 +252,7 @@ export default function AddGarmentPage() {
setUploading(false);
setVisionPending(false);
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" && (
<div className="mx-4 mt-4">
<div
onClick={() => 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"
>
<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">Take or upload a photo</p>
<p className="text-sm text-gray-400 mt-1">Tip: flat-lay on a light surface for best tagging</p>
<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>
<span className="px-4 py-2 bg-rose-400 text-white rounded-full text-sm font-medium">
📷 Add Photo
</span>
<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>
{/* No capture="environment" so Android shows "Camera / Gallery" chooser */}
</div>
{/* Gallery input — no capture attribute, Android shows full media picker */}
<input
ref={fileRef}
type="file"
@ -301,6 +313,15 @@ export default function AddGarmentPage() {
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>
)}

View file

@ -3,7 +3,10 @@ import { requireFamily } from "@/lib/auth";
import { sql } from "@/db";
import { GARMENT_CATEGORIES } from "@/db/schema/wardrobe";
function toDto(g: Record<string, unknown>, baseUrl: string) {
function toDto(g: Record<string, unknown>) {
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<string, unknown>, 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<string, unknown>, 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]

View file

@ -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<string, unknown>, baseUrl: string) {
function toDto(g: Record<string, unknown>) {
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<string, unknown>, 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,

View file

@ -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<string, string> = {
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)}`,
});
}

View file

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

View file

@ -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<TimePreset>("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("");
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;

View file

@ -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 });
}
/**