Files
learning-lab-express/src/components/Page.tsx
Iqbal f219b26f71 Initial commit: Learning Lab Express
Includes fixes found while reviewing the app:
- Results: open each result by a type+agent+id key, so a video and a
  material draft with the same id no longer open each other's popup
- AuthGate: clear the query cache during render, before pages build their
  queries, and remount pages on organization switch (pages no longer stay
  on skeletons for up to a minute after sign-in or refresh)
- Instruction: keep only failed rules after a partial approve, so retrying
  does not create the successful schedules twice; drop the monthly note
  once count or frequency is edited
- Parser/schedules: never schedule a count below one
- Audio: ignore the end event of a cancelled utterance when changing speed
- Downloads: revoke the object URL later so Safari/Firefox can read it
- Results: refetch SCORM content when any part's revision changes; show a
  failed video as failed instead of "still rendering"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:42:12 +07:00

72 lines
2.5 KiB
TypeScript

import type { ReactNode } from 'react'
import { Icon, type IconName } from './Icon'
export function PageHead({ title, description, actions }: { title: string; description: string; actions?: ReactNode }) {
return (
<div className="page-head">
<div><h1>{title}</h1><p>{description}</p></div>
{actions && <div className="head-actions">{actions}</div>}
</div>
)
}
// Shimmer placeholders shaped like the content they stand in for, so a page keeps its layout while loading.
export function Skeleton({ w, h, className }: { w?: number | string; h?: number | string; className?: string }) {
return <span className={className ? `skel ${className}` : 'skel'} style={{ width: w, height: h }} aria-hidden="true" />
}
const LINE_WIDTHS = ['62%', '48%', '70%', '55%']
export function SkeletonRows({ count = 3, label }: { count?: number; label: string }) {
return (
<div className="result-list" role="status" aria-label={label}>
{Array.from({ length: count }, (_, i) => (
<div key={i} className="result-row skel-row">
<Skeleton className="skel-cover" />
<div className="result-main">
<Skeleton w={LINE_WIDTHS[i % LINE_WIDTHS.length]} h={14} />
<Skeleton w="34%" h={11} />
</div>
<Skeleton className="skel-pill" />
</div>
))}
</div>
)
}
export function SkeletonCards({ count = 4, label }: { count?: number; label: string }) {
return (
<div className="content-grid" role="status" aria-label={label}>
{Array.from({ length: count }, (_, i) => (
<div key={i} className="content-card">
<Skeleton className="skel-block" />
<div className="content-card-body skel-stack">
<Skeleton w="40%" h={20} className="skel-pill" />
<Skeleton w="90%" h={14} />
<Skeleton w={LINE_WIDTHS[i % LINE_WIDTHS.length]} h={14} />
<Skeleton w="50%" h={11} />
</div>
</div>
))}
</div>
)
}
export function SkeletonLines({ count = 2, label }: { count?: number; label: string }) {
return (
<div className="skel-stack" role="status" aria-label={label}>
{Array.from({ length: count }, (_, i) => <Skeleton key={i} w={LINE_WIDTHS[i % LINE_WIDTHS.length]} h={12} />)}
</div>
)
}
export function Empty({ icon = 'book', title, children }: { icon?: IconName; title: string; children?: ReactNode }) {
return (
<div className="empty">
<span className="empty-icon"><Icon name={icon} /></span>
<h3>{title}</h3>
{children && <p>{children}</p>}
</div>
)
}