diff --git a/src/app/[handle]/[collection]/[rkey]/page.tsx b/src/app/[handle]/[collection]/[rkey]/page.tsx index 0701ca0..dfd236f 100644 --- a/src/app/[handle]/[collection]/[rkey]/page.tsx +++ b/src/app/[handle]/[collection]/[rkey]/page.tsx @@ -460,6 +460,32 @@ async function RecordContent({ handle, collection, rkey }: { handle: string; col )} + {/* The record fetch returns null for both a deleted/nonexistent record + and a transient host failure, so this notice covers both honestly + rather than silently showing the picker with no preview and no + explanation. The picker still renders below — its client links work + if the record exists elsewhere or the host recovers. */} + {!recordData && ( +
+ + We couldn’t load a preview for this record. + {' '} + It may have been deleted, or the account’s host server may be + temporarily unavailable. You can still try opening it in a client below. +
+ )} + - )} + {/* Covers both a deleted post and a transient host failure (the fetch + returns null for both), so the visitor isn't shown a bare picker + with no explanation. The picker still renders below. */} + {!post && ( +
+ + We couldn’t load a preview for this post. + {' '} + It may have been deleted, or the account’s host server may be + temporarily unavailable. You can still try opening it in a client below. +
+ )} + { + if (event.key === 'Escape') { + setIsExpanded(false); + setIsSearchExpanded(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleKeyDown); }; }, [compact, isExpanded, isSearchExpanded]); @@ -217,6 +228,8 @@ export default function Header({ simple = false, compact = false }: HeaderProps) {/* Search panel — sits in the same expanding region as the menu panel but is its own card so the two slide independently. */} - {displayed === null ? ( + {error && (displayed === null || displayed.length === 0) ? ( +
+ Couldn’t reach the UFOs API: {error} +
+ ) : displayed === null ? ( ) : displayed.length === 0 ? (

diff --git a/src/components/margin/JsonModal.tsx b/src/components/margin/JsonModal.tsx new file mode 100644 index 0000000..cd21769 --- /dev/null +++ b/src/components/margin/JsonModal.tsx @@ -0,0 +1,204 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { X } from 'lucide-react'; + +/** + * Shared "view raw record data" modal for the margin.* previews. Built on the + * native

element via showModal(), which provides the accessibility the + * old hand-rolled fixed-div versions lacked: role=dialog + aria-modal, a focus + * trap, Escape-to-close, focus moved into the dialog on open, and focus + * restored to the trigger on close. Previously each of the seven previews + * carried its own copy of a plain
overlay with none of that. + */ +export default function JsonModal({ + open, + onClose, + title, + subtitle, + value, +}: { + open: boolean; + onClose: () => void; + title: string; + subtitle: string; + value: unknown; +}) { + const ref = useRef(null); + + // Drive the native modal state from the `open` prop. + useEffect(() => { + const dlg = ref.current; + if (!dlg) return; + if (open && !dlg.open) { + dlg.showModal(); + } else if (!open && dlg.open) { + dlg.close(); + } + }, [open]); + + // The dialog fires `close` on Escape (and on dlg.close()); mirror it back so + // the parent's `showJsonModal` state stays in sync. + useEffect(() => { + const dlg = ref.current; + if (!dlg) return; + const handleClose = () => onClose(); + dlg.addEventListener('close', handleClose); + return () => dlg.removeEventListener('close', handleClose); + }, [onClose]); + + return ( + { + if (e.target === ref.current) onClose(); + }} + > +
+
+
+
+ {title} +
+
+ {subtitle} +
+
+ +
+ +
+
+            {JSON.stringify(value, null, 2)}
+          
+
+
+ + +
+ ); +} diff --git a/src/components/margin/MarginAnnotationPreview.tsx b/src/components/margin/MarginAnnotationPreview.tsx index 745d4da..2e9dd66 100644 --- a/src/components/margin/MarginAnnotationPreview.tsx +++ b/src/components/margin/MarginAnnotationPreview.tsx @@ -8,7 +8,8 @@ import { useState } from 'react'; import { GenericRecord } from '@/utils/recordFetcher'; import { sanitizeUrl } from '@/utils/sanitize'; -import { X, ExternalLink, Tag, Calendar } from 'lucide-react'; +import { ExternalLink, Tag, Calendar } from 'lucide-react'; +import JsonModal from './JsonModal'; type MarginAnnotationPreviewProps = { record: GenericRecord; @@ -297,132 +298,13 @@ export default function MarginAnnotationPreview({
- {/* Full JSON Modal */} - {showJsonModal && ( -
setShowJsonModal(false)} - > -
e.stopPropagation()} - > -
-
-
- Raw Annotation Data -
-
- at.margin.annotation -
-
- -
- -
-
-                {JSON.stringify(value, null, 2)}
-              
-
-
-
- )} + setShowJsonModal(false)} + title="Raw Annotation Data" + subtitle="at.margin.annotation" + value={value} + />