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:
parent
e99a874309
commit
3cfcbdc0ca
8 changed files with 124 additions and 47 deletions
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)}`,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue