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;