feat: certification rules, content sanitization, handle draft course updates and reset content, better redis logging
This commit is contained in:
@@ -71,6 +71,6 @@
|
||||
</Button>
|
||||
</div>
|
||||
<pre bind:this={preRef} draggable={false}>
|
||||
<NodeViewContent as="code" class={`language-${defaultLanguage}`} {...node.attrs} />
|
||||
<NodeViewContent as="code" class={`language-${defaultLanguage} ui:pt-3!`} {...node.attrs} />
|
||||
</pre>
|
||||
</NodeViewWrapper>
|
||||
|
||||
+10
-4
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionLabels } from '@cio/question-types';
|
||||
import { Badge } from '../../../../base/badge';
|
||||
|
||||
@@ -10,13 +12,17 @@
|
||||
|
||||
let { labels, showCorrect = false, showIncorrect = false }: Props = $props();
|
||||
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
const ariaCorrectLabel = $derived(getExerciseQuestionLabel(labels, 'common.correct_badge', 'Correct'));
|
||||
const ariaIncorrectLabel = $derived(getExerciseQuestionLabel(labels, 'common.incorrect_badge', 'Incorrect'));
|
||||
</script>
|
||||
|
||||
{#if showCorrect}
|
||||
<Badge variant="success" class="ui:ml-2">{label('common.correct_badge')}</Badge>
|
||||
<Badge variant="success" class="ui:ml-2 ui:p-1" aria-label={ariaCorrectLabel} title={ariaCorrectLabel}>
|
||||
<CheckIcon aria-hidden="true" class="custom" />
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if showIncorrect}
|
||||
<Badge variant="destructive" class="ui:ml-2">{label('common.incorrect_badge')}</Badge>
|
||||
<Badge variant="destructive" class="ui:ml-2 ui:p-1" aria-label={ariaIncorrectLabel} title={ariaIncorrectLabel}>
|
||||
<XIcon aria-hidden="true" class="custom" />
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { Spinner } from '../../base/spinner';
|
||||
import { CheckboxField } from '../checkbox-field';
|
||||
import { Search } from '../search';
|
||||
import { cn } from '../../tools';
|
||||
import type { MultiSelectListItem } from './types';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
heading: string;
|
||||
/** When omitted, the list renders without a heading row (e.g. when the parent supplies the title). */
|
||||
heading?: string;
|
||||
headingSnippet?: Snippet;
|
||||
emptyMessage: string;
|
||||
items: MultiSelectListItem[];
|
||||
isLoading?: boolean;
|
||||
isSelected: (id: string) => boolean;
|
||||
onToggle: (id: string) => void;
|
||||
/** Prefix for each row's checkbox `name` (e.g. `assign-course` → `assign-course-<id>`). */
|
||||
@@ -14,35 +20,78 @@
|
||||
class?: string;
|
||||
/** Extra classes for the scrollable list container (`data-slot="multi-select-list-items"`). */
|
||||
listClass?: string;
|
||||
searchPlaceholder?: string;
|
||||
searchValue?: string;
|
||||
onSearchValueChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
heading,
|
||||
heading = '',
|
||||
headingSnippet,
|
||||
emptyMessage,
|
||||
items,
|
||||
isLoading = false,
|
||||
isSelected,
|
||||
onToggle,
|
||||
namePrefix = 'multi-select',
|
||||
class: className,
|
||||
listClass
|
||||
listClass,
|
||||
searchPlaceholder = '',
|
||||
searchValue = $bindable(''),
|
||||
onSearchValueChange
|
||||
}: Props = $props();
|
||||
|
||||
const hasHeading = $derived(heading || headingSnippet);
|
||||
</script>
|
||||
|
||||
<div data-slot="multi-select-list" class={cn('ui:border-border space-y-3 rounded-md border', className)}>
|
||||
<p class="ui:border-border border-b px-2 py-2 text-sm font-medium">
|
||||
{heading} ({items.length})
|
||||
</p>
|
||||
{#if items.length === 0}
|
||||
<p class="ui:text-muted-foreground px-2 pb-2 text-sm">{emptyMessage}</p>
|
||||
<div data-slot="multi-select-list" class={cn('ui:border-border ui:space-y-3 ui:rounded-md ui:border', className)}>
|
||||
{#if hasHeading || searchPlaceholder}
|
||||
<div
|
||||
class={cn(
|
||||
'ui:border-border ui:flex ui:gap-3 ui:border-b ui:px-2 ui:py-2 ui:items-center ui:justify-between',
|
||||
!hasHeading && 'ui:justify-end'
|
||||
)}
|
||||
>
|
||||
{#if heading}
|
||||
<p class="ui:text-sm ui:font-medium">
|
||||
{heading} ({items.length})
|
||||
</p>
|
||||
{:else if headingSnippet}
|
||||
{@render headingSnippet()}
|
||||
{/if}
|
||||
|
||||
{#if searchPlaceholder}
|
||||
<Search
|
||||
placeholder={searchPlaceholder}
|
||||
bind:value={searchValue}
|
||||
onValueChange={onSearchValueChange}
|
||||
class="ui:w-full ui:max-w-sm"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if isLoading}
|
||||
<div class="ui:flex ui:min-h-40 ui:items-center ui:justify-center ui:px-2 ui:pb-2">
|
||||
<Spinner class="ui:size-5 ui:text-muted-foreground" />
|
||||
</div>
|
||||
{:else if items.length === 0}
|
||||
<p class="ui:text-muted-foreground ui:px-2 ui:pb-2 ui:text-sm">{emptyMessage}</p>
|
||||
{:else}
|
||||
<div data-slot="multi-select-list-items" class={cn('max-h-60 space-y-2 overflow-y-auto px-2 pb-2', listClass)}>
|
||||
<div
|
||||
data-slot="multi-select-list-items"
|
||||
class={cn('ui:max-h-60 ui:space-y-2 ui:overflow-y-auto ui:px-2 ui:pb-2', listClass)}
|
||||
>
|
||||
{#each items as item (item.id)}
|
||||
<CheckboxField
|
||||
name={`${namePrefix}-${item.id}`}
|
||||
label={item.label || item.id}
|
||||
checked={isSelected(item.id)}
|
||||
onclick={() => onToggle(item.id)}
|
||||
/>
|
||||
>
|
||||
{#if item.description}
|
||||
<p class="ui:ml-2 ui:text-muted-foreground ui:text-sm">{item.description}</p>
|
||||
{/if}
|
||||
</CheckboxField>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export type MultiSelectListItem = { id: string; label: string };
|
||||
export type MultiSelectListItem = { id: string; label: string; description?: string };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import * as InputGroup from '../../base/input-group';
|
||||
import SearchIcon from '@lucide/svelte/icons/search';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import { cn } from '../../tools';
|
||||
|
||||
@@ -8,12 +9,14 @@
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
class?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
placeholder = '',
|
||||
value = $bindable(''),
|
||||
class: className = '',
|
||||
onValueChange,
|
||||
...restProps
|
||||
}: Props & Omit<ComponentProps<typeof InputGroup.Root>, 'class' | 'children'> = $props();
|
||||
|
||||
@@ -22,10 +25,26 @@
|
||||
'data-slot': 'search',
|
||||
...restProps
|
||||
});
|
||||
|
||||
function handleInput(event: Event) {
|
||||
onValueChange?.((event.currentTarget as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
function clearValue() {
|
||||
value = '';
|
||||
onValueChange?.('');
|
||||
}
|
||||
</script>
|
||||
|
||||
<InputGroup.Root {...mergedProps}>
|
||||
<InputGroup.Input {placeholder} bind:value />
|
||||
<InputGroup.Input {placeholder} bind:value oninput={handleInput} />
|
||||
{#if value}
|
||||
<InputGroup.Addon align="inline-end">
|
||||
<InputGroup.Button aria-label="Clear search" onclick={clearValue}>
|
||||
<XIcon />
|
||||
</InputGroup.Button>
|
||||
</InputGroup.Addon>
|
||||
{/if}
|
||||
<InputGroup.Addon>
|
||||
<SearchIcon />
|
||||
</InputGroup.Addon>
|
||||
|
||||
Reference in New Issue
Block a user