Restore row-click navigation, add image lightbox, refine OCR prompts

- Change cell copy from click-to-copy to a small hover button so clicking
  a table row navigates to the card detail page again
- Add fullscreen lightbox popup on card detail page images with Escape to
  close and click-outside dismiss
- Further strengthen nextStep OCR prompts to emphasize inspecting each
  checkbox square independently and note most respondents check only one

Made-with: Cursor
This commit is contained in:
Randall Stillwell 2026-04-08 21:10:45 -05:00
parent 13fb94fee9
commit daec81884c
4 changed files with 75 additions and 33 deletions

View file

@ -24,6 +24,8 @@ import {
Clock,
Monitor,
LayoutGrid,
Maximize2,
X,
} from "lucide-react";
import { Header } from "@/components/layout/header";
@ -647,18 +649,66 @@ export default function CardDetailPage() {
}
function ImagePanel({ label, url }: { label: string; url: string | null }) {
const [lightboxOpen, setLightboxOpen] = React.useState(false);
React.useEffect(() => {
if (!lightboxOpen) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setLightboxOpen(false);
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [lightboxOpen]);
return (
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</Label>
{url ? (
<div className="relative aspect-[3/4] overflow-hidden rounded-xl border border-border/50 bg-muted/30">
<div className="group relative aspect-[3/4] overflow-hidden rounded-xl border border-border/50 bg-muted/30">
<Image src={url} alt={label} fill className="object-contain" unoptimized />
<button
type="button"
onClick={() => setLightboxOpen(true)}
className="absolute inset-0 flex items-center justify-center bg-black/0 opacity-0 transition-all group-hover:bg-black/20 group-hover:opacity-100"
>
<div className="rounded-full bg-background/90 p-2 shadow-lg">
<Maximize2 className="size-4 text-foreground" />
</div>
</button>
</div>
) : (
<div className="flex aspect-[3/4] items-center justify-center rounded-xl border border-border/50 bg-muted/20">
<ImageIcon className="size-12 text-muted-foreground/20" />
</div>
)}
{lightboxOpen && url && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm animate-in fade-in-0 duration-200"
onClick={() => setLightboxOpen(false)}
>
<button
type="button"
onClick={() => setLightboxOpen(false)}
className="absolute right-4 top-4 z-10 rounded-full bg-background/20 p-2 text-white transition-colors hover:bg-background/40"
>
<X className="size-6" />
</button>
<div
className="relative max-h-[90vh] max-w-[90vw]"
onClick={(e) => e.stopPropagation()}
>
<Image
src={url}
alt={label}
width={1200}
height={1600}
className="max-h-[90vh] w-auto rounded-lg object-contain"
unoptimized
/>
</div>
</div>
)}
</div>
);
}

View file

@ -239,28 +239,17 @@ export function DataTable<TData extends ResponseCard>({
target.closest("button") ||
target.closest("input") ||
target.closest('[data-slot="checkbox"]') ||
target.closest('[data-slot="dropdown-menu"]') ||
target.closest("[data-copy-cell]")
target.closest('[data-slot="dropdown-menu"]')
) {
return;
}
router.push(`/cards/${rowId}`);
};
const handleCellClick = React.useCallback(
async (e: React.MouseEvent<HTMLTableCellElement>, cellId: string, columnId: string) => {
if (NON_COPYABLE_COLUMNS.has(columnId)) return;
if (
(e.target as HTMLElement).closest("button") ||
(e.target as HTMLElement).closest("input") ||
(e.target as HTMLElement).closest('[data-slot="checkbox"]')
) {
return;
}
const handleCopyClick = React.useCallback(
async (e: React.MouseEvent, cellElement: HTMLTableCellElement, cellId: string) => {
e.stopPropagation();
const text = getCellText(e.currentTarget);
const text = getCellText(cellElement);
if (!text || text === "—") return;
try {
@ -418,29 +407,30 @@ export function DataTable<TData extends ResponseCard>({
const isSticky = colId === "select" || !!colMeta?.sticky;
const cellKey = `${row.id}_${colId}`;
const isCopied = copiedCellId === cellKey;
const cellRef = React.createRef<HTMLTableCellElement>();
return (
<TableCell
key={cell.id}
data-copy-cell={isCopyable ? "true" : undefined}
ref={cellRef}
className={cn(
"px-4 py-3 relative group/cell",
isCopyable && "cursor-cell",
isSticky && "sticky left-0 z-10 bg-background",
isCopied && "ring-2 ring-primary/40 ring-inset"
)}
onClick={
isCopyable
? (e) => handleCellClick(e, cellKey, colId)
: undefined
}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
{isCopyable && (
<span className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/cell:opacity-100 transition-opacity pointer-events-none">
<button
type="button"
className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/cell:opacity-100 transition-opacity p-0.5 rounded hover:bg-muted"
onClick={(e) => {
if (cellRef.current) handleCopyClick(e, cellRef.current, cellKey);
}}
>
{isCopied ? (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-primary/10 text-primary">
Copied
@ -448,7 +438,7 @@ export function DataTable<TData extends ResponseCard>({
) : (
<Copy className="size-3 text-muted-foreground/50" />
)}
</span>
</button>
)}
</TableCell>
);

View file

@ -63,7 +63,7 @@ const surveySchema = z.object({
),
messageTopicsOther: z.string().nullable().describe("Value if Other is filled in"),
nextStep: z.array(z.string()).describe(
"This question has exactly 2 checkboxes. Include ONLY the ones with a visible mark (X, checkmark, filled). 'Baptism' = the checkbox for 'Expressing my faith in Jesus / baptized'. 'Next Steps' = the checkbox for 'Learning more about becoming a partner / attend Next Steps'. Return empty array if neither checkbox is marked."
"CRITICAL: This question has EXACTLY 2 separate checkboxes, each on its own line. Inspect each checkbox square individually. 'Baptism' = FIRST checkbox (next to 'Expressing my faith in Jesus...'). 'Next Steps' = SECOND checkbox (next to 'Learning more about becoming a partner...'). A checkbox is checked ONLY if its square contains a visible X, checkmark, or fill. An empty square = NOT checked. Most people check only one. Return empty array if neither is marked."
),
attendanceDuration: z.string().nullable().describe(
"The single checked radio option. One of: Less than 6 months, 6 Months - 1 Year, 1-3 Years, 4-6 Years, or 7+ Years. null if none is marked."
@ -97,12 +97,14 @@ const SURVEY_SYSTEM_PROMPT = `You are analyzing a scanned church Easter survey f
Extract ALL of the following fields from the image.
CHECKBOX RULES - be very strict:
- A checkbox is CHECKED only if it has a visible mark inside it: an X, a checkmark, a filled square, or pen/pencil marks inside the box.
- An EMPTY box (no marks inside) means UNCHECKED, even if text appears next to it.
- A checkbox is CHECKED only if its square box contains a visible mark: an X, a checkmark, a filled square, or pen/pencil marks INSIDE the box.
- An EMPTY box (no marks inside the square) means UNCHECKED, even if text appears next to it.
- When in doubt, treat a checkbox as UNCHECKED.
- For array fields (messageTopics, nextStep, campusPreference, howHeard): ONLY include items whose checkbox is physically marked. Return empty arrays when no checkboxes in that group are marked.
- For array fields (messageTopics, nextStep, campusPreference, howHeard): ONLY include items whose checkbox square is physically marked. Return empty arrays when no checkboxes in that group are marked.
- Do NOT confuse section headings or question titles with checked answers.
SPECIAL ATTENTION for "Next Step" question (question 2): It has exactly 2 checkboxes on separate lines. Look at each checkbox square independently. Most respondents check only one. Do not assume both are checked.
Be precise: return null for fields you cannot read.`;
export interface OcrResult {

View file

@ -64,10 +64,10 @@ CHECKBOX RULES - be very strict:
- For array fields: ONLY include items whose checkbox is physically marked. Return empty arrays when no checkboxes in that group are marked.
- Do NOT confuse section headings or question titles with checked answers.
IMPORTANT for nextStep: There are exactly 2 checkboxes.
- "Baptism" = the checkbox for "Expressing my faith in Jesus / I want to be baptized..."
- "Next Steps" = the checkbox for "Learning more about becoming a partner / attend Next Steps..."
Only include the one(s) whose box is physically marked.
SPECIAL ATTENTION for nextStep: There are exactly 2 checkboxes on separate lines.
- "Baptism" = FIRST checkbox (next to "Expressing my faith in Jesus / I want to be baptized...")
- "Next Steps" = SECOND checkbox (next to "Learning more about becoming a partner / attend Next Steps...")
Inspect each checkbox square independently. A checkbox is checked ONLY if its square contains a visible X, checkmark, or fill. An empty square = NOT checked. Most respondents check only one. Do NOT assume both are checked.
Return ONLY valid JSON with this exact structure (no markdown, no code fences):
{