feat: widgets and ai chat
This commit is contained in:
@@ -3,9 +3,10 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
href: string;
|
||||
href?: string;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
onclick?: (event: MouseEvent) => void;
|
||||
shimmerColor?: string;
|
||||
shimmerSize?: string;
|
||||
shimmerDuration?: string;
|
||||
@@ -19,6 +20,7 @@
|
||||
href,
|
||||
target = '',
|
||||
rel = '',
|
||||
onclick,
|
||||
shimmerColor = '#ffffff',
|
||||
shimmerSize = '0.05em',
|
||||
shimmerDuration = '3s',
|
||||
@@ -29,39 +31,64 @@
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<a
|
||||
style:--spread="90deg"
|
||||
style:--shimmer-color={shimmerColor}
|
||||
style:--radius={borderRadius}
|
||||
style:--speed={shimmerDuration}
|
||||
style:--cut={shimmerSize}
|
||||
style:--bg={background}
|
||||
class={cn(
|
||||
'group relative z-0 flex cursor-pointer items-center justify-center overflow-hidden rounded-(--radius) border border-white/10 px-6 py-3 whitespace-nowrap text-white [background:var(--bg)] dark:text-white',
|
||||
'transform-gpu transition-transform duration-300 ease-in-out active:translate-y-px',
|
||||
className
|
||||
)}
|
||||
{href}
|
||||
{target}
|
||||
{rel}
|
||||
data-rewardful
|
||||
>
|
||||
<div class={cn('-z-30 blur-[2px]', '@container-[size] absolute inset-0 overflow-visible')}>
|
||||
<div class="animate-magicslide absolute inset-0 aspect-[1] h-[100cqh] rounded-none [mask:none]">
|
||||
{#snippet shimmerContent()}
|
||||
<div class={cn('ui:-z-30 ui:blur-[2px]', 'ui:@container-[size] ui:absolute ui:inset-0 ui:overflow-visible')}>
|
||||
<div
|
||||
class="ui:animate-magicslide ui:absolute ui:inset-0 ui:aspect-[1] ui:h-[100cqh] ui:rounded-none ui:[mask:none]"
|
||||
>
|
||||
<div
|
||||
class="animate-spin-around absolute -inset-full w-auto [translate:0_0] rotate-0 [background:conic-gradient(from_calc(270deg-(var(--spread)*0.5)),transparent_0,var(--shimmer-color)_var(--spread),transparent_var(--spread))]"
|
||||
class="ui:animate-spin-around ui:absolute ui:-inset-full ui:w-auto ui:[translate:0_0] ui:rotate-0 ui:[background:conic-gradient(from_calc(270deg-(var(--spread)*0.5)),transparent_0,var(--shimmer-color)_var(--spread),transparent_var(--spread))]"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{@render children?.()}
|
||||
<div
|
||||
class={cn(
|
||||
'absolute inset-0 h-full w-full',
|
||||
'rounded-2xl px-4 py-1.5 text-sm font-medium shadow-[inset_0_-8px_10px_#ffffff1f]',
|
||||
'transform-gpu transition-all duration-300 ease-in-out',
|
||||
'group-hover:shadow-[inset_0_-6px_10px_#ffffff3f]',
|
||||
'group-active:shadow-[inset_0_-10px_10px_#ffffff3f]'
|
||||
'ui:absolute ui:inset-0 ui:h-full ui:w-full',
|
||||
'ui:rounded-2xl ui:px-4 ui:py-1.5 ui:text-sm ui:font-medium ui:shadow-[inset_0_-8px_10px_#ffffff1f]',
|
||||
'ui:transform-gpu ui:transition-all ui:duration-300 ui:ease-in-out',
|
||||
'ui:group-hover:shadow-[inset_0_-6px_10px_#ffffff3f]',
|
||||
'ui:group-active:shadow-[inset_0_-10px_10px_#ffffff3f]'
|
||||
)}
|
||||
></div>
|
||||
<div class={cn('absolute inset-(--cut) -z-20 rounded-(--radius) [background:var(--bg)]')}></div>
|
||||
</a>
|
||||
<div class={cn('ui:absolute ui:inset-(--cut) ui:-z-20 ui:rounded-(--radius) ui:[background:var(--bg)]')}></div>
|
||||
{/snippet}
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
style:--spread="90deg"
|
||||
style:--shimmer-color={shimmerColor}
|
||||
style:--radius={borderRadius}
|
||||
style:--speed={shimmerDuration}
|
||||
style:--cut={shimmerSize}
|
||||
style:--bg={background}
|
||||
class={cn(
|
||||
'ui:group ui:relative ui:z-0 ui:flex ui:cursor-pointer ui:items-center ui:justify-center ui:overflow-hidden ui:rounded-(--radius) ui:border ui:border-white/10 ui:px-6 ui:py-3 ui:whitespace-nowrap ui:text-white ui:[background:var(--bg)] ui:dark:text-white',
|
||||
'ui:transform-gpu ui:transition-transform ui:duration-300 ui:ease-in-out ui:active:translate-y-px',
|
||||
className
|
||||
)}
|
||||
{href}
|
||||
{target}
|
||||
{rel}
|
||||
data-rewardful
|
||||
>
|
||||
{@render shimmerContent()}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
style:--spread="90deg"
|
||||
style:--shimmer-color={shimmerColor}
|
||||
style:--radius={borderRadius}
|
||||
style:--speed={shimmerDuration}
|
||||
style:--cut={shimmerSize}
|
||||
style:--bg={background}
|
||||
class={cn(
|
||||
'ui:group ui:relative ui:z-0 ui:flex ui:cursor-pointer ui:items-center ui:justify-center ui:overflow-hidden ui:rounded-(--radius) ui:border ui:border-white/10 ui:px-6 ui:py-3 ui:whitespace-nowrap ui:text-white ui:[background:var(--bg)] ui:dark:text-white',
|
||||
'ui:transform-gpu ui:transition-transform ui:duration-300 ui:ease-in-out ui:active:translate-y-px',
|
||||
className
|
||||
)}
|
||||
{onclick}
|
||||
>
|
||||
{@render shimmerContent()}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '../../tools';
|
||||
import { Textarea } from '../../base/textarea';
|
||||
import { MentionPopover } from '../mention-popover';
|
||||
import type { MentionItem } from '../mention-popover/types';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
/** Two-way bound text value (includes raw mention syntax like `@[Title](type:id)`). */
|
||||
value: string;
|
||||
/** Forwarded textarea element ref for parent-controlled focus. */
|
||||
ref?: HTMLTextAreaElement | null;
|
||||
/** Items available for @ mention. */
|
||||
mentionItems?: MentionItem[];
|
||||
/** Fires when Enter is pressed (without Shift). */
|
||||
onSubmit?: () => void;
|
||||
/** Custom icon snippet for mention popover items. */
|
||||
icon?: Snippet<[{ item: MentionItem }]>;
|
||||
/** Custom type badge label for mention popover items. */
|
||||
typeLabel?: (item: MentionItem) => string;
|
||||
/** Action buttons rendered inside the textarea container (e.g. send, attach). */
|
||||
actions?: Snippet;
|
||||
/** Empty state text when no mention items match the query. */
|
||||
emptyMessage?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
/** Initial visible textarea row count. Defaults to 1 (single-line). */
|
||||
rows?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
ref = $bindable(null),
|
||||
mentionItems = [],
|
||||
onSubmit,
|
||||
icon,
|
||||
typeLabel,
|
||||
actions,
|
||||
emptyMessage = 'No results',
|
||||
placeholder = '',
|
||||
disabled = false,
|
||||
rows = 1,
|
||||
class: className
|
||||
}: Props = $props();
|
||||
|
||||
let showPopover = $state(false);
|
||||
let mentionQuery = $state('');
|
||||
let mentionStartIndex = $state(-1);
|
||||
let selectedIndex = $state(0);
|
||||
|
||||
const filteredItems = $derived(
|
||||
mentionQuery
|
||||
? mentionItems.filter((item) => item.label.toLowerCase().includes(mentionQuery.toLowerCase()))
|
||||
: mentionItems
|
||||
);
|
||||
|
||||
function detectMention() {
|
||||
if (!ref) return;
|
||||
|
||||
const cursorPos = ref.selectionStart;
|
||||
const textBeforeCursor = value.slice(0, cursorPos);
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
|
||||
|
||||
if (lastAtIndex >= 0) {
|
||||
const charBeforeAt = lastAtIndex > 0 ? textBeforeCursor[lastAtIndex - 1] : ' ';
|
||||
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);
|
||||
const hasClosingBracket = textAfterAt.includes(')');
|
||||
|
||||
if ((charBeforeAt === ' ' || charBeforeAt === '\n' || lastAtIndex === 0) && !hasClosingBracket) {
|
||||
mentionStartIndex = lastAtIndex;
|
||||
mentionQuery = textAfterAt;
|
||||
selectedIndex = 0;
|
||||
showPopover = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
showPopover = false;
|
||||
}
|
||||
|
||||
function insertMention(item: MentionItem) {
|
||||
const before = value.slice(0, mentionStartIndex);
|
||||
const afterCursor = ref ? value.slice(ref.selectionStart) : '';
|
||||
const mention = `@[${item.label}](${item.type}:${item.id}) `;
|
||||
|
||||
value = before + mention + afterCursor;
|
||||
showPopover = false;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (ref) {
|
||||
ref.focus();
|
||||
const newPos = before.length + mention.length;
|
||||
ref.setSelectionRange(newPos, newPos);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (showPopover && filteredItems.length > 0) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
selectedIndex = (selectedIndex + 1) % filteredItems.length;
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
selectedIndex = (selectedIndex - 1 + filteredItems.length) % filteredItems.length;
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
insertMention(filteredItems[selectedIndex]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
showPopover = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
onSubmit?.();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div data-slot="chat-textarea" class={cn('ui:relative', className)}>
|
||||
{#if showPopover && mentionItems.length > 0}
|
||||
<div class="ui:absolute ui:bottom-full ui:left-0 ui:z-50 ui:mb-1">
|
||||
{#if icon}
|
||||
<MentionPopover
|
||||
items={mentionItems}
|
||||
query={mentionQuery}
|
||||
{selectedIndex}
|
||||
onSelect={insertMention}
|
||||
{icon}
|
||||
{typeLabel}
|
||||
{emptyMessage}
|
||||
/>
|
||||
{:else}
|
||||
<MentionPopover
|
||||
items={mentionItems}
|
||||
query={mentionQuery}
|
||||
{selectedIndex}
|
||||
onSelect={insertMention}
|
||||
{typeLabel}
|
||||
{emptyMessage}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="ui:border-input ui:focus-within:border-ring ui:focus-within:ring-ring/50 ui:rounded-lg ui:border ui:shadow-xs ui:transition-[color,box-shadow] ui:focus-within:ring-[3px]"
|
||||
>
|
||||
<Textarea
|
||||
bind:ref
|
||||
bind:value
|
||||
oninput={detectMention}
|
||||
onkeydown={handleKeydown}
|
||||
{placeholder}
|
||||
{disabled}
|
||||
{rows}
|
||||
class="ui:min-h-0 ui:max-h-32 ui:resize-none ui:border-0 ui:py-2 ui:text-sm ui:shadow-none ui:focus-visible:ring-0"
|
||||
/>
|
||||
|
||||
{#if actions}
|
||||
<div class="ui:flex ui:items-center ui:gap-1 ui:px-2 ui:pb-2">
|
||||
{@render actions()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
import ChatTextarea from './chat-textarea.svelte';
|
||||
|
||||
export { ChatTextarea };
|
||||
export { ChatTextarea as default };
|
||||
export type { MentionItem } from './types';
|
||||
@@ -0,0 +1 @@
|
||||
export type { MentionItem } from '../mention-popover/types';
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '../../tools';
|
||||
import CheckboxOptionCard from './checkbox-option-card.svelte';
|
||||
|
||||
export interface CheckboxOptionCardOption {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
type SnippetWithOption = import('svelte').Snippet<[option: CheckboxOptionCardOption]>;
|
||||
|
||||
interface Props {
|
||||
options: CheckboxOptionCardOption[];
|
||||
/** Bound array of selected option values */
|
||||
value?: string[];
|
||||
/** Overridable CSS class for the grid container (e.g. grid-cols-2, md:grid-cols-3) */
|
||||
class?: string;
|
||||
titleSuffix?: SnippetWithOption;
|
||||
leading?: SnippetWithOption;
|
||||
footer?: SnippetWithOption;
|
||||
}
|
||||
|
||||
let {
|
||||
options,
|
||||
value = $bindable([]),
|
||||
class: className,
|
||||
titleSuffix: parentTitleSuffix,
|
||||
leading: parentLeading,
|
||||
footer: parentFooter
|
||||
}: Props = $props();
|
||||
|
||||
function toggle(optionValue: string, isChecked: boolean) {
|
||||
if (isChecked) {
|
||||
if (value.includes(optionValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
value = [...value, optionValue];
|
||||
return;
|
||||
}
|
||||
|
||||
value = value.filter((entry) => entry !== optionValue);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div role="group" data-slot="checkbox-option-card-group" class={cn('ui:grid ui:gap-3', className)}>
|
||||
{#each options as option (option.id)}
|
||||
<CheckboxOptionCard
|
||||
id={option.id}
|
||||
title={option.title}
|
||||
description={option.description}
|
||||
checked={value.includes(option.value)}
|
||||
disabled={option.disabled}
|
||||
onCheckedChange={(isChecked) => toggle(option.value, isChecked)}
|
||||
>
|
||||
{#snippet titleSuffix()}
|
||||
{@render parentTitleSuffix?.(option)}
|
||||
{/snippet}
|
||||
{#snippet leading()}
|
||||
{@render parentLeading?.(option)}
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
{@render parentFooter?.(option)}
|
||||
{/snippet}
|
||||
</CheckboxOptionCard>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import { Checkbox } from '../../base/checkbox';
|
||||
import * as Field from '../../base/field';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
titleSuffix?: import('svelte').Snippet;
|
||||
leading?: import('svelte').Snippet;
|
||||
footer?: import('svelte').Snippet;
|
||||
/** Overridable CSS class (e.g. for grid column span or custom layout) */
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
checked,
|
||||
disabled = false,
|
||||
onCheckedChange,
|
||||
titleSuffix,
|
||||
leading,
|
||||
footer,
|
||||
class: className
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<Field.Label for={id} class={className}>
|
||||
<Field.Field orientation="horizontal">
|
||||
{#if leading}
|
||||
{@render leading()}
|
||||
{/if}
|
||||
<Field.Content>
|
||||
<Field.Title class="ui:flex ui:items-center ui:gap-2">
|
||||
{title}
|
||||
{#if titleSuffix}
|
||||
{@render titleSuffix()}
|
||||
{/if}
|
||||
</Field.Title>
|
||||
{#if description}
|
||||
<Field.Description>{description}</Field.Description>
|
||||
{/if}
|
||||
</Field.Content>
|
||||
<Checkbox
|
||||
{id}
|
||||
aria-label={title}
|
||||
{disabled}
|
||||
{checked}
|
||||
onCheckedChange={(value) => onCheckedChange?.(value === true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
{#if footer}
|
||||
<div class="-mt-1 min-w-0 px-4 pb-3">
|
||||
{@render footer()}
|
||||
</div>
|
||||
{/if}
|
||||
</Field.Label>
|
||||
@@ -0,0 +1,6 @@
|
||||
import CheckboxOptionCard from './checkbox-option-card.svelte';
|
||||
import CheckboxOptionCardGroup from './checkbox-option-card-group.svelte';
|
||||
|
||||
export { CheckboxOptionCard, CheckboxOptionCardGroup };
|
||||
export type { CheckboxOptionCardOption } from './checkbox-option-card-group.svelte';
|
||||
export { CheckboxOptionCard as default };
|
||||
@@ -1,21 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { Button, type ButtonProps } from '../../base/button';
|
||||
import { Button, type ButtonProps, type ButtonVariant, type ButtonSize } from '../../base/button';
|
||||
import { Tooltip, Provider, Trigger, Content } from '../../base/tooltip';
|
||||
|
||||
interface Props extends Omit<ButtonProps, 'variant' | 'size'> {
|
||||
tooltip?: string;
|
||||
tooltipSide?: 'top' | 'bottom' | 'left' | 'right';
|
||||
tooltipClass?: string;
|
||||
variant?: ButtonVariant;
|
||||
size?: Extract<ButtonSize, 'icon' | 'icon-2xs' | 'icon-xs' | 'icon-sm' | 'icon-lg'>;
|
||||
}
|
||||
|
||||
let { tooltip, tooltipSide = 'top', tooltipClass = '', children, ...buttonProps }: Props = $props();
|
||||
let {
|
||||
tooltip,
|
||||
tooltipSide = 'top',
|
||||
tooltipClass = '',
|
||||
variant = 'secondary',
|
||||
size = 'icon',
|
||||
children,
|
||||
...buttonProps
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if tooltip}
|
||||
<Provider>
|
||||
<Tooltip>
|
||||
<Trigger class={tooltipClass}>
|
||||
<Button variant="secondary" size="icon" {...buttonProps}>
|
||||
<Button {variant} {size} {...buttonProps}>
|
||||
{@render children?.()}
|
||||
</Button>
|
||||
</Trigger>
|
||||
@@ -25,7 +35,7 @@
|
||||
</Tooltip>
|
||||
</Provider>
|
||||
{:else}
|
||||
<Button variant="secondary" size="icon" {...buttonProps}>
|
||||
<Button {variant} {size} {...buttonProps}>
|
||||
{@render children?.()}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Button, { type ButtonElementProps } from '../../base/button/button.svelte';
|
||||
import { useImageCropperCancel } from './image-cropper.svelte.js';
|
||||
import { useImageCropperCancel } from './image-cropper-context.svelte';
|
||||
import Trash2Icon from '@lucide/svelte/icons/trash-2';
|
||||
|
||||
let { ref = $bindable(null), variant = 'outline', size = 'sm', onclick, ...rest }: ButtonElementProps = $props();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Button, { type ButtonElementProps } from '../../base/button/button.svelte';
|
||||
import { useImageCropperCrop } from './image-cropper.svelte.js';
|
||||
import { useImageCropperCrop } from './image-cropper-context.svelte';
|
||||
import CropIcon from '@lucide/svelte/icons/crop';
|
||||
|
||||
let { ref = $bindable(null), variant = 'default', size = 'sm', onclick, ...rest }: ButtonElementProps = $props();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Cropper from 'svelte-easy-crop';
|
||||
import { useImageCropperCropper } from './image-cropper.svelte.js';
|
||||
import type { ImageCropperCropperProps } from './types.js';
|
||||
import { useImageCropperCropper } from './image-cropper-context.svelte';
|
||||
import type { ImageCropperCropperProps } from './types';
|
||||
|
||||
let { cropShape = 'round', aspect = 1, showGrid = false, ...rest }: ImageCropperCropperProps = $props();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '../../base/dialog';
|
||||
import { cn } from '../../tools';
|
||||
import { useImageCropperDialog } from './image-cropper.svelte.js';
|
||||
import { useImageCropperDialog } from './image-cropper-context.svelte';
|
||||
import type { ImageCropperDialogProps } from './types';
|
||||
|
||||
let { children, class: className, ...rest }: ImageCropperDialogProps = $props();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import * as Avatar from '../../base/avatar';
|
||||
import type { ImageCropperPreviewProps } from './types';
|
||||
import { useImageCropperPreview } from './image-cropper.svelte.js';
|
||||
import { useImageCropperPreview } from './image-cropper-context.svelte';
|
||||
import UploadIcon from '@lucide/svelte/icons/upload';
|
||||
import { cn } from '../../tools';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { useImageCropperTrigger } from './image-cropper.svelte.js';
|
||||
import { useImageCropperTrigger } from './image-cropper-context.svelte';
|
||||
import type { ImageCropperUploadTriggerProps } from './types';
|
||||
import { cn } from '../../tools';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { box } from 'svelte-toolbelt';
|
||||
import { useImageCropperRoot } from './image-cropper.svelte.js';
|
||||
import { useImageCropperRoot } from './image-cropper-context.svelte';
|
||||
import type { ImageCropperRootProps } from './types';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { useId } from 'bits-ui';
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import MentionPopover from './mention-popover.svelte';
|
||||
|
||||
export { MentionPopover };
|
||||
export { MentionPopover as default };
|
||||
export type { MentionItem } from './types';
|
||||
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '../../tools';
|
||||
import type { MentionItem } from './types';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
items: MentionItem[];
|
||||
query: string;
|
||||
selectedIndex: number;
|
||||
onSelect: (item: MentionItem) => void;
|
||||
/** Custom icon snippet — receives `{ item }` and should render a small icon. */
|
||||
icon?: Snippet<[{ item: MentionItem }]>;
|
||||
/** Custom label for each item's type badge. */
|
||||
typeLabel?: (item: MentionItem) => string;
|
||||
emptyMessage?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
items,
|
||||
query,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
icon,
|
||||
typeLabel,
|
||||
emptyMessage = 'No results',
|
||||
class: className
|
||||
}: Props = $props();
|
||||
|
||||
const filteredItems = $derived(
|
||||
query ? items.filter((item) => item.label.toLowerCase().includes(query.toLowerCase())) : items
|
||||
);
|
||||
|
||||
/**
|
||||
* Split a label into segments: plain text and highlighted matches.
|
||||
* Returns an array of `{ text, highlighted }` pairs for rendering.
|
||||
*/
|
||||
function highlightSegments(label: string, search: string): Array<{ text: string; highlighted: boolean }> {
|
||||
if (!search) return [{ text: label, highlighted: false }];
|
||||
|
||||
const lowerLabel = label.toLowerCase();
|
||||
const lowerSearch = search.toLowerCase();
|
||||
const matchIndex = lowerLabel.indexOf(lowerSearch);
|
||||
|
||||
if (matchIndex === -1) return [{ text: label, highlighted: false }];
|
||||
|
||||
const segments: Array<{ text: string; highlighted: boolean }> = [];
|
||||
|
||||
if (matchIndex > 0) {
|
||||
segments.push({ text: label.slice(0, matchIndex), highlighted: false });
|
||||
}
|
||||
|
||||
segments.push({ text: label.slice(matchIndex, matchIndex + search.length), highlighted: true });
|
||||
|
||||
if (matchIndex + search.length < label.length) {
|
||||
segments.push({ text: label.slice(matchIndex + search.length), highlighted: false });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if filteredItems.length > 0}
|
||||
<div
|
||||
data-slot="mention-popover"
|
||||
class={cn(
|
||||
'ui:bg-popover ui:text-popover-foreground ui:max-h-52 ui:w-72 ui:overflow-y-auto ui:rounded-md ui:border ui:p-1 ui:shadow-md',
|
||||
className
|
||||
)}
|
||||
role="listbox"
|
||||
>
|
||||
{#each filteredItems as item, index (item.id)}
|
||||
<button
|
||||
role="option"
|
||||
aria-selected={index === selectedIndex}
|
||||
class={cn(
|
||||
'ui:flex ui:w-full ui:items-center ui:gap-2 ui:rounded-sm ui:px-2 ui:py-1.5 ui:text-left ui:text-sm ui:transition-colors',
|
||||
index === selectedIndex ? 'ui:bg-accent ui:text-accent-foreground' : 'hover:ui:bg-accent/50'
|
||||
)}
|
||||
onmousedown={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect(item);
|
||||
}}
|
||||
>
|
||||
{#if icon}
|
||||
<span class="ui:shrink-0 ui:opacity-60">
|
||||
{@render icon({ item })}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="ui:min-w-0 ui:flex-1 ui:truncate">
|
||||
{#each highlightSegments(item.label, query) as segment (segment.text + String(segment.highlighted))}
|
||||
{#if segment.highlighted}
|
||||
<mark class="ui:bg-primary/20 ui:text-foreground ui:rounded-sm">{segment.text}</mark>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</span>
|
||||
{#if typeLabel}
|
||||
<span class="ui:text-muted-foreground ui:shrink-0 ui:text-xs">{typeLabel(item)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if query}
|
||||
<div
|
||||
data-slot="mention-popover"
|
||||
class={cn(
|
||||
'ui:bg-popover ui:text-popover-foreground ui:w-72 ui:rounded-md ui:border ui:p-3 ui:shadow-md',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<p class="ui:text-muted-foreground ui:text-sm">{emptyMessage}</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type MentionItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
type: string;
|
||||
};
|
||||
@@ -41,7 +41,7 @@
|
||||
<main>
|
||||
<BoldHero {hero} />
|
||||
|
||||
<section class="ui:relative ui:bg-muted/30 ui:px-6 ui:overflow-hidden">
|
||||
<section class="ui:relative ui:bg-muted/30 ui:px-6 ui:overflow-hidden ui:mb-12">
|
||||
<DotPattern class="ui:opacity-[0.15]" />
|
||||
<div class="ui:relative ui:max-w-7xl ui:mx-auto">
|
||||
<div class="ui:flex ui:items-end ui:justify-between ui:mb-12">
|
||||
@@ -108,7 +108,7 @@
|
||||
variant="outline"
|
||||
class="ui:rounded-lg ui:font-bold ui:text-sm {disableCourseLinks
|
||||
? 'ui:pointer-events-none'
|
||||
: 'ui:group-hover:bg-primary ui:group-hover:text-primary-foreground ui:transition-colors'}"
|
||||
: 'ui:hover:bg-primary ui:hover:text-primary-foreground ui:group-hover:bg-primary ui:group-hover:text-primary-foreground ui:transition-colors'}"
|
||||
aria-disabled={disableCourseLinks}
|
||||
tabindex={disableCourseLinks ? -1 : undefined}
|
||||
>
|
||||
|
||||
@@ -63,7 +63,9 @@
|
||||
})}
|
||||
<BlurFade delay={0.1 * index} once={true} class="ui:w-full ui:flex ui:justify-center">
|
||||
<CourseCard
|
||||
href={disableCourseLinks ? undefined : course.link || `/course/${course.slug || course.id}`}
|
||||
href={disableCourseLinks
|
||||
? undefined
|
||||
: course.link || (course.slug ? `/course/${course.slug}` : undefined)}
|
||||
bannerImage={course.logo || ''}
|
||||
bannerAlt={course.title}
|
||||
title={course.title}
|
||||
|
||||
@@ -49,7 +49,9 @@
|
||||
{@const primaryTag = getPrimaryCourseTag(course)}
|
||||
<BlurFade delay={0.1 * index} once={true}>
|
||||
<a
|
||||
href={disableCourseLinks ? undefined : course.link || `/course/${course.slug || course.id}`}
|
||||
href={disableCourseLinks
|
||||
? undefined
|
||||
: course.link || (course.slug ? `/course/${course.slug}` : undefined)}
|
||||
class="ui:block ui:h-full ui:no-underline {disableCourseLinks
|
||||
? 'ui:cursor-default'
|
||||
: 'ui:transition-colors'}"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import * as Avatar from '../../base/avatar';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
|
||||
interface Props {
|
||||
src?: string | null;
|
||||
@@ -17,6 +16,6 @@
|
||||
<Avatar.Image {src} {alt} />
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:text-muted-foreground {iconClass || 'h-1/2 w-1/2'}" />
|
||||
<img src="/images/avatar.svg" alt="User avatar" class="size-10 rounded-md {iconClass}" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { getStarSegments } from '../utils';
|
||||
|
||||
let {
|
||||
rating = null,
|
||||
ratingCount,
|
||||
showCount = true
|
||||
}: { rating?: number | null; ratingCount?: number | null; showCount?: boolean } = $props();
|
||||
|
||||
const segments = $derived(getStarSegments(rating));
|
||||
const showStars = $derived(rating != null);
|
||||
</script>
|
||||
|
||||
<span class="cio-stars">
|
||||
{#if showStars}
|
||||
{#each Array(segments.filled) as _, i (`f-${i}`)}
|
||||
<span class="cio-star">★</span>
|
||||
{/each}
|
||||
{#each Array(segments.empty) as _, i (`e-${i}`)}
|
||||
<span class="cio-star cio-star-empty">★</span>
|
||||
{/each}
|
||||
<span class="cio-rating-val">{rating?.toFixed(1)}</span>
|
||||
{#if showCount && ratingCount}
|
||||
<span class="cio-rating-count">({ratingCount})</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</span>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
let { src = null, alt = '', className = '' }: { src?: string | null; alt?: string; className?: string } = $props();
|
||||
</script>
|
||||
|
||||
{#if src}
|
||||
<img {src} {alt} class={`cio-img-cover ${className}`} loading="lazy" />
|
||||
{:else}
|
||||
<span class={`cio-thumb-fallback ${className}`} aria-hidden="true"></span>
|
||||
{/if}
|
||||
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { getCourseTypeMeta } from '../utils';
|
||||
|
||||
let { courseType }: { courseType: string | null | undefined } = $props();
|
||||
const meta = $derived(getCourseTypeMeta(courseType));
|
||||
</script>
|
||||
|
||||
{#if meta}
|
||||
<span class={meta.className}>{meta.label}</span>
|
||||
{/if}
|
||||
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import type { TWidgetPayload } from '@cio/utils/validation/widget';
|
||||
import Stars from './atoms/stars.svelte';
|
||||
import TypeBadge from './atoms/type-badge.svelte';
|
||||
import Thumb from './atoms/thumb.svelte';
|
||||
import { formatLessonsLine, getCourseExcerpt } from './utils';
|
||||
|
||||
let { payload }: { payload: TWidgetPayload } = $props();
|
||||
|
||||
const options = $derived(payload.design.layoutOptions.cardGrid);
|
||||
const showRating = $derived(options.showRating);
|
||||
const courses = $derived(payload.courses.slice(0, options.maxCount));
|
||||
const cssVars = $derived(`--cio-cg-cols:${options.columns}`);
|
||||
</script>
|
||||
|
||||
{#if courses.length === 0}
|
||||
<div class="cio-empty">No courses to display.</div>
|
||||
{:else}
|
||||
<div class="cio-cg-grid" style={cssVars}>
|
||||
{#each courses as course (course.id)}
|
||||
<a class="cio-cg-card" href={course.url} target="_blank" rel="noreferrer noopener" aria-label={course.title}>
|
||||
{#if payload.design.content.showCourseImage}
|
||||
<div class="cio-cg-img">
|
||||
<Thumb src={course.imageUrl} alt={course.title} className="cio-cg-img-art" />
|
||||
{#if payload.design.content.showCourseTypeBadge}
|
||||
<span style="position:relative;z-index:1">
|
||||
<TypeBadge courseType={course.courseType} />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="cio-cg-body">
|
||||
<h3 class="cio-cg-title">{course.title}</h3>
|
||||
{#if payload.design.content.showDescriptionExcerpt && course.description}
|
||||
<p class="cio-cg-desc">{getCourseExcerpt(course)}</p>
|
||||
{/if}
|
||||
{#if course.tags.length > 0}
|
||||
<div class="cio-cg-tags">
|
||||
{#each course.tags.slice(0, 3) as tag (tag.id)}
|
||||
<span class="cio-pill">{tag.name}</span>
|
||||
{/each}
|
||||
{#if course.tags.length > 3}
|
||||
<span class="cio-pill">+{course.tags.length - 3}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="cio-cg-footer">
|
||||
{#if showRating}
|
||||
<Stars rating={course.rating} ratingCount={course.ratingCount} />
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
<span class="cio-cg-meta">{formatLessonsLine(course)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,169 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import type { TWidgetPayload } from '@cio/utils/validation/widget';
|
||||
import Stars from './atoms/stars.svelte';
|
||||
import TypeBadge from './atoms/type-badge.svelte';
|
||||
import Thumb from './atoms/thumb.svelte';
|
||||
import { formatLessonsLine } from './utils';
|
||||
|
||||
let { payload }: { payload: TWidgetPayload } = $props();
|
||||
|
||||
const options = $derived(payload.design.layoutOptions.carousel);
|
||||
const courses = $derived(payload.courses.slice(0, options.maxCount));
|
||||
const visible = $derived(Number(options.visibleCards));
|
||||
const maxIndex = $derived(Math.max(courses.length - visible, 0));
|
||||
|
||||
let position = $state(0);
|
||||
let trackEl = $state<HTMLDivElement | null>(null);
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const cardBasis = $derived(`calc((100% - ${(visible - 1) * 12}px) / ${visible})`);
|
||||
const translatePct = $derived(courses.length > 0 ? `calc(-${position} * (100% + 12px) / ${visible})` : '0');
|
||||
|
||||
function go(index: number) {
|
||||
if (maxIndex === 0) {
|
||||
position = 0;
|
||||
return;
|
||||
}
|
||||
if (options.loop) {
|
||||
position = ((index % (maxIndex + 1)) + (maxIndex + 1)) % (maxIndex + 1);
|
||||
} else {
|
||||
position = Math.max(0, Math.min(index, maxIndex));
|
||||
}
|
||||
}
|
||||
|
||||
function startAutoplay() {
|
||||
if (!options.autoPlay || maxIndex === 0) return;
|
||||
timer = setInterval(() => go(position + 1), Math.max(options.autoPlayIntervalMs, 3000));
|
||||
}
|
||||
|
||||
function stopAutoplay() {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
startAutoplay();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
stopAutoplay();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (position > maxIndex) position = maxIndex;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if courses.length === 0}
|
||||
<div class="cio-empty">No courses to display.</div>
|
||||
{:else}
|
||||
<div
|
||||
class="cio-cr-wrap"
|
||||
onmouseenter={stopAutoplay}
|
||||
onmouseleave={startAutoplay}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
>
|
||||
<div class="cio-cr-outer">
|
||||
<div class="cio-cr-track" bind:this={trackEl} style={`transform: translateX(${translatePct})`}>
|
||||
{#each courses as course (course.id)}
|
||||
<a
|
||||
class="cio-cr-card"
|
||||
href={course.url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
style={`flex:0 0 ${cardBasis}`}
|
||||
>
|
||||
{#if payload.design.content.showCourseImage}
|
||||
<div class="cio-cr-img">
|
||||
<Thumb src={course.imageUrl} alt={course.title} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="cio-cr-body">
|
||||
<h3 class="cio-cr-title">{course.title}</h3>
|
||||
<div class="cio-cr-rating-row">
|
||||
<Stars rating={course.rating} ratingCount={course.ratingCount} showCount={false} />
|
||||
</div>
|
||||
{#if payload.design.content.showCourseTypeBadge}
|
||||
<div class="cio-cr-tags-row">
|
||||
<TypeBadge courseType={course.courseType} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="cio-cr-meta">{formatLessonsLine(course)}</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if maxIndex > 0 && (options.showDots || options.showArrows)}
|
||||
<div class="cio-cr-nav">
|
||||
{#if options.showDots}
|
||||
<div class="cio-cr-dots">
|
||||
{#each Array(maxIndex + 1) as _, i (i)}
|
||||
<button
|
||||
type="button"
|
||||
class="cio-cr-dot"
|
||||
class:cio-on={i === position}
|
||||
aria-label={`Go to slide ${i + 1}`}
|
||||
onclick={() => go(i)}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
{#if options.showArrows}
|
||||
<div class="cio-cr-arrows">
|
||||
<button
|
||||
type="button"
|
||||
class="cio-cr-btn"
|
||||
onclick={() => go(position - 1)}
|
||||
disabled={!options.loop && position === 0}
|
||||
aria-label="Previous"
|
||||
>
|
||||
<svg
|
||||
class="cio-cr-btn-icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cio-cr-btn"
|
||||
onclick={() => go(position + 1)}
|
||||
disabled={!options.loop && position === maxIndex}
|
||||
aria-label="Next"
|
||||
>
|
||||
<svg
|
||||
class="cio-cr-btn-icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import type { TWidgetPayload, TWidgetPayloadCourse } from '@cio/utils/validation/widget';
|
||||
import Stars from './atoms/stars.svelte';
|
||||
import TypeBadge from './atoms/type-badge.svelte';
|
||||
import Thumb from './atoms/thumb.svelte';
|
||||
|
||||
let { payload }: { payload: TWidgetPayload } = $props();
|
||||
|
||||
const options = $derived(payload.design.layoutOptions.categoryShelf);
|
||||
const courseById = $derived(new Map(payload.courses.map((course) => [course.id, course])));
|
||||
const categories = $derived(payload.categories);
|
||||
const tabs = $derived(
|
||||
options.showAllTab
|
||||
? [
|
||||
{ id: 'all', label: 'All courses', courseIds: payload.courses.map((c) => c.id) },
|
||||
...categories.map((cat) => ({ id: cat.tagId, label: cat.name, courseIds: cat.courseIds }))
|
||||
]
|
||||
: categories.map((cat) => ({ id: cat.tagId, label: cat.name, courseIds: cat.courseIds }))
|
||||
);
|
||||
|
||||
let activeId = $state<string>(
|
||||
untrack(() => {
|
||||
const initialOptions = payload.design.layoutOptions.categoryShelf;
|
||||
const initialCategories = payload.categories;
|
||||
return (
|
||||
initialOptions.defaultCategoryTagId ?? (initialOptions.showAllTab ? 'all' : (initialCategories[0]?.tagId ?? ''))
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
const activeCourses = $derived.by<TWidgetPayloadCourse[]>(() => {
|
||||
const tab = tabs.find((entry) => entry.id === activeId) ?? tabs[0];
|
||||
if (!tab) return [];
|
||||
return tab.courseIds
|
||||
.map((id) => courseById.get(id))
|
||||
.filter((course): course is TWidgetPayloadCourse => course !== undefined)
|
||||
.slice(0, options.maxPerCategory);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (tabs.length > 0 && !tabs.some((tab) => tab.id === activeId)) {
|
||||
activeId = tabs[0].id;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if tabs.length === 0}
|
||||
<div class="cio-empty">Add at least one category tag to display this layout.</div>
|
||||
{:else}
|
||||
<div class="cio-shelf-tabs" role="tablist">
|
||||
{#each tabs as tab (tab.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="cio-shelf-tab"
|
||||
class:cio-on={tab.id === activeId}
|
||||
onclick={() => (activeId = tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="cio-shelf-pane">
|
||||
{#if activeCourses.length === 0}
|
||||
<div class="cio-empty">No courses in this category yet.</div>
|
||||
{:else}
|
||||
<div class="cio-shelf-grid">
|
||||
{#each activeCourses as course (course.id)}
|
||||
<a class="cio-shelf-card" href={course.url} target="_blank" rel="noreferrer noopener">
|
||||
{#if payload.design.content.showCourseImage}
|
||||
<div class="cio-shelf-img">
|
||||
<Thumb src={course.imageUrl} alt={course.title} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="cio-shelf-body">
|
||||
<h3 class="cio-shelf-title">{course.title}</h3>
|
||||
<div class="cio-shelf-meta-row">
|
||||
{#if payload.design.content.showCourseTypeBadge}
|
||||
<TypeBadge courseType={course.courseType} />
|
||||
{/if}
|
||||
<Stars rating={course.rating} ratingCount={course.ratingCount} />
|
||||
</div>
|
||||
{#if course.tags.length > 0}
|
||||
<div class="cio-shelf-tags-row">
|
||||
{#each course.tags.slice(0, 2) as tag (tag.id)}
|
||||
<span class="cio-pill">{tag.name}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import type { TWidgetPayload } from '@cio/utils/validation/widget';
|
||||
import Stars from './atoms/stars.svelte';
|
||||
import TypeBadge from './atoms/type-badge.svelte';
|
||||
import Thumb from './atoms/thumb.svelte';
|
||||
|
||||
let { payload }: { payload: TWidgetPayload } = $props();
|
||||
|
||||
const options = $derived(payload.design.layoutOptions.compactList);
|
||||
const courses = $derived(payload.courses.slice(0, options.maxCount));
|
||||
</script>
|
||||
|
||||
{#if courses.length === 0}
|
||||
<div class="cio-empty">No courses to display.</div>
|
||||
{:else}
|
||||
<div class="cio-cl-list" class:cio-density-compact={options.density === 'compact'}>
|
||||
{#each courses as course (course.id)}
|
||||
<a class="cio-cl-item" href={course.url} target="_blank" rel="noreferrer noopener" aria-label={course.title}>
|
||||
{#if options.showThumbnail && payload.design.content.showCourseImage}
|
||||
<div class="cio-cl-thumb">
|
||||
<Thumb src={course.imageUrl} alt={course.title} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="cio-cl-info">
|
||||
<div class="cio-cl-top">
|
||||
<span class="cio-cl-title">{course.title}</span>
|
||||
{#if payload.design.content.showLessonsCount}
|
||||
<span class="cio-cl-lessons">{course.lessonCount} lessons</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="cio-cl-mid">
|
||||
{#if payload.design.content.showCourseTypeBadge}
|
||||
<TypeBadge courseType={course.courseType} />
|
||||
{/if}
|
||||
<Stars rating={course.rating} ratingCount={course.ratingCount} />
|
||||
</div>
|
||||
{#if options.showTags && course.tags.length > 0}
|
||||
<div class="cio-cl-bottom">
|
||||
{#each course.tags.slice(0, 3) as tag (tag.id)}
|
||||
<span class="cio-pill">{tag.name}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import type { TWidgetPayload } from '@cio/utils/validation/widget';
|
||||
import CardGrid from './card-grid.svelte';
|
||||
import TagFilter from './tag-filter.svelte';
|
||||
import Carousel from './carousel.svelte';
|
||||
import PrimaryCourse from './primary-course.svelte';
|
||||
import CompactList from './compact-list.svelte';
|
||||
import EditorialSpotlight from './editorial-spotlight.svelte';
|
||||
import CategoryShelf from './category-shelf.svelte';
|
||||
import { ensureWidgetGoogleFontsLoaded } from './ensure-google-fonts';
|
||||
import { buildCssVarsFromDesign, buildPoweredByMarketingUrl } from './utils';
|
||||
|
||||
let { payload }: { payload: TWidgetPayload | null | undefined } = $props();
|
||||
|
||||
let rootEl: HTMLDivElement | null = null;
|
||||
|
||||
const cssVars = $derived(payload ? buildCssVarsFromDesign(payload.design) : '');
|
||||
const poweredByHref = $derived(payload ? buildPoweredByMarketingUrl(payload) : '');
|
||||
|
||||
$effect(() => {
|
||||
if (!payload || !rootEl) return;
|
||||
ensureWidgetGoogleFontsLoaded(rootEl, payload.design);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !payload}
|
||||
<div class="cio-widget cio-widget--empty">No widget payload provided.</div>
|
||||
{:else}
|
||||
<div bind:this={rootEl} class="cio-widget" style={cssVars} data-cio-layout={payload.layoutType}>
|
||||
{#if payload.layoutType === 'card_grid'}
|
||||
<CardGrid {payload} />
|
||||
{:else if payload.layoutType === 'tag_filter'}
|
||||
<TagFilter {payload} />
|
||||
{:else if payload.layoutType === 'carousel'}
|
||||
<Carousel {payload} />
|
||||
{:else if payload.layoutType === 'primary_course'}
|
||||
<PrimaryCourse {payload} />
|
||||
{:else if payload.layoutType === 'compact_list'}
|
||||
<CompactList {payload} />
|
||||
{:else if payload.layoutType === 'editorial_spotlight'}
|
||||
<EditorialSpotlight {payload} />
|
||||
{:else if payload.layoutType === 'category_shelf'}
|
||||
<CategoryShelf {payload} />
|
||||
{/if}
|
||||
|
||||
{#if payload.design.branding.showPoweredBy}
|
||||
<a class="cio-branding" href={poweredByHref} target="_blank" rel="noopener noreferrer">
|
||||
{payload.labels.poweredByLabel}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import type { TWidgetPayload } from '@cio/utils/validation/widget';
|
||||
import Stars from './atoms/stars.svelte';
|
||||
import TypeBadge from './atoms/type-badge.svelte';
|
||||
import Thumb from './atoms/thumb.svelte';
|
||||
import { getCourseExcerpt } from './utils';
|
||||
|
||||
let { payload }: { payload: TWidgetPayload } = $props();
|
||||
|
||||
const options = $derived(payload.design.layoutOptions.editorialSpotlight);
|
||||
const main = $derived(payload.courses[0] ?? null);
|
||||
const secondary = $derived(payload.courses.slice(1, 1 + options.secondaryMaxCount));
|
||||
</script>
|
||||
|
||||
{#if !main}
|
||||
<div class="cio-empty">Pick a main course to display.</div>
|
||||
{:else}
|
||||
<div class="cio-ed-grid">
|
||||
<a class="cio-ed-main" href={main.url} target="_blank" rel="noreferrer noopener" aria-label={main.title}>
|
||||
{#if payload.design.content.showCourseImage}
|
||||
<div class="cio-ed-img-wrap">
|
||||
<Thumb src={main.imageUrl} alt={main.title} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="cio-ed-body">
|
||||
{#if payload.design.content.showCourseTypeBadge || main.tags.length > 0}
|
||||
<div class="cio-ed-top-meta">
|
||||
{#if payload.design.content.showCourseTypeBadge}
|
||||
<TypeBadge courseType={main.courseType} />
|
||||
{/if}
|
||||
{#each main.tags.slice(0, 1) as tag (tag.id)}
|
||||
<span class="cio-pill">{tag.name}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<h2 class="cio-ed-title" class:cio-title-sans={options.titleStyle === 'sans'}>{main.title}</h2>
|
||||
{#if payload.design.content.showDescriptionExcerpt && main.description}
|
||||
<p class="cio-ed-desc">{getCourseExcerpt(main, 200)}</p>
|
||||
{/if}
|
||||
<div class="cio-ed-foot">
|
||||
<Stars rating={main.rating} ratingCount={main.ratingCount} />
|
||||
{#if main.tags.length > 0}
|
||||
<div style="display:flex;gap:4px;flex-wrap:wrap">
|
||||
{#each main.tags.slice(0, 3) as tag (tag.id)}
|
||||
<span class="cio-pill">{tag.name}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{#if secondary.length > 0}
|
||||
<div class="cio-ed-stack">
|
||||
{#each secondary as course (course.id)}
|
||||
<a class="cio-ed-mini" href={course.url} target="_blank" rel="noreferrer noopener">
|
||||
{#if payload.design.content.showCourseImage}
|
||||
<div class="cio-ed-mini-img">
|
||||
<Thumb src={course.imageUrl} alt={course.title} />
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<h3 class="cio-ed-mini-title">{course.title}</h3>
|
||||
<div class="cio-ed-mini-meta">
|
||||
{#if payload.design.content.showCourseTypeBadge}
|
||||
<TypeBadge courseType={course.courseType} />
|
||||
{/if}
|
||||
<Stars rating={course.rating} ratingCount={course.ratingCount} showCount={false} />
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { buildWidgetGoogleFontsStylesheetHref, type TWidgetConfig } from '@cio/utils/validation/widget';
|
||||
|
||||
const ATTR_PRECONNECT = 'data-cio-widget-font-preconnect';
|
||||
|
||||
function ensurePreconnect(head: HTMLHeadElement): void {
|
||||
const doc = head.ownerDocument;
|
||||
if (!head.querySelector(`link[${ATTR_PRECONNECT}="googleapis"]`)) {
|
||||
const a = doc.createElement('link');
|
||||
a.rel = 'preconnect';
|
||||
a.href = 'https://fonts.googleapis.com';
|
||||
a.setAttribute(ATTR_PRECONNECT, 'googleapis');
|
||||
head.appendChild(a);
|
||||
}
|
||||
if (!head.querySelector(`link[${ATTR_PRECONNECT}="gstatic"]`)) {
|
||||
const b = doc.createElement('link');
|
||||
b.rel = 'preconnect';
|
||||
b.href = 'https://fonts.gstatic.com';
|
||||
b.setAttribute('crossorigin', '');
|
||||
b.setAttribute(ATTR_PRECONNECT, 'gstatic');
|
||||
head.appendChild(b);
|
||||
}
|
||||
}
|
||||
|
||||
function stylesheetAlreadyPresent(head: HTMLHeadElement, href: string): boolean {
|
||||
for (const link of head.querySelectorAll('link[rel="stylesheet"]')) {
|
||||
if (link.getAttribute('href') === href) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotently loads Google Fonts for the widget into `document.head` (embed shadow trees share the
|
||||
* document’s font cache once the stylesheet is in the main document).
|
||||
*/
|
||||
export function ensureWidgetGoogleFontsLoaded(hostElement: HTMLElement, design: TWidgetConfig): void {
|
||||
const head = hostElement.ownerDocument?.head;
|
||||
if (!head) return;
|
||||
|
||||
ensurePreconnect(head);
|
||||
|
||||
const href = buildWidgetGoogleFontsStylesheetHref(design);
|
||||
if (!href || stylesheetAlreadyPresent(head, href)) return;
|
||||
|
||||
const link = hostElement.ownerDocument.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = href;
|
||||
head.appendChild(link);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import widgetStyles from './styles.css?raw';
|
||||
|
||||
export { default as CourseWidget } from './course-widget.svelte';
|
||||
export { default as CardGridLayout } from './card-grid.svelte';
|
||||
export { default as TagFilterLayout } from './tag-filter.svelte';
|
||||
export { default as CarouselLayout } from './carousel.svelte';
|
||||
export { default as PrimaryCourseLayout } from './primary-course.svelte';
|
||||
export { default as CompactListLayout } from './compact-list.svelte';
|
||||
export { default as EditorialSpotlightLayout } from './editorial-spotlight.svelte';
|
||||
export { default as CategoryShelfLayout } from './category-shelf.svelte';
|
||||
|
||||
export {
|
||||
buildCssVarsFromDesign,
|
||||
buildPoweredByMarketingUrl,
|
||||
formatLessonsLine,
|
||||
getCourseExcerpt,
|
||||
getOrgDomainForUtm,
|
||||
getCourseTypeMeta,
|
||||
getStarSegments
|
||||
} from './utils';
|
||||
|
||||
export { ensureWidgetGoogleFontsLoaded } from './ensure-google-fonts';
|
||||
|
||||
export { widgetStyles };
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import type { TWidgetPayload } from '@cio/utils/validation/widget';
|
||||
import Stars from './atoms/stars.svelte';
|
||||
import TypeBadge from './atoms/type-badge.svelte';
|
||||
import Thumb from './atoms/thumb.svelte';
|
||||
import { formatLessonsLine, getCourseExcerpt } from './utils';
|
||||
|
||||
let { payload }: { payload: TWidgetPayload } = $props();
|
||||
|
||||
const options = $derived(payload.design.layoutOptions.primaryCourse);
|
||||
const featured = $derived(payload.courses[0] ?? null);
|
||||
const secondary = $derived(payload.courses.slice(1, 1 + options.secondaryMaxCount));
|
||||
</script>
|
||||
|
||||
{#if !featured}
|
||||
<div class="cio-empty">Pick a featured course to display.</div>
|
||||
{:else}
|
||||
<a class="cio-ph-hero" href={featured.url} target="_blank" rel="noreferrer noopener">
|
||||
<div>
|
||||
<div class="cio-ph-eyebrow">{options.eyebrowLabel}</div>
|
||||
<h2 class="cio-ph-title">{featured.title}</h2>
|
||||
{#if payload.design.content.showDescriptionExcerpt && featured.description}
|
||||
<p class="cio-ph-desc">{getCourseExcerpt(featured, 220)}</p>
|
||||
{/if}
|
||||
<div class="cio-ph-row">
|
||||
{#if featured.rating != null}
|
||||
<span class="cio-ph-rating-val">{featured.rating.toFixed(1)}</span>
|
||||
<span class="cio-ph-star">★★★★★</span>
|
||||
{#if featured.ratingCount}
|
||||
<span class="cio-ph-count">({featured.ratingCount} reviews)</span>
|
||||
{/if}
|
||||
<span class="cio-ph-sep">·</span>
|
||||
{/if}
|
||||
<span class="cio-ph-meta">{formatLessonsLine(featured)}</span>
|
||||
</div>
|
||||
{#if payload.design.content.showCourseTypeBadge || featured.tags.length > 0}
|
||||
<div class="cio-ph-badges">
|
||||
{#if payload.design.content.showCourseTypeBadge}
|
||||
<TypeBadge courseType={featured.courseType} />
|
||||
{/if}
|
||||
{#each featured.tags.slice(0, 2) as tag (tag.id)}
|
||||
<span class="cio-pill">{tag.name}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<span class="cio-ph-btn">{options.ctaLabel} →</span>
|
||||
</div>
|
||||
{#if payload.design.content.showCourseImage}
|
||||
<div class="cio-ph-img">
|
||||
<Thumb src={featured.imageUrl} alt={featured.title} />
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
{#if secondary.length > 0}
|
||||
<div class="cio-ph-grid">
|
||||
{#each secondary as course (course.id)}
|
||||
<a class="cio-ph-mini" href={course.url} target="_blank" rel="noreferrer noopener">
|
||||
<h3 class="cio-ph-mini-title">{course.title}</h3>
|
||||
<div class="cio-ph-mini-meta">
|
||||
{#if payload.design.content.showCourseTypeBadge}
|
||||
<TypeBadge courseType={course.courseType} />
|
||||
{/if}
|
||||
<Stars rating={course.rating} ratingCount={course.ratingCount} showCount={false} />
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -0,0 +1,873 @@
|
||||
/*
|
||||
* Widget layouts v2 — scoped CSS for the public course widget.
|
||||
*
|
||||
* Every selector is namespaced under `.cio-widget` so styles can be safely
|
||||
* injected into a host page (via Shadow DOM in the embed bundle, or via
|
||||
* <svelte:head> in the dashboard preview iframe) without bleeding into other
|
||||
* UI on the page.
|
||||
*/
|
||||
|
||||
.cio-widget,
|
||||
.cio-widget * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cio-widget {
|
||||
--cio-color-bg-primary: var(--cio-bg, #ffffff);
|
||||
--cio-color-bg-secondary: var(--cio-highlight, #f8fafc);
|
||||
--cio-color-text-primary: var(--cio-text, #0f172a);
|
||||
--cio-color-text-secondary: rgba(15, 23, 42, 0.7);
|
||||
--cio-color-text-tertiary: rgba(15, 23, 42, 0.55);
|
||||
--cio-color-border-primary: color-mix(in srgb, var(--cio-border, rgba(15, 23, 42)) 100%, transparent);
|
||||
--cio-color-border-secondary: color-mix(in srgb, var(--cio-border, rgba(15, 23, 42)) 92%, transparent);
|
||||
--cio-color-border-tertiary: color-mix(in srgb, var(--cio-border, rgba(15, 23, 42)) 80%, transparent);
|
||||
--cio-color-primary: var(--cio-primary, #0f172a);
|
||||
--cio-color-badge-bg: var(--cio-badge, #e2e8f0);
|
||||
|
||||
--cio-radius-sm: 6px;
|
||||
--cio-radius-md: 10px;
|
||||
--cio-radius-lg: var(--cio-radius, 14px);
|
||||
--cio-shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
|
||||
font-family: var(--cio-font, 'DM Sans', Inter, ui-sans-serif, system-ui, sans-serif);
|
||||
font-size: calc(14px * var(--cio-font-scale, 1));
|
||||
color: var(--cio-color-text-primary);
|
||||
background: var(--cio-color-bg-primary);
|
||||
border-radius: var(--cio-radius-lg);
|
||||
padding: 16px;
|
||||
width: 100%;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cio-widget.cio-widget--empty {
|
||||
color: var(--cio-color-text-tertiary);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cio-widget a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cio-widget button {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Shared atoms */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
.cio-type-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 3px 8px;
|
||||
border-radius: 20px;
|
||||
letter-spacing: 0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cio-type-live {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
.cio-type-self {
|
||||
background: #ede9fe;
|
||||
color: #5b21b6;
|
||||
}
|
||||
.cio-type-compliance {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
.cio-type-default {
|
||||
background: var(--cio-color-badge-bg);
|
||||
color: var(--cio-color-text-secondary);
|
||||
}
|
||||
|
||||
.cio-stars {
|
||||
display: inline-flex;
|
||||
gap: 1px;
|
||||
align-items: center;
|
||||
}
|
||||
.cio-star {
|
||||
font-size: 11px;
|
||||
color: #f59e0b;
|
||||
line-height: 1;
|
||||
}
|
||||
.cio-star.cio-star-empty {
|
||||
color: #d1d5db;
|
||||
}
|
||||
.cio-rating-val {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--cio-color-text-secondary);
|
||||
margin-left: 4px;
|
||||
}
|
||||
.cio-rating-count {
|
||||
font-size: 11px;
|
||||
color: var(--cio-color-text-tertiary);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.cio-pill {
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
border: 0.5px solid var(--cio-color-border-tertiary);
|
||||
color: var(--cio-color-text-secondary);
|
||||
background: transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cio-thumb-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.85) 0%, rgba(56, 189, 248, 0.85) 100%);
|
||||
}
|
||||
|
||||
.cio-img-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cio-empty {
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--cio-color-text-tertiary);
|
||||
border: 1px dashed var(--cio-color-border-tertiary);
|
||||
border-radius: var(--cio-radius-lg);
|
||||
}
|
||||
|
||||
.cio-branding {
|
||||
display: block;
|
||||
margin-top: 16px;
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
color: var(--cio-color-text-tertiary);
|
||||
opacity: 0.8;
|
||||
text-decoration: none;
|
||||
}
|
||||
.cio-branding:hover {
|
||||
opacity: 1;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.cio-branding:visited {
|
||||
color: var(--cio-color-text-tertiary);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 1. Card grid */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
.cio-cg-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--cio-cg-cols, 3), minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.cio-cg-card {
|
||||
background: var(--cio-color-bg-primary);
|
||||
border: 0.5px solid var(--cio-color-border-tertiary);
|
||||
border-radius: var(--cio-radius-lg);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
transform 0.15s;
|
||||
}
|
||||
.cio-cg-card:hover {
|
||||
border-color: var(--cio-color-border-primary);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.cio-cg-img {
|
||||
height: 136px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: 10px;
|
||||
background: var(--cio-color-bg-secondary);
|
||||
}
|
||||
.cio-cg-img-art {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.cio-cg-body {
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
}
|
||||
.cio-cg-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
color: var(--cio-color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
.cio-cg-desc {
|
||||
font-size: 11px;
|
||||
color: var(--cio-color-text-secondary);
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
}
|
||||
.cio-cg-tags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cio-cg-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 10px;
|
||||
border-top: 0.5px solid var(--cio-color-border-tertiary);
|
||||
margin-top: auto;
|
||||
gap: 8px;
|
||||
}
|
||||
.cio-cg-meta {
|
||||
font-size: 11px;
|
||||
color: var(--cio-color-text-tertiary);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 2. Tag filter */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
.cio-tf-bar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.cio-tf-btn {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
border: 0.5px solid var(--cio-color-border-secondary);
|
||||
background: var(--cio-color-bg-primary);
|
||||
color: var(--cio-color-text-secondary);
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.cio-tf-btn:hover:not(.cio-on) {
|
||||
background: var(--cio-color-bg-secondary);
|
||||
}
|
||||
.cio-tf-btn.cio-on {
|
||||
background: var(--cio-color-primary);
|
||||
color: var(--cio-color-bg-primary);
|
||||
border-color: var(--cio-color-primary);
|
||||
}
|
||||
.cio-tf-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.cio-tf-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--cio-color-bg-primary);
|
||||
border: 0.5px solid var(--cio-color-border-tertiary);
|
||||
border-radius: var(--cio-radius-lg);
|
||||
align-items: center;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
.cio-tf-row:hover {
|
||||
border-color: var(--cio-color-border-primary);
|
||||
}
|
||||
.cio-tf-thumb {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: var(--cio-radius-md);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: var(--cio-color-bg-secondary);
|
||||
}
|
||||
.cio-tf-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.cio-tf-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
color: var(--cio-color-text-primary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.cio-tf-sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.cio-tf-tags-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 3. Carousel */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
.cio-cr-wrap {
|
||||
position: relative;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.cio-cr-outer {
|
||||
overflow: hidden;
|
||||
}
|
||||
.cio-cr-track {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
transition: transform 0.32s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.cio-cr-card {
|
||||
background: var(--cio-color-bg-primary);
|
||||
border: 0.5px solid var(--cio-color-border-tertiary);
|
||||
border-radius: var(--cio-radius-lg);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.cio-cr-card:hover {
|
||||
border-color: var(--cio-color-border-primary);
|
||||
}
|
||||
.cio-cr-img {
|
||||
height: 110px;
|
||||
position: relative;
|
||||
background: var(--cio-color-bg-secondary);
|
||||
}
|
||||
.cio-cr-body {
|
||||
padding: 11px 12px 13px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.cio-cr-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
color: var(--cio-color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
.cio-cr-rating-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.cio-cr-tags-row {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cio-cr-meta {
|
||||
font-size: 10px;
|
||||
color: var(--cio-color-text-tertiary);
|
||||
}
|
||||
.cio-cr-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.cio-cr-dots {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
.cio-cr-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--cio-color-border-secondary);
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.cio-cr-dot.cio-on {
|
||||
background: var(--cio-color-primary);
|
||||
}
|
||||
.cio-cr-arrows {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
/* `button { font: inherit }` above — scope controls so icon sizing applies */
|
||||
.cio-widget button.cio-cr-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--cio-color-bg-primary);
|
||||
border: 0.5px solid var(--cio-color-border-secondary);
|
||||
color: var(--cio-color-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.cio-widget button.cio-cr-btn .cio-cr-btn-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cio-widget button.cio-cr-btn:hover {
|
||||
background: var(--cio-color-bg-secondary);
|
||||
}
|
||||
.cio-widget button.cio-cr-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 4. Primary course (hero) */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
.cio-ph-hero {
|
||||
background: var(--cio-color-bg-primary);
|
||||
color: var(--cio-color-text-primary);
|
||||
border: 1px solid var(--cio-color-border-secondary);
|
||||
border-radius: var(--cio-radius-lg);
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 160px;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.cio-ph-hero:hover {
|
||||
border-color: var(--cio-color-border-primary);
|
||||
}
|
||||
.cio-ph-eyebrow {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--cio-color-text-tertiary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.cio-ph-title {
|
||||
font-family: var(--cio-font-serif, 'Lora', Georgia, serif);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--cio-color-text-primary);
|
||||
line-height: 1.3;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.cio-ph-desc {
|
||||
font-size: 12px;
|
||||
color: var(--cio-color-text-secondary);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.cio-ph-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cio-ph-rating-val {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--cio-color-text-primary);
|
||||
}
|
||||
.cio-ph-star {
|
||||
font-size: 11px;
|
||||
color: #f59e0b;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.cio-ph-count {
|
||||
font-size: 11px;
|
||||
color: var(--cio-color-text-tertiary);
|
||||
}
|
||||
.cio-ph-sep {
|
||||
font-size: 11px;
|
||||
color: var(--cio-color-border-secondary);
|
||||
}
|
||||
.cio-ph-meta {
|
||||
font-size: 11px;
|
||||
color: var(--cio-color-text-secondary);
|
||||
}
|
||||
.cio-ph-badges {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cio-ph-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--cio-color-primary);
|
||||
color: var(--cio-color-bg-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 9px 18px;
|
||||
border-radius: var(--cio-radius-md);
|
||||
border: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.cio-ph-btn:hover {
|
||||
opacity: 0.88;
|
||||
}
|
||||
.cio-ph-img {
|
||||
width: 160px;
|
||||
height: 110px;
|
||||
border-radius: var(--cio-radius-md);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: var(--cio-color-bg-secondary);
|
||||
}
|
||||
.cio-ph-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.cio-ph-mini {
|
||||
background: var(--cio-color-bg-primary);
|
||||
border: 0.5px solid var(--cio-color-border-tertiary);
|
||||
border-radius: var(--cio-radius-lg);
|
||||
padding: 12px;
|
||||
transition: border-color 0.15s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.cio-ph-mini:hover {
|
||||
border-color: var(--cio-color-border-primary);
|
||||
}
|
||||
.cio-ph-mini-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--cio-color-text-primary);
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
}
|
||||
.cio-ph-mini-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 5. Compact list */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
.cio-cl-list {
|
||||
display: block;
|
||||
}
|
||||
.cio-cl-item {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 0.5px solid var(--cio-color-border-tertiary);
|
||||
align-items: flex-start;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.cio-cl-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.cio-cl-item:hover {
|
||||
opacity: 0.78;
|
||||
}
|
||||
.cio-cl-list.cio-density-compact .cio-cl-item {
|
||||
padding: 8px 0;
|
||||
gap: 10px;
|
||||
}
|
||||
.cio-cl-thumb {
|
||||
width: 60px;
|
||||
height: 48px;
|
||||
border-radius: var(--cio-radius-md);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: var(--cio-color-bg-secondary);
|
||||
}
|
||||
.cio-cl-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.cio-cl-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.cio-cl-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--cio-color-text-primary);
|
||||
line-height: 1.35;
|
||||
margin: 0;
|
||||
}
|
||||
.cio-cl-mid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cio-cl-bottom {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cio-cl-lessons {
|
||||
font-size: 11px;
|
||||
color: var(--cio-color-text-tertiary);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 6. Editorial spotlight */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
.cio-ed-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.cio-ed-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.cio-ed-main {
|
||||
background: var(--cio-color-bg-primary);
|
||||
border: 0.5px solid var(--cio-color-border-tertiary);
|
||||
border-radius: var(--cio-radius-lg);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.cio-ed-img-wrap {
|
||||
height: 190px;
|
||||
background: var(--cio-color-bg-secondary);
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.cio-ed-main:hover .cio-ed-img-wrap {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.cio-ed-body {
|
||||
padding: 14px;
|
||||
}
|
||||
.cio-ed-top-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cio-ed-title {
|
||||
font-family: var(--cio-font-serif, 'Lora', Georgia, serif);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--cio-color-text-primary);
|
||||
line-height: 1.35;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.cio-ed-title.cio-title-sans {
|
||||
font-family: inherit;
|
||||
}
|
||||
.cio-ed-desc {
|
||||
font-size: 12px;
|
||||
color: var(--cio-color-text-secondary);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.cio-ed-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cio-ed-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.cio-ed-mini {
|
||||
background: var(--cio-color-bg-primary);
|
||||
border: 0.5px solid var(--cio-color-border-tertiary);
|
||||
border-radius: var(--cio-radius-lg);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
align-items: flex-start;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.cio-ed-mini:hover {
|
||||
border-color: var(--cio-color-border-primary);
|
||||
}
|
||||
.cio-ed-mini-img {
|
||||
width: 52px;
|
||||
height: 44px;
|
||||
border-radius: var(--cio-radius-md);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: var(--cio-color-bg-secondary);
|
||||
}
|
||||
.cio-ed-mini-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--cio-color-text-primary);
|
||||
line-height: 1.3;
|
||||
margin: 0 0 3px;
|
||||
}
|
||||
.cio-ed-mini-meta {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 7. Category shelf */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
.cio-shelf-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 0;
|
||||
border-bottom: 0.5px solid var(--cio-color-border-tertiary);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.cio-shelf-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.cio-shelf-tab {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 9px 16px;
|
||||
color: var(--cio-color-text-tertiary);
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
white-space: nowrap;
|
||||
text-transform: capitalize;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.cio-shelf-tab.cio-on {
|
||||
color: var(--cio-color-text-primary);
|
||||
border-bottom-color: var(--cio-color-text-primary);
|
||||
}
|
||||
.cio-shelf-tab:hover:not(.cio-on) {
|
||||
color: var(--cio-color-text-secondary);
|
||||
}
|
||||
.cio-shelf-pane {
|
||||
padding-top: 14px;
|
||||
}
|
||||
.cio-shelf-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.cio-shelf-card {
|
||||
background: var(--cio-color-bg-primary);
|
||||
border: 0.5px solid var(--cio-color-border-tertiary);
|
||||
border-radius: var(--cio-radius-lg);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.cio-shelf-card:hover {
|
||||
border-color: var(--cio-color-border-primary);
|
||||
}
|
||||
.cio-shelf-img {
|
||||
height: 100px;
|
||||
background: var(--cio-color-bg-secondary);
|
||||
}
|
||||
.cio-shelf-body {
|
||||
padding: 10px 12px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.cio-shelf-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--cio-color-text-primary);
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
}
|
||||
.cio-shelf-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cio-shelf-tags-row {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Responsive collapses */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.cio-cg-grid {
|
||||
grid-template-columns: repeat(min(var(--cio-cg-cols, 3), 2), minmax(0, 1fr));
|
||||
}
|
||||
.cio-tf-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.cio-shelf-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.cio-ph-hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.cio-ph-img {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
}
|
||||
.cio-ph-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.cio-cg-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.cio-shelf-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.cio-ph-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.cio-widget :focus-visible {
|
||||
outline: 2px solid var(--cio-color-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--cio-radius-sm);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import type { TWidgetPayload } from '@cio/utils/validation/widget';
|
||||
import Stars from './atoms/stars.svelte';
|
||||
import TypeBadge from './atoms/type-badge.svelte';
|
||||
import Thumb from './atoms/thumb.svelte';
|
||||
|
||||
let { payload }: { payload: TWidgetPayload } = $props();
|
||||
|
||||
const options = $derived(payload.design.layoutOptions.tagFilter);
|
||||
const courses = $derived(payload.courses.slice(0, options.maxCount));
|
||||
const tagPool = $derived(payload.tagPool);
|
||||
let activeTagId = $state<string | null>(untrack(() => payload.design.layoutOptions.tagFilter.defaultTagId ?? null));
|
||||
|
||||
const visibleCourses = $derived(
|
||||
activeTagId === null ? courses : courses.filter((course) => course.tags.some((tag) => tag.id === activeTagId))
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if tagPool.length > 0}
|
||||
<div class="cio-tf-bar" role="tablist">
|
||||
{#if options.showAllOption}
|
||||
<button type="button" class="cio-tf-btn" class:cio-on={activeTagId === null} onclick={() => (activeTagId = null)}
|
||||
>All</button
|
||||
>
|
||||
{/if}
|
||||
{#each tagPool as tag (tag.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="cio-tf-btn"
|
||||
class:cio-on={activeTagId === tag.id}
|
||||
onclick={() => (activeTagId = activeTagId === tag.id ? null : tag.id)}>{tag.name}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if visibleCourses.length === 0}
|
||||
<div class="cio-empty">No courses match this filter.</div>
|
||||
{:else}
|
||||
<div class="cio-tf-grid">
|
||||
{#each visibleCourses as course (course.id)}
|
||||
<a class="cio-tf-row" href={course.url} target="_blank" rel="noreferrer noopener" aria-label={course.title}>
|
||||
{#if payload.design.content.showCourseImage}
|
||||
<div class="cio-tf-thumb">
|
||||
<Thumb src={course.imageUrl} alt={course.title} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="cio-tf-info">
|
||||
<h3 class="cio-tf-title">{course.title}</h3>
|
||||
<div class="cio-tf-sub">
|
||||
{#if payload.design.content.showCourseTypeBadge}
|
||||
<TypeBadge courseType={course.courseType} />
|
||||
{/if}
|
||||
<Stars rating={course.rating} ratingCount={course.ratingCount} showCount={false} />
|
||||
</div>
|
||||
{#if course.tags.length > 0}
|
||||
<div class="cio-tf-tags-row">
|
||||
{#each course.tags.slice(0, 3) as tag (tag.id)}
|
||||
<span class="cio-pill">{tag.name}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
getWidgetThemeSerifStack,
|
||||
type TWidgetConfig,
|
||||
type TWidgetPayload,
|
||||
type TWidgetPayloadCourse
|
||||
} from '@cio/utils/validation/widget';
|
||||
|
||||
const COURSE_TYPE_MAP: Record<string, { label: string; className: string }> = {
|
||||
LIVE_CLASS: { label: 'Live class', className: 'cio-type-badge cio-type-live' },
|
||||
SELF_PACED: { label: 'Self paced', className: 'cio-type-badge cio-type-self' },
|
||||
COMPLIANCE: { label: 'Compliance', className: 'cio-type-badge cio-type-compliance' }
|
||||
};
|
||||
|
||||
export function getCourseTypeMeta(courseType: string | null | undefined): { label: string; className: string } | null {
|
||||
if (!courseType) return null;
|
||||
return COURSE_TYPE_MAP[courseType] ?? { label: courseType, className: 'cio-type-badge cio-type-default' };
|
||||
}
|
||||
|
||||
export function getStarSegments(rating: number | null | undefined): { filled: number; empty: number } {
|
||||
if (rating == null) return { filled: 0, empty: 5 };
|
||||
const rounded = Math.round(rating);
|
||||
const filled = Math.max(0, Math.min(5, rounded));
|
||||
return { filled, empty: 5 - filled };
|
||||
}
|
||||
|
||||
export function buildCssVarsFromDesign(design: TWidgetConfig): string {
|
||||
const shadowBlur =
|
||||
design.content.shadowSize === 'lg'
|
||||
? 28
|
||||
: design.content.shadowSize === 'md'
|
||||
? 18
|
||||
: design.content.shadowSize === 'sm'
|
||||
? 8
|
||||
: 0;
|
||||
|
||||
return [
|
||||
`--cio-primary:${design.colors.primaryColor}`,
|
||||
`--cio-bg:${design.colors.backgroundColor}`,
|
||||
`--cio-text:${design.colors.textColor}`,
|
||||
`--cio-border:${design.colors.borderColor}`,
|
||||
`--cio-badge:${design.colors.badgeColor}`,
|
||||
`--cio-highlight:${design.colors.highlightColor}`,
|
||||
`--cio-radius:${design.content.borderRadius}px`,
|
||||
`--cio-font:${design.typography.fontFamily}`,
|
||||
`--cio-font-serif:${getWidgetThemeSerifStack(design.themePreset)}`,
|
||||
`--cio-font-scale:${design.typography.fontSizeScale}`,
|
||||
`--cio-shadow:0 8px ${shadowBlur}px ${design.content.shadowColor}`
|
||||
].join(';');
|
||||
}
|
||||
|
||||
export function getCourseExcerpt(course: TWidgetPayloadCourse, maxLength = 160): string {
|
||||
if (!course.description) return '';
|
||||
const flat = course.description.replace(/\s+/g, ' ').trim();
|
||||
if (flat.length <= maxLength) return flat;
|
||||
return `${flat.slice(0, maxLength - 1).trim()}…`;
|
||||
}
|
||||
|
||||
export function formatLessonsLine(course: TWidgetPayloadCourse): string {
|
||||
const parts: string[] = [];
|
||||
if (course.lessonCount) parts.push(`${course.lessonCount} lessons`);
|
||||
if (course.exerciseCount) parts.push(`${course.exerciseCount} exercises`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/** Hostname used in UTM (custom domain or `{siteName}.classroomio.com`). */
|
||||
export function getOrgDomainForUtm(organization: TWidgetPayload['organization']): string {
|
||||
const custom = organization.customDomain?.trim();
|
||||
if (custom) {
|
||||
try {
|
||||
if (custom.includes('://')) {
|
||||
return new URL(custom).hostname;
|
||||
}
|
||||
} catch {
|
||||
/* use raw host below */
|
||||
}
|
||||
return custom.replace(/^\/+/, '').split('/')[0] || organization.siteName;
|
||||
}
|
||||
const site = organization.siteName.trim();
|
||||
return site ? `${site}.classroomio.com` : 'classroomio.com';
|
||||
}
|
||||
|
||||
/** Marketing homepage link with widget + org attribution for analytics. */
|
||||
export function buildPoweredByMarketingUrl(payload: TWidgetPayload): string {
|
||||
const orgDomain = getOrgDomainForUtm(payload.organization);
|
||||
const params = new URLSearchParams({
|
||||
utm_source: 'classroomio_widget',
|
||||
utm_medium: 'embed',
|
||||
utm_campaign: 'powered_by',
|
||||
utm_content: payload.widgetId,
|
||||
utm_term: orgDomain
|
||||
});
|
||||
return `https://classroomio.com/?${params.toString()}`;
|
||||
}
|
||||
Reference in New Issue
Block a user