Skip to content
Draft
Show file tree
Hide file tree
Changes from 8 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
60 changes: 31 additions & 29 deletions .github/scripts/get-shard-files.js
Original file line number Diff line number Diff line change
@@ -1,68 +1,70 @@
const fs = require('fs');
const path = require('path');
const fs = require('fs')
const path = require('path')

const shardIndex = parseInt(process.argv[2], 10);
const shardIndex = parseInt(process.argv[2], 10)
if (isNaN(shardIndex) || shardIndex < 1 || shardIndex > 5) {
console.error('Invalid shard index. Must be between 1 and 5.');
process.exit(1);
console.error('Invalid shard index. Must be between 1 and 5.')
process.exit(1)
}

const testsDir = path.join(__dirname, '../../playwright/tests');
const allFiles = fs.readdirSync(testsDir).filter(file => file.endsWith('.spec.ts'));
const testsDir = path.join(__dirname, '../../playwright/tests')
const allFiles = fs
.readdirSync(testsDir)
.filter((file) => file.endsWith('.spec.ts'))

// Load timings from playwright/timings.json
const timingsPath = path.join(__dirname, '../../playwright/timings.json');
let timings = { durations: [] };
const timingsPath = path.join(__dirname, '../../playwright/timings.json')
let timings = { durations: [] }
if (fs.existsSync(timingsPath)) {
try {
timings = JSON.parse(fs.readFileSync(timingsPath, 'utf8'));
timings = JSON.parse(fs.readFileSync(timingsPath, 'utf8'))
} catch (err) {
console.error('Error parsing timings.json:', err);
console.error('Error parsing timings.json:', err)
}
}

// Map of spec file to duration
const durationMap = new Map();
const durationMap = new Map()
for (const entry of timings.durations) {
// Normalize spec path to just the filename
const baseName = path.basename(entry.spec);
durationMap.set(baseName, entry.duration);
const baseName = path.basename(entry.spec)
durationMap.set(baseName, entry.duration)
}

// Map each file to its estimated/historical duration
const filesWithDuration = allFiles.map(file => {
const filesWithDuration = allFiles.map((file) => {
return {
file,
duration: durationMap.has(file) ? durationMap.get(file) : 30 // default to 30s for new tests
};
});
duration: durationMap.has(file) ? durationMap.get(file) : 30, // default to 30s for new tests
}
})

// Sort files by duration descending for the greedy bin-packing algorithm
filesWithDuration.sort((a, b) => b.duration - a.duration);
filesWithDuration.sort((a, b) => b.duration - a.duration)

// Initialize 5 shards
const numShards = 5;
const numShards = 5
const shards = Array.from({ length: numShards }, () => ({
files: [],
totalDuration: 0
}));
totalDuration: 0,
}))

// Distribute files using greedy bin-packing
for (const item of filesWithDuration) {
// Find the shard with the smallest total duration
let minShard = shards[0];
let minShard = shards[0]
for (let i = 1; i < numShards; i++) {
if (shards[i].totalDuration < minShard.totalDuration) {
minShard = shards[i];
minShard = shards[i]
}
}
minShard.files.push(item.file);
minShard.totalDuration += item.duration;
minShard.files.push(item.file)
minShard.totalDuration += item.duration
}

// Select the files for the requested shard
const targetFiles = shards[shardIndex - 1].files;
const targetFiles = shards[shardIndex - 1].files

// Map files to their relative path from the playwright package directory
const relativePaths = targetFiles.map(file => `tests/${file}`);
console.log(relativePaths.join(' '));
const relativePaths = targetFiles.map((file) => `tests/${file}`)
console.log(relativePaths.join(' '))
8 changes: 7 additions & 1 deletion docs/frontend-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
type: Frontend Conventions
title: Frontend Conventions
description: Shared conventions for manage, pwa, control, and auth — design system, Apollo with generated ops, i18n, Formik, data-cy, and CSP rules.
timestamp: '2026-07-07'
timestamp: '2026-07-08'
tags:
- frontend
---
Expand All @@ -20,6 +20,12 @@ Scope: `frontend-manage`, `frontend-pwa`, `frontend-control`, `auth` — all Nex
- **Shared components** (`packages/shared-components`): Loader, DataTable, question renderers, Leaderboard, charts, evaluation. **Deep-import** them (`@klicker-uzh/shared-components/src/Loader`) — there is no barrel index.
- Function components with hooks only; PascalCase files; app-local components under `src/components/` with relative imports.

## Markdown and Video Embeds

- **Link Interception for Videos**: Links in markdown fields (`@klicker-uzh/markdown`) with a label of `video` or `embed` (case-insensitive, trimmed) targeting YouTube or UZH Kaltura (SWITCHcast) are automatically intercepted and rendered as responsive `<iframe />` players via `VideoEmbed`.
- **Kaltura PlaykitJs Bypass**: To prevent frame-based third-party cookie login loops, Kaltura embeds resolve the video's `entryId` and parse `partnerId` (defaults to `106`) and `uiConfId` (defaults to `23449004`) to render using the PlaykitJs secure embed structure:
`https://api.cast.switch.ch/p/${partnerId}/embedPlaykitJs/uiconf_id/${uiConfId}/partner_id/${partnerId}?iframeembed=true&playerId=kaltura_player&entry_id=${entryId}`

## Data fetching

Apollo Client with **generated documents only** — `import { UserProfileDocument } from '@klicker-uzh/graphql/dist/ops'`; never inline `gql`. Standard query guard: `if (!data?.field) return <Loader />`. Mutations declare `refetchQueries`. New/changed ops require the codegen ritual ([API layer](./graphql-api-layer.md)). Server state lives in Apollo cache; local state in React hooks. The PWA additionally uses **localforage** as an offline side-channel for live-quiz answers (`apps/frontend-pwa/src/components/liveQuiz/storageHelpers.ts`).
Expand Down
4 changes: 4 additions & 0 deletions docs/log.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Log

## 2026-07-08

- **Update**: [frontend-conventions](./frontend-conventions.md) updated with Markdown link interception behavior and Kaltura PlaykitJs bypass player details.

## 2026-07-07

- **Update**: migration-in-flight banners added to [graphql-api-layer](./graphql-api-layer.md), [architecture-overview](./architecture-overview.md) (GraphQL→tRPC, PR #5132), and [chat-platform](./chat-platform.md) (AI-SDK→Mastra, PRs #5126/#5129) — pages stay authoritative until those PRs merge.
Expand Down
97 changes: 71 additions & 26 deletions packages/markdown/src/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ import remarkRehype from 'remark-rehype'
import { twMerge } from 'tailwind-merge'
import { unified } from 'unified'
import ImgWithModal from './ImgWithModal.js'
import {
VideoEmbed,
getKalturaId,
getKalturaPartnerId,
getKalturaUiConfId,
getYoutubeId,
} from './VideoEmbed.js'

export interface MarkdownProps {
className?: {
Expand Down Expand Up @@ -135,32 +142,70 @@ function Markdown({
withModal={withModal}
/>
),
a: withLinkButtons
? ({
href,
children,
}: {
href: string
children: React.ReactNode
}) => {
const isExcel = href.includes('.xls')
const isPDF = href.includes('.pdf')
return (
<a
className={twMerge(
'my-1 flex flex-row gap-3 rounded-sm border px-4 py-3 text-sm hover:bg-slate-200'
)}
href={href}
>
<div>
{isExcel && <FontAwesomeIcon icon={faFileExcel} />}
{isPDF && <FontAwesomeIcon icon={faFilePdf} />}
</div>
<div>{children}</div>
</a>
)
}
: 'a',
a: ({
href,
children,
target,
rel,
...rest
}: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
const labelText =
typeof children === 'string'
? children.trim().toLowerCase()
: ''
const isVideoLabel =
labelText === 'video' || labelText === 'embed'
const youtubeId =
href && isVideoLabel ? getYoutubeId(href) : null
const kalturaId =
href && isVideoLabel ? getKalturaId(href) : null
const kalturaPartnerId =
href && kalturaId ? getKalturaPartnerId(href) : null
const kalturaUiConfId =
href && kalturaId ? getKalturaUiConfId(href) : null

if (youtubeId) {
return <VideoEmbed provider="youtube" videoId={youtubeId} />
}
if (kalturaId) {
return (
<VideoEmbed
provider="kaltura"
videoId={kalturaId}
partnerId={kalturaPartnerId}
uiConfId={kalturaUiConfId}
/>
)
}

if (withLinkButtons) {
const isExcel = href?.includes('.xls')
const isPDF = href?.includes('.pdf')
return (
<a
className={twMerge(
'my-1 flex flex-row gap-3 rounded-sm border px-4 py-3 text-sm hover:bg-slate-200'
)}
href={href}
target={target}
rel={rel}
{...rest}
>
<div>
{isExcel && <FontAwesomeIcon icon={faFileExcel} />}
{isPDF && <FontAwesomeIcon icon={faFilePdf} />}
</div>
<div>{children}</div>
</a>
)
}

return (
<a href={href} target={target} rel={rel} {...rest}>
{children}
</a>
)
},
...components,
},
})
Expand Down
145 changes: 145 additions & 0 deletions packages/markdown/src/VideoEmbed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import React from 'react'

export function getYoutubeId(url: string): string | null {
try {
const parsed = new URL(url)
if (
parsed.hostname === 'youtube.com' ||
parsed.hostname.endsWith('.youtube.com') ||
parsed.hostname === 'youtu.be' ||
parsed.hostname.endsWith('.youtu.be')
) {
if (parsed.hostname.endsWith('youtu.be')) {
const id = parsed.pathname.slice(1)
if (/^[a-zA-Z0-9_-]{11}$/.test(id)) {
return id
}
}
if (parsed.pathname.startsWith('/embed/')) {
const id = parsed.pathname.split('/')[2]
if (id && /^[a-zA-Z0-9_-]{11}$/.test(id)) {
return id
}
}
const v = parsed.searchParams.get('v')
if (v && /^[a-zA-Z0-9_-]{11}$/.test(v)) {
return v
}
}
} catch {
// Ignore URL parse error for relative/broken URLs
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

const regExp =
/^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/
const match = url.match(regExp)
const id = match?.[2]
return id && /^[a-zA-Z0-9_-]{11}$/.test(id) ? id : null
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
}

export function getKalturaId(url: string): string | null {
try {
const parsedUrl = new URL(url)
const host = parsedUrl.hostname.toLowerCase()
const isKalturaHost =
host === 'kaltura.com' ||
host.endsWith('.kaltura.com') ||
host === 'cast.switch.ch' ||
host.endsWith('.cast.switch.ch')
if (!isKalturaHost) {
return null
}

const mediaSpaceMatch = url.match(
/\/media\/(?:t\/|[^/]+\/)?([01]_[a-zA-Z0-9]{8})(?:[/?#]|$)/
)
if (mediaSpaceMatch && mediaSpaceMatch[1]) {
return mediaSpaceMatch[1]
}

const entryIdPathMatch = url.match(
/\/entryId\/([01]_[a-zA-Z0-9]{8})(?:[/?#]|$)/i
)
if (entryIdPathMatch && entryIdPathMatch[1]) {
return entryIdPathMatch[1]
}

const entryId = parsedUrl.searchParams.get('entry_id')
if (entryId && /^[01]_[a-zA-Z0-9]{8}$/.test(entryId)) {
return entryId
}
} catch {
// Ignore URL parse error
}
return null
}

export function getKalturaUiConfId(url: string): string | null {
try {
const pathMatch = url.match(/\/uiConfId\/(\d+)/i)
if (pathMatch && pathMatch[1]) {
return pathMatch[1]
}
const parsedUrl = new URL(url)
const queryId = parsedUrl.searchParams.get('uiconf_id')
if (queryId && /^\d+$/.test(queryId)) {
return queryId
}
} catch {
// Ignore URL parse error
}
return null
}

export function getKalturaPartnerId(url: string): string | null {
try {
const pathMatch = url.match(/\/partner_id\/(\d+)/i)
if (pathMatch && pathMatch[1]) {
return pathMatch[1]
}
const parsedUrl = new URL(url)
const queryId = parsedUrl.searchParams.get('partner_id')
if (queryId && /^\d+$/.test(queryId)) {
return queryId
}
} catch {
// Ignore URL parse error
}
return null
}

interface VideoEmbedProps {
provider: 'youtube' | 'kaltura'
videoId: string
partnerId?: string | null
uiConfId?: string | null
}

export function VideoEmbed({
provider,
videoId,
partnerId,
uiConfId,
}: VideoEmbedProps): React.ReactElement {
const src =
provider === 'youtube'
? `https://www.youtube.com/embed/${videoId}`
: `https://api.cast.switch.ch/p/${partnerId || '106'}/embedPlaykitJs/uiconf_id/${uiConfId || '23449004'}/partner_id/${partnerId || '106'}?iframeembed=true&playerId=kaltura_player&entry_id=${videoId}`

return (
<div className="my-4 aspect-video w-full overflow-hidden rounded-md border border-slate-200">
<iframe
title={
provider === 'youtube'
? 'YouTube video player'
: 'Kaltura video player'
}
src={src}
className="h-full w-full border-0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
loading="lazy"
/>
</div>
)
}
Loading