Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion app/(frontend)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ export default async function RootLayout({
return (
<html lang="en" className={isDarkMode ? "dark" : ""}>
<head>
<ThemeStyle lightMode={siteTheme.lightMode} darkMode={siteTheme.darkMode} />
<ThemeStyle lightMode={siteTheme.lightMode} darkMode={siteTheme.darkMode} headerAnimation={siteTheme.headerAnimation} />
<link
rel="preload"
href="/fonts/raleway/Raleway-Variable.ttf"
Expand Down
69 changes: 8 additions & 61 deletions app/api/search/archive-date/route.ts
Original file line number Diff line number Diff line change
@@ -1,70 +1,17 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@/payload.config";
import { Pool } from "pg";

const responseHeaders = {
"Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400",
};

let legacyPool: Pool | null = null;

function getLegacyPool(): Pool {
if (!legacyPool) {
legacyPool = new Pool({ connectionString: process.env.LEGACY_DATABASE_URI });
}
return legacyPool;
}
// Search reaches back to 2001. This used to be derived from the legacy Wagtail
// DB's MIN(first_published_at), but that column holds import dates, so it
// reported 2018 — later than what search actually covers.
const ARCHIVE_START_YEAR = 2001;

export async function GET() {
// Try legacy DB first
try {
const pool = getLegacyPool();
const client = await pool.connect();
try {
const { rows } = await client.query(
`SELECT MIN(wp.first_published_at) AS oldest
FROM core_articlepage a
JOIN wagtailcore_page wp ON a.page_ptr_id = wp.id
WHERE wp.live = true AND wp.first_published_at IS NOT NULL`,
);
const oldest: Date | null = rows[0]?.oldest ?? null;
if (oldest) {
const year = new Date(oldest).getFullYear();
return NextResponse.json(
{ subtitle: `Search archives date back to ${year}` },
{ headers: responseHeaders },
);
}
} finally {
client.release();
}
} catch (err) {
console.error("[archive-date] Legacy DB error:", err);
}

// Fallback: oldest Payload article
try {
const payload = await getPayload({ config });
const results = await payload.find({
collection: "articles",
where: { _status: { equals: "published" } },
sort: "publishedDate",
limit: 1,
depth: 0,
});
const oldest = results.docs[0]?.publishedDate;
if (oldest) {
const date = new Date(oldest);
const label = date.toLocaleDateString("en-US", { month: "long", year: "numeric" });
return NextResponse.json(
{ subtitle: `Search archives date back to ${label}` },
{ headers: responseHeaders },
);
}
} catch (err) {
console.error("[archive-date] Payload error:", err);
}

return NextResponse.json({ subtitle: null }, { headers: responseHeaders });
return NextResponse.json(
{ subtitle: `Search archives date back to ${ARCHIVE_START_YEAR}` },
{ headers: responseHeaders },
);
}
107 changes: 75 additions & 32 deletions components/SearchOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,14 +158,14 @@ function InlineSelect({
label,
value,
options,
rainbow,
sweep,
onChange,
}: {
label: string;
value: string;
options: { value: string; label: string }[];
// Rainbow while searching, lingering a second before fading back to normal.
rainbow: boolean;
// The rainbow sweeps diagonally across the word once the search settles.
sweep: boolean;
onChange: (value: string) => void;
}) {
const text = (options.find((option) => option.value === value) ?? options[0]).label;
Expand All @@ -174,15 +174,19 @@ function InlineSelect({
{text}
<span
aria-hidden="true"
className="search-rainbow-text pointer-events-none absolute inset-0 select-none"
style={{ opacity: rainbow ? 1 : 0, transition: `opacity 0.8s ease-out ${rainbow ? "0s" : "1s"}` }}
className="search-sweep-text pointer-events-none absolute inset-0 select-none"
style={{ opacity: sweep ? 1 : 0, transition: `opacity ${sweep ? "0.35s" : "0.5s"} ease-in-out` }}
>
{text}
</span>
<select
aria-label={label}
value={value}
onChange={(e) => onChange(e.target.value)}
onChange={(e) => {
onChange(e.target.value);
// Otherwise the select keeps focus and the word stays accent-colored.
e.target.blur();
}}
className="absolute inset-0 h-full w-full cursor-pointer appearance-none opacity-0"
>
{options.map((option) => (
Expand Down Expand Up @@ -412,6 +416,9 @@ export default function SearchOverlay({
const resultsKeyRef = useRef<string | null>(restored?.resultsKey ?? null);
// Restored results are already current, so the first fetch for them is skipped.
const restoredKeyRef = useRef<string | null>(restored && !restored.isLoading ? restored.resultsKey : null);
const waveRanRef = useRef(false);
// A filter change gets the wave bar only; the words stay put.
const filterChangeRef = useRef(false);

// Spell check
const [spellCorrection, setSpellCorrection] = useState<SpellCorrectionState | null>(restored?.spellCorrection ?? null);
Expand All @@ -430,8 +437,30 @@ export default function SearchOverlay({
// 3: cursor starts blinking, input is live

const showTypingOverlay = query.length === 0 && stage >= 1 && stage < 3;
const [settleSweep, setSettleSweep] = useState(false);
const showWave = (isWaveTypingActive || isLoading) && stage >= 2;

// The filter words catch the rainbow half a second after the wave stops.
useEffect(() => {
if (showWave) {
waveRanRef.current = true;
setSettleSweep(false);
return;
}
if (!waveRanRef.current) return;
waveRanRef.current = false;
if (filterChangeRef.current) {
filterChangeRef.current = false;
return;
}
const start = setTimeout(() => setSettleSweep(true), 500);
const stop = setTimeout(() => setSettleSweep(false), 1350);
return () => {
clearTimeout(start);
clearTimeout(stop);
};
}, [showWave]);

const clearWaveTypingTimer = useCallback(() => {
if (waveTypingTimerRef.current) {
clearTimeout(waveTypingTimerRef.current);
Expand Down Expand Up @@ -480,13 +509,15 @@ export default function SearchOverlay({
if (normalized.length > MAX_SEARCH_QUERY_LENGTH) {
triggerCharacterLimitNotice();
}
filterChangeRef.current = false;
setQuery(normalized.slice(0, MAX_SEARCH_QUERY_LENGTH));
setPage(0);
};

const updateFilters = (patch: Partial<SearchFilters>) => {
const next = { ...filters, ...patch };
if (next.section === filters.section && next.range === filters.range && next.sort === filters.sort) return;
filterChangeRef.current = true;
setFilters(next);
setPage(0);
};
Expand Down Expand Up @@ -863,7 +894,7 @@ export default function SearchOverlay({
.catch(() => setArchiveSubtitle(null));
}, [restored]);

// Lock body scroll and handle Esc
// Lock body scroll and close on Escape.
useEffect(() => {
document.body.style.overflow = "hidden";

Expand Down Expand Up @@ -896,10 +927,6 @@ export default function SearchOverlay({
ref={containerRef}
data-search-overlay
className={`fixed inset-0 z-[100] overflow-y-auto bg-bg-main/88 backdrop-blur-sm transition-opacity ease-out${forceDarkMode ? ' dark' : ''}`}
onClick={(e) => {
const target = e.target as HTMLElement;
if (!target.closest("input, select, a, button, [data-search-area]")) handleClose();
}}
style={{
...(forceDarkMode ? {
'--background': '#0a0a0a',
Expand Down Expand Up @@ -960,17 +987,24 @@ export default function SearchOverlay({
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
@keyframes rainbowTextShift {
to { background-position: 200% 0; }
@keyframes searchSweep {
from { background-position: 130% -30%; }
to { background-position: -30% 130%; }
}
.search-rainbow-text {
background-image: linear-gradient(90deg, #ff4040, #ff9900, #ffee00, #44dd44, #4488ff, #cc44ff, #ff4040);
background-size: 200% 100%;
/* The header wave's own colors, set from the theme by ThemeStyle. */
.search-sweep-text {
background-image: linear-gradient(225deg,
var(--wave-color-1) 0%,
var(--wave-color-2) 30%,
var(--wave-color-3) 50%,
var(--wave-color-2) 70%,
var(--wave-color-1) 100%);
background-size: 150% 150%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
animation: rainbowTextShift 1.5s linear infinite;
animation: searchSweep 2s linear infinite;
}
@keyframes rainbowLetterFlash {
0% { color: #f4a6a6; }
Expand All @@ -990,27 +1024,36 @@ export default function SearchOverlay({
}
`}</style>

{/* Close control, pinned to the screen (outside the transformed column). */}
<div
className="relative mx-auto max-w-[1280px] px-4 pb-16 pt-3 transition-[opacity,transform] ease-out md:px-6 md:pt-6 xl:px-[30px]"
className="fixed right-3 top-[calc(var(--safe-area-top)+0.25rem)] z-20 flex items-center gap-2 md:right-6"
style={{
opacity: isVisible ? 1 : 0,
transform: isVisible ? "translateY(0)" : isClosing ? "translateY(0)" : "translateY(8px)",
transitionDuration: `${OVERLAY_TRANSITION_MS}ms`,
opacity: stage >= 2 ? 1 : 0,
transform: stage >= 2 ? "translateY(0)" : "translateY(-20px)",
transition: "opacity 0.3s ease-out, transform 0.3s ease-out",
}}
>
{/* X button */}
<span className="pointer-events-none hidden select-none rounded border border-text-muted/40 px-1.5 py-[1px] font-meta text-[10px] uppercase tracking-[0.1em] text-text-muted md:inline-block">
esc
</span>
<button
onClick={handleClose}
className="fixed top-[calc(var(--safe-area-top)-0.15rem)] right-3 z-20 flex h-7 w-7 cursor-pointer items-center justify-center text-text-muted/70 transition-colors hover:text-accent md:absolute md:top-[0.45rem] md:right-6 md:h-10 md:w-10 xl:right-[30px]"
style={{
opacity: stage >= 2 ? 1 : 0,
transform: stage >= 2 ? "translateY(0)" : "translateY(-20px)",
transition: "opacity 0.3s ease-out, transform 0.3s ease-out",
}}
className="flex h-10 w-10 cursor-pointer items-center justify-center transition-opacity hover:opacity-70 md:h-12 md:w-12"
style={{ color: "#D6001C" }}
aria-label="Close search"
>
<X className="h-3.5 w-3.5 md:h-5 md:w-5" />
<X className="h-6 w-6 md:h-8 md:w-8" strokeWidth={2.25} />
</button>
</div>

<div
className="relative mx-auto max-w-[1280px] px-4 pb-16 pt-3 transition-[opacity,transform] ease-out md:px-6 md:pt-6 xl:px-[30px]"
style={{
opacity: isVisible ? 1 : 0,
transform: isVisible ? "translateY(0)" : isClosing ? "translateY(0)" : "translateY(8px)",
transitionDuration: `${OVERLAY_TRANSITION_MS}ms`,
}}
>

<div data-search-area className="relative flex items-center">
{/* Bottom line / loading wave */}
Expand Down Expand Up @@ -1186,22 +1229,22 @@ export default function SearchOverlay({
Showing{" "}
<InlineSelect
label="Section"
rainbow={showWave}
sweep={settleSweep && !showWave}
value={filters.section ?? ""}
options={SECTION_FILTERS}
onChange={(section) => updateFilters({ section: (section || null) as SearchFilters["section"] })}
/>{" "}
from{" "}
<InlineSelect
label="Date"
rainbow={showWave}
sweep={settleSweep && !showWave}
value={filters.range}
options={RANGE_FILTERS}
onChange={(range) => updateFilters({ range: range as SearchFilters["range"] })}
/>,{" "}
<InlineSelect
label="Sort"
rainbow={showWave}
sweep={settleSweep && !showWave}
value={filters.sort}
options={SORT_FILTERS}
onChange={(sort) => updateFilters({ sort: sort as SearchFilters["sort"] })}
Expand Down
22 changes: 19 additions & 3 deletions components/ThemeStyle.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { ThemeColors } from '@/lib/getTheme'
import type { HeaderAnimationConfig, ThemeColors } from '@/lib/getTheme'

type Props = {
lightMode: ThemeColors
darkMode: ThemeColors
headerAnimation: HeaderAnimationConfig
}

// Allow only valid CSS color values: hex, rgb(), rgba(), hsl(), hsla(), named colors.
Expand Down Expand Up @@ -42,10 +43,25 @@ function colorVars(colors: ThemeColors): string {
return lines.join('\n')
}

export default function ThemeStyle({ lightMode, darkMode }: Props) {
// The header wave's colors, so other effects (like the search filters) can match it.
function waveVars(animation: HeaderAnimationConfig): string {
return ([
['--wave-color-1', animation.waveColor1],
['--wave-color-2', animation.waveColor2],
['--wave-color-3', animation.waveColor3],
] as const)
.map(([prop, val]) => {
const safe = sanitizeCssColor(val)
return safe ? ` ${prop}: ${safe};` : null
})
.filter(Boolean)
.join('\n')
}

export default function ThemeStyle({ lightMode, darkMode, headerAnimation }: Props) {
// Use html:root (higher specificity than :root alone) so this always wins
// over the bare :root block in globals.css regardless of stylesheet load order.
const css = `html:root {\n${colorVars(lightMode)}\n}\nhtml.dark:root {\n${colorVars(darkMode)}\n}`
const css = `html:root {\n${colorVars(lightMode)}\n${waveVars(headerAnimation)}\n}\nhtml.dark:root {\n${colorVars(darkMode)}\n}`
// Values are sanitized above — only valid CSS color syntax passes through.
// eslint-disable-next-line react/no-danger
return <style dangerouslySetInnerHTML={{ __html: css }} />
Expand Down
Loading