Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
31e3363
fix(security): pin esbuild >=0.28.1 + vite supported.destructuring (D…
important-new Jun 14, 2026
7484a50
feat(cover-crop): add cover_crop + cover_image_key columns (Task 1)
important-new Jun 14, 2026
d2da476
feat(cover-crop): resolveCoverUrl prefers baked cover_image_key (Task 2)
important-new Jun 14, 2026
42953c7
feat(cover-crop): CoverCropSchema validation (Task 3)
important-new Jun 14, 2026
2bee6f1
feat(cover-crop): setCroppedCover service method bakes JPEG to R2 (Ta…
important-new Jun 14, 2026
4b1010e
feat(cover-crop): POST /api/inspections/:id/cover endpoint (Task 5)
important-new Jun 14, 2026
934ec63
fix(cover): renumber migration 0006 + 400 on malformed crop
important-new Jun 14, 2026
4c63c63
feat(image-studio): add react-easy-crop + canvas bake util (Task 6)
important-new Jun 14, 2026
2f74524
feat(image-studio): CoverCropper component (Task 7)
important-new Jun 14, 2026
55a81a8
feat(image-studio): wire CoverCropper into settings sheet + crop-cove…
important-new Jun 14, 2026
3320452
revert: esbuild back to ^0.25.0 (0.28.1 off Vite 6 matrix, breaks dev…
important-new Jun 14, 2026
64a6f8f
feat(gallery): add react-photo-album + yet-another-react-lightbox
important-new Jun 14, 2026
070ae72
feat(gallery): extract shared flattenMedia helper + refactor settings…
important-new Jun 14, 2026
0a59f28
feat(gallery): add inspection-media BFF resource route (?w=480 galler…
important-new Jun 14, 2026
134dc2c
feat(gallery): isolation wrappers for react-photo-album + lightbox
important-new Jun 14, 2026
39cb160
feat(gallery): PhotoGallery panel (fetcher + grid + lightbox + actions)
important-new Jun 14, 2026
60ae175
feat(gallery): add Photos tab to SideRail
important-new Jun 14, 2026
9fcb1c7
feat(gallery): wire gallery cover/annotate actions in editor
important-new Jun 14, 2026
4d91f5f
chore(deps): add react-konva@^18 (React 18 + konva 10 peer)
important-new Jun 14, 2026
05d3c59
feat(annotate): annotation model + JSON serialization (natural-px coo…
important-new Jun 14, 2026
c92e564
feat(annotate): PhotoAnnotator react-konva (all tools + save export)
important-new Jun 14, 2026
00b0e83
feat(annotate): persist annotations via saveAnnotation endpoint + swa…
important-new Jun 14, 2026
e01fdf1
feat(image-studio): add AvatarCropper (round 512² avatar crop)
important-new Jun 14, 2026
128a73f
feat(settings): crop inspector avatar before upload via AvatarCropper
important-new Jun 14, 2026
a114ccb
feat(image-studio): add LogoUploader component with fit preview
important-new Jun 14, 2026
d1bfc4e
feat(settings): add logo-upload action intent in workspace settings
important-new Jun 14, 2026
0747642
feat(settings): wire LogoUploader into workspace settings UI
important-new Jun 14, 2026
b4bbff0
feat(api): guard logo upload size and MIME type
important-new Jun 14, 2026
a405678
fix(gallery): move set-cover/annotate into lightbox toolbar (were cov…
important-new Jun 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 31 additions & 5 deletions app/components/editor/InspectionSettingsSheet.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useState, useEffect, useRef } from "react";
import { useFetcher } from "react-router";
import { TemplateCombobox } from "~/components/TemplateCombobox";
import { CoverCropper } from "~/components/image-studio/CoverCropper";
import { fullResUrl } from "~/components/image-studio/cropImage";

interface SettingsForm {
date: string;
Expand Down Expand Up @@ -80,8 +82,9 @@ export function InspectionSettingsSheet({ open, onClose, inspectionId, referralS
// `coverKey` is the chosen cover R2 key (optimistic; PATCHed via coverFetcher).
const [photos, setPhotos] = useState<CoverPhoto[]>([]);
const [coverKey, setCoverKey] = useState<string>("");
const coverFetcher = useFetcher<{ ok: boolean; intent?: string; coverKey?: string; coverUrl?: string | null }>();
const coverFetcher = useFetcher<{ ok: boolean; intent?: string; coverKey?: string | null; coverUrl?: string | null }>();
const coverFileRef = useRef<HTMLInputElement>(null);
const [cropSource, setCropSource] = useState<{ key: string; url: string } | null>(null);

// Trigger load when the sheet opens or inspectionId changes
useEffect(() => {
Expand Down Expand Up @@ -121,9 +124,13 @@ export function InspectionSettingsSheet({ open, onClose, inspectionId, referralS
}, [loadFetcher.data]);

function selectCover(key: string) {
const next = coverKey === key ? "" : key; // click current cover to clear
setCoverKey(next);
coverFetcher.submit({ intent: "set-cover", coverPhotoId: next }, { method: "post" });
if (coverKey === key) {
setCoverKey("");
coverFetcher.submit({ intent: "set-cover", coverPhotoId: "" }, { method: "post" });
return;
}
const photo = photos.find((p) => p.key === key);
if (photo) setCropSource({ key, url: photo.url });
}

// DB-16 — direct cover upload (Spectora parity). The file rides the BFF relay
Expand All @@ -143,11 +150,14 @@ export function InspectionSettingsSheet({ open, onClose, inspectionId, referralS
if (coverFetcher.state === "idle" && d?.intent === "upload-cover" && d.ok && d.coverKey) {
const key = d.coverKey;
const url = d.coverUrl ?? null;
setCoverKey(key);
if (url) {
setPhotos((prev) => (prev.some((p) => p.key === key) ? prev : [{ key, url, label: "Uploaded" }, ...prev]));
setCropSource({ key, url });
}
}
if (coverFetcher.state === "idle" && d?.intent === "crop-cover" && d.ok && d.coverKey) {
setCoverKey(d.coverKey);
}
}, [coverFetcher.state, coverFetcher.data]);

// Sync loading state with fetcher
Expand Down Expand Up @@ -376,6 +386,22 @@ export function InspectionSettingsSheet({ open, onClose, inspectionId, referralS
)}
</div>
</aside>
{cropSource && (
<CoverCropper
sourceUrl={fullResUrl(cropSource.url)}
sourceKey={cropSource.key}
onCancel={() => setCropSource(null)}
onSave={(blob, c) => {
const fd = new FormData();
fd.append("intent", "crop-cover");
fd.append("sourceKey", cropSource.key);
fd.append("crop", JSON.stringify({ aspect: c.aspect, orientation: c.orientation, ...c.pixels }));
fd.append("image", new File([blob], "cover.jpg", { type: "image/jpeg" }));
coverFetcher.submit(fd, { method: "post", encType: "multipart/form-data" });
setCropSource(null);
}}
/>
)}
</>
);
}
15 changes: 13 additions & 2 deletions app/components/editor/SideRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState } from "react";
import { renderTemplate } from "../../lib/mustache";
import { DEFECT_TRADE_LABELS, DEFECT_DEADLINE_LABELS, DEFECT_TIMEFRAME_LABELS } from "../../lib/defect-fields";
import { photoDisplayName, withDownload } from "../../lib/photo-name";
import { PhotoGallery } from "~/components/image-studio/PhotoGallery";

interface SideRailProps {
activeItem?: { id: string; label: string; type?: string } | null;
Expand All @@ -10,16 +11,19 @@ interface SideRailProps {
getRatingColor?: (id: string) => string;
getRatingLabel?: (id: string) => string;
inspectionId?: string;
onGallerySetCover?: (photo: { key: string; url: string }) => void;
onGalleryAnnotate?: (photo: { key: string; url: string }) => void;
}

type TabId = "preview" | "library";
type TabId = "preview" | "library" | "photos";

const TABS: Array<{ id: TabId; label: string; icon: string }> = [
{ id: "preview", label: "Preview", icon: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" },
{ id: "library", label: "Library", icon: "M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" },
{ id: "photos", label: "Photos", icon: "M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14M4 6h16a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V8a2 2 0 012-2z" },
];

export function SideRail({ activeItem, activeResult, getRatingColor, getRatingLabel, inspectionId }: SideRailProps) {
export function SideRail({ activeItem, activeResult, getRatingColor, getRatingLabel, inspectionId, onGallerySetCover, onGalleryAnnotate }: SideRailProps) {
const [activeTab, setActiveTab] = useState<TabId>("preview");
const [open, setOpen] = useState(false);

Expand Down Expand Up @@ -145,6 +149,13 @@ export function SideRail({ activeItem, activeResult, getRatingColor, getRatingLa
<p className="text-[13px] text-ih-fg-3 text-center py-8">Type <kbd className="px-1 py-0.5 bg-ih-bg-muted rounded text-[10px] font-mono border">/</kbd> in the note field to search.</p>
</div>
)}
{activeTab === "photos" && (
inspectionId ? (
<PhotoGallery inspectionId={inspectionId} onSetCover={(p) => onGallerySetCover?.(p)} onAnnotate={(p) => onGalleryAnnotate?.(p)} />
) : (
<p className="text-[13px] text-ih-fg-3 text-center py-8">Open an inspection to browse photos.</p>
)
)}
</div>
</div>
)}
Expand Down
40 changes: 40 additions & 0 deletions app/components/image-studio/AvatarCropper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useState, useCallback } from "react";
import Cropper from "react-easy-crop";
import { bakeCrop, type PixelCrop } from "./cropImage";

const AVATAR_EDGE = 512;
export interface AvatarCropperProps {
sourceUrl: string;
onCancel: () => void;
onSave: (blob: Blob) => void;
}
export function AvatarCropper({ sourceUrl, onCancel, onSave }: AvatarCropperProps) {
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [pixels, setPixels] = useState<PixelCrop | null>(null);
const [busy, setBusy] = useState(false);
const onComplete = useCallback((_a: unknown, p: PixelCrop) => setPixels(p), []);
async function handleSave() {
if (!pixels) return;
setBusy(true);
try { onSave(await bakeCrop(sourceUrl, pixels, AVATAR_EDGE)); } finally { setBusy(false); }
}
return (
<div className="fixed inset-0 z-[80] bg-[rgba(15,23,42,0.7)] flex flex-col" role="dialog" aria-modal="true" aria-label="Crop avatar">
<div className="relative flex-1">
<Cropper image={sourceUrl} crop={crop} zoom={zoom} aspect={1} cropShape="round" showGrid={false} restrictPosition
onCropChange={setCrop} onZoomChange={setZoom} onCropComplete={onComplete} />
</div>
<div className="bg-ih-bg-card border-t border-ih-border px-5 py-3 space-y-3">
<div className="flex items-center gap-3">
<span className="text-[11px] font-bold uppercase tracking-wide text-ih-fg-3">Zoom</span>
<input type="range" min={1} max={3} step={0.01} value={zoom} onChange={(e) => setZoom(Number(e.target.value))} className="flex-1 accent-ih-primary" aria-label="Zoom" />
</div>
<div className="flex items-center justify-end gap-3">
<button type="button" onClick={onCancel} className="h-9 px-4 rounded-md border border-ih-border text-ih-fg-2 text-[13px] font-bold hover:bg-ih-bg-muted">Cancel</button>
<button type="button" onClick={handleSave} disabled={busy || !pixels} className="h-9 px-4 rounded-md bg-ih-primary text-white text-[13px] font-bold hover:bg-ih-primary-600 disabled:opacity-50">{busy ? "Saving…" : "Save photo"}</button>
</div>
</div>
</div>
);
}
68 changes: 68 additions & 0 deletions app/components/image-studio/CoverCropper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useState, useCallback } from "react";
import Cropper from "react-easy-crop";
import { bakeCrop, type PixelCrop } from "./cropImage";

type Aspect = "3:2" | "16:9" | "1.91:1" | "4:3";
const RATIOS: Record<Aspect, number> = { "3:2": 3 / 2, "16:9": 16 / 9, "1.91:1": 1.91, "4:3": 4 / 3 };
const PRESETS: Aspect[] = ["3:2", "16:9", "1.91:1", "4:3"];

export interface CoverCropperProps {
sourceUrl: string;
sourceKey: string;
onCancel: () => void;
onSave: (blob: Blob, crop: { aspect: Aspect; orientation: "landscape" | "portrait"; pixels: PixelCrop }) => void;
}

export function CoverCropper({ sourceUrl, sourceKey, onCancel, onSave }: CoverCropperProps) {
void sourceKey;
const [aspect, setAspect] = useState<Aspect>("3:2");
const [portrait, setPortrait] = useState(false);
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [pixels, setPixels] = useState<PixelCrop | null>(null);
const [busy, setBusy] = useState(false);
const ratio = portrait ? 1 / RATIOS[aspect] : RATIOS[aspect];
const onCropComplete = useCallback((_a: unknown, areaPixels: PixelCrop) => setPixels(areaPixels), []);

async function handleSave() {
if (!pixels) return;
setBusy(true);
try {
const blob = await bakeCrop(sourceUrl, pixels);
onSave(blob, { aspect, orientation: portrait ? "portrait" : "landscape", pixels });
} finally { setBusy(false); }
}

return (
<div className="fixed inset-0 z-[70] bg-[rgba(15,23,42,0.7)] flex flex-col" role="dialog" aria-modal="true" aria-label="Crop cover photo">
<div className="relative flex-1">
<Cropper image={sourceUrl} crop={crop} zoom={zoom} aspect={ratio} showGrid restrictPosition
onCropChange={setCrop} onZoomChange={setZoom} onCropComplete={onCropComplete} />
</div>
<div className="bg-ih-bg-card border-t border-ih-border px-5 py-3 space-y-3">
<div className="flex items-center gap-2 flex-wrap">
{PRESETS.map((a) => (
<button key={a} type="button" onClick={() => setAspect(a)}
className={`h-8 px-3 rounded-md text-[12px] font-bold border transition-colors ${aspect === a ? "border-ih-primary text-ih-primary" : "border-ih-border text-ih-fg-2 hover:border-ih-primary/60"}`}>
{a === "3:2" ? "3:2 · Cover" : a}
</button>
))}
<button type="button" onClick={() => setPortrait((p) => !p)} title="Switch portrait/landscape" aria-pressed={portrait}
className={`h-8 px-3 rounded-md text-[12px] font-bold border transition-colors ${portrait ? "border-ih-primary text-ih-primary" : "border-ih-border text-ih-fg-2 hover:border-ih-primary/60"}`}>
↔ {portrait ? "Portrait" : "Landscape"}
</button>
</div>
<div className="flex items-center gap-3">
<span className="text-[11px] font-bold uppercase tracking-wide text-ih-fg-3">Zoom</span>
<input type="range" min={1} max={3} step={0.01} value={zoom} onChange={(e) => setZoom(Number(e.target.value))} className="flex-1 accent-ih-primary" aria-label="Zoom" />
</div>
<div className="flex items-center justify-end gap-3">
<button type="button" onClick={onCancel} className="h-9 px-4 rounded-md border border-ih-border text-ih-fg-2 text-[13px] font-bold hover:bg-ih-bg-muted">Cancel</button>
<button type="button" onClick={handleSave} disabled={busy || !pixels} className="h-9 px-4 rounded-md bg-ih-primary text-white text-[13px] font-bold hover:bg-ih-primary-600 disabled:opacity-50">
{busy ? "Saving…" : "Save cover"}
</button>
</div>
</div>
</div>
);
}
33 changes: 33 additions & 0 deletions app/components/image-studio/LogoUploader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useRef } from "react";
export interface LogoUploaderProps {
currentUrl: string | null;
uploading: boolean;
onSelect: (file: File) => void;
}
/** Image Studio — company logo uploader. Logos keep their original format
* (transparent PNG / SVG): NO crop, NO bake. Just upload + fit preview. */
export function LogoUploader({ currentUrl, uploading, onSelect }: LogoUploaderProps) {
const inputRef = useRef<HTMLInputElement>(null);
return (
<div className="flex flex-col sm:flex-row items-center gap-5 p-5 bg-ih-bg-muted rounded-md border border-dashed border-ih-border hover:border-ih-primary transition-colors">
<div className="w-28 h-28 bg-ih-bg-card rounded-md border border-ih-border flex items-center justify-center overflow-hidden">
{currentUrl ? (
<img src={currentUrl} className="w-full h-full object-contain p-3" alt="Logo" />
) : (
<div className="text-ih-fg-4">
<svg className="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
</div>
)}
</div>
<div className="space-y-2 flex-1 text-center sm:text-left">
<input ref={inputRef} type="file" accept="image/png,image/svg+xml,image/jpeg,image/webp" className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) onSelect(f); e.target.value = ""; }} />
<button type="button" onClick={() => inputRef.current?.click()} disabled={uploading}
className="h-9 px-3 rounded-md border border-ih-border text-ih-fg-2 text-[12px] font-bold hover:border-ih-primary hover:text-ih-primary transition-colors disabled:opacity-50">
{uploading ? "Uploading…" : "Upload logo"}
</button>
<p className="text-[11px] text-ih-fg-3 font-bold uppercase tracking-widest">PNG / SVG recommended (transparent)</p>
</div>
</div>
);
}
Loading
Loading