feat: implement automation feature
This commit is contained in:
@@ -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>
|
||||
+4
-4
@@ -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">
|
||||
|
||||
+15
-6
@@ -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>
|
||||
|
||||
+8
-4
@@ -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}
|
||||
|
||||
+4
-2
@@ -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>
|
||||
@@ -0,0 +1,71 @@
|
||||
// SaaS default CSP domains, baked in at build time.
|
||||
// Self-hosted (adapter-node) starts with empty lists; runtime domains
|
||||
// are added via env vars in hooks.server.ts so pre-built Docker images stay configurable.
|
||||
|
||||
const saasDefaults = {
|
||||
scriptSrc: [
|
||||
'https://assets.cdn.clsrio.com',
|
||||
'https://cdnjs.cloudflare.com',
|
||||
'https://*.posthog.com',
|
||||
'https://*.senja.io',
|
||||
'https://umami.hz.oncws.com',
|
||||
'https://www.youtube.com',
|
||||
'https://youtube.com',
|
||||
'https://google.com'
|
||||
],
|
||||
styleSrc: [
|
||||
'https://cdn.plyr.io',
|
||||
'https://unpkg.com/katex@0.12.0/dist/katex.min.css',
|
||||
'https://assets.cdn.clsrio.com/eqneditor_1.css',
|
||||
'https://fonts.googleapis.com'
|
||||
],
|
||||
connectSrc: [
|
||||
'https://*.classroomio.com',
|
||||
'https://classroomio.com',
|
||||
'https://app.classroomio.com',
|
||||
'https://api.classroomio.com',
|
||||
'https://pgrest.classroomio.com',
|
||||
'https://play.classroomio.com',
|
||||
'wss://*.classroomio.com',
|
||||
'https://assets.cdn.clsrio.com',
|
||||
'https://cdn.plyr.io',
|
||||
'https://*.posthog.com',
|
||||
'https://umami.hz.oncws.com',
|
||||
'https://*.r2.cloudflarestorage.com',
|
||||
'https://*.senja.io',
|
||||
'https://*.ytimg.com',
|
||||
'https://noembed.com'
|
||||
],
|
||||
frameSrc: [
|
||||
'https://www.youtube.com',
|
||||
'https://youtube.com',
|
||||
'https://www.youtube-nocookie.com',
|
||||
'https://www.google.com',
|
||||
'https://google.com'
|
||||
],
|
||||
fontSrc: ['https://fonts.gstatic.com', 'https://cdn.plyr.io'],
|
||||
mediaSrc: ['https:']
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {boolean} isSelfHosted
|
||||
* @param {string | undefined} serverUrl - PUBLIC_SERVER_URL, added to connect-src for SaaS builds
|
||||
*/
|
||||
export function getCspDomains(isSelfHosted, serverUrl) {
|
||||
if (isSelfHosted) {
|
||||
return {
|
||||
scriptSrc: [],
|
||||
styleSrc: [],
|
||||
connectSrc: [],
|
||||
frameSrc: [],
|
||||
fontSrc: [],
|
||||
mediaSrc: [],
|
||||
apiOrigin: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...saasDefaults,
|
||||
apiOrigin: serverUrl ?? null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
function parseCspDomains(value: string | undefined): string[] {
|
||||
if (!value) return [];
|
||||
return value
|
||||
.split(',')
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean)
|
||||
.map((d) => (d.startsWith('http://') || d.startsWith('https://') ? d : `https://${d}`));
|
||||
}
|
||||
|
||||
function buildCspExtensions(): Record<string, string[]> {
|
||||
const allDomains = parseCspDomains(process.env.ALLOWED_EXTERNAL_DOMAINS);
|
||||
|
||||
const or = (envVar: string) => (allDomains.length ? allDomains : parseCspDomains(process.env[envVar]));
|
||||
|
||||
const scriptSrc = or('CSP_SCRIPT_SRC_DOMAINS');
|
||||
const styleSrc = or('CSP_STYLE_SRC_DOMAINS');
|
||||
const connectSrcBase = or('CSP_CONNECT_SRC_DOMAINS');
|
||||
const frameSrc = or('CSP_FRAME_SRC_DOMAINS');
|
||||
const fontSrc = or('CSP_FONT_SRC_DOMAINS');
|
||||
const mediaSrc = or('CSP_MEDIA_SRC_DOMAINS');
|
||||
|
||||
const serverUrl = process.env.PUBLIC_SERVER_URL;
|
||||
const connectSrc = serverUrl ? [...connectSrcBase, serverUrl] : connectSrcBase;
|
||||
|
||||
const extensions: Record<string, string[]> = {};
|
||||
if (scriptSrc.length) extensions['script-src'] = scriptSrc;
|
||||
if (styleSrc.length) {
|
||||
extensions['style-src'] = styleSrc;
|
||||
extensions['style-src-elem'] = styleSrc;
|
||||
}
|
||||
if (connectSrc.length) extensions['connect-src'] = connectSrc;
|
||||
if (frameSrc.length) extensions['frame-src'] = frameSrc;
|
||||
if (fontSrc.length) extensions['font-src'] = fontSrc;
|
||||
if (mediaSrc.length) {
|
||||
extensions['img-src'] = mediaSrc;
|
||||
extensions['media-src'] = mediaSrc;
|
||||
}
|
||||
|
||||
return extensions;
|
||||
}
|
||||
|
||||
let cachedExtensions: Record<string, string[]> | null = null;
|
||||
|
||||
function getExtensions(): Record<string, string[]> {
|
||||
if (!cachedExtensions) cachedExtensions = buildCspExtensions();
|
||||
return cachedExtensions;
|
||||
}
|
||||
|
||||
function extendHeader(header: string, extensions: Record<string, string[]>): string {
|
||||
return header
|
||||
.split(';')
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean)
|
||||
.map((directive) => {
|
||||
const spaceIdx = directive.indexOf(' ');
|
||||
const name = spaceIdx === -1 ? directive : directive.substring(0, spaceIdx);
|
||||
const extra = extensions[name];
|
||||
return extra?.length ? `${directive} ${extra.join(' ')}` : directive;
|
||||
})
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
export function applyCspExtensions(response: Response): Response {
|
||||
const extensions = getExtensions();
|
||||
if (Object.keys(extensions).length === 0) return response;
|
||||
|
||||
for (const name of ['content-security-policy', 'content-security-policy-report-only']) {
|
||||
const header = response.headers.get(name);
|
||||
if (header) {
|
||||
response.headers.set(name, extendHeader(header, extensions));
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { browser } from '$app/environment';
|
||||
export const isHtmlValueEmpty = (html: string) => {
|
||||
if (!html || typeof html !== 'string') return true;
|
||||
|
||||
if (/<svg\b/i.test(html)) return false;
|
||||
|
||||
if (!document || !browser) return false;
|
||||
|
||||
return getTextFromHTML(html) === '';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"refresh": "Opdater",
|
||||
"unsaved_changes": {
|
||||
"message": "Du har ikke-gemte ændringer. Er du sikker på, at du vil tage afsted?"
|
||||
}
|
||||
@@ -1508,6 +1509,10 @@
|
||||
"placeholder": "Søg efter lektioner eller øvelser",
|
||||
"empty": "Ingen resultater fundet"
|
||||
},
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing."
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "Tilbage til kurset",
|
||||
"content_count": {
|
||||
@@ -1582,6 +1587,12 @@
|
||||
}
|
||||
},
|
||||
"course_filter": {
|
||||
"date_created_newest": "Date Created: Newest first",
|
||||
"date_created_oldest": "Date Created: Oldest first",
|
||||
"published_first": "Published: Published first",
|
||||
"unpublished_first": "Published: Unpublished first",
|
||||
"most_lessons": "Lessons: Most first",
|
||||
"fewest_lessons": "Lessons: Fewest first",
|
||||
"date_created": "Dato oprettet",
|
||||
"published": "Udgivet",
|
||||
"lessons": "Lektioner"
|
||||
@@ -1592,6 +1603,9 @@
|
||||
"clear_all": "Ryd alle",
|
||||
"popover_title": "Filtre",
|
||||
"sort": "Sorter",
|
||||
"order": "Rækkefølge",
|
||||
"ascending": "Asc",
|
||||
"descending": "Desc",
|
||||
"tags": "Tags",
|
||||
"empty_tags": "Ingen tags tilgængelige"
|
||||
}
|
||||
@@ -2212,7 +2226,6 @@
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"sso_enabled": "{provider} er aktiveret for dit domæne",
|
||||
"sso_redirect_error": "SSO-logon er forkert konfigureret. Indstil PUBLIC_SERVER_URL til din API-server.",
|
||||
"sso_required": "Log venligst ind med SSO. Adgangskodelogin er deaktiveret.",
|
||||
"sign_in_with_sso": "Log ind med {provider}",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"refresh": "Aktualisieren",
|
||||
"unsaved_changes": {
|
||||
"message": "Sie haben nicht gespeicherte Änderungen. Sind Sie sicher, dass Sie gehen möchten?"
|
||||
}
|
||||
@@ -1508,6 +1509,10 @@
|
||||
"placeholder": "Suchen Sie nach Lektionen oder Übungen",
|
||||
"empty": "Keine Ergebnisse gefunden"
|
||||
},
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing."
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "Zurück zum Kurs",
|
||||
"content_count": {
|
||||
@@ -1582,6 +1587,12 @@
|
||||
}
|
||||
},
|
||||
"course_filter": {
|
||||
"date_created_newest": "Date Created: Newest first",
|
||||
"date_created_oldest": "Date Created: Oldest first",
|
||||
"published_first": "Published: Published first",
|
||||
"unpublished_first": "Published: Unpublished first",
|
||||
"most_lessons": "Lessons: Most first",
|
||||
"fewest_lessons": "Lessons: Fewest first",
|
||||
"date_created": "Erstellungsdatum",
|
||||
"published": "Veröffentlicht",
|
||||
"lessons": "Lektionen"
|
||||
@@ -1592,6 +1603,9 @@
|
||||
"clear_all": "Alles löschen",
|
||||
"popover_title": "Filter",
|
||||
"sort": "Sortieren",
|
||||
"order": "Reihenfolge",
|
||||
"ascending": "Asc",
|
||||
"descending": "Desc",
|
||||
"tags": "Schlagworte",
|
||||
"empty_tags": "Keine Tags verfügbar"
|
||||
}
|
||||
@@ -2212,7 +2226,6 @@
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"sso_enabled": "{provider} ist für Ihre Domain aktiviert",
|
||||
"sso_redirect_error": "Die SSO-Anmeldung ist falsch konfiguriert. Bitte legen Sie PUBLIC_SERVER_URL auf Ihren API-Server fest.",
|
||||
"sso_required": "Bitte melden Sie sich mit SSO an. Die Passwortanmeldung ist deaktiviert.",
|
||||
"sign_in_with_sso": "Melden Sie sich mit {provider} an",
|
||||
|
||||
@@ -93,6 +93,9 @@
|
||||
"placeholder": "Search"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"login": {
|
||||
"welcome": "Welcome back",
|
||||
"email": "Your email",
|
||||
@@ -240,7 +243,7 @@
|
||||
},
|
||||
"login": {
|
||||
"or_continue_with": "Or continue with email and password",
|
||||
"sso_enabled": "{provider} is enabled for your domain",
|
||||
"sso_enabled": "{provider} is enabled for this organization",
|
||||
"sso_redirect_error": "SSO sign-in is misconfigured. Please set PUBLIC_SERVER_URL to your API server.",
|
||||
"sso_required": "Please sign in with SSO. Password login is disabled.",
|
||||
"sign_in_with_sso": "Sign in with {provider}",
|
||||
@@ -566,6 +569,7 @@
|
||||
"actions": {
|
||||
"create_tag": "Create Tag",
|
||||
"create_group": "Create Tag Group",
|
||||
"actions_menu": "Tag actions",
|
||||
"edit_group": "Edit Group",
|
||||
"edit_tag": "Edit Tag",
|
||||
"save": "Save",
|
||||
@@ -865,10 +869,19 @@
|
||||
"clear_all": "Clear all",
|
||||
"popover_title": "Filters",
|
||||
"sort": "Sort",
|
||||
"order": "Order",
|
||||
"ascending": "Asc",
|
||||
"descending": "Desc",
|
||||
"tags": "Tags",
|
||||
"empty_tags": "No tags available"
|
||||
},
|
||||
"course_filter": {
|
||||
"date_created_newest": "Date Created: Newest first",
|
||||
"date_created_oldest": "Date Created: Oldest first",
|
||||
"published_first": "Published: Published first",
|
||||
"unpublished_first": "Published: Unpublished first",
|
||||
"most_lessons": "Lessons: Most first",
|
||||
"fewest_lessons": "Lessons: Fewest first",
|
||||
"date_created": "Date Created",
|
||||
"published": "Published",
|
||||
"lessons": "Lessons"
|
||||
@@ -947,6 +960,10 @@
|
||||
"placeholder": "Search lessons or exercises",
|
||||
"empty": "No results found"
|
||||
},
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing."
|
||||
},
|
||||
"navItem": {
|
||||
"news_feed": {
|
||||
"heading": "News Feed",
|
||||
@@ -2083,6 +2100,10 @@
|
||||
"created_at_label": "Created:",
|
||||
"last_used_label": "Last used:",
|
||||
"never_used": "Never",
|
||||
"table_key": "Key",
|
||||
"table_status": "Status",
|
||||
"table_created": "Created",
|
||||
"table_last_used": "Last used",
|
||||
"scopes_label": "Scopes:",
|
||||
"revoke_confirm": "Revoke this automation key?",
|
||||
"rotate_confirm": "Rotate this automation key?"
|
||||
@@ -2097,9 +2118,7 @@
|
||||
"keys_description": "Use one MCP key per integration so access can be rotated or revoked cleanly.",
|
||||
"rate_limits_title": "Rate limits",
|
||||
"rate_limits_description": "Read / write / publish requests allowed per minute.",
|
||||
"used_label": "Used:",
|
||||
"per_key_label": "Per key:",
|
||||
"per_org_label": "Per org:"
|
||||
"used_label": "Used:"
|
||||
},
|
||||
"keys": {
|
||||
"title": "MCP keys",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"refresh": "Actualizar",
|
||||
"unsaved_changes": {
|
||||
"message": "Tienes cambios sin guardar. ¿Estás seguro de que quieres irte?"
|
||||
}
|
||||
@@ -1508,6 +1509,10 @@
|
||||
"placeholder": "Buscar lecciones o ejercicios",
|
||||
"empty": "No se encontraron resultados"
|
||||
},
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing."
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "volver al curso",
|
||||
"content_count": {
|
||||
@@ -1582,6 +1587,12 @@
|
||||
}
|
||||
},
|
||||
"course_filter": {
|
||||
"date_created_newest": "Date Created: Newest first",
|
||||
"date_created_oldest": "Date Created: Oldest first",
|
||||
"published_first": "Published: Published first",
|
||||
"unpublished_first": "Published: Unpublished first",
|
||||
"most_lessons": "Lessons: Most first",
|
||||
"fewest_lessons": "Lessons: Fewest first",
|
||||
"date_created": "Fecha de creación",
|
||||
"published": "Publicado",
|
||||
"lessons": "Lecciones"
|
||||
@@ -1592,6 +1603,9 @@
|
||||
"clear_all": "Borrar todo",
|
||||
"popover_title": "Filtros",
|
||||
"sort": "ordenar",
|
||||
"order": "Orden",
|
||||
"ascending": "Asc",
|
||||
"descending": "Desc",
|
||||
"tags": "Etiquetas",
|
||||
"empty_tags": "No hay etiquetas disponibles"
|
||||
}
|
||||
@@ -2212,7 +2226,6 @@
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"sso_enabled": "{provider} está habilitado para tu dominio",
|
||||
"sso_redirect_error": "El inicio de sesión SSO está mal configurado. Configure PUBLIC_SERVER_URL en su servidor API.",
|
||||
"sso_required": "Inicie sesión con SSO. El inicio de sesión con contraseña está deshabilitado.",
|
||||
"sign_in_with_sso": "Iniciar sesión con {proveedor}",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"refresh": "Actualiser",
|
||||
"unsaved_changes": {
|
||||
"message": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir partir ?"
|
||||
}
|
||||
@@ -1508,6 +1509,10 @@
|
||||
"placeholder": "Rechercher des leçons ou des exercices",
|
||||
"empty": "Aucun résultat trouvé"
|
||||
},
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing."
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "Retour au cours",
|
||||
"content_count": {
|
||||
@@ -1582,6 +1587,12 @@
|
||||
}
|
||||
},
|
||||
"course_filter": {
|
||||
"date_created_newest": "Date Created: Newest first",
|
||||
"date_created_oldest": "Date Created: Oldest first",
|
||||
"published_first": "Published: Published first",
|
||||
"unpublished_first": "Published: Unpublished first",
|
||||
"most_lessons": "Lessons: Most first",
|
||||
"fewest_lessons": "Lessons: Fewest first",
|
||||
"date_created": "Date de création",
|
||||
"published": "Publié",
|
||||
"lessons": "Leçons"
|
||||
@@ -1592,6 +1603,9 @@
|
||||
"clear_all": "Tout effacer",
|
||||
"popover_title": "Filtres",
|
||||
"sort": "Trier",
|
||||
"order": "Ordre",
|
||||
"ascending": "Asc",
|
||||
"descending": "Desc",
|
||||
"tags": "Balises",
|
||||
"empty_tags": "Aucune balise disponible"
|
||||
}
|
||||
@@ -2212,7 +2226,6 @@
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"sso_enabled": "{provider} est activé pour votre domaine",
|
||||
"sso_redirect_error": "La connexion SSO est mal configurée. Veuillez définir PUBLIC_SERVER_URL sur votre serveur API.",
|
||||
"sso_required": "Veuillez vous connecter avec SSO. La connexion par mot de passe est désactivée.",
|
||||
"sign_in_with_sso": "Connectez-vous avec {provider}",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"refresh": "रीफ़्रेश करें",
|
||||
"unsaved_changes": {
|
||||
"message": "आपके पास सहेजे नहीं गए परिवर्तन हैं. क्या आपकी वाकई छोड़ने की इच्छा है?"
|
||||
}
|
||||
@@ -1508,6 +1509,10 @@
|
||||
"placeholder": "पाठ या अभ्यास खोजें",
|
||||
"empty": "कोई परिणाम नहीं मिला"
|
||||
},
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing."
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "पाठ्यक्रम पर वापस जाएँ",
|
||||
"content_count": {
|
||||
@@ -1582,6 +1587,12 @@
|
||||
}
|
||||
},
|
||||
"course_filter": {
|
||||
"date_created_newest": "Date Created: Newest first",
|
||||
"date_created_oldest": "Date Created: Oldest first",
|
||||
"published_first": "Published: Published first",
|
||||
"unpublished_first": "Published: Unpublished first",
|
||||
"most_lessons": "Lessons: Most first",
|
||||
"fewest_lessons": "Lessons: Fewest first",
|
||||
"date_created": "दिनांक निर्मित",
|
||||
"published": "प्रकाशित",
|
||||
"lessons": "सबक"
|
||||
@@ -1592,6 +1603,9 @@
|
||||
"clear_all": "सब साफ़ करें",
|
||||
"popover_title": "फिल्टर",
|
||||
"sort": "क्रमबद्ध करें",
|
||||
"order": "क्रम",
|
||||
"ascending": "Asc",
|
||||
"descending": "Desc",
|
||||
"tags": "टैग",
|
||||
"empty_tags": "कोई टैग उपलब्ध नहीं है"
|
||||
}
|
||||
@@ -2212,7 +2226,6 @@
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"sso_enabled": "आपके डोमेन के लिए {प्रदाता} सक्षम है",
|
||||
"sso_redirect_error": "SSO साइन-इन ग़लत कॉन्फ़िगर किया गया है. कृपया अपने एपीआई सर्वर पर PUBLIC_SERVER_URL सेट करें।",
|
||||
"sso_required": "कृपया एसएसओ के साथ साइन इन करें। पासवर्ड लॉगिन अक्षम है.",
|
||||
"sign_in_with_sso": "{प्रदाता} के साथ साइन इन करें",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"refresh": "Odśwież",
|
||||
"unsaved_changes": {
|
||||
"message": "Masz niezapisane zmiany. Czy na pewno chcesz wyjść?"
|
||||
}
|
||||
@@ -1508,6 +1509,10 @@
|
||||
"placeholder": "Wyszukaj lekcje lub ćwiczenia",
|
||||
"empty": "Nie znaleziono żadnych wyników"
|
||||
},
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing."
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "Powrót na kurs",
|
||||
"content_count": {
|
||||
@@ -1582,6 +1587,12 @@
|
||||
}
|
||||
},
|
||||
"course_filter": {
|
||||
"date_created_newest": "Date Created: Newest first",
|
||||
"date_created_oldest": "Date Created: Oldest first",
|
||||
"published_first": "Published: Published first",
|
||||
"unpublished_first": "Published: Unpublished first",
|
||||
"most_lessons": "Lessons: Most first",
|
||||
"fewest_lessons": "Lessons: Fewest first",
|
||||
"date_created": "Data utworzenia",
|
||||
"published": "Opublikowano",
|
||||
"lessons": "Lekcje"
|
||||
@@ -1592,6 +1603,9 @@
|
||||
"clear_all": "Wyczyść wszystko",
|
||||
"popover_title": "Filtry",
|
||||
"sort": "Sortuj",
|
||||
"order": "Kolejność",
|
||||
"ascending": "Asc",
|
||||
"descending": "Desc",
|
||||
"tags": "Tagi",
|
||||
"empty_tags": "Brak dostępnych tagów"
|
||||
}
|
||||
@@ -2212,7 +2226,6 @@
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"sso_enabled": "Usługa {provider} jest włączona w Twojej domenie",
|
||||
"sso_redirect_error": "Logowanie jednokrotne jest błędnie skonfigurowane. Ustaw PUBLIC_SERVER_URL na swój serwer API.",
|
||||
"sso_required": "Proszę zalogować się za pomocą SSO. Logowanie hasłem jest wyłączone.",
|
||||
"sign_in_with_sso": "Zaloguj się przez {provider}",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"refresh": "Atualizar",
|
||||
"unsaved_changes": {
|
||||
"message": "Você tem alterações não salvas. Tem certeza de que deseja sair?"
|
||||
}
|
||||
@@ -1508,6 +1509,10 @@
|
||||
"placeholder": "Pesquise aulas ou exercícios",
|
||||
"empty": "Nenhum resultado encontrado"
|
||||
},
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing."
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "De volta ao curso",
|
||||
"content_count": {
|
||||
@@ -1582,6 +1587,12 @@
|
||||
}
|
||||
},
|
||||
"course_filter": {
|
||||
"date_created_newest": "Date Created: Newest first",
|
||||
"date_created_oldest": "Date Created: Oldest first",
|
||||
"published_first": "Published: Published first",
|
||||
"unpublished_first": "Published: Unpublished first",
|
||||
"most_lessons": "Lessons: Most first",
|
||||
"fewest_lessons": "Lessons: Fewest first",
|
||||
"date_created": "Data de criação",
|
||||
"published": "Publicado",
|
||||
"lessons": "Lições"
|
||||
@@ -1592,6 +1603,9 @@
|
||||
"clear_all": "Limpar tudo",
|
||||
"popover_title": "Filtros",
|
||||
"sort": "Classificar",
|
||||
"order": "Ordem",
|
||||
"ascending": "Asc",
|
||||
"descending": "Desc",
|
||||
"tags": "Etiquetas",
|
||||
"empty_tags": "Nenhuma tag disponível"
|
||||
}
|
||||
@@ -2212,7 +2226,6 @@
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"sso_enabled": "{provider} está ativado para seu domínio",
|
||||
"sso_redirect_error": "O login do SSO está configurado incorretamente. Defina PUBLIC_SERVER_URL para seu servidor API.",
|
||||
"sso_required": "Faça login com SSO. O login por senha está desabilitado.",
|
||||
"sign_in_with_sso": "Faça login com {provedor}",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"refresh": "Обновить",
|
||||
"unsaved_changes": {
|
||||
"message": "У вас есть несохраненные изменения. Вы уверены, что хотите уйти?"
|
||||
}
|
||||
@@ -1508,6 +1509,10 @@
|
||||
"placeholder": "Поиск уроков или упражнений",
|
||||
"empty": "Результаты не найдены"
|
||||
},
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing."
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "Вернуться к курсу",
|
||||
"content_count": {
|
||||
@@ -1582,6 +1587,12 @@
|
||||
}
|
||||
},
|
||||
"course_filter": {
|
||||
"date_created_newest": "Date Created: Newest first",
|
||||
"date_created_oldest": "Date Created: Oldest first",
|
||||
"published_first": "Published: Published first",
|
||||
"unpublished_first": "Published: Unpublished first",
|
||||
"most_lessons": "Lessons: Most first",
|
||||
"fewest_lessons": "Lessons: Fewest first",
|
||||
"date_created": "Дата создания",
|
||||
"published": "Опубликовано",
|
||||
"lessons": "Уроки"
|
||||
@@ -1592,6 +1603,9 @@
|
||||
"clear_all": "Очистить все",
|
||||
"popover_title": "Фильтры",
|
||||
"sort": "Сортировать",
|
||||
"order": "Порядок",
|
||||
"ascending": "Asc",
|
||||
"descending": "Desc",
|
||||
"tags": "Теги",
|
||||
"empty_tags": "Нет доступных тегов"
|
||||
}
|
||||
@@ -2212,7 +2226,6 @@
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"sso_enabled": "{provider} включен для вашего домена",
|
||||
"sso_redirect_error": "Вход в систему единого входа настроен неправильно. Установите PUBLIC_SERVER_URL для вашего сервера API.",
|
||||
"sso_required": "Пожалуйста, войдите с помощью SSO. Вход по паролю отключен.",
|
||||
"sign_in_with_sso": "Войдите через {provider}",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"refresh": "Làm mới",
|
||||
"unsaved_changes": {
|
||||
"message": "Bạn có những thay đổi chưa được lưu. Bạn có chắc chắn muốn rời đi không?"
|
||||
}
|
||||
@@ -1508,6 +1509,10 @@
|
||||
"placeholder": "Tìm kiếm bài học hoặc bài tập",
|
||||
"empty": "Không tìm thấy kết quả nào"
|
||||
},
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing."
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "Quay lại khóa học",
|
||||
"content_count": {
|
||||
@@ -1582,6 +1587,12 @@
|
||||
}
|
||||
},
|
||||
"course_filter": {
|
||||
"date_created_newest": "Date Created: Newest first",
|
||||
"date_created_oldest": "Date Created: Oldest first",
|
||||
"published_first": "Published: Published first",
|
||||
"unpublished_first": "Published: Unpublished first",
|
||||
"most_lessons": "Lessons: Most first",
|
||||
"fewest_lessons": "Lessons: Fewest first",
|
||||
"date_created": "Ngày tạo",
|
||||
"published": "Đã xuất bản",
|
||||
"lessons": "Bài học"
|
||||
@@ -1592,6 +1603,9 @@
|
||||
"clear_all": "Xóa tất cả",
|
||||
"popover_title": "Bộ lọc",
|
||||
"sort": "Sắp xếp",
|
||||
"order": "Thứ tự",
|
||||
"ascending": "Asc",
|
||||
"descending": "Desc",
|
||||
"tags": "Thẻ",
|
||||
"empty_tags": "Không có thẻ nào"
|
||||
}
|
||||
@@ -2212,7 +2226,6 @@
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"sso_enabled": "{provider} đã được bật cho miền của bạn",
|
||||
"sso_redirect_error": "Đăng nhập SSO bị định cấu hình sai. Vui lòng đặt PUBLIC_SERVER_URL cho máy chủ API của bạn.",
|
||||
"sso_required": "Vui lòng đăng nhập bằng SSO. Đăng nhập bằng mật khẩu bị vô hiệu hóa.",
|
||||
"sign_in_with_sso": "Đăng nhập bằng {nhà cung cấp}",
|
||||
|
||||
Reference in New Issue
Block a user