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>
This commit is contained in:
71
src/components/Page.tsx
Normal file
71
src/components/Page.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user