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
136 changes: 136 additions & 0 deletions src/components/tess-listings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { useEffect, useState, type ComponentType, type ReactNode, type SVGProps } from 'react';
import { BookOpenIcon, CalendarIcon, ExclamationTriangleIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import {
browseLinks,
eventDates,
eventLink,
eventPlace,
materialLink,
materials,
pastEvents,
upcomingEvents,
type TessResult,
} from '../lib/tess';

const ITEM = 'py-4 first:pt-0 last:pb-0';
const ITEM_LINK = 'group -m-2 block rounded-lg p-2 transition-colors hover:bg-gray-50 dark:hover:bg-white/[0.04] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent';
const ITEM_TITLE = 'font-semibold text-brand-primary dark:text-white transition-colors group-hover:text-accent';
const LIST = 'divide-y divide-gray-200/60 dark:divide-gray-700/30';

/** Null while TESS is still being asked. */
function useTess<T>(load: () => Promise<TessResult<T>>): TessResult<T> | null {
const [result, setResult] = useState<TessResult<T> | null>(null);
useEffect(() => {
load().then(setResult);
}, [load]);
return result;
}
Comment thread
yasinmiran marked this conversation as resolved.

function Loading({ label }: { label: string }) {
return (
<div role="status">
<span className="sr-only">{label}</span>
<div className={LIST} aria-hidden="true">
{[0, 1, 2].map(row => (
<div key={row} className={`${ITEM} motion-safe:animate-pulse`}>
<div className="h-4 w-2/3 rounded bg-gray-200 dark:bg-white/10" />
<div className="mt-2 h-3 w-1/3 rounded bg-gray-100 dark:bg-white/5" />
</div>
))}
</div>
</div>
);
}

function Empty({ icon: Icon, children }: { icon: ComponentType<SVGProps<SVGSVGElement>>; children: ReactNode }) {
return (
<p className="flex items-center justify-center py-10 text-sm text-gray-500 dark:text-gray-400">
<Icon className="mr-2 h-5 w-5" aria-hidden="true" />
{children}
</p>
);
}

function Unavailable({ href }: { href: string }) {
return (
<div className="flex flex-col items-center gap-2 py-10 text-center">
<ExclamationTriangleIcon className="h-5 w-5 text-gray-500 dark:text-gray-400" aria-hidden="true" />
<p className="text-sm text-gray-600 dark:text-gray-400">
This listing could not be loaded from TeSS right now.
</p>
<a href={href} target="_blank" rel="noopener noreferrer"
className="rounded text-sm font-medium text-accent hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">
Browse it on TeSS instead
</a>
</div>
);
}

export function TessEvents({ when }: { when: 'upcoming' | 'past' }) {
const result = useTess(when === 'upcoming' ? upcomingEvents : pastEvents);

if (!result) return <Loading label={`Loading ${when} training events from TeSS`} />;
if (!result.reachable) return <Unavailable href={browseLinks[when]} />;
if (result.items.length === 0) {
return (
<Empty icon={CalendarIcon}>
{when === 'upcoming' ? 'No upcoming training events at the moment.' : 'No past training events to show.'}
</Empty>
);
}

return (
<ul className={LIST}>
{result.items.map(event => (
<li key={event.id} className={ITEM}>
<a href={eventLink(event)} target="_blank" rel="noopener noreferrer" className={ITEM_LINK}>
<p className={ITEM_TITLE}>{event.title}</p>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
{[eventDates(event), eventPlace(event)].filter(Boolean).join(' · ')}
</p>
</a>
</li>
))}
</ul>
);
}

export function TessMaterials() {
const result = useTess(materials);
const [filter, setFilter] = useState('');

if (!result) return <Loading label="Loading training materials from TeSS" />;
if (!result.reachable) return <Unavailable href={browseLinks.materials} />;
if (result.items.length === 0) return <Empty icon={BookOpenIcon}>No training materials listed at the moment.</Empty>;

const term = filter.trim().toLowerCase();
const shown = result.items.filter(material => material.title.toLowerCase().includes(term));

return (
<>
<label htmlFor="material-filter" className="sr-only">Filter training materials</label>
<div className="relative">
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" aria-hidden="true" />
<input id="material-filter" type="search" autoComplete="off"
placeholder="Filter materials by title"
value={filter} onChange={e => setFilter(e.target.value)}
className="w-full rounded-lg border border-gray-200/60 dark:border-gray-700/30 bg-white dark:bg-white/[0.03] py-2 pl-9 pr-3 text-sm text-brand-grey dark:text-gray-200 placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent" />
</div>
<ul className={`mt-4 ${LIST}`}>
{shown.map(material => (
<li key={material.id} className={ITEM}>
<a href={materialLink(material)} target="_blank" rel="noopener noreferrer" className={ITEM_LINK}>
<p className={ITEM_TITLE}>{material.title}</p>
{material.description && (
<p className="mt-1 line-clamp-2 text-sm text-gray-600 dark:text-gray-400">{material.description}</p>
)}
</a>
</li>
))}
</ul>
<p aria-live="polite" className="text-center text-sm text-gray-500 dark:text-gray-400">
{shown.length === 0 && <span className="block py-10">No materials match that filter.</span>}
</p>
</>
);
}
21 changes: 0 additions & 21 deletions src/components/tess-unavailable.astro

This file was deleted.

46 changes: 31 additions & 15 deletions src/lib/tess.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
const API = "https://tess.elixir-europe.org";

const ATTEMPTS: number = 5;
const TIMEOUT_MS = 20_000;
// Sized for someone waiting on the page: one slow TESS response is tolerated,
// a dead one gives way to the "could not be loaded" state inside a minute.
const ATTEMPTS: number = 3;
const TIMEOUT_MS = 15_000;

export interface TessEvent {
id: number;
Expand Down Expand Up @@ -47,9 +49,10 @@ function query(params: Params): string {
return q.toString();
}

// Without an explicit JSON Accept header TESS serves an HTML 403 instead of the
// API response, which is what broke the browser widget this replaced: the error
// page carries no CORS headers, so the failure surfaced as a CORS violation.
// Without an explicit JSON Accept header TESS serves an HTML 403 with no CORS
// headers, which the browser reports as a CORS failure. Accept is also the only
// header sent, and that is deliberate: it keeps this a simple request, because
// TESS answers the preflight a custom header would trigger with a 403.
// TESS also returns 5xx intermittently, hence the retries.
async function fetchList<T>(path: string, params: Params): Promise<TessResult<T>> {
const url = `${API}/${path}?${query(params)}`;
Expand All @@ -72,7 +75,7 @@ async function fetchList<T>(path: string, params: Params): Promise<TessResult<T>
console.warn(`[tess] ${path} failed after ${tries} (${reason}); the section will say so`);
return { items: [], reachable: false };
}
await new Promise(r => setTimeout(r, attempt * 1500));
await new Promise(r => setTimeout(r, attempt * 1000));
}
}
return { items: [], reachable: false };
Expand All @@ -81,22 +84,35 @@ async function fetchList<T>(path: string, params: Params): Promise<TessResult<T>
export const upcomingEvents = (pageSize = 5) =>
fetchList<TessEvent>("events", { page_size: pageSize, country: ["Norway"] });

export const pastEvents = (pageSize = 10) =>
fetchList<TessEvent>("events", {
page_size: pageSize,
sort: "new",
const hasEnded = (event: TessEvent) => Date.parse(event.end ?? event.start ?? "") < Date.now();

// include_expired adds ended events to the upcoming ones rather than replacing
// them, so the ones still to come are filtered out here; fetching double keeps
// the list full once they are gone. "late" sorts by start date, newest first.
export async function pastEvents(pageSize = 10): Promise<TessResult<TessEvent>> {
const result = await fetchList<TessEvent>("events", {
page_size: pageSize * 2,
sort: "late",
country: ["Norway"],
include_expired: true,
include_disabled: false,
});
return { ...result, items: result.items.filter(hasEnded).slice(0, pageSize) };
}

export const materials = (pageSize = 10) =>
fetchList<TessMaterial>("materials", { page_size: pageSize, node: ["Norway"] });

// These URLs come from a feed we do not control and get baked into static HTML,
// so a bad one would stay on the page until the next build. Anything that is not
// http(s), a `javascript:` URL being the case that matters, is dropped for a
// TESS address we build ourselves.
/** The same listings on TESS itself, for "browse all" links and fallbacks. */
export const browseLinks = {
upcoming: `${API}/events?country[]=Norway`,
past: `${API}/events?country[]=Norway&include_expired=true`,
materials: `${API}/materials?node[]=Norway`,
} as const;

// These URLs come from a feed we do not control and go straight into an href.
// Anything that is not http(s), a `javascript:` URL being the case that matters,
// is dropped for a TESS address we build ourselves.
function safeUrl(raw: string | undefined, fallback: string): string {
if (!raw) return fallback;
try {
Expand All @@ -117,7 +133,7 @@ export function eventLink(event: TessEvent): string {

/** Link to the material on TESS, falling back to the Norwegian listing. */
export function materialLink(material: TessMaterial): string {
return safeUrl(material.url, `${API}/materials?node[]=Norway`);
return safeUrl(material.url, browseLinks.materials);
}

/** "12 Mar 2026", or a range when the event spans more than one day. */
Expand Down
Loading
Loading