feat: implement automation feature

This commit is contained in:
Rotimi Best
2026-03-18 23:39:12 +01:00
parent 576d0366de
commit ef4424e267
121 changed files with 4544 additions and 1055 deletions
@@ -0,0 +1,44 @@
/**
* Global SSO auth state for the current org. Shared by login and signup
* so we don't re-fetch when navigating between them.
*/
import { get, writable } from 'svelte/store';
import { INITIAL_SSO_AUTH_STATE, ssoStateFromOrgInfo, type OrgSsoInfoShape, type SsoAuthState } from './auth-sso';
export type AuthSsoStore = {
/** Org we loaded SSO info for; when nav changes org we re-fetch */
loadedForOrgId: string | null;
/** Org-level SSO state from getOrgSsoInfo */
ssoState: SsoAuthState;
orgSupportsSso: boolean;
};
const initial: AuthSsoStore = {
loadedForOrgId: null,
ssoState: INITIAL_SSO_AUTH_STATE,
orgSupportsSso: false
};
export const authSsoStore = writable<AuthSsoStore>(initial);
/**
* Ensures SSO info for the given org is loaded. If we already have data for
* this orgId, does nothing. Otherwise fetches and updates the store.
*/
export async function ensureSsoInfoLoaded(
orgId: string,
fetchOrgSsoInfo: (id: string) => Promise<OrgSsoInfoShape>
): Promise<void> {
const current = get(authSsoStore);
if (current.loadedForOrgId === orgId) return;
const orgInfo = await fetchOrgSsoInfo(orgId);
const ssoState = ssoStateFromOrgInfo(orgInfo);
authSsoStore.set({
loadedForOrgId: orgId,
ssoState,
orgSupportsSso: orgInfo?.hasSso ?? false
});
}
@@ -0,0 +1,101 @@
/**
* Shared SSO auth state and helpers for login/signup flows.
* Used by (auth)/login and (auth)/signup to avoid duplicating SSO logic.
*/
export type SsoAuthState = {
checking: boolean;
required: boolean;
available: boolean;
redirectUrl: string | null;
providerName: string | null;
providerId: string | null;
};
export const INITIAL_SSO_AUTH_STATE: SsoAuthState = {
checking: false,
required: false,
available: false,
redirectUrl: null,
providerName: null,
providerId: null
};
/** Shape returned by getOrgSsoInfo (avoids importing org types here). */
export type OrgSsoInfoShape = {
hasSso: boolean;
ssoRequired?: boolean;
redirectUrl?: string | null;
providerName?: string | null;
providerId?: string | null;
} | null;
/** Shape returned by discoverSso (avoids importing org types here). */
export type SsoDiscoveryResultShape = {
ssoRequired?: boolean;
ssoAvailable?: boolean;
redirectUrl?: string | null;
providerName?: string | null;
providerId?: string | null;
} | null;
export function ssoStateFromOrgInfo(orgInfo: OrgSsoInfoShape): SsoAuthState {
if (!orgInfo) return INITIAL_SSO_AUTH_STATE;
return {
checking: false,
required: orgInfo.ssoRequired ?? false,
available: orgInfo.hasSso ?? false,
redirectUrl: orgInfo.redirectUrl ?? null,
providerName: orgInfo.providerName ?? null,
providerId: orgInfo.providerId ?? null
};
}
export function ssoStateFromDiscovery(result: SsoDiscoveryResultShape): SsoAuthState {
if (!result) return INITIAL_SSO_AUTH_STATE;
return {
checking: false,
required: result.ssoRequired ?? false,
available: result.ssoAvailable ?? false,
redirectUrl: result.redirectUrl ?? null,
providerName: result.providerName ?? null,
providerId: result.providerId ?? null
};
}
export function buildSsoRedirectUrl(redirectParam: string | null): string {
const base = typeof window !== 'undefined' ? window.location.origin : '';
const path = redirectParam && redirectParam.startsWith('/') ? redirectParam : '';
return `${base}${path}`;
}
const SSO_EMAIL_DEBOUNCE_MS = 500;
export function createSsoEmailChecker(options: {
getEmail: () => string;
getOrgSupportsSso: () => boolean;
discoverSso: (email: string) => Promise<SsoDiscoveryResultShape>;
onChecking: () => void;
onResult: (state: SsoAuthState) => void;
onClear: () => void;
}) {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
return function handleEmailChange() {
options.onClear();
if (!options.getOrgSupportsSso()) return;
clearTimeout(timeoutId);
const email = options.getEmail();
if (!email || !email.includes('@')) {
options.onResult(INITIAL_SSO_AUTH_STATE);
return;
}
options.onChecking();
timeoutId = setTimeout(async () => {
const result = await options.discoverSso(email);
options.onResult(ssoStateFromDiscovery(result));
}, SSO_EMAIL_DEBOUNCE_MS);
};
}
@@ -1,7 +1,9 @@
import type {
AutomationKey,
AutomationKeyType,
AutomationUsage,
CreateAutomationKeyRequest,
GetAutomationUsageRequest,
ListAutomationKeysRequest,
RevokeAutomationKeyRequest,
RotateAutomationKeyRequest
@@ -15,6 +17,7 @@ class AutomationApi extends BaseApiWithErrors {
generatedSecret = $state<string | null>(null);
isFetched = $state(false);
keys = $state<AutomationKey[]>([]);
usage = $state<AutomationUsage | null>(null);
async listKeys(type?: AutomationKeyType) {
return this.execute<ListAutomationKeysRequest>({
@@ -30,6 +33,19 @@ class AutomationApi extends BaseApiWithErrors {
});
}
async getUsage(type: AutomationKeyType = 'mcp') {
return this.execute<GetAutomationUsageRequest>({
requestFn: () =>
classroomio.organization.automation.usage.$get({
query: { type }
}),
logContext: 'fetching automation usage',
onSuccess: (response) => {
this.usage = response.data;
}
});
}
async createKey(type: AutomationKeyType, label?: string) {
return this.execute<CreateAutomationKeyRequest>({
requestFn: () =>
@@ -43,6 +59,9 @@ class AutomationApi extends BaseApiWithErrors {
onSuccess: (response) => {
this.generatedSecret = response.data.secret;
this.keys = [response.data.key, ...this.keys];
if (this.usage) {
this.usage = { ...this.usage, activeKeys: this.usage.activeKeys + 1 };
}
snackbar.success('snackbar.automation.created');
}
});
@@ -54,6 +73,10 @@ class AutomationApi extends BaseApiWithErrors {
logContext: 'revoking automation key',
onSuccess: (response) => {
this.keys = this.keys.map((key) => (key.id === response.data.id ? response.data : key));
if (this.usage) {
const activeKeys = this.keys.filter((key) => key.type === 'mcp' && !key.revokedAt).length;
this.usage = { ...this.usage, activeKeys };
}
snackbar.success('snackbar.automation.revoked');
}
});
@@ -0,0 +1,14 @@
<script lang="ts">
import { t } from '$lib/utils/functions/translations';
import * as Alert from '@cio/ui/base/alert';
import * as Field from '@cio/ui/base/field';
</script>
<Field.Group class="w-full max-w-5xl! space-y-8 px-2">
<Alert.Callout
variant="information"
title={$t('automation.tabs.api')}
description={$t('automation.coming_soon')}
class="w-full"
/>
</Field.Group>
@@ -1,269 +0,0 @@
<script lang="ts">
import { automationApi } from '$features/automation/api/automation.svelte';
import {
getClaudeCodeSnippet,
getCodexSnippet,
getCursorSnippet,
getMaskedAutomationSecret
} from '$features/automation/utils/automation-utils';
import { isOrgAdmin } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import * as Alert from '@cio/ui/base/alert';
import { Badge } from '@cio/ui/base/badge';
import { Button } from '@cio/ui/base/button';
import * as Card from '@cio/ui/base/card';
import * as Code from '@cio/ui/custom/code';
import * as Field from '@cio/ui/base/field';
import * as Tabs from '@cio/ui/base/tabs';
import KeyIcon from '@lucide/svelte/icons/key';
import SparklesIcon from '@lucide/svelte/icons/sparkles';
import WandSparklesIcon from '@lucide/svelte/icons/wand-sparkles';
import BracesIcon from '@lucide/svelte/icons/braces';
let activeTab = $state('mcp');
let activeSetupTab = $state('claude-code');
let isRevokingKeyId = $state<string | null>(null);
let isRotatingKeyId = $state<string | null>(null);
$effect(() => {
if (!$isOrgAdmin || automationApi.isFetched) return;
automationApi.listKeys('mcp');
});
async function onGenerateKey() {
await automationApi.createKey('mcp');
}
async function onRevokeKey(keyId: string) {
if (!confirm(t.get('automation.keys.revoke_confirm'))) return;
isRevokingKeyId = keyId;
await automationApi.revokeKey(keyId);
isRevokingKeyId = null;
}
async function onRotateKey(keyId: string) {
if (!confirm(t.get('automation.keys.rotate_confirm'))) return;
isRotatingKeyId = keyId;
await automationApi.rotateKey(keyId);
isRotatingKeyId = null;
}
const mcpSecret = $derived(automationApi.generatedSecret);
const mcpKeys = $derived(automationApi.keys.filter((key) => key.type === 'mcp'));
const claudeCodeSnippet = $derived(getClaudeCodeSnippet(mcpSecret));
const codexSnippet = $derived(getCodexSnippet(mcpSecret));
const cursorSnippet = $derived(getCursorSnippet(mcpSecret));
</script>
<Field.Group class="w-full max-w-5xl! space-y-8 px-2">
{#if !$isOrgAdmin}
<Alert.Callout
variant="information"
title={$t('automation.permissions.disabled_title')}
description={$t('automation.permissions.disabled_description')}
class="w-full"
/>
{/if}
<Tabs.Root bind:value={activeTab} class="w-full">
<Tabs.List class="grid w-full grid-cols-3">
<Tabs.Trigger value="mcp">{$t('automation.tabs.mcp')}</Tabs.Trigger>
<Tabs.Trigger value="zapier" disabled>
<span>{$t('automation.tabs.zapier')}</span>
<Badge variant="outline" class="text-[10px]!">{$t('automation.coming_soon')}</Badge>
</Tabs.Trigger>
<Tabs.Trigger value="api" disabled>
<span>{$t('automation.tabs.api')}</span>
<Badge variant="outline" class="text-[10px]!">{$t('automation.coming_soon')}</Badge>
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="mcp" class="space-y-8">
<Field.Set>
<Field.Legend class="flex items-center gap-2">
<KeyIcon class="size-5" />
{$t('automation.mcp.keys.title')}
</Field.Legend>
<Field.Description>{$t('automation.mcp.keys.description')}</Field.Description>
<div class="flex flex-wrap items-center gap-3">
<Button
onclick={onGenerateKey}
variant="secondary"
loading={automationApi.isLoading}
disabled={!$isOrgAdmin || automationApi.isLoading}
>
{$t('automation.mcp.keys.generate')}
</Button>
</div>
{#if automationApi.generatedSecret}
<div class="mt-2 rounded-lg border border-amber-200 bg-amber-50 p-4">
<p class="ui:text-amber-800 text-sm font-medium">{$t('automation.mcp.keys.secret_once')}</p>
<div class="mt-3">
<Code.Root
code={automationApi.generatedSecret}
lang="bash"
hideLines={true}
class="ui:break-all ui:text-xs"
>
<Code.CopyButton />
</Code.Root>
</div>
</div>
{/if}
{#if $isOrgAdmin}
{#if mcpKeys.length > 0}
<div class="mt-6 grid gap-4 md:grid-cols-2">
{#each mcpKeys as key (key.id)}
<Card.Root>
<Card.Header>
<div class="flex items-start justify-between gap-3">
<div>
<Card.Title>{key.label}</Card.Title>
<Card.Description>{getMaskedAutomationSecret(key.secretPrefix)}</Card.Description>
</div>
<Badge variant={key.revokedAt ? 'secondary' : 'default'}>
{key.revokedAt ? $t('automation.keys.revoked') : $t('automation.keys.active')}
</Badge>
</div>
</Card.Header>
<Card.Content class="space-y-2 text-sm">
<p>
<span class="font-medium">{$t('automation.keys.created_at_label')}</span>
{new Date(key.createdAt).toLocaleString()}
</p>
<p>
<span class="font-medium">{$t('automation.keys.last_used_label')}</span>
{key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleString() : $t('automation.keys.never_used')}
</p>
<p>
<span class="font-medium">{$t('automation.keys.scopes_label')}</span>
{key.scopes.join(', ')}
</p>
</Card.Content>
<Card.Footer class="flex gap-2">
<Button
variant="secondary"
onclick={() => onRotateKey(key.id)}
loading={isRotatingKeyId === key.id}
disabled={!$isOrgAdmin || Boolean(key.revokedAt)}
>
{$t('automation.keys.rotate')}
</Button>
<Button
variant="destructive"
onclick={() => onRevokeKey(key.id)}
loading={isRevokingKeyId === key.id}
disabled={!$isOrgAdmin || Boolean(key.revokedAt)}
>
{$t('automation.keys.revoke')}
</Button>
</Card.Footer>
</Card.Root>
{/each}
</div>
{:else}
<Alert.Callout
variant="information"
title={$t('automation.keys.empty_title')}
description={$t('automation.keys.empty_description')}
class="mt-6 w-full"
/>
{/if}
{/if}
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend class="flex items-center gap-2">
<SparklesIcon class="size-5" />
{$t('automation.mcp.setup.title')}
</Field.Legend>
<Field.Description>{$t('automation.mcp.setup.description')}</Field.Description>
<Tabs.Root bind:value={activeSetupTab} class="w-full">
<Tabs.List class="inline-flex w-auto">
<Tabs.Trigger value="claude-code">{$t('automation.clients.claude_code')}</Tabs.Trigger>
<Tabs.Trigger value="codex">{$t('automation.clients.codex')}</Tabs.Trigger>
<Tabs.Trigger value="cursor">{$t('automation.clients.cursor')}</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="claude-code" class="mt-4">
<Card.Root>
<Card.Header>
<Card.Title>{$t('automation.clients.claude_code')}</Card.Title>
<Card.Description>{$t('automation.mcp.setup.claude_code_description')}</Card.Description>
</Card.Header>
<Card.Content>
<Code.FileName filename="terminal" />
<Code.Overflow>
<Code.Root code={claudeCodeSnippet} lang="bash" class="ui:w-full ui:border-none">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Card.Content>
</Card.Root>
</Tabs.Content>
<Tabs.Content value="codex" class="mt-4">
<Card.Root>
<Card.Header>
<Card.Title>{$t('automation.clients.codex')}</Card.Title>
<Card.Description>{$t('automation.mcp.setup.codex_description')}</Card.Description>
</Card.Header>
<Card.Content>
<Code.FileName filename="terminal" />
<Code.Overflow>
<Code.Root code={codexSnippet} lang="bash" class="ui:w-full ui:border-none">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Card.Content>
</Card.Root>
</Tabs.Content>
<Tabs.Content value="cursor" class="mt-4">
<Card.Root>
<Card.Header>
<Card.Title>{$t('automation.clients.cursor')}</Card.Title>
<Card.Description>{$t('automation.mcp.setup.cursor_description')}</Card.Description>
</Card.Header>
<Card.Content>
<Code.FileName filename="mcp.json" />
<Code.Overflow>
<Code.Root code={cursorSnippet} lang="json" class="ui:w-full ui:border-none">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Card.Content>
</Card.Root>
</Tabs.Content>
</Tabs.Root>
</Field.Set>
</Tabs.Content>
<Tabs.Content value="zapier" class="mt-6">
<Alert.Callout
variant="information"
title={$t('automation.tabs.zapier')}
description={$t('automation.coming_soon')}
class="w-full"
/>
</Tabs.Content>
<Tabs.Content value="api" class="mt-6">
<Alert.Callout
variant="information"
title={$t('automation.tabs.api')}
description={$t('automation.coming_soon')}
class="w-full"
/>
</Tabs.Content>
</Tabs.Root>
</Field.Group>
@@ -1 +1,3 @@
export { default as AutomationPage } from './automation.svelte';
export { default as ApiAutomationPage } from './api.svelte';
export { default as McpAutomationPage } from './mcp.svelte';
export { default as ZapierAutomationPage } from './zapier.svelte';
@@ -0,0 +1,239 @@
<script lang="ts">
import { automationApi } from '$features/automation/api/automation.svelte';
import {
getClaudeCodeSnippet,
getCodexSnippet,
getCursorSnippet,
getMaskedAutomationSecret
} from '$features/automation/utils/automation-utils';
import { isOrgAdmin } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import * as Alert from '@cio/ui/base/alert';
import { Badge } from '@cio/ui/base/badge';
import * as Code from '@cio/ui/custom/code';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import * as Field from '@cio/ui/base/field';
import * as Table from '@cio/ui/base/table';
import * as Tabs from '@cio/ui/base/tabs';
import { IconButton } from '@cio/ui/custom/icon-button';
import CoinsIcon from '@lucide/svelte/icons/coins';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import GaugeIcon from '@lucide/svelte/icons/gauge';
import KeyIcon from '@lucide/svelte/icons/key';
import PlusIcon from '@lucide/svelte/icons/plus';
import SparklesIcon from '@lucide/svelte/icons/sparkles';
import { ActivityCard } from '$features/ui';
let activeSetupTab = $state('cursor');
let generatedSecret = $state<string | null>(null);
async function onGenerateKey() {
await automationApi.createKey('mcp');
generatedSecret = automationApi.generatedSecret;
automationApi.clearGeneratedSecret();
}
async function onRevokeKey(keyId: string) {
if (!confirm(t.get('automation.keys.revoke_confirm'))) return;
await automationApi.revokeKey(keyId);
}
async function onRotateKey(keyId: string) {
if (!confirm(t.get('automation.keys.rotate_confirm'))) return;
await automationApi.rotateKey(keyId);
generatedSecret = automationApi.generatedSecret;
automationApi.clearGeneratedSecret();
}
const mcpKeys = $derived(automationApi.keys.filter((key) => key.type === 'mcp'));
const usageCards = $derived.by(() => {
if (!automationApi.usage) return [];
return [
{
icon: CoinsIcon,
title: t.get('automation.mcp.usage.credits_title'),
percentage: automationApi.usage.monthlyCreditsRemaining,
description: `${t.get('automation.mcp.usage.used_label')} ${automationApi.usage.monthlyCreditsUsed} / ${automationApi.usage.monthlyCreditsIncluded}`,
hidePercentage: true
},
{
icon: KeyIcon,
title: t.get('automation.mcp.usage.keys_title'),
percentage: automationApi.usage.activeKeys,
description: `${t.get('automation.mcp.usage.used_label')} ${automationApi.usage.activeKeys} / ${automationApi.usage.maxActiveKeys}`,
hidePercentage: true
},
{
icon: GaugeIcon,
title: t.get('automation.mcp.usage.rate_limits_title'),
percentage: `${automationApi.usage.rateLimits.perKey.readPerMinute} / ${automationApi.usage.rateLimits.perKey.writePerMinute} / ${automationApi.usage.rateLimits.perKey.publishPerMinute}`,
description: `${t.get('automation.mcp.usage.rate_limits_description')}`,
hidePercentage: true
}
];
});
const isLastUsageCard = (index: number) => index === usageCards.length - 1;
</script>
<Field.Group class="mx-auto w-full space-y-2">
{#if !$isOrgAdmin}
<Alert.Callout
variant="information"
title={$t('automation.permissions.disabled_title')}
description={$t('automation.permissions.disabled_description')}
class="w-full"
/>
{/if}
{#if $isOrgAdmin && automationApi.usage}
<div class="grid gap-4 md:grid-cols-3">
{#each usageCards as card, i (i)}
<ActivityCard
activity={card}
classes={{
percentage: isLastUsageCard(i) ? 'text-lg' : undefined
}}
/>
{/each}
</div>
{/if}
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 font-medium">
<KeyIcon class="size-5" />
{$t('automation.mcp.keys.title')}
</div>
{#if $isOrgAdmin}
<IconButton
onclick={onGenerateKey}
disabled={automationApi.isLoading}
tooltip={$t('automation.mcp.keys.generate')}
>
<PlusIcon size={16} />
</IconButton>
{/if}
</div>
<p class="ui:text-muted-foreground text-sm">{$t('automation.mcp.keys.description')}</p>
{#if generatedSecret}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
<p class="ui:text-amber-800 text-sm font-medium">{$t('automation.mcp.keys.secret_once')}</p>
<div class="mt-3">
<Code.Root code={generatedSecret} lang="bash" hideLines={true} class="ui:break-all ui:text-xs">
<Code.CopyButton />
</Code.Root>
</div>
</div>
{/if}
{#if $isOrgAdmin}
{#if mcpKeys.length > 0}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{$t('automation.keys.table_key')}</Table.Head>
<Table.Head>{$t('automation.keys.table_status')}</Table.Head>
<Table.Head>{$t('automation.keys.table_created')}</Table.Head>
<Table.Head>{$t('automation.keys.table_last_used')}</Table.Head>
<Table.Head class="w-10"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each mcpKeys as key (key.id)}
<Table.Row>
<Table.Cell>
<div>
<p class="font-medium">{key.label}</p>
<p class="ui:text-muted-foreground text-xs">
{getMaskedAutomationSecret(key.secretPrefix)}
</p>
</div>
</Table.Cell>
<Table.Cell>
<Badge variant={key.revokedAt ? 'secondary' : 'default'}>
{key.revokedAt ? $t('automation.keys.revoked') : $t('automation.keys.active')}
</Badge>
</Table.Cell>
<Table.Cell class="ui:text-muted-foreground text-sm">
{new Date(key.createdAt).toLocaleDateString()}
</Table.Cell>
<Table.Cell class="ui:text-muted-foreground text-sm">
{key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleDateString() : $t('automation.keys.never_used')}
</Table.Cell>
<Table.Cell>
{#if !key.revokedAt}
<DropdownMenu.Root>
<DropdownMenu.Trigger class="inline-flex items-center justify-center">
<IconButton aria-label="actions">
<EllipsisVerticalIcon size={16} />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => onRotateKey(key.id)}>
{$t('automation.keys.rotate')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => onRevokeKey(key.id)} class="ui:text-destructive">
{$t('automation.keys.revoke')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{:else}
<Alert.Callout
variant="information"
title={$t('automation.keys.empty_title')}
description={$t('automation.keys.empty_description')}
class="w-full"
/>
{/if}
{/if}
</div>
<Field.Set class="gap-3!">
<Field.Legend class="flex items-center gap-2">
<SparklesIcon class="size-5" />
{$t('automation.mcp.setup.title')}
</Field.Legend>
<Field.Description>{$t('automation.mcp.setup.description')}</Field.Description>
<Tabs.Root bind:value={activeSetupTab} class="w-full">
<Tabs.List class="inline-flex w-auto">
<Tabs.Trigger value="cursor">{$t('automation.clients.cursor')}</Tabs.Trigger>
<Tabs.Trigger value="claude-code">{$t('automation.clients.claude_code')}</Tabs.Trigger>
<Tabs.Trigger value="codex">{$t('automation.clients.codex')}</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="claude-code" class="mt-4">
<Code.Overflow>
<Code.Root code={getClaudeCodeSnippet(generatedSecret)} lang="bash">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Tabs.Content>
<Tabs.Content value="codex" class="mt-4">
<Code.Overflow>
<Code.Root code={getCodexSnippet(generatedSecret)} lang="bash">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Tabs.Content>
<Tabs.Content value="cursor" class="mt-4">
<Code.Overflow>
<Code.Root code={getCursorSnippet(generatedSecret)} lang="json">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Tabs.Content>
</Tabs.Root>
</Field.Set>
</Field.Group>
@@ -0,0 +1,14 @@
<script lang="ts">
import { t } from '$lib/utils/functions/translations';
import * as Alert from '@cio/ui/base/alert';
import * as Field from '@cio/ui/base/field';
</script>
<Field.Group class="w-full max-w-5xl! space-y-8 px-2">
<Alert.Callout
variant="information"
title={$t('automation.tabs.zapier')}
description={$t('automation.coming_soon')}
class="w-full"
/>
</Field.Group>
@@ -1,15 +1,18 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
export type ListAutomationKeysRequest = (typeof classroomio.organization)['automation']['keys']['$get'];
export type GetAutomationUsageRequest = (typeof classroomio.organization)['automation']['usage']['$get'];
export type CreateAutomationKeyRequest = (typeof classroomio.organization)['automation']['keys']['$post'];
export type RevokeAutomationKeyRequest = (typeof classroomio.organization)['automation']['keys'][':keyId']['$delete'];
export type RotateAutomationKeyRequest =
(typeof classroomio.organization)['automation']['keys'][':keyId']['rotate']['$post'];
export type ListAutomationKeysSuccess = Extract<InferResponseType<ListAutomationKeysRequest>, { success: true }>;
export type GetAutomationUsageSuccess = Extract<InferResponseType<GetAutomationUsageRequest>, { success: true }>;
export type CreateAutomationKeySuccess = Extract<InferResponseType<CreateAutomationKeyRequest>, { success: true }>;
export type RevokeAutomationKeySuccess = Extract<InferResponseType<RevokeAutomationKeyRequest>, { success: true }>;
export type RotateAutomationKeySuccess = Extract<InferResponseType<RotateAutomationKeyRequest>, { success: true }>;
export type AutomationKey = ListAutomationKeysSuccess['data'][number];
export type AutomationKeyType = AutomationKey['type'];
export type AutomationUsage = GetAutomationUsageSuccess['data'];
@@ -6,7 +6,7 @@
import { shortenName } from '$lib/utils/functions/string';
import { Vote } from '$features/ui';
import { CircleCheckIcon } from '$features/ui/icons';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
interface CommentAuthor {
id: number | string | null;
@@ -65,7 +65,7 @@
{/if}
</header>
<article class="prose prose-sm sm:prose p-2">
{@html sanitizeHtml(comment.body ?? '')}
<SafeHtmlContent content={comment.body ?? ''} />
</article>
</div>
</div>
@@ -22,7 +22,7 @@
import { CommunityDeleteModal, CommunityCommentItem } from '$features/community/components';
import type { CommunityQuestionSuccess } from '../utils/types';
import { currentCommunityQuestion } from '../utils/store';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
interface Props {
slug: string;
@@ -211,7 +211,7 @@
</div>
{:else}
<section class="prose prose-sm sm:prose p-2">
{@html sanitizeHtml(communityApi.question?.body ?? '')}
<SafeHtmlContent content={communityApi.question?.body ?? ''} />
</section>
{/if}
</div>
@@ -20,6 +20,7 @@
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
import { calcCourseProgress, calcProgressRate } from '$features/course/utils/functions';
import CardDropdown from './card-dropdown.svelte';
import CoursePublishBadge from './course-publish-badge.svelte';
import type { OrgCourses, UserEnrolledCourses } from '$features/course/types';
import type { OrgPublicCourses } from '$features/org/utils/types';
@@ -196,13 +197,7 @@
</div>
{/if}
{:else}
<Badge variant={isPublished ? 'default' : 'outline'}>
{#if isPublished}
{$t('courses.course_card.published')}
{:else}
{$t('courses.course_card.unpublished')}
{/if}
</Badge>
<CoursePublishBadge {isPublished} />
{/if}
</div>
</div>
@@ -1,6 +1,7 @@
<script lang="ts">
import { COURSE_SORT_OPTIONS } from '../utils/constants';
import * as Popover from '@cio/ui/base/popover';
import * as ToggleGroup from '@cio/ui/base/toggle-group';
import { Button } from '@cio/ui/base/button';
import { Spinner } from '@cio/ui/base/spinner';
import { Checkbox } from '@cio/ui/base/checkbox';
@@ -27,6 +28,7 @@
interface Props {
selectedId?: string;
selectedOrder?: 'asc' | 'desc';
selectedTags?: string[];
tagGroups?: FilterTagGroup[];
sortOptions?: readonly SortOption[];
@@ -37,6 +39,7 @@
let {
selectedId = $bindable('0'),
selectedOrder = $bindable('desc' as 'asc' | 'desc'),
selectedTags = [],
tagGroups = [],
sortOptions = COURSE_SORT_OPTIONS,
@@ -47,8 +50,7 @@
let isOpen = $state(false);
const hasActiveFilters = $derived(selectedId !== '0' || selectedTags.length > 0);
const hasActiveTagFilters = $derived(selectedTags.length > 0);
const hasActiveFilters = $derived(selectedId !== '0' || selectedOrder !== 'desc' || selectedTags.length > 0);
function isTagSelected(tagSlug: string) {
return selectedTags.includes(tagSlug);
@@ -66,7 +68,7 @@
<FilterIcon size={16} />
{/if}
</IconButton>
{#if hasActiveTagFilters}
{#if hasActiveFilters}
<span
class="ui:bg-primary absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border border-white"
aria-hidden="true"
@@ -103,6 +105,26 @@
</div>
</div>
<div class="space-y-2">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
{$t('courses.tag_filters.order')}
</p>
<ToggleGroup.Root
type="single"
variant="outline"
size="sm"
value={selectedOrder}
onValueChange={(value) => {
if (value === 'asc' || value === 'desc') {
selectedOrder = value;
}
}}
>
<ToggleGroup.Item value="asc">{$t('courses.tag_filters.ascending')}</ToggleGroup.Item>
<ToggleGroup.Item value="desc">{$t('courses.tag_filters.descending')}</ToggleGroup.Item>
</ToggleGroup.Root>
</div>
<div class="space-y-3">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('courses.tag_filters.tags')}</p>
{#if tagGroups.length === 0}
@@ -3,21 +3,35 @@
import * as Sidebar from '@cio/ui/base/sidebar';
import BellIcon from '@lucide/svelte/icons/bell';
import { Button } from '@cio/ui/base/button';
import { IconButton } from '@cio/ui/custom/icon-button';
import * as Popover from '@cio/ui/base/popover';
import Search from '$features/ui/search.svelte';
import { HoverableItem, PreviewIcon } from '@cio/ui/custom/moving-icons';
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
import * as Empty from '@cio/ui/base/empty';
import { currentOrg } from '$lib/utils/store/org';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { isStudentExperience } from '$lib/utils/store/app';
import { setupProgressApi } from '$features/setup/api/setup-progress.svelte';
import { courseApi } from '$features/course/api';
import { openCoursePreview } from '$features/course/utils/course-preview';
import { t } from '$lib/utils/functions/translations';
import CoursePublishBadge from './course-publish-badge.svelte';
const siteName = $derived($currentOrg.siteName);
const showCoursePublishBadge = $derived(!$isStudentExperience);
$effect(() => {
if (!siteName) return;
setupProgressApi.fetchSetupProgress(siteName);
});
function handlePreview() {
openCoursePreview({
courseId: courseApi.course?.id ?? '',
courseSlug: courseApi.course?.slug,
currentOrgDomain: $currentOrgDomain
});
}
</script>
<header
@@ -30,14 +44,18 @@
<Separator orientation="vertical" />
</div>
<p class="max-w-2xs truncate text-sm font-medium">
{courseApi.course?.title || ''}
</p>
<div class="flex min-w-0 items-center gap-2">
<p class="max-w-2xs truncate text-sm font-medium">
{courseApi.course?.title || ''}
</p>
{#if showCoursePublishBadge}
<CoursePublishBadge isPublished={courseApi.course?.isPublished} />
{/if}
</div>
<span class="grow"></span>
<Search />
<Popover.Root>
<Popover.Trigger>
<Button variant="outline" size="icon">
@@ -56,11 +74,24 @@
<Empty.Content>
<Button variant="outline" size="sm">
<RefreshCcwIcon />
Refresh
{$t('common.refresh')}
</Button>
</Empty.Content>
</Empty.Root>
</Popover.Content>
</Popover.Root>
<HoverableItem>
{#snippet children(isHovered)}
<IconButton
onclick={handlePreview}
disabled={!courseApi.course?.id}
tooltip={$t('course.header.preview')}
aria-label={$t('course.header.preview')}
>
<PreviewIcon {isHovered} size={16} />
</IconButton>
{/snippet}
</HoverableItem>
</div>
</header>
@@ -0,0 +1,18 @@
<script lang="ts">
import { Badge } from '@cio/ui/base/badge';
import { t } from '$lib/utils/functions/translations';
interface Props {
isPublished?: boolean;
}
let { isPublished = false }: Props = $props();
</script>
<Badge variant={isPublished ? 'default' : 'outline'}>
{#if isPublished}
{$t('courses.course_card.published')}
{:else}
{$t('courses.course_card.unpublished')}
{/if}
</Badge>
@@ -1,6 +1,6 @@
<script lang="ts">
import { questionnaire } from './store';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { t } from '$lib/utils/functions/translations';
import { InputField } from '@cio/ui/custom/input-field';
@@ -49,9 +49,9 @@
</div>
<article class="preview prose prose-sm sm:prose mt-3 p-2">
{@html sanitizeHtml(
$questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.description.no')
)}
<SafeHtmlContent
content={$questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.description.no')}
/>
</article>
{:else}
<InputField
@@ -21,7 +21,7 @@
import { toApiPayload, type AnswerData } from '@cio/question-types';
import { exerciseApi, presignApi } from '$features/course/api';
import type { SubmissionListItem } from '$features/course/utils/types';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { ContentType } from '@cio/utils/constants/content';
@@ -422,9 +422,10 @@
</div>
<article class="preview prose prose-sm sm:prose mt-3 p-2">
{@html sanitizeHtml(
$questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.view_mode.no_description')
)}
<SafeHtmlContent
content={$questionnaire.description ||
$t('course.navItem.lessons.exercises.all_exercises.view_mode.no_description')}
/>
</article>
<Button onclick={handleStart} type="button" class="float-right my-5">
@@ -8,7 +8,7 @@
import { lessonApi } from '$features/course/api';
import { diffLines } from 'diff';
import { courseApi } from '$features/course/api';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
@@ -83,7 +83,7 @@
);
}
function fetchLessonHistory(lessonId: string, locale: string, endRange: number) {
function fetchLessonHistory(lessonId: string, locale: TLocale, endRange: number) {
untrack(async () => {
try {
isMoreHistoryLoading = true;
@@ -100,9 +100,18 @@
const data = response.data;
// Filter out duplicates based on timestamp
const toLessonHistory = (item: (typeof data)[number]): LessonHistory => ({
old_content: item.oldContent ?? '',
new_content: item.newContent ?? '',
timestamp: item.timestamp ? new Date(item.timestamp) : new Date(),
locale: (item.locale as TLocale) ?? 'en',
lesson_id: item.lessonId ?? ''
});
const existingTimestamps = new Set(lessonHistory.map((item) => new Date(item.timestamp).getMinutes()));
const newEntries = data.filter((item) => !existingTimestamps.has(new Date(item.timestamp!).getMinutes()));
const newEntries = data
.filter((item) => !existingTimestamps.has(new Date(item.timestamp ?? 0).getMinutes()))
.map(toLessonHistory);
lessonHistory = removeDuplicate([...lessonHistory, ...newEntries]);
if (lessonHistory.length > 0) {
@@ -206,7 +215,7 @@
{#key lessonId}
<HTMLRender id="display" className="m-auto">
<div class="amen">
{@html sanitizeHtml(content)}
<SafeHtmlContent {content} />
</div>
</HTMLRender>
{/key}
@@ -222,7 +231,7 @@
</p>
<div>
{#each lessonHistory as version, index}
{#each lessonHistory as version, index (index)}
<button
onclick={() => updateContentVersion(version, index)}
class="flex w-full cursor-pointer items-start p-4 px-10 hover:bg-gray-200 dark:hover:bg-neutral-700 {index ==
@@ -1,6 +1,6 @@
<script lang="ts">
import { HTMLRender, TextEditor } from '$features/ui';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
import { lessonApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations';
@@ -43,10 +43,6 @@
}
const content = $derived(lessonApi.translations[lessonId]?.[lessonApi.currentLocale] || '');
$effect(() => {
console.log('content', content);
});
</script>
{#if mode === MODES.edit}
@@ -67,7 +63,7 @@
<!-- View Mode -->
{#if !isHtmlValueEmpty(content)}
<HTMLRender>
{@html sanitizeHtml(content)}
<SafeHtmlContent {content} />
</HTMLRender>
{:else if hasAtLeastOneTranslation}
<p class="text-md py-2 font-normal italic dark:text-white">
@@ -1,12 +1,12 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Badge } from '@cio/ui/base/badge';
import * as Table from '@cio/ui/base/table';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte';
import { t } from '$lib/utils/functions/translations';
import CoursePublishBadge from './course-publish-badge.svelte';
interface Props {
id?: string;
@@ -88,13 +88,7 @@
<Table.Cell>{totalLessons}</Table.Cell>
<Table.Cell>{totalStudents}</Table.Cell>
<Table.Cell>
<Badge variant={isPublished ? 'default' : 'outline'}>
{#if isPublished}
{$t('courses.course_card.published')}
{:else}
{$t('courses.course_card.unpublished')}
{/if}
</Badge>
<CoursePublishBadge {isPublished} />
</Table.Cell>
{/if}
<Table.Cell class="text-center">
@@ -4,7 +4,7 @@
import { calDateDiff } from '$lib/utils/functions/date';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { t } from '$lib/utils/functions/translations';
import type { Feed } from '$features/course/utils/types';
@@ -59,7 +59,7 @@
{#if !isHtmlValueEmpty(feed.content || '')}
<HTMLRender className="w-[80%]">
<div>
{@html sanitizeHtml(feed.content || '')}
<SafeHtmlContent content={feed.content || ''} />
</div>
</HTMLRender>
{/if}
@@ -35,7 +35,7 @@
import { IconButton } from '@cio/ui/custom/icon-button';
import UpdateDescription from '$features/course/components/exercise/update-description.svelte';
import { ContentNavigationActions } from '$features/course/components/lesson';
import { RoleBasedSecurity } from '$features/ui';
import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
import { getOrderedNavigableContent } from '$features/course/utils/content';
import { Empty } from '@cio/ui/custom/empty';
import VideoIcon from '@lucide/svelte/icons/video';
@@ -266,6 +266,8 @@
</div>
</div>
{/if}
<RefreshPageData />
</RoleBasedSecurity>
</div>
</Page.Action>
@@ -18,6 +18,7 @@
import { globalStore } from '$lib/utils/store/app';
import { currentOrg } from '$lib/utils/store/org';
import { snackbar } from '$features/ui/snackbar/store';
import { RefreshPageData } from '$features/ui';
import LessonVersionHistory from '$features/course/components/lesson/lesson-version-history.svelte';
import { courseApi, lessonApi } from '$features/course/api';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
@@ -260,6 +261,7 @@
if (!lessonApi.lesson || !lessonId) return;
if (!lessonApi.isDirty) return;
if ($isLoading) return;
console.log('autoSave');
autoSave();
});
@@ -268,6 +270,7 @@
let didHandleExitEdit = false;
$effect(() => {
if (!lessonId) return;
console.log('didHandleExitEdit');
const currentParam = $page.url.searchParams.get('mode');
const prev = prevModeParam;
@@ -345,8 +348,10 @@
</IconButton>
</div>
<LanguageSelector />
<RefreshPageData onRefresh={() => lessonApi.get(courseId, lessonId)} />
</RoleBasedSecurity>
<LanguageSelector />
</div>
</Page.Action>
</Page.Header>
@@ -0,0 +1,27 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { snackbar } from '$features/ui/snackbar/store';
interface OpenCoursePreviewOptions {
courseId: string;
courseSlug?: string | null;
currentOrgDomain?: string;
}
export function openCoursePreview({ courseId, courseSlug, currentOrgDomain = '' }: OpenCoursePreviewOptions) {
if (!courseSlug) {
snackbar.info('course.header.preview_missing_slug');
if (courseId) {
goto(resolve(`/courses/${courseId}/settings`, {}));
}
return false;
}
const origin = currentOrgDomain || window.location.origin;
const link = new URL(resolve(`/course/${courseSlug}`, {}), origin).toString();
window.open(link, '_blank', 'noopener,noreferrer');
return true;
}
@@ -8,10 +8,12 @@
import * as Select from '@cio/ui/base/select';
import * as Table from '@cio/ui/base/table';
import { Empty } from '@cio/ui/custom/empty';
import { IconButton } from '@cio/ui/custom/icon-button';
import { InputField } from '@cio/ui/custom/input-field';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import TagIcon from '@lucide/svelte/icons/tag';
import { tagApi } from '$features/tag/api';
@@ -222,7 +224,7 @@
</Table.Header>
<Table.Body>
{#each tagApi.tagGroups as group (group.id)}
<Table.Row class="ui:bg-muted/20">
<Table.Row class="group ui:bg-muted/20">
<Table.Cell colspan={5}>
<div class="flex items-start justify-between gap-4 py-1">
<div class="space-y-1">
@@ -231,9 +233,22 @@
<p class="ui:text-muted-foreground max-w-[320px] truncate text-xs">{group.description}</p>
{/if}
</div>
<Button variant="ghost-default" size="sm" onclick={() => openEditTagGroupModal(group)}>
{$t('tags_admin.actions.edit_group')}
</Button>
<DropdownMenu.Root>
<DropdownMenu.Trigger class="flex shrink-0 items-center justify-center">
<IconButton
variant="outline"
class="pointer-events-none opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"
aria-label={$t('tags_admin.actions.actions_menu')}
>
<EllipsisVerticalIcon size={16} />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => openEditTagGroupModal(group)}>
{$t('tags_admin.actions.edit_group')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</Table.Cell>
</Table.Row>
@@ -246,7 +261,7 @@
</Table.Row>
{:else}
{#each group.tags as tag (tag.id)}
<Table.Row>
<Table.Row class="group">
<Table.Cell>
<div class="flex items-center gap-2">
<span
@@ -269,9 +284,22 @@
</Table.Cell>
<Table.Cell>{group.name}</Table.Cell>
<Table.Cell class="text-right">
<Button variant="ghost-default" size="sm" onclick={() => openEditTagModal(tag)}>
{$t('tags_admin.actions.edit_tag')}
</Button>
<DropdownMenu.Root>
<DropdownMenu.Trigger class="inline-flex items-center justify-center">
<IconButton
variant="outline"
class="pointer-events-none opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"
aria-label={$t('tags_admin.actions.actions_menu')}
>
<EllipsisVerticalIcon size={16} />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => openEditTagModal(tag)}>
{$t('tags_admin.actions.edit_tag')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
@@ -14,15 +14,23 @@
interface Props {
activity: Activity;
className?: string;
classes?: {
container?: string;
icon?: string;
title?: string;
percentage?: string;
description?: string;
};
}
let { activity, className = '' }: Props = $props();
let { activity, className = '', classes = {} }: Props = $props();
</script>
<div
class={cn(
'flex w-full flex-col items-center justify-evenly gap-2 rounded-xl border p-3 md:flex-row md:p-5',
className
'flex w-full flex-col items-center justify-evenly gap-2 rounded-xl border p-3 md:flex-row md:px-5 md:py-3',
className,
classes.container
)}
>
<div class="bg-primary-200 w-fit rounded-full p-4 text-black">
@@ -30,17 +38,19 @@
</div>
<div>
<p class="text-center text-sm font-medium text-gray-600 md:text-left dark:text-gray-200">
<p class={cn('text-center text-sm font-medium text-gray-600 md:text-left dark:text-gray-200', classes.title)}>
{activity.title}
</p>
<p class="text-center text-2xl md:text-left">
<p class={cn('text-center text-2xl md:text-left', classes.percentage)}>
{activity.percentage}
{#if !activity.hidePercentage}
%
{/if}
</p>
{#if activity.description}
<p class="text-center text-xs text-gray-500 md:text-left md:text-sm dark:text-gray-300">
<p
class={cn('text-center text-xs text-gray-500 md:text-left md:text-sm dark:text-gray-300', classes.description)}
>
{activity.description}
</p>
{/if}
@@ -1,4 +1,5 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { page } from '$app/state';
import * as Avatar from '@cio/ui/base/avatar';
import { t } from '$lib/utils/functions/translations';
@@ -19,7 +20,8 @@
hideGoogleAuth?: boolean;
redirectPathname?: string;
handleSubmit?: () => void;
children?: import('svelte').Snippet;
children?: Snippet;
getPasswordAuthAlternative?: Snippet;
}
let {
@@ -30,7 +32,8 @@
hideGoogleAuth = false,
redirectPathname = '',
handleSubmit = () => {},
children
children,
getPasswordAuthAlternative
}: Props = $props();
async function signInWithGoogle() {
@@ -100,12 +103,17 @@
<Separator />
<span class="ui:bg-card ui:text-muted-foreground absolute px-2 text-sm"> Or continue With </span>
</div>
<Button variant="outline" onclick={signInWithGoogle} disabled={isLoading} class="w-full">
<GoogleIconColored />
<span>
{isLogin ? $t('login.login_with_google') : $t('login.signup_with_google')}
</span>
</Button>
{#if getPasswordAuthAlternative}
{@render getPasswordAuthAlternative()}
{:else}
<Button variant="outline" onclick={signInWithGoogle} disabled={isLoading} class="w-full">
<GoogleIconColored />
<span>
{isLogin ? $t('login.login_with_google') : $t('login.signup_with_google')}
</span>
</Button>
{/if}
</div>
{/if}
</Card.Content>
@@ -1,5 +1,6 @@
<script lang="ts">
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
interface Props {
code?: string;
}
@@ -8,5 +9,5 @@
</script>
<div class="mb-3 rounded-md bg-gray-200 p-5 dark:bg-gray-500">
{@html sanitizeHtml(code)}
<SafeHtmlContent content={code} />
</div>
@@ -32,6 +32,7 @@
import { Button } from '@cio/ui/base/button';
import { courseApi } from '$features/course/api';
import { generateSlug } from '@cio/utils/functions';
import { openCoursePreview } from '$features/course/utils/course-preview';
import * as Sidebar from '@cio/ui/base/sidebar';
import { useSidebar } from '@cio/ui/base/sidebar';
@@ -164,9 +165,12 @@
syncCourseStore(course);
}
async function handlePreview() {
const link = `${$currentOrgDomain}/course/${course.slug}`;
window.open(link, '_blank');
function handlePreview() {
openCoursePreview({
courseId,
courseSlug: course.slug,
currentOrgDomain: $currentOrgDomain
});
}
function setter(value: any, setterKey: string) {
@@ -193,7 +197,7 @@
</Button>
<HoverableItem>
{#snippet children(isHovered)}
<IconButton onclick={handlePreview} disabled={loading || !course.slug}>
<IconButton onclick={handlePreview} disabled={loading} aria-label={$t('course.header.preview')}>
<PreviewIcon {isHovered} size={16} />
</IconButton>
{/snippet}
@@ -5,7 +5,7 @@
import { isCourseFree, calcCourseDiscount } from '$lib/utils/functions/course';
import { currentOrg } from '$lib/utils/store/org';
import { goto } from '$app/navigation';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import HTMLRender from '$features/ui/html-render.svelte';
import PaymentModal from './payment-modal.svelte';
import type { Course } from '$features/course/utils/types';
@@ -175,7 +175,9 @@
<!-- Gift Container -->
{#if courseData?.metadata?.reward?.show}
<div class="flex flex-col items-center border-t border-b border-gray-300 p-10">
<HTMLRender>{@html sanitizeHtml(get(courseData, 'metadata.reward.description', '') as string)}</HTMLRender>
<HTMLRender
><SafeHtmlContent content={get(courseData, 'metadata.reward.description', '') as string} /></HTMLRender
>
</div>
{/if}
</aside>
@@ -4,7 +4,7 @@
import { page } from '$app/state';
import { onMount, onDestroy } from 'svelte';
import { resolve } from '$app/paths';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import PlayIcon from '@lucide/svelte/icons/play';
import { getLectureNo } from '$features/course/utils/functions';
@@ -215,7 +215,7 @@
</h3>
<ul class="list font-light">
<HTMLRender>{@html sanitizeHtml(get(courseData, 'metadata.requirements', ''))}</HTMLRender>
<HTMLRender><SafeHtmlContent content={get(courseData, 'metadata.requirements', '')} /></HTMLRender>
</ul>
</NavSection>
{/if}
@@ -228,7 +228,7 @@
</h3>
<HTMLRender className="dark:text-white text-sm font-light">
{@html sanitizeHtml(get(courseData, 'metadata.description', ''))}
<SafeHtmlContent content={get(courseData, 'metadata.description', '')} />
</HTMLRender>
</NavSection>
{/if}
@@ -238,7 +238,7 @@
<NavSection id="goals">
<h3 class="mt-0 mb-3 text-2xl">{$t('course.navItem.landing_page.learn')}</h3>
<ul class="list font-light">
<HTMLRender>{@html sanitizeHtml(get(courseData, 'metadata.goals', ''))}</HTMLRender>
<HTMLRender><SafeHtmlContent content={get(courseData, 'metadata.goals', '')} /></HTMLRender>
</ul>
</NavSection>
{/if}
@@ -17,6 +17,7 @@ export * from './question';
export { default as UploadWidget } from './upload-widget/upload-widget.svelte';
export { default as PageLoadProgress } from './page-load-progress.svelte';
export { default as PoweredBy } from './upgrade-powered-by.svelte';
export { default as RefreshPageData } from './refresh-page-data.svelte';
export { default as RoleBasedSecurity } from './role-based-security.svelte';
export { default as Search } from './search.svelte';
export { default as SenjaEmbed } from './senja-embed.svelte';
@@ -108,7 +108,36 @@ const baseNavConfig: NavItemConfig[] = [
icon: BracesIcon,
requiresAdmin: true,
disableWhenNotAdmin: true,
matchPattern: '^/org/[^/]+/automation(/.*)?$'
useHashUrl: true,
matchPattern: '^/org/[^/]+/automation(/.*)?$',
items: [
{
titleKey: 'automation.tabs.mcp',
path: '/automation/mcp'
},
{
titleKey: 'automation.tabs.zapier',
path: '/automation/zapier'
},
{
titleKey: 'automation.tabs.api',
path: '/automation/api'
}
],
nestedRoutes: [
{
path: 'mcp',
titleKey: 'automation.tabs.mcp'
},
{
path: 'zapier',
titleKey: 'automation.tabs.zapier'
},
{
path: 'api',
titleKey: 'automation.tabs.api'
}
]
},
{
titleKey: 'org_navigation.settings',
@@ -0,0 +1,43 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
import { IconButton } from '@cio/ui/custom/icon-button';
import { t } from '$lib/utils/functions/translations';
interface Props {
disabled?: boolean;
onRefresh?: () => Promise<unknown> | unknown;
}
let { disabled = false, onRefresh }: Props = $props();
let isRefreshing = $state(false);
async function handleRefresh() {
if (disabled || isRefreshing) return;
isRefreshing = true;
try {
if (onRefresh) {
await onRefresh();
return;
}
await invalidateAll();
} finally {
isRefreshing = false;
}
}
</script>
<IconButton
onclick={handleRefresh}
disabled={disabled || isRefreshing}
tooltip={t.get('common.refresh')}
tooltipSide="bottom"
aria-label={t.get('common.refresh')}
>
<RefreshCcwIcon class={isRefreshing ? 'animate-spin' : ''} size={16} />
</IconButton>