feat: enhance API and middleware functionality: add public API routes, implement program and course member checks, and improve rate limiting for invite acceptance. Update OpenAPI spec generation to focus on public API endpoints and add new middleware for automation key scopes. Include new documentation for lesson AI features and public API tests.

This commit is contained in:
Rotimi Best
2026-04-12 08:58:41 +01:00
parent 5a8f50f67b
commit d6e812d08b
573 changed files with 31964 additions and 8708 deletions
@@ -0,0 +1,176 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import {
BlurFade,
BlurIn,
BorderBeam,
CardBody,
CardContainer,
DotPattern,
GradualSpacing,
HeroVideoDialog,
MagicCard,
Meteors,
ShimmerButton,
ShinnyText,
Sparkle,
SquareCardBody,
SquareCardIcon
} from '@cio/ui/custom/animation';
const { Story } = defineMeta({
title: 'Molecules/Animation',
parameters: {
layout: 'fullscreen'
},
tags: ['autodocs']
});
</script>
<Story name="BorderBeam">
{#snippet template()}
<div
class="ui:relative ui:mx-auto ui:flex ui:h-48 ui:w-80 ui:items-center ui:justify-center ui:rounded-xl ui:border ui:bg-muted"
>
<BorderBeam />
<span class="ui:relative ui:z-10 ui:text-sm ui:font-medium">Border beam</span>
</div>
{/snippet}
</Story>
<Story name="DotPattern">
{#snippet template()}
<div class="ui:relative ui:mx-auto ui:h-48 ui:w-80 ui:overflow-hidden ui:rounded-xl ui:border ui:bg-background">
<DotPattern />
<div class="ui:relative ui:z-10 ui:flex ui:h-full ui:items-center ui:justify-center ui:text-sm">Dot pattern</div>
</div>
{/snippet}
</Story>
<Story name="CardContainer">
{#snippet template()}
<CardContainer className="ui:max-w-lg">
{#snippet children()}
<CardBody>
{#snippet children()}
<p class="ui:text-center ui:text-sm">Card container with body</p>
{/snippet}
</CardBody>
{/snippet}
</CardContainer>
{/snippet}
</Story>
<Story name="BlurFade">
{#snippet template()}
<div class="ui:p-8">
<BlurFade>
{#snippet children()}
<p class="ui:text-lg">Scroll into view (blur fade)</p>
{/snippet}
</BlurFade>
</div>
{/snippet}
</Story>
<Story name="BlurIn">
{#snippet template()}
<div class="ui:p-8 ui:text-center">
<BlurIn>
{#snippet children()}
Blur in heading
{/snippet}
</BlurIn>
</div>
{/snippet}
</Story>
<Story name="Meteors">
{#snippet template()}
<div
class="ui:relative ui:mx-auto ui:min-h-64 ui:w-full ui:max-w-xl ui:overflow-visible ui:rounded-xl ui:border ui:bg-slate-950 ui:p-4"
>
<Meteors number={12} />
<p class="ui:relative ui:z-10 ui:p-6 ui:text-center ui:text-sm ui:text-slate-300">
Meteors (absolute layer behind this copy)
</p>
</div>
{/snippet}
</Story>
<Story name="ShimmerButton">
{#snippet template()}
<div class="ui:flex ui:justify-center ui:bg-neutral-950 ui:p-8">
<ShimmerButton href="https://example.com" target="_blank" rel="noreferrer">
{#snippet children()}
Shimmer CTA
{/snippet}
</ShimmerButton>
</div>
{/snippet}
</Story>
<Story name="Sparkle">
{#snippet template()}
<div class="ui:flex ui:justify-center ui:bg-background ui:p-12">
<Sparkle text="Sparkle text" sparklesCount={6} />
</div>
{/snippet}
</Story>
<Story name="HeroVideoDialog">
{#snippet template()}
<div class="ui:mx-auto ui:max-w-2xl ui:p-8">
<HeroVideoDialog
thumbnailSrc="https://images.unsplash.com/photo-1492691527719-9d1e07e534b4?w=1200&q=80"
videoSrc="https://www.youtube.com/embed/dQw4w9WgXcQ"
thumbnailAlt="Sample"
/>
</div>
{/snippet}
</Story>
<Story name="ShinnyText">
{#snippet template()}
<div class="ui:flex ui:justify-center ui:bg-background ui:p-12">
<ShinnyText shimmerWidth={120}>
{#snippet children()}
Shiny marketing copy
{/snippet}
</ShinnyText>
</div>
{/snippet}
</Story>
<Story name="MagicCard">
{#snippet template()}
<div class="ui:mx-auto ui:h-40 ui:max-w-sm ui:p-4">
<MagicCard>
{#snippet children()}
<p class="ui:text-center ui:text-sm">Hover for gradient</p>
{/snippet}
</MagicCard>
</div>
{/snippet}
</Story>
<Story name="GradualSpacing">
{#snippet template()}
<div class="ui:bg-background ui:p-12">
<GradualSpacing words="Hello" />
</div>
{/snippet}
</Story>
<Story name="SquareCard">
{#snippet template()}
<div class="ui:relative ui:mx-auto ui:w-64 ui:rounded-lg ui:border ui:p-6">
<SquareCardIcon class="ui:right-2 ui:top-2" />
<SquareCardBody>
{#snippet children()}
<p class="ui:text-sm">Square card body with icon</p>
{/snippet}
</SquareCardBody>
</div>
{/snippet}
</Story>
@@ -13,8 +13,10 @@
ORDERING_FIXTURE,
RADIO_FIXTURE,
SHORT_ANSWER_FIXTURE,
STAR_FIXTURE,
TEXTAREA_FIXTURE,
TRUE_FALSE_FIXTURE
TRUE_FALSE_FIXTURE,
WORD_BANK_FIXTURE
} from './question-fixtures';
const { Story } = defineMeta({
@@ -57,6 +59,16 @@
{/snippet}
</Story>
<Story name="WordBank">
{#snippet template()}
<QuestionTypeModes
question={WORD_BANK_FIXTURE.question}
answer={WORD_BANK_FIXTURE.answer}
wrongAnswer={WORD_BANK_FIXTURE.wrongAnswer}
/>
{/snippet}
</Story>
<Story
name="Hotspot"
parameters={{
@@ -127,6 +139,25 @@
{/snippet}
</Story>
<Story
name="Star"
parameters={{
docs: {
description: {
story: '**Premium in the app** – Gated behind a paid plan in the exercise editor.'
}
}
}}
>
{#snippet template()}
<QuestionTypeModes
question={STAR_FIXTURE.question}
answer={STAR_FIXTURE.answer}
wrongAnswer={STAR_FIXTURE.wrongAnswer}
/>
{/snippet}
</Story>
<Story name="Ordering">
{#snippet template()}
<QuestionTypeModes
@@ -131,6 +131,18 @@ export const NUMERIC_FIXTURE: QuestionStoryFixture = {
wrongAnswer: { type: 'NUMERIC', value: 42 }
};
export const STAR_FIXTURE: QuestionStoryFixture = {
question: {
id: 'q-star',
key: 'q-star',
title: 'How clear were the lesson objectives? [STAR]',
questionType: 'STAR',
settings: { correctValue: 4, maxStars: 5 }
},
answer: { type: 'STAR', value: 4 },
wrongAnswer: { type: 'STAR', value: 2 }
};
export const FILL_BLANK_FIXTURE: QuestionStoryFixture = {
question: {
id: 'q-fill-blank',
@@ -143,6 +155,29 @@ export const FILL_BLANK_FIXTURE: QuestionStoryFixture = {
wrongAnswer: { type: 'FILL_BLANK', values: ['SOAP'] }
};
export const WORD_BANK_FIXTURE: QuestionStoryFixture = {
question: {
id: 'q-word-bank',
key: 'q-word-bank',
title: 'Match each term with the sentence. [WORD_BANK]',
questionType: 'WORD_BANK',
settings: {
template:
'Measurements to describe our data are called ___. The ___ shows values and how often they occur. Outliers appear when data is skewed or has ___.',
correctAnswers: ['summary statistics', 'distribution', 'outliers'],
distractors: ['categorical variables', 'linear relationships', 'numeric variables']
}
},
answer: {
type: 'WORD_BANK',
filledBlanks: ['summary statistics', 'distribution', 'outliers']
},
wrongAnswer: {
type: 'WORD_BANK',
filledBlanks: ['numeric variables', 'distribution', 'linear relationships']
}
};
export const FILE_UPLOAD_FIXTURE: QuestionStoryFixture = {
question: {
id: 'q-file-upload',
@@ -66,10 +66,35 @@ export const QUESTION_LABELS = {
'numeric.review.your_answer_label': 'Submitted:',
'numeric.review.in_range': 'Within tolerance',
'numeric.review.out_of_range': 'Outside tolerance',
'star.edit.correct_value_label': 'Correct star value',
'star.edit.correct_value_info': 'Expected star count for full credit.',
'star.edit.max_stars_label': 'Maximum stars',
'star.edit.max_stars_info': 'Stars shown to learners (1–10).',
'star.edit.correct_value_placeholder': 'e.g. 3',
'star.edit.max_stars_placeholder': 'e.g. 5',
'star.take.helper': 'Select your rating.',
'star.take.star_sr': 'Star {n} of {max}',
'star.preview.correct_label': 'Correct answer',
'fill_blank.edit.accepted_answers_placeholder': 'Accepted answers (comma separated)',
'fill_blank.take.placeholder': 'Enter answers separated by commas',
'fill_blank.preview.accepted_answers_label': 'Accepted answers',
'fill_blank.review.your_answer_label': 'Submitted:',
'word_bank.edit.template_label': 'Sentence template',
'word_bank.edit.template_placeholder': 'Use ___ for each blank...',
'word_bank.edit.template_helper': 'Use three underscores (___) for each blank.',
'word_bank.edit.no_blanks_warning': 'Add at least one ___ blank marker in your template.',
'word_bank.edit.correct_answers_heading': 'Correct answer for each blank',
'word_bank.edit.blank_label': 'Blank',
'word_bank.edit.correct_placeholder': 'Correct term or phrase',
'word_bank.edit.distractors_heading': 'Extra words (distractors)',
'word_bank.edit.distractor_placeholder': 'Wrong option',
'word_bank.edit.add_distractor': 'Add distractor',
'word_bank.take.helper': 'Click or drag words into blanks.',
'word_bank.take.bank_heading': 'Word bank',
'word_bank.take.clear_blank_sr': 'Remove word from blank',
'word_bank.preview.filled_label': 'Expected answers in context',
'word_bank.review.your_answer_label': 'Your answers',
'word_bank.review.correct_label': 'Correct answers',
'file_upload.edit.accepted_types_placeholder': 'Select accepted file formats',
'file_upload.edit.max_size_placeholder': 'Max size (MB)',
'file_upload.take.selected_file_label': 'Selected file',
@@ -63,11 +63,45 @@
return { type: 'SHORT_ANSWER', text: 'Use deterministic fixtures' };
case 'NUMERIC':
return { type: 'NUMERIC', value: 88 + (index % 6) };
case 'STAR': {
const rawMax = storyQuestion.settings?.maxStars;
const maxStars =
typeof rawMax === 'number' && Number.isFinite(rawMax) ? Math.min(10, Math.max(1, Math.floor(rawMax))) : 5;
const rawCorrect = storyQuestion.settings?.correctValue;
const correct =
typeof rawCorrect === 'number' && Number.isFinite(rawCorrect)
? Math.min(maxStars, Math.max(1, Math.floor(rawCorrect)))
: Math.min(3, maxStars);
if (index % 4 === 0) return { type: 'STAR', value: correct };
return { type: 'STAR', value: (index % maxStars) + 1 };
}
case 'FILL_BLANK':
return {
type: 'FILL_BLANK',
values: index % 2 === 0 ? ['API'] : ['endpoint']
};
case 'WORD_BANK': {
const correct = Array.isArray(storyQuestion.settings?.correctAnswers)
? (storyQuestion.settings.correctAnswers as string[]).map((s) => String(s ?? ''))
: ['alpha', 'beta'];
const blankCount = Math.max(1, correct.length);
if (index % 4 === 0) {
return { type: 'WORD_BANK', filledBlanks: correct.slice(0, blankCount) };
}
if (index % 4 === 1) {
const wrongFirst = ['wrong term', ...correct.slice(1)].slice(0, blankCount);
return { type: 'WORD_BANK', filledBlanks: wrongFirst };
}
if (index % 4 === 2 && blankCount > 1) {
const swapped = [...correct.slice(0, blankCount)];
[swapped[0], swapped[1]] = [swapped[1], swapped[0]];
return { type: 'WORD_BANK', filledBlanks: swapped };
}
return {
type: 'WORD_BANK',
filledBlanks: Array.from({ length: blankCount }, () => '')
};
}
case 'FILE_UPLOAD':
return {
type: 'FILE_UPLOAD',
@@ -0,0 +1,112 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import {
NewsfeedReactions,
type NewsfeedReactionCounts,
type NewsfeedReactionType
} from '@cio/ui/custom/newsfeed-reactions';
const { Story } = defineMeta({
title: 'Molecules/NewsfeedReactions',
component: NewsfeedReactions,
parameters: {
layout: 'centered'
},
tags: ['autodocs']
});
</script>
<script lang="ts">
let summaryReactionCounts = $state<NewsfeedReactionCounts>({
clap: 12,
smile: 4,
thumbsup: 8,
thumbsdown: 1
});
let summarySelectedReaction = $state<NewsfeedReactionType | null>('smile');
let openReactionCounts = $state<NewsfeedReactionCounts>({
clap: 6,
smile: 3,
thumbsup: 5,
thumbsdown: 2
});
let openSelectedReaction = $state<NewsfeedReactionType | null>('clap');
let openPopover = $state(true);
let interactiveReactionCounts = $state<NewsfeedReactionCounts>({
clap: 2,
smile: 7,
thumbsup: 1
});
let interactiveSelectedReaction = $state<NewsfeedReactionType | null>('smile');
let interactivePopover = $state(false);
function updateCounts(
reactionCounts: NewsfeedReactionCounts,
nextReactionType: NewsfeedReactionType,
currentReactionType: NewsfeedReactionType | null
) {
const nextCounts: NewsfeedReactionCounts = {
clap: reactionCounts.clap ?? 0,
smile: reactionCounts.smile ?? 0,
thumbsup: reactionCounts.thumbsup ?? 0,
thumbsdown: reactionCounts.thumbsdown ?? 0
};
if (currentReactionType === nextReactionType) {
nextCounts[nextReactionType] = Math.max(0, (nextCounts[nextReactionType] ?? 0) - 1);
return nextCounts;
}
if (currentReactionType) {
nextCounts[currentReactionType] = Math.max(0, (nextCounts[currentReactionType] ?? 0) - 1);
}
nextCounts[nextReactionType] = (nextCounts[nextReactionType] ?? 0) + 1;
return nextCounts;
}
</script>
<Story name="Summary">
{#snippet template()}
<div class="ui:w-[360px] ui:rounded-2xl ui:border ui:bg-background ui:p-4">
<NewsfeedReactions reactionCounts={summaryReactionCounts} selectedReactionType={summarySelectedReaction} />
</div>
{/snippet}
</Story>
<Story name="Popover Open">
{#snippet template()}
<div class="ui:w-[360px] ui:rounded-2xl ui:border ui:bg-background ui:p-4">
<NewsfeedReactions
bind:open={openPopover}
reactionCounts={openReactionCounts}
selectedReactionType={openSelectedReaction}
/>
</div>
{/snippet}
</Story>
<Story name="Interactive">
{#snippet template()}
<div class="ui:w-[360px] ui:rounded-2xl ui:border ui:bg-background ui:p-4">
<NewsfeedReactions
bind:open={interactivePopover}
reactionCounts={interactiveReactionCounts}
selectedReactionType={interactiveSelectedReaction}
onReactionToggle={(reactionType) => {
interactiveReactionCounts = updateCounts(
interactiveReactionCounts,
reactionType,
interactiveSelectedReaction
);
interactiveSelectedReaction = interactiveSelectedReaction === reactionType ? null : reactionType;
}}
/>
<p class="ui:mt-3 ui:text-sm ui:text-muted-foreground">
Click the trigger, pick a reaction, then click the active reaction again to clear it.
</p>
</div>
{/snippet}
</Story>
@@ -0,0 +1,27 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import { QuestionTypePicker } from '@cio/ui';
const { Story } = defineMeta({
title: 'Molecules/QuestionTypePicker',
component: QuestionTypePicker,
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Marketing-style question type picker with a left nav and live take-mode previews. Also published as a CDN embed (`apps/embeds`).'
}
}
},
tags: ['autodocs']
});
</script>
<Story name="Default">
{#snippet template()}
<div class="ui:max-w-5xl">
<QuestionTypePicker />
</div>
{/snippet}
</Story>
@@ -0,0 +1,2 @@
/** Controls surfaced in Storybook for `ResourceListRow.Root`. */
export const FIELDS = ['variant', 'size', 'align', 'density', 'class'] as string[];
@@ -0,0 +1,449 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import * as ResourceListRow from '@cio/ui/custom/resource-list-row';
import * as Item from '@cio/ui/base/item';
import * as Avatar from '@cio/ui/base/avatar';
import * as Checkbox from '@cio/ui/base/checkbox';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import { Badge } from '@cio/ui/base/badge';
import { Button } from '@cio/ui/base/button';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import ListChecksIcon from '@lucide/svelte/icons/list-checks';
import ClockIcon from '@lucide/svelte/icons/clock';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import GitBranchIcon from '@lucide/svelte/icons/git-branch';
import GitCommitHorizontalIcon from '@lucide/svelte/icons/git-commit-horizontal';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import UserIcon from '@lucide/svelte/icons/user';
import { FIELDS } from './fields';
/** Storybook-only sample data for the courses-style list row. */
const sampleCourses = [
{
title: 'Introduction to Python',
type: 'Self-paced',
lastUpdated: 'Last updated Mar 28, 2026',
tags: ['Beginner', 'Programming'],
lessons: 24,
exercises: 12,
students: 128,
published: true
},
{
title: 'Advanced TypeScript',
type: 'Live',
lastUpdated: 'Last updated Mar 26, 2026',
tags: ['TypeScript', 'Advanced'],
lessons: 18,
exercises: 9,
students: 42,
published: true
},
{
title: 'Draft: Data visualization',
type: 'Self-paced',
lastUpdated: 'Last updated Mar 20, 2026',
tags: [],
lessons: 6,
exercises: 2,
students: 0,
published: false
}
];
const { Story } = defineMeta({
title: 'Molecules/ResourceListRow',
component: ResourceListRow.Root,
args: {
variant: 'default',
size: 'sm',
align: 'start',
density: 'default',
class: ''
},
argTypes: {
variant: {
control: 'select',
options: ['default', 'outline', 'muted', 'muted-border'],
description: 'Passed through to `Item.Root` (border / background).'
},
size: {
control: 'select',
options: ['default', 'sm'],
description: 'Item padding and gap (`Item` size variant).'
},
align: {
control: 'select',
options: ['center', 'start'],
description: 'Vertical alignment of row sections (e.g. start for multi-line cells).'
},
density: {
control: 'select',
options: ['default', 'toolbar'],
description: '`toolbar` tightens padding for a “select all” style header; otherwise use default.'
},
class: {
control: 'text',
description: 'Extra classes merged onto the row (Tailwind / `ui:`).'
},
child: { control: false },
children: { control: false },
ref: { control: false }
},
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
</script>
<Story name="Default">
{#snippet template()}
{@const studentAvatarSrc = [
'https://github.com/shadcn.png',
'https://github.com/maxleiter.png',
'https://github.com/evilrabbit.png'
]}
<ResourceListRow.Group class="w-3xl">
{#each sampleCourses as course (course.title)}
<ResourceListRow.Root variant="default" align="start" class="ui:cursor-pointer ui:py-3">
<div
class="ui:grid ui:w-full ui:items-start ui:gap-3"
style="grid-template-columns: 2fr 5.5rem 2fr 4.5rem 6rem 2.5rem"
>
<!-- Title -->
<div class="ui:flex ui:min-w-0 ui:flex-col ui:gap-0.5">
<p class="ui:line-clamp-1 ui:font-semibold ui:text-sm">{course.title}</p>
<p class="ui:text-muted-foreground ui:mt-0.5 ui:text-sm">{course.type}</p>
<p class="ui:text-muted-foreground ui:mt-0.5 ui:text-xs">{course.lastUpdated}</p>
</div>
<!-- Published -->
<div>
{#if course.published}
<Badge variant="success" class="ui:whitespace-nowrap">Published</Badge>
{:else}
<Badge variant="secondary" class="ui:whitespace-nowrap">Draft</Badge>
{/if}
</div>
<!-- Tags -->
<div class="ui:flex ui:min-w-0 ui:flex-wrap ui:items-center ui:gap-1">
{#if course.tags.length === 0}
<span class="ui:text-muted-foreground ui:text-xs">—</span>
{:else}
{#each course.tags as tag (tag)}
<Badge variant="outline" class="ui:max-w-[140px] ui:truncate">{tag}</Badge>
{/each}
{/if}
</div>
<!-- Lessons / exercises -->
<div class="ui:flex ui:flex-col ui:gap-1 ui:tabular-nums">
<p class="ui:flex ui:items-center ui:gap-1.5 ui:text-sm" aria-label="{course.lessons} lessons">
<BookOpenIcon class="ui:text-muted-foreground ui:size-3.5 ui:shrink-0" aria-hidden="true" />
<span class="ui:font-medium">{course.lessons}</span>
</p>
<p class="ui:flex ui:items-center ui:gap-1.5 ui:text-sm" aria-label="{course.exercises} exercises">
<ListChecksIcon class="ui:text-muted-foreground ui:size-3.5 ui:shrink-0" aria-hidden="true" />
<span class="ui:font-medium">{course.exercises}</span>
</p>
</div>
<!-- Students -->
<div class="flex items-center gap-1">
{#if course.students > 0}
<div class="flex -space-x-2">
{#each studentAvatarSrc as src, index (src)}
{#if index < Math.min(2, course.students)}
<Avatar.Root class="ui:size-6 ui:border-2 ui:border-background">
<Avatar.Image {src} alt="" />
<Avatar.Fallback>?</Avatar.Fallback>
</Avatar.Root>
{/if}
{/each}
{#if course.students > 2}
<Avatar.Root class="ui:size-6 ui:border-2 ui:border-background">
<Avatar.Fallback class="ui:text-[10px]">+{course.students - 2}</Avatar.Fallback>
</Avatar.Root>
{/if}
</div>
{:else}
<Avatar.Root class="ui:size-6">
<Avatar.Fallback>
<UserIcon class="ui:size-3 ui:text-muted-foreground" />
</Avatar.Fallback>
</Avatar.Root>
<p class="ui:text-xs">0</p>
{/if}
</div>
<!-- Actions -->
<div class="ui:flex ui:justify-end">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="ghost"
size="icon"
class="ui:size-8"
aria-label="Course actions"
onclick={(e) => e.stopPropagation()}
>
<EllipsisVerticalIcon class="ui:size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item>Open</DropdownMenu.Item>
<DropdownMenu.Item>Clone</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item variant="destructive">Delete</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</div>
</ResourceListRow.Root>
{/each}
</ResourceListRow.Group>
{/snippet}
</Story>
<Story name="DomainStyle">
{#snippet template()}
<ResourceListRow.Group class="w-3xl!">
<ResourceListRow.Root density="toolbar">
<ResourceListRow.Lead>
<Checkbox.Root aria-label="Select all domains" />
</ResourceListRow.Lead>
<ResourceListRow.Main>
<span class="ui:text-sm ui:font-semibold">Select all</span>
</ResourceListRow.Main>
<Item.Actions>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="ui:size-8" aria-label="Bulk actions">
<MoreHorizontalIcon class="ui:size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item>Export</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Item.Actions>
</ResourceListRow.Root>
<ResourceListRow.Root>
<ResourceListRow.Lead>
<Checkbox.Root aria-label="Select fastlearner.io" />
</ResourceListRow.Lead>
<ResourceListRow.Main>
<Item.Title class="ui:font-semibold">fastlearner.io</Item.Title>
<p class="ui:flex ui:items-center ui:gap-1.5 ui:text-sm ui:text-amber-800 dark:ui:text-amber-200">
<ClockIcon class="ui:size-3.5 ui:shrink-0" />
Expires 16 Jan 2027
</p>
</ResourceListRow.Main>
<ResourceListRow.End>
<span class="ui:text-muted-foreground ui:text-sm">Jan 16</span>
<Avatar.Root class="ui:size-8">
<Avatar.Image src="https://github.com/shadcn.png" alt="" />
<Avatar.Fallback>CN</Avatar.Fallback>
</Avatar.Root>
</ResourceListRow.End>
<Item.Actions>
<Button variant="ghost" size="icon" class="ui:size-8" aria-label="Row actions">
<MoreHorizontalIcon class="ui:size-4" />
</Button>
</Item.Actions>
</ResourceListRow.Root>
<ResourceListRow.Root>
<ResourceListRow.Lead>
<Checkbox.Root aria-label="Select macusaone.com" />
</ResourceListRow.Lead>
<ResourceListRow.Main>
<Item.Title class="ui:font-semibold">macusaone.com</Item.Title>
<p class="ui:text-muted-foreground ui:text-sm">Third Party</p>
</ResourceListRow.Main>
<ResourceListRow.End>
<span class="ui:text-muted-foreground ui:text-sm">11/20/25</span>
<Avatar.Root class="ui:size-8">
<Avatar.Image src="https://github.com/maxleiter.png" alt="" />
<Avatar.Fallback>ML</Avatar.Fallback>
</Avatar.Root>
</ResourceListRow.End>
<Item.Actions>
<Button variant="ghost" size="icon" class="ui:size-8" aria-label="Row actions">
<MoreHorizontalIcon class="ui:size-4" />
</Button>
</Item.Actions>
</ResourceListRow.Root>
</ResourceListRow.Group>
{/snippet}
</Story>
<Story name="WithoutSelection">
{#snippet template()}
<ResourceListRow.Group class="w-3xl!">
<ResourceListRow.Root>
<ResourceListRow.Main>
<Item.Title class="ui:font-semibold">fastlearner.io</Item.Title>
<p class="ui:flex ui:items-center ui:gap-1.5 ui:text-sm ui:text-amber-800 dark:ui:text-amber-200">
<ClockIcon class="ui:size-3.5 ui:shrink-0" />
Expires 16 Jan 2027
</p>
</ResourceListRow.Main>
<ResourceListRow.End>
<span class="ui:text-muted-foreground ui:text-sm">Jan 16</span>
<Avatar.Root class="ui:size-8">
<Avatar.Image src="https://github.com/shadcn.png" alt="" />
<Avatar.Fallback>CN</Avatar.Fallback>
</Avatar.Root>
</ResourceListRow.End>
<Item.Actions>
<Button variant="ghost" size="icon" class="ui:size-8" aria-label="Row actions">
<MoreHorizontalIcon class="ui:size-4" />
</Button>
</Item.Actions>
</ResourceListRow.Root>
<ResourceListRow.Root>
<ResourceListRow.Main>
<Item.Title class="ui:font-semibold">macusaone.com</Item.Title>
<p class="ui:text-muted-foreground ui:text-sm">Third Party</p>
</ResourceListRow.Main>
<ResourceListRow.End>
<span class="ui:text-muted-foreground ui:text-sm">11/20/25</span>
<Avatar.Root class="ui:size-8">
<Avatar.Image src="https://github.com/maxleiter.png" alt="" />
<Avatar.Fallback>ML</Avatar.Fallback>
</Avatar.Root>
</ResourceListRow.End>
<Item.Actions>
<Button variant="ghost" size="icon" class="ui:size-8" aria-label="Row actions">
<MoreHorizontalIcon class="ui:size-4" />
</Button>
</Item.Actions>
</ResourceListRow.Root>
</ResourceListRow.Group>
{/snippet}
</Story>
<Story name="Density">
{#snippet template()}
<div class="ui:text-muted-foreground ui:mb-3 ui:w-full ui:max-w-md ui:text-xs">
Use rows inside <code>ResourceListGroup</code>. Optional <code>density=&quot;toolbar&quot;</code> shortens the row
for a “select all” header.
</div>
<ResourceListRow.Group class="max-w-md">
<ResourceListRow.Root density="default">
<ResourceListRow.Main>
<span class="ui:text-sm ui:font-medium">density=&quot;default&quot;</span>
</ResourceListRow.Main>
</ResourceListRow.Root>
<ResourceListRow.Root density="toolbar">
<ResourceListRow.Main>
<span class="ui:text-sm ui:font-medium">density=&quot;toolbar&quot;</span>
</ResourceListRow.Main>
</ResourceListRow.Root>
</ResourceListRow.Group>
{/snippet}
</Story>
<Story name="DeploymentStyle">
{#snippet template()}
<ResourceListRow.Group class="w-5xl">
<ResourceListRow.Root align="start" class="ui:py-3">
<ResourceListRow.Main class="ui:max-w-[140px]">
<span class="ui:font-mono ui:text-sm ui:font-semibold">2CSQFANmQ</span>
<button
type="button"
class="ui:text-primary ui:w-fit ui:text-left ui:text-xs ui:underline ui:underline-offset-2"
>
Preview
</button>
</ResourceListRow.Main>
<div class="ui:flex ui:min-w-0 ui:shrink-0 ui:items-center ui:gap-2 ui:px-2">
<span class="ui:bg-destructive ui:size-2 ui:shrink-0 ui:rounded-full" aria-hidden="true"></span>
<div class="ui:flex ui:min-w-0 ui:flex-col ui:gap-0.5">
<span class="ui:text-sm ui:font-medium">Error</span>
<span class="ui:text-muted-foreground ui:text-xs">3s</span>
</div>
</div>
<div class="ui:flex ui:shrink-0 ui:items-center ui:gap-2 ui:px-2">
<span class="ui:flex ui:size-6 ui:items-center ui:justify-center ui:rounded-full ui:bg-blue-600/15">
<span class="ui:size-2 ui:rounded-full ui:bg-blue-600"></span>
</span>
<span class="ui:text-sm">cio-com</span>
</div>
<div class="ui:min-w-0 ui:flex-1 ui:px-2">
<p class="ui:flex ui:items-center ui:gap-1.5 ui:text-sm">
<GitBranchIcon class="ui:text-muted-foreground ui:size-3.5 ui:shrink-0" />
<span class="ui:truncate">release-v2/debug-course</span>
</p>
<p class="ui:text-muted-foreground ui:flex ui:items-center ui:gap-1.5 ui:truncate ui:text-xs">
<GitCommitHorizontalIcon class="ui:size-3.5 ui:shrink-0" />
<span class="ui:truncate">b00bf14 fix: move org id into the inv…</span>
</p>
</div>
<ResourceListRow.End class="ui:flex-col ui:items-end ui:gap-1 ui:self-start">
<span class="ui:text-muted-foreground ui:text-xs">Mar 30</span>
<span class="ui:text-muted-foreground ui:text-xs">by Chifez</span>
<Avatar.Root class="ui:size-6">
<Avatar.Image src="https://github.com/shadcn.png" alt="" />
<Avatar.Fallback>C</Avatar.Fallback>
</Avatar.Root>
</ResourceListRow.End>
</ResourceListRow.Root>
<ResourceListRow.Root align="start" class="ui:py-3">
<ResourceListRow.Main class="ui:max-w-[140px]">
<span class="ui:font-mono ui:text-sm ui:font-semibold">AiS1LEGci</span>
<button
type="button"
class="ui:text-primary ui:w-fit ui:text-left ui:text-xs ui:underline ui:underline-offset-2"
>
Preview
</button>
</ResourceListRow.Main>
<div class="ui:flex ui:min-w-0 ui:shrink-0 ui:items-center ui:gap-2 ui:px-2">
<span class="ui:bg-destructive ui:size-2 ui:shrink-0 ui:rounded-full" aria-hidden="true"></span>
<div class="ui:flex ui:min-w-0 ui:flex-col ui:gap-0.5">
<span class="ui:text-sm ui:font-medium">Error</span>
<span class="ui:text-muted-foreground ui:text-xs">8s</span>
</div>
</div>
<div class="ui:flex ui:shrink-0 ui:items-center ui:gap-2 ui:px-2">
<span class="ui:flex ui:size-6 ui:items-center ui:justify-center ui:rounded-full ui:bg-blue-600/15">
<span class="ui:size-2 ui:rounded-full ui:bg-blue-600"></span>
</span>
<span class="ui:text-sm">cio-docs</span>
</div>
<div class="ui:min-w-0 ui:flex-1 ui:px-2">
<p class="ui:flex ui:items-center ui:gap-1.5 ui:text-sm">
<GitBranchIcon class="ui:text-muted-foreground ui:size-3.5 ui:shrink-0" />
<span class="ui:truncate">feat/release-v2</span>
</p>
<p class="ui:text-muted-foreground ui:flex ui:items-center ui:gap-1.5 ui:truncate ui:text-xs">
<GitCommitHorizontalIcon class="ui:size-3.5 ui:shrink-0" />
<span class="ui:truncate">a1b2c3d docs: update API reference</span>
</p>
</div>
<ResourceListRow.End class="ui:flex-col ui:items-end ui:gap-1 ui:self-start">
<span class="ui:text-muted-foreground ui:text-xs">Mar 29</span>
<span class="ui:text-muted-foreground ui:text-xs">by rotimi-best</span>
<Avatar.Root class="ui:size-6">
<Avatar.Image src="https://github.com/maxleiter.png" alt="" />
<Avatar.Fallback>R</Avatar.Fallback>
</Avatar.Root>
</ResourceListRow.End>
</ResourceListRow.Root>
</ResourceListRow.Group>
{/snippet}
</Story>