diff --git a/app/[lang]/layout.tsx b/app/[lang]/layout.tsx index a836513..7d1af82 100644 --- a/app/[lang]/layout.tsx +++ b/app/[lang]/layout.tsx @@ -83,7 +83,7 @@ export default async function LangLayout({ }) { const { lang } = await params - if (!locales.includes(lang as any)) { + if (!(locales as readonly string[]).includes(lang)) { notFound() } diff --git a/app/api/revalidate/route.ts b/app/api/revalidate/route.ts index 015fa14..50bdd6a 100644 --- a/app/api/revalidate/route.ts +++ b/app/api/revalidate/route.ts @@ -24,7 +24,7 @@ export async function POST(request: NextRequest) { if (Array.isArray(body.tags)) { for (const tag of body.tags) { if (typeof tag === 'string') { - revalidateTag(tag) + revalidateTag(tag, { expire: 0 }) revalidated.tags.push(tag) } } diff --git a/app/api/stats/route.ts b/app/api/stats/route.ts index 085f36c..a628efa 100644 --- a/app/api/stats/route.ts +++ b/app/api/stats/route.ts @@ -8,6 +8,6 @@ export async function GET() { } export async function POST() { - revalidateTag('site-stats') + revalidateTag('site-stats', { expire: 0 }) return NextResponse.json({ revalidated: true }) } diff --git a/components/blog/ShareButtons.tsx b/components/blog/ShareButtons.tsx index 803f94d..e61d5d1 100644 --- a/components/blog/ShareButtons.tsx +++ b/components/blog/ShareButtons.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState } from 'react' +import { useSyncExternalStore } from 'react' import { usePathname } from '@/src/i18n/navigation' import { useTranslations } from 'next-intl' @@ -11,17 +11,15 @@ interface ShareButtonsProps { const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://catholicdigitalcommons.org' +const subscribeNoop = () => () => {} +const getCanShare = () => typeof navigator.share === 'function' +const getCanShareServer = () => false + export default function ShareButtons({ title, namespace = 'blog' }: ShareButtonsProps) { const t = useTranslations(namespace) const pathname = usePathname() - const [canShare, setCanShare] = useState(false) - const [url, setUrl] = useState('') - - useEffect(() => { - const fullUrl = `${SITE_URL}${pathname}` - setUrl(fullUrl) - setCanShare(typeof navigator.share === 'function') - }, [pathname]) + const canShare = useSyncExternalStore(subscribeNoop, getCanShare, getCanShareServer) + const url = `${SITE_URL}${pathname}` const encodedUrl = encodeURIComponent(url) const encodedTitle = encodeURIComponent(title) diff --git a/components/projects/RepoLanguages.tsx b/components/projects/RepoLanguages.tsx index aa6383c..ccb87bc 100644 --- a/components/projects/RepoLanguages.tsx +++ b/components/projects/RepoLanguages.tsx @@ -38,7 +38,7 @@ type LanguageData = Record> export default function RepoLanguages({ repos, label }: RepoLanguagesProps) { const [languages, setLanguages] = useState(null) - const [loading, setLoading] = useState(true) + const [fetchedKey, setFetchedKey] = useState(null) // Extract owner/repo from GitHub URLs const repoIds = repos @@ -55,19 +55,35 @@ export default function RepoLanguages({ repos, label }: RepoLanguagesProps) { }) .filter((id): id is string => id !== null) + const repoKey = repoIds.join(',') + const loading = repoIds.length > 0 && fetchedKey !== repoKey + useEffect(() => { if (repoIds.length === 0) { - setLoading(false) + // Clear stale data via microtask to satisfy react-hooks/set-state-in-effect + void Promise.resolve().then(() => { + setLanguages(null) + setFetchedKey(null) + }) return } - fetch(`/api/github/languages?repos=${repoIds.join(',')}`) + const controller = new AbortController() + fetch(`/api/github/languages?repos=${repoIds.join(',')}`, { signal: controller.signal }) .then((res) => (res.ok ? res.json() : null)) - .then((data) => setLanguages(data)) - .catch(() => setLanguages(null)) - .finally(() => setLoading(false)) + .then((data) => { + setLanguages(data) + setFetchedKey(repoKey) + }) + .catch((err) => { + if (err.name !== 'AbortError') { + setLanguages(null) + setFetchedKey(repoKey) + } + }) + return () => { controller.abort() } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [repos.join(',')]) + }, [repoKey]) if (loading) { return ( diff --git a/components/sections/FishBackground.tsx b/components/sections/FishBackground.tsx index 07c787d..0cb3165 100644 --- a/components/sections/FishBackground.tsx +++ b/components/sections/FishBackground.tsx @@ -1,6 +1,3 @@ -'use client' - -import { useEffect, useState } from 'react' /** * Inline SVG path data for stylized fish in various poses. @@ -52,44 +49,50 @@ interface PlacedFish { opacity: number } -function randomBetween(min: number, max: number) { - return min + Math.random() * (max - min) +/** Simple deterministic PRNG (mulberry32) seeded from count */ +function createSeededRng(seed: number) { + let s = seed | 0 + return () => { + s = (s + 0x6d2b79f5) | 0 + let t = Math.imul(s ^ (s >>> 15), 1 | s) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } } -export default function FishBackground({ count = 5 }: { count?: number }) { - const [fish, setFish] = useState([]) +function seededBetween(rng: () => number, min: number, max: number) { + return min + rng() * (max - min) +} - useEffect(() => { - const placed: PlacedFish[] = [] - const minDistance = 8 // minimum % distance between fish centers +export default function FishBackground({ count = 5, seed }: { count?: number; seed?: number }) { + const rng = createSeededRng(seed ?? count * 7919) + const fish: PlacedFish[] = [] + const minDistance = 8 // minimum % distance between fish centers - for (let i = 0; i < count; i++) { - let candidate: PlacedFish | null = null - for (let attempt = 0; attempt < 50; attempt++) { - const x = randomBetween(5, 90) - const y = randomBetween(5, 90) - const tooClose = placed.some((p) => { - const dx = p.x - x - const dy = p.y - y - return Math.sqrt(dx * dx + dy * dy) < minDistance - }) - if (!tooClose) { - candidate = { - pathIndex: Math.floor(Math.random() * fishPaths.length), - x, - y, - rotation: randomBetween(-35, 35), - size: randomBetween(80, 140), - opacity: randomBetween(0.1, 0.18), - } - break + for (let i = 0; i < count; i++) { + let candidate: PlacedFish | null = null + for (let attempt = 0; attempt < 50; attempt++) { + const x = seededBetween(rng, 5, 90) + const y = seededBetween(rng, 5, 90) + const tooClose = fish.some((p) => { + const dx = p.x - x + const dy = p.y - y + return Math.sqrt(dx * dx + dy * dy) < minDistance + }) + if (!tooClose) { + candidate = { + pathIndex: Math.floor(rng() * fishPaths.length), + x, + y, + rotation: seededBetween(rng, -35, 35), + size: seededBetween(rng, 80, 140), + opacity: seededBetween(rng, 0.1, 0.18), } + break } - if (candidate) placed.push(candidate) } - - setFish(placed) - }, [count]) + if (candidate) fish.push(candidate) + } if (fish.length === 0) return null diff --git a/components/sections/HeroBanner.tsx b/components/sections/HeroBanner.tsx index 38073af..f9a5af4 100644 --- a/components/sections/HeroBanner.tsx +++ b/components/sections/HeroBanner.tsx @@ -48,10 +48,13 @@ export default function HeroBanner({ hero }: HeroBannerProps) {
{hero.heroShowLogo && ( - )} diff --git a/components/sections/ReferCommunityProjectModal.tsx b/components/sections/ReferCommunityProjectModal.tsx index 9ff3bef..30492bf 100644 --- a/components/sections/ReferCommunityProjectModal.tsx +++ b/components/sections/ReferCommunityProjectModal.tsx @@ -13,7 +13,8 @@ export default function ReferCommunityProjectModal({ buttonLabel }: ReferCommuni const t = useTranslations('communityProjects') const dialogRef = useRef(null) const openedAtRef = useRef(0) - const formDataRef = useRef<{ fields: Record; tags: string[] }>({ fields: {}, tags: [] }) + const [formData, setFormData] = useState<{ fields: Record; tags: string[] }>({ fields: {}, tags: [] }) + const [formKey, setFormKey] = useState(0) const [status, setStatus] = useState('idle') const [verificationCode, setVerificationCode] = useState('') const [codeError, setCodeError] = useState('') @@ -26,8 +27,9 @@ export default function ReferCommunityProjectModal({ buttonLabel }: ReferCommuni setCodeError('') setTags([]) setTagInput('') + setFormKey((k) => k + 1) openedAtRef.current = Date.now() - formDataRef.current = { fields: {}, tags: [] } + setFormData({ fields: {}, tags: [] }) dialogRef.current?.showModal() }, []) @@ -53,7 +55,7 @@ export default function ReferCommunityProjectModal({ buttonLabel }: ReferCommuni website: data.get('website') as string, } - formDataRef.current = { fields, tags } + setFormData({ fields, tags }) const payload = { ...fields, @@ -88,8 +90,8 @@ export default function ReferCommunityProjectModal({ buttonLabel }: ReferCommuni method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - ...formDataRef.current.fields, - tags: formDataRef.current.tags, + ...formData.fields, + tags: formData.tags, verification_code: verificationCode, elapsed_ms: Date.now() - openedAtRef.current, }), @@ -127,8 +129,8 @@ export default function ReferCommunityProjectModal({ buttonLabel }: ReferCommuni method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - ...formDataRef.current.fields, - tags: formDataRef.current.tags, + ...formData.fields, + tags: formData.tags, elapsed_ms: Date.now() - openedAtRef.current, }), }) @@ -147,6 +149,9 @@ export default function ReferCommunityProjectModal({ buttonLabel }: ReferCommuni setStatus('idle') setVerificationCode('') setCodeError('') + setTags(formData.tags) + setTagInput('') + setFormKey((k) => k + 1) } const isCodeView = status === 'awaiting_code' || status === 'submitting' @@ -209,7 +214,7 @@ export default function ReferCommunityProjectModal({ buttonLabel }: ReferCommuni

{t('checkEmailTitle')}

- {t('checkEmailMessage', { email: formDataRef.current.fields.submitter_email })} + {t('checkEmailMessage', { email: formData.fields.submitter_email })}

@@ -272,7 +277,7 @@ export default function ReferCommunityProjectModal({ buttonLabel }: ReferCommuni )} -
+ { void handleSendCode(e) }} className="space-y-4"> {/* Honeypot — hidden from real users */}

{t('checkEmailTitle')}

- {t('checkEmailMessage', { email: formDataRef.current.submitter_email })} + {t('checkEmailMessage', { email: formData.submitter_email })}

@@ -268,7 +272,7 @@ export default function ReferLocalGroupModal({ buttonLabel }: ReferLocalGroupMod )} - + { void handleSendCode(e) }} className="space-y-4"> {/* Honeypot — hidden from real users */}

{t('checkEmailTitle')}

- {t('checkEmailMessage', { email: formDataRef.current.fields.submitter_email })} + {t('checkEmailMessage', { email: formData.fields.submitter_email })}

@@ -292,7 +295,7 @@ export default function SubmitProjectModal({ buttonLabel }: SubmitProjectModalPr )} - + { void handleSendCode(e) }} className="space-y-4"> {/* Honeypot — hidden from real users */}