From 96796635891500672559619e7e80d2dd146a8f31 Mon Sep 17 00:00:00 2001 From: Jonas Braathen Date: Wed, 14 Oct 2026 00:29:18 +0200 Subject: [PATCH] web: prerender the search page shell and show a skeleton for the event list --- web/src/app/sok/page.tsx | 45 +++++++++++-------- web/src/components/events/EventIndexView.tsx | 4 +- .../components/events/EventListSkeleton.tsx | 40 +++++++++++++++++ .../events/eventContainer.module.scss | 29 ++++++++++++ 4 files changed, 98 insertions(+), 20 deletions(-) create mode 100644 web/src/components/events/EventListSkeleton.tsx diff --git a/web/src/app/sok/page.tsx b/web/src/app/sok/page.tsx index 90e5b03..fa0e6d3 100644 --- a/web/src/app/sok/page.tsx +++ b/web/src/app/sok/page.tsx @@ -1,3 +1,4 @@ +import { Suspense } from "react"; import { connection } from "next/server"; import { getClient } from "@/app/client"; import { uncached } from "@/lib/revalidation"; @@ -10,17 +11,27 @@ import { graphql } from "@/gql"; // TODO: seo metadata? -// Results depend on the query in the URL, so the route renders per request -// and is allowed to block -export const instant = false; +// The results depend on the query in the URL and render per request; the +// heading and the search field around them are prerendered +export const ensureStatic = "shell"; -export default async function Page({ +type SearchParams = Promise<{ q?: string }>; + +export default function Page({ searchParams, }: { - searchParams?: Promise<{ - q?: string; - }>; + searchParams?: SearchParams; }) { + return ( +
+ {null}}> + + +
+ ); +} + +async function Search({ searchParams }: { searchParams?: SearchParams }) { const { q: query } = (await searchParams) ?? {}; // search always runs at request time await connection(); @@ -80,16 +91,14 @@ export default async function Page({ } return ( -
- - {query ? ( - - ) : null} - -
+ + {query ? ( + + ) : null} + ); } diff --git a/web/src/components/events/EventIndexView.tsx b/web/src/components/events/EventIndexView.tsx index ec731cc..91757da 100644 --- a/web/src/components/events/EventIndexView.tsx +++ b/web/src/components/events/EventIndexView.tsx @@ -3,6 +3,7 @@ import { connection } from "next/server"; import { VenueFragment } from "@/gql/graphql"; import { cmsQueryForToday } from "@/app/client"; import { EventContainer } from "@/components/events/EventContainer"; +import { EventListSkeleton } from "@/components/events/EventListSkeleton"; import { PageHeader } from "@/components/general/PageHeader"; import { EventCategory, @@ -49,8 +50,7 @@ export function EventIndexView(props: EventIndexViewProps) { return (
- {/* the fallback reserves the list's height to keep the footer in place */} - }> + }>
diff --git a/web/src/components/events/EventListSkeleton.tsx b/web/src/components/events/EventListSkeleton.tsx new file mode 100644 index 0000000..d083cb7 --- /dev/null +++ b/web/src/components/events/EventListSkeleton.tsx @@ -0,0 +1,40 @@ +import { Icon } from "../general/Icon"; +import styles from "./eventContainer.module.scss"; + +// Stands in for the event list while it streams in. The toolbar is the real +// markup, so the cards start at exactly the height they will end up at; the +// cards have the real image ratio and room for a typical title and date +export function EventListSkeleton() { + return ( +
+
+
+
+ + +
+
+ +
+
+
    + {Array.from({ length: 6 }, (_, i) => ( +
  • +
    +
    +
  • + ))} +
+
+
+ ); +} diff --git a/web/src/components/events/eventContainer.module.scss b/web/src/components/events/eventContainer.module.scss index df3a8dd..4894789 100644 --- a/web/src/components/events/eventContainer.module.scss +++ b/web/src/components/events/eventContainer.module.scss @@ -20,6 +20,35 @@ padding-bottom: var(--spacing-section-bottom); } +// mirrors a large EventItem (eventItem.module.scss): image, then text +.skeletonItem { + list-style: none; + margin-bottom: 1rem; +} + +.skeletonImage { + padding-top: 60%; + margin-bottom: var(--spacing-xs); + background-color: var(--color-background-secondary); +} + +.skeletonText { + // a two-line title and a date; real cards vary with the title's length + height: 5rem; + background: + linear-gradient(var(--color-background-secondary) 0 0) 0 0.4rem / 80% 1.2rem, + linear-gradient(var(--color-background-secondary) 0 0) 0 2.2rem / 55% 1.2rem, + linear-gradient(var(--color-background-secondary) 0 0) 0 4rem / 30% 0.8rem; + background-repeat: no-repeat; +} + +@media (max-width: 1200px) { + .skeletonText { + // titles wrap less in the wider one- and two-column cards + height: 4.4rem; + } +} + .displayOptions { display: flex; flex-wrap: wrap;