Skip to content

Commit a0ae34b

Browse files
committed
add search; fix date one of tz error
1 parent 064371e commit a0ae34b

File tree

7 files changed

+300
-40
lines changed

7 files changed

+300
-40
lines changed

TODO.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@
44
- find edge cases
55
- styling
66
- search
7+
-

app/page.tsx

Lines changed: 166 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

33
import { useSearchParams } from "next/navigation";
4-
import { Suspense, useEffect, useState } from "react";
4+
import { Suspense, useCallback, useDeferredValue, useEffect, useRef, useState } from "react";
55
import type { DiffResult } from "../src/core";
66
import { DiffViewer } from "../src/react";
77
import type { UNDocumentMetadata } from "../src/un-fetcher";
@@ -25,6 +25,45 @@ function HomeContent() {
2525
const [loading, setLoading] = useState(false);
2626
const [error, setError] = useState<string | null>(null);
2727
const [copied, setCopied] = useState(false);
28+
const [searchInput, setSearchInput] = useState("");
29+
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
30+
const [matchCount, setMatchCount] = useState(0);
31+
const searchQuery = useDeferredValue(searchInput);
32+
const diffContainerRef = useRef<HTMLDivElement>(null);
33+
34+
// Count matches and reset index whenever the deferred query changes
35+
useEffect(() => {
36+
const container = diffContainerRef.current;
37+
if (!container) return;
38+
// RAF ensures the DOM has been updated after the deferred re-render
39+
const id = requestAnimationFrame(() => {
40+
const marks = container.querySelectorAll<HTMLElement>(".search-highlight");
41+
setMatchCount(marks.length);
42+
setCurrentMatchIndex(marks.length > 0 ? 0 : -1);
43+
if (marks.length > 0) {
44+
marks[0].scrollIntoView({ behavior: "smooth", block: "center" });
45+
marks[0].style.outline = "2px solid #009edb";
46+
}
47+
});
48+
return () => cancelAnimationFrame(id);
49+
}, [searchQuery]);
50+
51+
const navigateMatch = useCallback(
52+
(dir: 1 | -1) => {
53+
const container = diffContainerRef.current;
54+
if (!container) return;
55+
const marks =
56+
container.querySelectorAll<HTMLElement>(".search-highlight");
57+
if (marks.length === 0) return;
58+
// Remove outline from previous
59+
marks.forEach((m) => (m.style.outline = ""));
60+
const next = (currentMatchIndex + dir + marks.length) % marks.length;
61+
setCurrentMatchIndex(next);
62+
marks[next].scrollIntoView({ behavior: "smooth", block: "center" });
63+
marks[next].style.outline = "2px solid #009edb";
64+
},
65+
[currentMatchIndex],
66+
);
2867

2968
const handleShare = async () => {
3069
await navigator.clipboard.writeText(window.location.href);
@@ -44,6 +83,7 @@ function HomeContent() {
4483
setLoading(true);
4584
setError(null);
4685
setDiffData(null);
86+
setSearchInput("");
4787

4888
try {
4989
const response = await fetch("/api/diff", {
@@ -270,19 +310,131 @@ function HomeContent() {
270310
)}
271311

272312
{hasQueryParams && diffData && (
273-
<DiffViewer
274-
data={diffData}
275-
left={{
276-
symbol: symbol1,
277-
metadata: diffData.metadata?.left,
278-
format: diffData.formats?.left,
279-
}}
280-
right={{
281-
symbol: symbol2,
282-
metadata: diffData.metadata?.right,
283-
format: diffData.formats?.right,
284-
}}
285-
/>
313+
<>
314+
{/* Sticky search bar */}
315+
<div className="sticky top-4 z-10 -mx-3">
316+
<div className="rounded-xl bg-white/95 shadow-md ring-1 ring-black/5 backdrop-blur-sm px-4 py-2.5">
317+
<div className="flex items-center gap-2">
318+
<div className="relative flex-1">
319+
<svg
320+
className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400 pointer-events-none"
321+
fill="none"
322+
viewBox="0 0 24 24"
323+
stroke="currentColor"
324+
strokeWidth={2}
325+
>
326+
<path
327+
strokeLinecap="round"
328+
strokeLinejoin="round"
329+
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"
330+
/>
331+
</svg>
332+
<input
333+
type="text"
334+
value={searchInput}
335+
onChange={(e) => setSearchInput(e.target.value)}
336+
onKeyDown={(e) => {
337+
if (e.key === "Enter")
338+
navigateMatch(e.shiftKey ? -1 : 1);
339+
if (e.key === "Escape") setSearchInput("");
340+
}}
341+
placeholder="Search in documents…"
342+
className="w-full rounded-lg border border-gray-200 bg-gray-50 py-1.5 pr-9 pl-9 text-sm transition-colors focus:border-un-blue focus:bg-white focus:ring-1 focus:ring-un-blue focus:outline-none"
343+
/>
344+
{searchInput && (
345+
<button
346+
onClick={() => setSearchInput("")}
347+
className="absolute top-1/2 right-2.5 -translate-y-1/2 rounded text-gray-400 hover:text-gray-600"
348+
>
349+
<svg
350+
className="h-3.5 w-3.5"
351+
fill="none"
352+
viewBox="0 0 24 24"
353+
stroke="currentColor"
354+
strokeWidth={2.5}
355+
>
356+
<path
357+
strokeLinecap="round"
358+
strokeLinejoin="round"
359+
d="M6 18L18 6M6 6l12 12"
360+
/>
361+
</svg>
362+
</button>
363+
)}
364+
</div>
365+
366+
{searchQuery && (
367+
<>
368+
<span className="min-w-20 text-center text-xs tabular-nums text-gray-400">
369+
{matchCount === 0
370+
? "No matches"
371+
: `${currentMatchIndex + 1} / ${matchCount}`}
372+
</span>
373+
<div className="flex items-center gap-0.5 rounded-lg border border-gray-200 p-0.5">
374+
<button
375+
onClick={() => navigateMatch(-1)}
376+
disabled={matchCount === 0}
377+
title="Previous match (Shift+Enter)"
378+
className="rounded-md p-1 text-gray-500 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
379+
>
380+
<svg
381+
className="h-3.5 w-3.5"
382+
fill="none"
383+
viewBox="0 0 24 24"
384+
stroke="currentColor"
385+
strokeWidth={2.5}
386+
>
387+
<path
388+
strokeLinecap="round"
389+
strokeLinejoin="round"
390+
d="M5 15l7-7 7 7"
391+
/>
392+
</svg>
393+
</button>
394+
<button
395+
onClick={() => navigateMatch(1)}
396+
disabled={matchCount === 0}
397+
title="Next match (Enter)"
398+
className="rounded-md p-1 text-gray-500 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
399+
>
400+
<svg
401+
className="h-3.5 w-3.5"
402+
fill="none"
403+
viewBox="0 0 24 24"
404+
stroke="currentColor"
405+
strokeWidth={2.5}
406+
>
407+
<path
408+
strokeLinecap="round"
409+
strokeLinejoin="round"
410+
d="M19 9l-7 7-7-7"
411+
/>
412+
</svg>
413+
</button>
414+
</div>
415+
</>
416+
)}
417+
</div>
418+
</div>
419+
</div>
420+
421+
<div ref={diffContainerRef}>
422+
<DiffViewer
423+
data={diffData}
424+
searchQuery={searchQuery || undefined}
425+
left={{
426+
symbol: symbol1,
427+
metadata: diffData.metadata?.left,
428+
format: diffData.formats?.left,
429+
}}
430+
right={{
431+
symbol: symbol2,
432+
metadata: diffData.metadata?.right,
433+
format: diffData.formats?.right,
434+
}}
435+
/>
436+
</div>
437+
</>
286438
)}
287439

288440
{hasQueryParams && loading && !diffData && (

src/react/Comparison.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export interface ComparisonProps {
88
item: DiffItemType;
99
className?: string;
1010
gap?: string;
11+
searchQuery?: string;
1112
}
1213

1314
/**
@@ -18,6 +19,7 @@ export function Comparison({
1819
item,
1920
className = "",
2021
gap = "1rem",
22+
searchQuery,
2123
}: ComparisonProps) {
2224
const isAdded = item.right && !item.left && !item.leftBest;
2325
const isRemoved = item.left && !item.right && !item.rightBest;
@@ -36,17 +38,19 @@ export function Comparison({
3638
<DiffItem
3739
content={isRemoved ? item.left : item.leftHighlighted}
3840
color={isRemoved ? "red" : undefined}
41+
searchQuery={searchQuery}
3942
/>
4043
) : (
41-
<DiffItem content={item.leftHighlighted} />
44+
<DiffItem content={item.leftHighlighted} searchQuery={searchQuery} />
4245
)}
4346
{item.right ? (
4447
<DiffItem
4548
content={isAdded ? item.right : item.rightHighlighted}
4649
color={isAdded ? "lightgreen" : undefined}
50+
searchQuery={searchQuery}
4751
/>
4852
) : (
49-
<DiffItem content={item.rightHighlighted} />
53+
<DiffItem content={item.rightHighlighted} searchQuery={searchQuery} />
5054
)}
5155
</div>
5256
);

src/react/DiffItem.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@ export interface DiffItemProps {
77
content: string;
88
color?: "red" | "lightgreen" | "yellow" | "blue";
99
className?: string;
10+
searchQuery?: string;
1011
}
1112

1213
/**
1314
* Single diff item display component
1415
* Shows highlighted text with optional background color
1516
*/
16-
export function DiffItem({ content, color, className = "" }: DiffItemProps) {
17+
export function DiffItem({ content, color, className = "", searchQuery }: DiffItemProps) {
1718
const getColorStyle = () => {
1819
if (!color)
1920
return content ? { backgroundColor: "var(--diff-item-bg, #fff)" } : {};
@@ -45,7 +46,7 @@ export function DiffItem({ content, color, className = "" }: DiffItemProps) {
4546
...getColorStyle(),
4647
}}
4748
>
48-
<div style={{ width: "100%" }}>{parseHighlightedText(content || "")}</div>
49+
<div style={{ width: "100%" }}>{parseHighlightedText(content || "", searchQuery)}</div>
4950
</div>
5051
);
5152
}

src/react/DiffViewer.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export interface DiffViewerProps {
1919
format?: "doc" | "pdf";
2020
};
2121
className?: string;
22+
searchQuery?: string;
2223
}
2324

2425
/**
@@ -29,6 +30,7 @@ export function DiffViewer({
2930
left,
3031
right,
3132
className = "",
33+
searchQuery,
3234
}: DiffViewerProps) {
3335
return (
3436
<div
@@ -37,7 +39,7 @@ export function DiffViewer({
3739
>
3840
{/* Document Headers */}
3941
<div
40-
style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1rem" }}
42+
style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}
4143
>
4244
<DocumentHeader
4345
symbol={left.symbol}
@@ -54,7 +56,7 @@ export function DiffViewer({
5456
{/* Diff items */}
5557
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
5658
{data.items.map((item, index) => (
57-
<Comparison key={index} item={item} />
59+
<Comparison key={index} item={item} searchQuery={searchQuery} />
5860
))}
5961
</div>
6062
</div>

src/react/DocumentHeader.tsx

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,22 +22,56 @@ export function DocumentHeader({
2222
const pdfUrl = `https://documents.un.org/api/symbol/access?s=${encodeURIComponent(symbol)}&l=en&t=pdf`;
2323

2424
return (
25-
<div className={className} style={{ textAlign: "left" }}>
26-
<h3 style={{ fontSize: "1rem", fontWeight: 600, margin: 0 }}>{symbol}</h3>
25+
<div
26+
className={className}
27+
style={{
28+
textAlign: "left",
29+
background: "#fff",
30+
borderRadius: "0.75rem",
31+
padding: "1rem 1.25rem",
32+
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.07), 0 0 0 1px rgb(0 0 0 / 0.04)",
33+
}}
34+
>
35+
<p
36+
style={{
37+
margin: 0,
38+
fontSize: "0.65rem",
39+
fontWeight: 600,
40+
letterSpacing: "0.08em",
41+
textTransform: "uppercase",
42+
color: "#9ca3af",
43+
marginBottom: "0.35rem",
44+
}}
45+
></p>
46+
<h3
47+
style={{
48+
fontSize: "1.125rem",
49+
fontWeight: 700,
50+
margin: 0,
51+
letterSpacing: "-0.01em",
52+
color: "#111827",
53+
}}
54+
>
55+
{symbol}
56+
</h3>
2757

2858
{metadata?.date && (
2959
<p
3060
style={{
31-
marginTop: "0.25rem",
32-
fontSize: "0.875rem",
33-
color: "#4b5563",
61+
marginTop: "0.3rem",
62+
fontSize: "0.8125rem",
63+
color: "#6b7280",
3464
}}
3565
>
36-
{new Date(metadata.date).toLocaleDateString("en-US", {
37-
year: "numeric",
38-
month: "long",
39-
day: "numeric",
40-
})}
66+
{(() => {
67+
// Parse YYYY-MM-DD parts directly to avoid UTC→local timezone shift
68+
const [y, m, d] = metadata.date!.split("-").map(Number);
69+
return new Date(y, m - 1, d).toLocaleDateString("en-US", {
70+
year: "numeric",
71+
month: "long",
72+
day: "numeric",
73+
});
74+
})()}
4175
</p>
4276
)}
4377

@@ -46,10 +80,13 @@ export function DocumentHeader({
4680
target="_blank"
4781
rel="noopener noreferrer"
4882
style={{
49-
display: "inline-block",
50-
marginTop: "0.25rem",
51-
fontSize: "0.875rem",
52-
color: "var(--color-un-blue)",
83+
display: "inline-flex",
84+
alignItems: "center",
85+
gap: "0.25rem",
86+
marginTop: "0.6rem",
87+
fontSize: "0.8125rem",
88+
fontWeight: 500,
89+
color: "var(--color-un-blue, #009edb)",
5390
textDecoration: "none",
5491
}}
5592
onMouseEnter={(e) =>

0 commit comments

Comments
 (0)