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:
parent
13fb94fee9
commit
daec81884c
4 changed files with 75 additions and 33 deletions
|
|
@ -24,6 +24,8 @@ import {
|
||||||
Clock,
|
Clock,
|
||||||
Monitor,
|
Monitor,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
|
Maximize2,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { Header } from "@/components/layout/header";
|
import { Header } from "@/components/layout/header";
|
||||||
|
|
@ -647,18 +649,66 @@ export default function CardDetailPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImagePanel({ label, url }: { label: string; url: string | null }) {
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</Label>
|
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</Label>
|
||||||
{url ? (
|
{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 />
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex aspect-[3/4] items-center justify-center rounded-xl border border-border/50 bg-muted/20">
|
<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" />
|
<ImageIcon className="size-12 text-muted-foreground/20" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -239,28 +239,17 @@ export function DataTable<TData extends ResponseCard>({
|
||||||
target.closest("button") ||
|
target.closest("button") ||
|
||||||
target.closest("input") ||
|
target.closest("input") ||
|
||||||
target.closest('[data-slot="checkbox"]') ||
|
target.closest('[data-slot="checkbox"]') ||
|
||||||
target.closest('[data-slot="dropdown-menu"]') ||
|
target.closest('[data-slot="dropdown-menu"]')
|
||||||
target.closest("[data-copy-cell]")
|
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push(`/cards/${rowId}`);
|
router.push(`/cards/${rowId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCellClick = React.useCallback(
|
const handleCopyClick = React.useCallback(
|
||||||
async (e: React.MouseEvent<HTMLTableCellElement>, cellId: string, columnId: string) => {
|
async (e: React.MouseEvent, cellElement: HTMLTableCellElement, cellId: 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const text = getCellText(e.currentTarget);
|
const text = getCellText(cellElement);
|
||||||
if (!text || text === "—") return;
|
if (!text || text === "—") return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -418,29 +407,30 @@ export function DataTable<TData extends ResponseCard>({
|
||||||
const isSticky = colId === "select" || !!colMeta?.sticky;
|
const isSticky = colId === "select" || !!colMeta?.sticky;
|
||||||
const cellKey = `${row.id}_${colId}`;
|
const cellKey = `${row.id}_${colId}`;
|
||||||
const isCopied = copiedCellId === cellKey;
|
const isCopied = copiedCellId === cellKey;
|
||||||
|
const cellRef = React.createRef<HTMLTableCellElement>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableCell
|
<TableCell
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
data-copy-cell={isCopyable ? "true" : undefined}
|
ref={cellRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-4 py-3 relative group/cell",
|
"px-4 py-3 relative group/cell",
|
||||||
isCopyable && "cursor-cell",
|
|
||||||
isSticky && "sticky left-0 z-10 bg-background",
|
isSticky && "sticky left-0 z-10 bg-background",
|
||||||
isCopied && "ring-2 ring-primary/40 ring-inset"
|
isCopied && "ring-2 ring-primary/40 ring-inset"
|
||||||
)}
|
)}
|
||||||
onClick={
|
|
||||||
isCopyable
|
|
||||||
? (e) => handleCellClick(e, cellKey, colId)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
cell.column.columnDef.cell,
|
cell.column.columnDef.cell,
|
||||||
cell.getContext()
|
cell.getContext()
|
||||||
)}
|
)}
|
||||||
{isCopyable && (
|
{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 ? (
|
{isCopied ? (
|
||||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-primary/10 text-primary">
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-primary/10 text-primary">
|
||||||
Copied
|
Copied
|
||||||
|
|
@ -448,7 +438,7 @@ export function DataTable<TData extends ResponseCard>({
|
||||||
) : (
|
) : (
|
||||||
<Copy className="size-3 text-muted-foreground/50" />
|
<Copy className="size-3 text-muted-foreground/50" />
|
||||||
)}
|
)}
|
||||||
</span>
|
</button>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ const surveySchema = z.object({
|
||||||
),
|
),
|
||||||
messageTopicsOther: z.string().nullable().describe("Value if Other is filled in"),
|
messageTopicsOther: z.string().nullable().describe("Value if Other is filled in"),
|
||||||
nextStep: z.array(z.string()).describe(
|
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(
|
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."
|
"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.
|
Extract ALL of the following fields from the image.
|
||||||
|
|
||||||
CHECKBOX RULES - be very strict:
|
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.
|
- 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) means UNCHECKED, even if text appears next to it.
|
- 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.
|
- 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.
|
- 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.`;
|
Be precise: return null for fields you cannot read.`;
|
||||||
|
|
||||||
export interface OcrResult {
|
export interface OcrResult {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- 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.
|
- Do NOT confuse section headings or question titles with checked answers.
|
||||||
|
|
||||||
IMPORTANT for nextStep: There are exactly 2 checkboxes.
|
SPECIAL ATTENTION for nextStep: There are exactly 2 checkboxes on separate lines.
|
||||||
- "Baptism" = the checkbox for "Expressing my faith in Jesus / I want to be baptized..."
|
- "Baptism" = FIRST checkbox (next to "Expressing my faith in Jesus / I want to be baptized...")
|
||||||
- "Next Steps" = the checkbox for "Learning more about becoming a partner / attend Next Steps..."
|
- "Next Steps" = SECOND checkbox (next to "Learning more about becoming a partner / attend Next Steps...")
|
||||||
Only include the one(s) whose box is physically marked.
|
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):
|
Return ONLY valid JSON with this exact structure (no markdown, no code fences):
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue