diff --git a/.cursor/rules/add-landing-template.mdc b/.cursor/rules/add-landing-template.mdc new file mode 100644 index 00000000..498be324 --- /dev/null +++ b/.cursor/rules/add-landing-template.mdc @@ -0,0 +1,31 @@ +--- +description: Workflow for adding a new org landing-page template (editorial, vibrant, terminal, etc.) to the ClassroomIO monorepo. Activate when the user asks to add/create/build a new landing template or theme, or hands over a design reference for a new landing visual style. +globs: + - prototypes/org-landing-page/** + - packages/ui/src/custom/org-landing-page/** + - packages/storybook/src/templates/org-landing-page/** + - apps/dashboard/src/lib/features/org/utils/landing-page*.ts + - apps/dashboard/src/lib/features/settings/pages/landingpage.svelte + - apps/dashboard/src/lib/utils/types/org.ts + - apps/dashboard/static/templates/** +alwaysApply: false +--- + +# Add a new landing template + +This is a three-phase workflow with **explicit approval gates between phases**. The canonical instructions live in [`skills/add-landing-template/SKILL.md`](mdc:skills/add-landing-template/SKILL.md) — read that file in full before starting, then follow it step-by-step. + +## Phase order — never skip ahead + +1. **Prototype** — single HTML file at `prototypes/org-landing-page/.html` using `--primary` as the only color token. **Ask the user for approval before moving on.** +2. **packages/ui + Storybook** — define `LANDING_THEME_VARS` entry, build nav/hero/course-card subcomponents and the page composer, wire variant branches into shared sections (callout, links, footer-tokens, secondary-action-button), export from `index.ts`, add a Storybook story. **Ask the user for approval before moving on.** +3. **Dashboard integration** — add to all four registries (three lazy switches + one eager map), settings theme-card picker, translations, preview image (< 500KB). Run `pnpm --filter @cio/ui build` and `svelte-check` once each, grep the captured output. **Ask the user for final approval.** + +## Critical correctness rules (full list in the canonical SKILL.md) + +- Components read colors **only** from `var(--landing-*)`. No `bg-background`, no inline hex (except decorative gradients). +- Heroes that consume `courses` / `orgName` must declare defensive defaults (`courses = []`, `orgName = ''`) — they're mounted from course pages without the full prop set. +- Use `` for non-template-signature CTAs. The button's colors come from per-theme `--landing-button-*` variables. +- Hardcoded user-facing strings route through `labels?.foo ?? 'default'`. +- The dashboard has **three lazy switches** (`importThemeComponent`, `importThemeNavHero`, `importThemeCourseCard` in `landing-page.ts`) and **one eager map** (`landing-page-components.ts`) — all four must be updated, or the live org-site routes silently fall back to `minimal`. +- Run `svelte-check` once, capture to `/tmp/check.log`, grep many times. Do not re-run for each grep. diff --git a/apps/api/src/routes/agent/agent.ts b/apps/api/src/routes/agent/agent.ts index 853d4126..27f249bc 100644 --- a/apps/api/src/routes/agent/agent.ts +++ b/apps/api/src/routes/agent/agent.ts @@ -432,12 +432,10 @@ const agentCoreRouter = new Hono() } } - if (role === AgentRole.TEACHER && !modelDescriptor.isFree) { - const isPaid = await isOrgOnPaidPlan(orgId); + const isOrgPaid = role === AgentRole.TEACHER ? await isOrgOnPaidPlan(orgId) : false; - if (!isPaid) { - throw new AppError('Upgrade required to use this AI model', 'UPGRADE_REQUIRED', 403); - } + if (role === AgentRole.TEACHER && !modelDescriptor.isFree && !isOrgPaid) { + throw new AppError('Upgrade required to use this AI model', 'UPGRADE_REQUIRED', 403); } // Students go through tutor policy (workspace toggle, pool, per-learner cap). @@ -520,7 +518,8 @@ const agentCoreRouter = new Hono() // approved plan/active template) is sent as a user-turn message instead so // it doesn't invalidate the tools+system cache. const systemPrompt = buildSystemPrompt(agentContext, { - tutorSettings: studentPolicy?.settings + tutorSettings: studentPolicy?.settings, + isOrgOnPaidPlan: isOrgPaid }); const contextMessageText = buildContextMessage(agentContext, { @@ -531,7 +530,7 @@ const agentCoreRouter = new Hono() const agentTools = role === AgentRole.STUDENT ? buildStudentAgentTools(orgId, user.id, courseId, studentPolicy!.settings) - : buildAgentTools(orgId, user.id, courseId, messages); + : buildAgentTools(orgId, user.id, courseId, messages, { isOrgOnPaidPlan: isOrgPaid }); const contextManaged = await buildModelContextMessages({ conversationId, @@ -569,27 +568,7 @@ const agentCoreRouter = new Hono() ] : convertedMessages; - console.log( - '[agent.chat] modelMessages →', - JSON.stringify( - modelMessages.map((m, i) => ({ - i, - role: m.role, - parts: Array.isArray(m.content) - ? m.content.map((p: any) => ({ - type: p.type, - toolCallId: p.toolCallId, - toolName: p.toolName, - textPreview: typeof p.text === 'string' ? p.text.slice(0, 60) : undefined - })) - : typeof m.content === 'string' - ? m.content.slice(0, 80) - : m.content - })), - null, - 2 - ) - ); + console.log(`[agent.chat] user=${user.id} messages=${modelMessages.length}`); // Cache the growing conversation prefix: each turn reads the prior // transcript at ~0.1x cost instead of reprocessing it at full price. diff --git a/apps/api/src/routes/agent/runs.ts b/apps/api/src/routes/agent/runs.ts index 2feeb422..1c6204cc 100644 --- a/apps/api/src/routes/agent/runs.ts +++ b/apps/api/src/routes/agent/runs.ts @@ -52,6 +52,7 @@ export const agentRunsRouter = new Hono() courseId: data.courseId, conversationId: data.conversationId, phase: data.phase, + modelId: data.model, approvedPlan: data.approvedPlan, executionCursor: data.executionCursor, sourceIds: data.sourceIds, diff --git a/apps/api/src/routes/jobs/jobs.ts b/apps/api/src/routes/jobs/jobs.ts index 8a103975..06922b08 100644 --- a/apps/api/src/routes/jobs/jobs.ts +++ b/apps/api/src/routes/jobs/jobs.ts @@ -10,6 +10,7 @@ import { getMediaJobSteps, listMediaJobEnvelopes, listMediaJobsForAssetEnvelopes, + startThumbnailRegenJob, startTranscriptionOnlyMediaJob } from '@api/services/jobs'; @@ -108,6 +109,27 @@ export const jobsRouter = new Hono() } } ) + .post( + '/media/asset/:assetId/regenerate-thumbnail', + authMiddleware, + orgMemberMiddleware, + zValidator('param', ZAssetJobsParam), + async (c) => { + try { + const orgId = c.req.header('cio-org-id')!; + const user = c.get('user')!; + const { assetId } = c.req.valid('param'); + const job = await startThumbnailRegenJob({ + organizationId: orgId, + assetId, + triggeredByProfileId: user?.id ?? null + }); + return c.json({ success: true, data: job }, 202); + } catch (error) { + return handleError(c, error, 'Failed to start thumbnail regeneration'); + } + } + ) .post( '/media/:jobId/cancel', authMiddleware, diff --git a/apps/api/src/services/assets/assets.ts b/apps/api/src/services/assets/assets.ts index 87d93338..3a685560 100644 --- a/apps/api/src/services/assets/assets.ts +++ b/apps/api/src/services/assets/assets.ts @@ -26,6 +26,7 @@ import { import { getMediaTranscriptByAsset } from '@cio/db/queries'; import { generateTranscriptVttPresignedUrl, TRANSCRIPT_VTT_PRESIGN_SECONDS } from '@api/utils/s3'; +import { getStorageConfig } from '@api/config/storage'; const YOUTUBE_VIDEO_ID_REGEX = /^[a-zA-Z0-9_-]{11}$/; @@ -310,6 +311,45 @@ export async function updateAssetService(orgId: string, assetId: string, data: T } } +/** + * Set the chosen thumbnail URL for an asset. Accepts URLs that are either + * (a) one of the asset's auto-generated candidates, or (b) a freshly uploaded + * URL on the org's media bucket. Anything else is rejected so callers can't + * point the asset at an arbitrary remote image. + */ +export async function selectAssetThumbnailService(orgId: string, assetId: string, thumbnailUrl: string) { + try { + const asset = await getAssetById(assetId, orgId); + assertAssetExists(asset); + + const candidates = (asset?.thumbnailCandidates ?? []) as string[]; + const mediaBase = getStorageConfig().mediaPublicBaseUrl?.replace(/\/$/, '') ?? null; + const isCandidate = candidates.includes(thumbnailUrl); + const isOnMediaBucket = Boolean(mediaBase && thumbnailUrl.startsWith(`${mediaBase}/`)); + + if (!isCandidate && !isOnMediaBucket) { + throw new AppError( + 'Thumbnail URL must be a generated candidate or a media-bucket upload', + ErrorCodes.VALIDATION_ERROR, + 400 + ); + } + + const updated = await updateAsset(assetId, orgId, { thumbnailUrl }); + return assertAssetExists(updated); + } catch (error) { + if (error instanceof AppError) { + throw error; + } + + throw new AppError( + error instanceof Error ? error.message : 'Failed to update asset thumbnail', + ErrorCodes.ASSET_UPDATE_FAILED, + 500 + ); + } +} + export async function deleteAssetService(orgId: string, assetId: string) { try { await getAssetService(orgId, assetId); diff --git a/apps/api/src/services/jobs/media-jobs.ts b/apps/api/src/services/jobs/media-jobs.ts index 559fa6cc..de708655 100644 --- a/apps/api/src/services/jobs/media-jobs.ts +++ b/apps/api/src/services/jobs/media-jobs.ts @@ -11,7 +11,12 @@ import { hasActiveMediaJobForAsset } from '@cio/db/queries'; import { getAssetById } from '@cio/db/queries/assets'; -import { enqueueLessonVideoPipeline, enqueueTranscriptionOnly, isRedisConfigured } from '@cio/jobs'; +import { + enqueueGenerateThumbnailOnly, + enqueueLessonVideoPipeline, + enqueueTranscriptionOnly, + isRedisConfigured +} from '@cio/jobs'; import { logRedisUnavailableOnce } from '@api/utils/redis/redis'; import type { TMediaJob } from '@db/types'; @@ -98,6 +103,85 @@ export async function startTranscriptionOnlyMediaJob(input: StartTranscriptionOn } } +export interface StartThumbnailRegenInput { + organizationId: string; + assetId: string; + triggeredByProfileId: string | null; +} + +/** + * Manual thumbnail regeneration: create a new `media_job` row and enqueue only + * the `generate-thumbnail` step. Rejects when the asset has no usable storage + * key or when another media job is already running for it. + */ +export async function startThumbnailRegenJob(input: StartThumbnailRegenInput): Promise { + const asset = await getAssetById(input.assetId); + if (!asset || asset.organizationId !== input.organizationId) { + throw new AppError('Asset not found', ErrorCodes.NOT_FOUND, 404); + } + + if (asset.kind !== 'video' || asset.provider !== 'upload' || !asset.storageKey) { + throw new AppError('Asset cannot be thumbnailed', ErrorCodes.ASSET_NOT_TRANSCRIBABLE, 400); + } + + const hasActive = await hasActiveMediaJobForAsset(input.assetId, input.organizationId); + if (hasActive) { + throw new AppError('A media job is already running for this asset', ErrorCodes.CONFLICT, 409); + } + + const job = await createMediaJob({ + organizationId: input.organizationId, + assetId: input.assetId, + triggeredByProfileId: input.triggeredByProfileId, + status: 'queued', + stage: 'thumbnailing', + progressPercent: 0 + }); + + if (!isRedisConfigured()) { + logRedisUnavailableOnce( + 'Redis not configured: thumbnail regen media job created but no BullMQ flow enqueued. ' + + 'Set REDIS_URL and run apps/jobs to process this job.' + ); + return job; + } + + try { + const enqueueResult = await enqueueGenerateThumbnailOnly({ + mediaJobId: job.id, + assetId: input.assetId, + storageKey: asset.storageKey, + actorContext: { + userId: input.triggeredByProfileId, + organizationId: input.organizationId + } + }); + + const updated = await updateMediaJob(job.id, { + rootJobId: enqueueResult.rootJobId, + jobIds: enqueueResult.jobIds + }); + + return updated ?? job; + } catch (error) { + console.error('startThumbnailRegenJob enqueue failed:', error); + await updateMediaJob(job.id, { + status: 'failed', + stage: 'failed', + error: { + code: 'ENQUEUE_FAILED', + message: error instanceof Error ? error.message : 'Failed to enqueue thumbnail job' + } + }); + + throw new AppError( + error instanceof Error ? error : 'Failed to enqueue thumbnail job', + ErrorCodes.INTERNAL_ERROR, + 500 + ); + } +} + export interface StartMediaJobInput { organizationId: string; assetId: string; diff --git a/apps/dashboard/src/lib/features/course/api/lesson.svelte.ts b/apps/dashboard/src/lib/features/course/api/lesson.svelte.ts index 66b73527..cc7ae398 100644 --- a/apps/dashboard/src/lib/features/course/api/lesson.svelte.ts +++ b/apps/dashboard/src/lib/features/course/api/lesson.svelte.ts @@ -718,6 +718,31 @@ export class LessonApi extends BaseApiWithErrors { this.isDirty = true; } + /** + * Updates the thumbnail URL on a lesson video's metadata so the card preview + * reflects the asset's new thumbnail without waiting for a reload. + */ + updateLessonVideoThumbnail(videoIndex: number, thumbnailUrl: string) { + if (!this.lesson) return; + + const videos = Array.isArray(this.lesson.videos) ? [...this.lesson.videos] : []; + const target = videos[videoIndex]; + if (!target) return; + + const metadata = + target.metadata && typeof target.metadata === 'object' && !Array.isArray(target.metadata) + ? (target.metadata as Record) + : {}; + + videos[videoIndex] = { + ...target, + metadata: { ...metadata, thumbnailUrl } + }; + + this.lesson = { ...this.lesson, videos }; + this.isDirty = true; + } + /** * Deletes a document from lesson materials */ diff --git a/apps/dashboard/src/lib/features/course/components/lesson/video/lesson-video-simple-card.svelte b/apps/dashboard/src/lib/features/course/components/lesson/video/lesson-video-simple-card.svelte index 7492ebc0..b8961143 100644 --- a/apps/dashboard/src/lib/features/course/components/lesson/video/lesson-video-simple-card.svelte +++ b/apps/dashboard/src/lib/features/course/components/lesson/video/lesson-video-simple-card.svelte @@ -3,7 +3,7 @@ import { Image } from '$features/ui'; import VideoIcon from '@lucide/svelte/icons/video'; import { ExternalLinkIcon, HoverableItem } from '@cio/ui/custom/moving-icons'; - import { courseApi } from '$features/course/api'; + import { courseApi, lessonApi } from '$features/course/api'; import VideoCardDropdown from './video-card-dropdown.svelte'; import { getVideoThumbnailUrl, @@ -138,7 +138,12 @@ {/if} {#if isEditMode} - + lessonApi.updateLessonVideoThumbnail(index, url)} + menuPlacement="inline" + /> {/if}

diff --git a/apps/dashboard/src/lib/features/course/components/lesson/video/video-card-dropdown.svelte b/apps/dashboard/src/lib/features/course/components/lesson/video/video-card-dropdown.svelte index e308e973..1e5a3262 100644 --- a/apps/dashboard/src/lib/features/course/components/lesson/video/video-card-dropdown.svelte +++ b/apps/dashboard/src/lib/features/course/components/lesson/video/video-card-dropdown.svelte @@ -1,8 +1,14 @@ onEdit(asset)} onkeydown={(event) => { if (event.key === 'Enter' || event.key === ' ') { @@ -57,10 +62,24 @@ event.stopPropagation()}> onEdit(asset)}> - {$t('media_manager.actions.edit')} + + + {$t('media_manager.actions.edit')} + + {#if asset.kind === 'video'} + onManageThumbnails(asset)}> + + + {$t('media_manager.actions.manage_thumbnails')} + + + {/if} onUsage(asset)}> - {$t('media_manager.actions.where_used')} + + + {$t('media_manager.actions.where_used')} + onDownload(asset)} disabled={downloadingAssetId === asset.id}> diff --git a/apps/dashboard/src/lib/features/media/components/index.ts b/apps/dashboard/src/lib/features/media/components/index.ts index 687f8b40..4e4f25f4 100644 --- a/apps/dashboard/src/lib/features/media/components/index.ts +++ b/apps/dashboard/src/lib/features/media/components/index.ts @@ -1,5 +1,6 @@ export { default as AssetCard } from './asset-card.svelte'; export { default as AssetUsageDialog } from './asset-usage-dialog.svelte'; export { default as EditAssetDialog } from './edit-asset-dialog.svelte'; +export { default as ManageThumbnailsDialog } from './manage-thumbnails-dialog.svelte'; export { default as MediaFilters } from './media-filters.svelte'; export { default as StorageCards } from './storage-cards.svelte'; diff --git a/apps/dashboard/src/lib/features/media/components/manage-thumbnails-dialog.svelte b/apps/dashboard/src/lib/features/media/components/manage-thumbnails-dialog.svelte new file mode 100644 index 00000000..f672f9ad --- /dev/null +++ b/apps/dashboard/src/lib/features/media/components/manage-thumbnails-dialog.svelte @@ -0,0 +1,284 @@ + + + { + if (!isOpen) resetState(); + }} +> + + + {$t('media_manager.thumbnails.manage_title')} + {$t('media_manager.thumbnails.manage_description')} + + + {#if asset} +

+
+ {#if isVideoLoading} + + {:else if videoUrl} + + + {:else} +
+ {$t('media_manager.thumbnails.preview_unavailable')} +
+ {/if} +
+ +
+
+

{$t('media_manager.thumbnails.generated')}

+ {#if allCandidatesMissing} +

+ {$t('media_manager.thumbnails.none_generated_warning')} +

+ {/if} +
+ +
+ {#each SLOT_KEYS as slotKey, index (slotKey)} + {@const url = candidates[index] ?? null} + {@const isSelected = url !== null && url === selectedUrl} + {#if isRegenerating} + + {:else if url} + + {:else} +
+ {$t(`media_manager.thumbnails.slot_${slotForIndex(index)}`)} +
+ {/if} + {/each} +
+
+ +
+

{$t('media_manager.thumbnails.upload_custom')}

+
+ + $t('media_manager.form.thumbnail_drop_size')} + /> + +
+ {#if customUrl} +
+ {$t('media_manager.thumbnails.custom_preview_alt')} +

+ {$t('media_manager.thumbnails.custom_selected_hint')} +

+
+ {/if} +
+
+ {/if} + + + + +
+ + +
+
+ + diff --git a/apps/dashboard/src/lib/features/media/components/storage-cards.svelte b/apps/dashboard/src/lib/features/media/components/storage-cards.svelte index dcc64e23..b7d9fcc8 100644 --- a/apps/dashboard/src/lib/features/media/components/storage-cards.svelte +++ b/apps/dashboard/src/lib/features/media/components/storage-cards.svelte @@ -4,7 +4,7 @@ import Link2Icon from '@lucide/svelte/icons/link-2'; import { ActivityCard } from '$features/ui'; - import { formatBytes } from '$features/media/utils'; + import { formatBytes, formatBytesSecondary } from '$features/media/utils'; import type { AssetStorageSummary } from '$features/media/utils'; import { t } from '$lib/utils/functions/translations'; @@ -14,19 +14,23 @@ let { storageSummary }: Props = $props(); + function bytesDescription(bytes: number | null | undefined, fallbackKey: string): string { + return formatBytesSecondary(bytes) ?? t.get(fallbackKey); + } + const storageCards = $derived([ { icon: FileIcon, title: t.get('media_manager.storage.total_bytes'), percentage: formatBytes(storageSummary?.totalBytes ?? 0), - description: t.get('media_manager.storage.total_bytes'), + description: bytesDescription(storageSummary?.totalBytes, 'media_manager.storage.total_bytes'), hidePercentage: true }, { icon: FileVideoIcon, title: t.get('media_manager.storage.internal_bytes'), percentage: formatBytes(storageSummary?.internalBytes ?? 0), - description: t.get('media_manager.storage.internal_bytes'), + description: bytesDescription(storageSummary?.internalBytes, 'media_manager.storage.internal_bytes'), hidePercentage: true }, { diff --git a/apps/dashboard/src/lib/features/media/pages/media.svelte b/apps/dashboard/src/lib/features/media/pages/media.svelte index ceaeaf3d..2e55d0f6 100644 --- a/apps/dashboard/src/lib/features/media/pages/media.svelte +++ b/apps/dashboard/src/lib/features/media/pages/media.svelte @@ -7,7 +7,14 @@ import type { TAssetUpdate } from '@cio/utils/validation/assets'; - import { AssetCard, AssetUsageDialog, EditAssetDialog, MediaFilters, StorageCards } from '$features/media/components'; + import { + AssetCard, + AssetUsageDialog, + EditAssetDialog, + ManageThumbnailsDialog, + MediaFilters, + StorageCards + } from '$features/media/components'; import { mediaApi } from '$features/media/api'; import type { AssetKindFilter, AssetStatusFilter, AssetUsageGraph, OrganizationAsset } from '$features/media/utils'; import { snackbar } from '$features/ui/snackbar/store'; @@ -28,6 +35,7 @@ let isRefreshing = $state(false); let editOpen = $state(false); let usageOpen = $state(false); + let manageThumbsOpen = $state(false); let isSavingAsset = $state(false); let isUsageLoading = $state(false); let downloadingAssetId = $state(null); @@ -66,6 +74,11 @@ editOpen = true; } + function openManageThumbnails(asset: OrganizationAsset) { + selectedAsset = asset; + manageThumbsOpen = true; + } + async function saveAsset(fields: TAssetUpdate) { if (!selectedAsset) return; @@ -148,7 +161,14 @@ {:else} {#each assets as asset (asset.id)} - + {/each} {/if} @@ -181,6 +201,8 @@ + + = 100 || exponent === 0 ? 0 : 2)} ${units[exponent]}`; } +/** + * For values that already render in GB or TB, return the equivalent in the + * next-smaller unit so users keep a feel for the scale (e.g. `1.50 GB` → + * `≈ 1,536 MB`). Returns `null` for sub-GB sizes where no extra context is + * helpful. + */ +export function formatBytesSecondary(bytes: number | null | undefined): string | null { + if (bytes == null || Number.isNaN(bytes) || bytes <= 0) { + return null; + } + + const KB = 1024; + const MB = KB * 1024; + const GB = MB * 1024; + const TB = GB * 1024; + + if (bytes >= TB) { + const inGb = bytes / GB; + return `≈ ${formatNumber(inGb, inGb >= 100 ? 0 : 2)} GB`; + } + + if (bytes >= GB) { + const inMb = bytes / MB; + return `≈ ${formatNumber(inMb, inMb >= 100 ? 0 : 2)} MB`; + } + + return null; +} + +function formatNumber(value: number, fractionDigits: number): string { + return value.toLocaleString(undefined, { + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits + }); +} + export function mapAssetProviderToLessonVideoType(provider: string): LessonVideoType { if (provider === 'youtube') return 'youtube'; if (provider === 'google_drive') return 'google_drive'; diff --git a/apps/dashboard/src/lib/features/media/utils/types.ts b/apps/dashboard/src/lib/features/media/utils/types.ts index 931ea663..5b82639e 100644 --- a/apps/dashboard/src/lib/features/media/utils/types.ts +++ b/apps/dashboard/src/lib/features/media/utils/types.ts @@ -57,5 +57,14 @@ export type GetAssetTranscriptResponse = InferResponseType; export type AssetTranscriptPayload = GetAssetTranscriptSuccess['data']; +export type SelectAssetThumbnailRequest = (typeof classroomio.organization)['assets'][':assetId']['thumbnail']['$put']; +export type SelectAssetThumbnailResponse = InferResponseType; +export type SelectAssetThumbnailSuccess = Extract; + +export type RegenerateAssetThumbnailRequest = + (typeof classroomio.jobs.media.asset)[':assetId']['regenerate-thumbnail']['$post']; +export type RegenerateAssetThumbnailResponse = InferResponseType; +export type RegenerateAssetThumbnailSuccess = Extract; + export type AssetKindFilter = 'all' | 'video' | 'document' | 'image' | 'audio' | 'other'; export type AssetStatusFilter = 'all' | 'active' | 'archived'; diff --git a/apps/dashboard/src/lib/features/org/utils/landing-page-components.ts b/apps/dashboard/src/lib/features/org/utils/landing-page-components.ts index 02194408..f2b76136 100644 --- a/apps/dashboard/src/lib/features/org/utils/landing-page-components.ts +++ b/apps/dashboard/src/lib/features/org/utils/landing-page-components.ts @@ -8,6 +8,9 @@ import { CorporateLandingHero, CorporateLandingNav, CorporateLandingPage, + EditorialLandingHero, + EditorialLandingNav, + EditorialLandingPage, MinimalLandingHero, MinimalLandingNav, MinimalLandingPage, @@ -22,7 +25,10 @@ import { TechLandingPage, TerminalLandingHero, TerminalLandingNav, - TerminalLandingPage + TerminalLandingPage, + VibrantLandingHero, + VibrantLandingNav, + VibrantLandingPage } from '@cio/ui/custom/org-landing-page'; export const landingPageThemeComponents = { @@ -33,7 +39,9 @@ export const landingPageThemeComponents = { tech: TechLandingPage, studio: StudioLandingPage, corporate: CorporateLandingPage, - terminal: TerminalLandingPage + terminal: TerminalLandingPage, + editorial: EditorialLandingPage, + vibrant: VibrantLandingPage } as const; export const landingPageNavComponents = { @@ -44,7 +52,9 @@ export const landingPageNavComponents = { tech: TechLandingNav, studio: StudioLandingNav, corporate: CorporateLandingNav, - terminal: TerminalLandingNav + terminal: TerminalLandingNav, + editorial: EditorialLandingNav, + vibrant: VibrantLandingNav } as const; export const landingPageHeroComponents = { @@ -55,5 +65,7 @@ export const landingPageHeroComponents = { tech: TechLandingHero, studio: StudioLandingHero, corporate: CorporateLandingHero, - terminal: TerminalLandingHero + terminal: TerminalLandingHero, + editorial: EditorialLandingHero, + vibrant: VibrantLandingHero } as const; diff --git a/apps/dashboard/src/lib/features/org/utils/landing-page.ts b/apps/dashboard/src/lib/features/org/utils/landing-page.ts index 97c1e2fd..6d900546 100644 --- a/apps/dashboard/src/lib/features/org/utils/landing-page.ts +++ b/apps/dashboard/src/lib/features/org/utils/landing-page.ts @@ -27,7 +27,9 @@ export const landingPageThemes = [ 'tech', 'studio', 'corporate', - 'terminal' + 'terminal', + 'editorial', + 'vibrant' ] as const satisfies OrgLandingPageTheme[]; export const defaultLandingPageHero: OrgLandingPageHero = { @@ -724,7 +726,154 @@ export function importThemeComponent(theme: LandingPageThemeKey) { return import('@cio/ui/custom/org-landing-page/corporate.svelte'); case 'terminal': return import('@cio/ui/custom/org-landing-page/terminal.svelte'); + case 'editorial': + return import('@cio/ui/custom/org-landing-page/editorial.svelte'); + case 'vibrant': + return import('@cio/ui/custom/org-landing-page/vibrant.svelte'); default: return import('@cio/ui/custom/org-landing-page/minimal.svelte'); } } + +const NAV_SNIPPET_THEMES: ReadonlySet = new Set([ + 'minimal', + 'saas', + 'tech', + 'studio', + 'corporate', + 'terminal', + 'editorial', + 'vibrant' +]); + +export function themeRendersNavInsideHero(theme: LandingPageThemeKey): boolean { + return NAV_SNIPPET_THEMES.has(theme); +} + +export function themeHeaderShellClass(_theme: LandingPageThemeKey): string { + return 'ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:font-sans'; +} + +export { themeStyle } from '@cio/ui/custom/org-landing-page/theme-style'; + +export async function importThemeCourseCard(theme: LandingPageThemeKey) { + switch (theme) { + case 'bold': + return (await import('@cio/ui/custom/org-landing-page/bold/course-card.svelte')).default; + case 'classic': + return (await import('@cio/ui/custom/org-landing-page/classic/course-card.svelte')).default; + case 'saas': + return (await import('@cio/ui/custom/org-landing-page/saas/course-card.svelte')).default; + case 'tech': + return (await import('@cio/ui/custom/org-landing-page/tech/course-card.svelte')).default; + case 'studio': + return (await import('@cio/ui/custom/org-landing-page/studio/course-card.svelte')).default; + case 'corporate': + return (await import('@cio/ui/custom/org-landing-page/corporate/course-card.svelte')).default; + case 'terminal': + return (await import('@cio/ui/custom/org-landing-page/terminal/course-card.svelte')).default; + case 'editorial': + return (await import('@cio/ui/custom/org-landing-page/editorial/course-card.svelte')).default; + case 'vibrant': + return (await import('@cio/ui/custom/org-landing-page/vibrant/course-card.svelte')).default; + default: + return (await import('@cio/ui/custom/org-landing-page/minimal/course-card.svelte')).default; + } +} + +export function themeCourseGridClass(theme: LandingPageThemeKey): string { + switch (theme) { + case 'corporate': + case 'tech': + return 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:border-t ui:border-l ui:border-[var(--landing-border)]'; + case 'saas': + return 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-px ui:bg-[var(--landing-border)] ui:border ui:border-[var(--landing-border)]'; + case 'minimal': + return 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-6'; + case 'studio': + return 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-3'; + case 'terminal': + return 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-[14px]'; + case 'editorial': + return 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-4'; + case 'vibrant': + return 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-5'; + default: + return 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-6'; + } +} + +export async function importThemeNavHero(theme: LandingPageThemeKey) { + switch (theme) { + case 'bold': { + const [nav, hero] = await Promise.all([ + import('@cio/ui/custom/org-landing-page/bold/nav.svelte'), + import('@cio/ui/custom/org-landing-page/bold/hero.svelte') + ]); + return { NavComponent: nav.default, HeroComponent: hero.default }; + } + case 'classic': { + const [nav, hero] = await Promise.all([ + import('@cio/ui/custom/org-landing-page/classic/nav.svelte'), + import('@cio/ui/custom/org-landing-page/classic/hero.svelte') + ]); + return { NavComponent: nav.default, HeroComponent: hero.default }; + } + case 'saas': { + const [nav, hero] = await Promise.all([ + import('@cio/ui/custom/org-landing-page/saas/nav.svelte'), + import('@cio/ui/custom/org-landing-page/saas/hero.svelte') + ]); + return { NavComponent: nav.default, HeroComponent: hero.default }; + } + case 'tech': { + const [nav, hero] = await Promise.all([ + import('@cio/ui/custom/org-landing-page/tech/nav.svelte'), + import('@cio/ui/custom/org-landing-page/tech/hero.svelte') + ]); + return { NavComponent: nav.default, HeroComponent: hero.default }; + } + case 'studio': { + const [nav, hero] = await Promise.all([ + import('@cio/ui/custom/org-landing-page/studio/nav.svelte'), + import('@cio/ui/custom/org-landing-page/studio/hero.svelte') + ]); + return { NavComponent: nav.default, HeroComponent: hero.default }; + } + case 'corporate': { + const [nav, hero] = await Promise.all([ + import('@cio/ui/custom/org-landing-page/corporate/nav.svelte'), + import('@cio/ui/custom/org-landing-page/corporate/hero.svelte') + ]); + return { NavComponent: nav.default, HeroComponent: hero.default }; + } + case 'terminal': { + const [nav, hero] = await Promise.all([ + import('@cio/ui/custom/org-landing-page/terminal/nav.svelte'), + import('@cio/ui/custom/org-landing-page/terminal/hero.svelte') + ]); + return { NavComponent: nav.default, HeroComponent: hero.default }; + } + case 'editorial': { + const [nav, hero] = await Promise.all([ + import('@cio/ui/custom/org-landing-page/editorial/nav.svelte'), + import('@cio/ui/custom/org-landing-page/editorial/hero.svelte') + ]); + return { NavComponent: nav.default, HeroComponent: hero.default }; + } + case 'vibrant': { + const [nav, hero] = await Promise.all([ + import('@cio/ui/custom/org-landing-page/vibrant/nav.svelte'), + import('@cio/ui/custom/org-landing-page/vibrant/hero.svelte') + ]); + return { NavComponent: nav.default, HeroComponent: hero.default }; + } + default: { + const [nav, hero] = await Promise.all([ + import('@cio/ui/custom/org-landing-page/minimal/nav.svelte'), + import('@cio/ui/custom/org-landing-page/minimal/hero.svelte') + ]); + return { NavComponent: nav.default, HeroComponent: hero.default }; + } + } +} diff --git a/apps/dashboard/src/lib/features/settings/components/theme-preview-dialog.svelte b/apps/dashboard/src/lib/features/settings/components/theme-preview-dialog.svelte new file mode 100644 index 00000000..4a824ebf --- /dev/null +++ b/apps/dashboard/src/lib/features/settings/components/theme-preview-dialog.svelte @@ -0,0 +1,70 @@ + + + + + Theme preview + Preview the selected landing page theme with sample content. + +
+ + + +
+ +
+ {#if ThemeComponent} + + {/if} +
+
+
diff --git a/apps/dashboard/src/lib/features/settings/pages/landingpage.svelte b/apps/dashboard/src/lib/features/settings/pages/landingpage.svelte index 57aa5249..d80eabb2 100644 --- a/apps/dashboard/src/lib/features/settings/pages/landingpage.svelte +++ b/apps/dashboard/src/lib/features/settings/pages/landingpage.svelte @@ -2,7 +2,10 @@ import { resolve } from '$app/paths'; import { goto } from '$app/navigation'; import ArrowRightIcon from '@lucide/svelte/icons/arrow-right'; + import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical'; + import EyeIcon from '@lucide/svelte/icons/eye'; import LayoutTemplateIcon from '@lucide/svelte/icons/layout-template'; + import PaintbrushIcon from '@lucide/svelte/icons/paintbrush'; import ZapIcon from '@lucide/svelte/icons/zap'; import { currentOrg, currentOrgPath, isFreePlan } from '$lib/utils/store/org'; @@ -10,12 +13,19 @@ import { t } from '$lib/utils/functions/translations'; import { user } from '$lib/utils/store/user'; import { basePath } from '$lib/utils/store/app'; + import type { AccountOrg } from '$features/app/types'; + import { orgApi } from '$features/org/api/org.svelte'; + import { snackbar } from '$features/ui/snackbar/store'; import { Button } from '@cio/ui/base/button'; import { Badge } from '@cio/ui/base/badge'; import * as Card from '@cio/ui/base/card'; + import * as DropdownMenu from '@cio/ui/base/dropdown-menu'; + import { IconButton } from '@cio/ui/custom/icon-button'; import { PremiumIcon } from '@cio/ui/custom/moving-icons'; + import ThemePreviewDialog from '$features/settings/components/theme-preview-dialog.svelte'; + import { createDefaultLandingPageSettings, landingPageThemes, @@ -86,6 +96,18 @@ preview: '/templates/terminal.png', titleKey: 'settings.landing_page.theme.cards.terminal.title', descriptionKey: 'settings.landing_page.theme.cards.terminal.description' + }, + { + value: 'editorial', + preview: '/templates/editorial.png', + titleKey: 'settings.landing_page.theme.cards.editorial.title', + descriptionKey: 'settings.landing_page.theme.cards.editorial.description' + }, + { + value: 'vibrant', + preview: '/templates/vibrant.png', + titleKey: 'settings.landing_page.theme.cards.vibrant.title', + descriptionKey: 'settings.landing_page.theme.cards.vibrant.description' } ] as const satisfies ReadonlyArray<{ value: LandingPageTheme; @@ -125,6 +147,37 @@ goto(resolve(`${$currentOrgPath}/settings/landingpage/edit?theme=${theme}`, {})); } + async function handleApplyTheme(theme: LandingPageTheme) { + if (!$currentOrg.id) return; + + if ($isFreePlan && isPaidTheme(theme)) { + openUpgradeModal(); + return; + } + + const updatedLandingPage = normalizeLandingPageSettings({ + ...normalized, + theme + }); + + await orgApi.update($currentOrg.id, { + landingpage: updatedLandingPage as AccountOrg['landingpage'] + }); + + normalized = updatedLandingPage; + snackbar.success('snackbar.success_update'); + } + + function handlePreviewTheme(theme: LandingPageTheme) { + const nextUrl = new URL(window.location.href); + nextUrl.searchParams.set('preview', theme); + goto(`${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`, { + replaceState: false, + keepFocus: true, + noScroll: true + }); + } + const navCount = $derived(normalized.navItems?.length ?? 0); const heroHeading = $derived(normalized.hero?.heading?.trim() || t.get('settings.landing_page.current.hero_empty')); const navCountLabel = $derived( @@ -227,12 +280,31 @@
{#if isLocked}
{$t('settings.landing_page.gallery.upgrade')}
{/if} + + + + + + + + handlePreviewTheme(themeCard.value)}> + + {$t('settings.landing_page.gallery.preview')} + + {#if !isLocked} + handleApplyTheme(themeCard.value)}> + + {$t('settings.landing_page.gallery.apply_theme')} + + {/if} + +
+ + diff --git a/apps/dashboard/src/lib/features/ui/course-landing-page/course-landing-page.svelte b/apps/dashboard/src/lib/features/ui/course-landing-page/course-landing-page.svelte index bb3ae542..f7e4765e 100644 --- a/apps/dashboard/src/lib/features/ui/course-landing-page/course-landing-page.svelte +++ b/apps/dashboard/src/lib/features/ui/course-landing-page/course-landing-page.svelte @@ -18,7 +18,13 @@ import { MagicCard } from '@cio/ui/custom/animation/magic-card'; import { getLectureNo } from '$features/course/utils/functions'; - import { normalizeLandingPageSettings } from '$features/org/utils/landing-page'; + import { + importThemeNavHero, + normalizeLandingPageSettings, + themeHeaderShellClass, + themeStyle, + themeRendersNavInsideHero + } from '$features/org/utils/landing-page'; import { currentOrg } from '$lib/utils/store/org'; import { basePath } from '$lib/utils/store/app'; import { user } from '$lib/utils/store/user'; @@ -57,11 +63,9 @@ const landingSettings = $derived(normalizeLandingPageSettings($currentOrg.landingpage)); - const heroShellClass = $derived( - landingSettings.theme === 'classic' - ? 'ui:w-full ui:bg-muted/10 ui:text-foreground ui:font-sans' - : 'ui:w-full ui:bg-background ui:text-foreground ui:font-sans' - ); + const heroShellClass = $derived(`ui:w-full ${themeHeaderShellClass(landingSettings.theme)}`); + const heroShellStyle = $derived(themeStyle(landingSettings.theme)); + const navInsideHero = $derived(themeRendersNavInsideHero(landingSettings.theme)); const orgNameForNav = $derived($currentOrg.name || get(courseData, 'org.name', '') || 'ClassroomIO'); const logoUrlForNav = $derived($currentOrg.avatarUrl || undefined); @@ -115,21 +119,10 @@ const theme = landingSettings.theme; let cancelled = false; - void Promise.all([ - theme === 'bold' - ? import('@cio/ui/custom/org-landing-page/bold/nav.svelte') - : theme === 'classic' - ? import('@cio/ui/custom/org-landing-page/classic/nav.svelte') - : import('@cio/ui/custom/org-landing-page/minimal/nav.svelte'), - theme === 'bold' - ? import('@cio/ui/custom/org-landing-page/bold/hero.svelte') - : theme === 'classic' - ? import('@cio/ui/custom/org-landing-page/classic/hero.svelte') - : import('@cio/ui/custom/org-landing-page/minimal/hero.svelte') - ]).then(([navMod, heroMod]) => { + void importThemeNavHero(theme).then(({ NavComponent: nav, HeroComponent: hero }) => { if (!cancelled) { - NavComponent = navMod.default; - HeroComponent = heroMod.default; + NavComponent = nav; + HeroComponent = hero; } }); @@ -202,10 +195,10 @@
{#if showMarketingHero} -
+
{#if NavComponent && HeroComponent} - {#if landingSettings.theme === 'minimal'} - + {#if navInsideHero} + {#snippet navigation()} - + {/if} {/if} diff --git a/apps/dashboard/src/lib/features/ui/navigation/app-header.svelte b/apps/dashboard/src/lib/features/ui/navigation/app-header.svelte index 15798455..ca52251b 100644 --- a/apps/dashboard/src/lib/features/ui/navigation/app-header.svelte +++ b/apps/dashboard/src/lib/features/ui/navigation/app-header.svelte @@ -11,6 +11,7 @@ import { currentOrg } from '$lib/utils/store/org'; import { setupProgressApi } from '$features/setup/api/setup-progress.svelte'; import AppSetup from './app-setup.svelte'; + import VisitOrgSiteBtn from '$features/ui/visit-org-site-btn.svelte'; const siteName = $derived($currentOrg.siteName); @@ -36,6 +37,8 @@ + + diff --git a/apps/dashboard/src/lib/features/ui/question/constants.ts b/apps/dashboard/src/lib/features/ui/question/constants.ts index 9ff4ad9e..340eaaf2 100644 --- a/apps/dashboard/src/lib/features/ui/question/constants.ts +++ b/apps/dashboard/src/lib/features/ui/question/constants.ts @@ -1,13 +1,6 @@ import { QUESTION_TYPE_KEY, QUESTION_TYPE_REGISTRY, type QuestionTypeKey } from '@cio/question-types'; -/** Question types that require a paid plan. On free plan, these show a premium icon and open upgrade modal on click. */ -export const PREMIUM_QUESTION_TYPE_KEYS = new Set([ - QUESTION_TYPE_KEY.FILE_UPLOAD, - QUESTION_TYPE_KEY.ORDERING, - QUESTION_TYPE_KEY.LINK, - QUESTION_TYPE_KEY.STAR, - QUESTION_TYPE_KEY.VIDEO_RECORDING -]); +export { PREMIUM_QUESTION_TYPE_KEYS } from '@cio/question-types'; const QUESTION_TYPE_LABEL_KEYS: Partial> = { [QUESTION_TYPE_KEY.RADIO]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.single', diff --git a/apps/dashboard/src/lib/features/ui/visit-org-site-btn.svelte b/apps/dashboard/src/lib/features/ui/visit-org-site-btn.svelte index ba2f0889..4cc37068 100644 --- a/apps/dashboard/src/lib/features/ui/visit-org-site-btn.svelte +++ b/apps/dashboard/src/lib/features/ui/visit-org-site-btn.svelte @@ -1,7 +1,8 @@ - diff --git a/apps/dashboard/src/lib/utils/services/auth/server.ts b/apps/dashboard/src/lib/utils/services/auth/server.ts index ec4621dc..3319170c 100644 --- a/apps/dashboard/src/lib/utils/services/auth/server.ts +++ b/apps/dashboard/src/lib/utils/services/auth/server.ts @@ -9,4 +9,3 @@ export const authServerClient = createAuthClient({ } }); console.log('baseURL', baseURL); -console.log('authServerClient', authServerClient); diff --git a/apps/dashboard/src/lib/utils/translations/da.json b/apps/dashboard/src/lib/utils/translations/da.json index ab227d28..83cddf52 100644 --- a/apps/dashboard/src/lib/utils/translations/da.json +++ b/apps/dashboard/src/lib/utils/translations/da.json @@ -346,7 +346,11 @@ "transcription_started": "Transskription startede", "transcription_start_failed": "Kunne ikke starte transskription", "transcription_completed": "Transskription afsluttet", - "transcript_fetch_failed": "Kunne ikke indlæse transskription" + "transcript_fetch_failed": "Kunne ikke indlæse transskription", + "thumbnail_updated": "Miniaturebilledet er opdateret", + "thumbnail_update_failed": "Kunne ikke opdatere thumbnail", + "thumbnail_regen_started": "Regenererer thumbnails...", + "thumbnail_regen_failed": "Kunne ikke genskabe miniaturebilleder" }, "tags": { "group_created": "Taggruppe oprettet", @@ -2059,7 +2063,8 @@ "overview_metrics": { "heading": "Kursus øjebliksbillede", "description": "Eleven tæller efter status for dette kursus." - } + }, + "empty_overview_description": "Hold dine medarbejdere kompatible og hold øje med, hvem der står bag." } }, "search": { @@ -2164,9 +2169,7 @@ "depth": "Hvor dybt skal kurset gå?", "topic": "Emne eller niche", "expertise_level": "Mål ekspertiseniveau", - "documentation_url": { - "hint": "Vi læser dette websted for at basere kurset på dit rigtige produkt." - } + "documentation_url": "Dokumentations-URL (valgfrit)" }, "expert_on_x": { "title": "Bliv ekspert på X", @@ -2340,7 +2343,8 @@ "login_activity_title": "Student login aktivitet", "login_activity_description": "Ugens mest aktive dage (sidste 90 dage)", "login_activity_no_data": "Ingen login-aktivitet endnu", - "login_activity_logins": "logins" + "login_activity_logins": "logins", + "open_academy": "Åbent Akademi" }, "schedule": { "title": "Dit skema", @@ -2621,7 +2625,8 @@ "actions": { "edit": "Rediger", "where_used": "Hvor brugt", - "download": "Download" + "download": "Download", + "manage_thumbnails": "Administrer thumbnails" }, "table": { "title": "Titel", @@ -2678,7 +2683,22 @@ "not_available": "N/A" }, "empty_description": "Aktivet findes ikke eller er ikke tilgængeligt i denne organisation.", - "page_subtitle": "Keep files, videos, and links for your courses in one library." + "page_subtitle": "Keep files, videos, and links for your courses in one library.", + "thumbnails": { + "manage_title": "Administrer thumbnails", + "manage_description": "Se forhåndsvisning af videoen, vælg en af de automatisk genererede frames, eller upload din egen.", + "preview_unavailable": "Forhåndsvisning er ikke tilgængelig", + "generated": "Genererede thumbnails", + "slot_start": "Start", + "slot_middle": "Midten", + "slot_end": "Slut", + "none_generated_warning": "Der er endnu ikke genereret miniaturebilleder. Prøv at genskabe eller uploade et brugerdefineret billede.", + "upload_custom": "Upload tilpasset miniaturebillede", + "custom_selected_hint": "Tilpasset upload vil blive gemt, når du gemmer dit valg.", + "custom_preview_alt": "Forhåndsvisning af tilpasset miniaturebillede", + "regenerate": "Regenerer", + "save_selection": "Gem thumbnail" + } }, "settings": { "heading": "Indstillinger", @@ -3003,6 +3023,14 @@ "saas": { "title": "SaaS", "description": "Stiplet ramme, bløde accenter, moderne produktfølelse" + }, + "vibrant": { + "title": "Levende", + "description": "Fed primære paneler, solnedgangsgradienthelt, energisk plakatlook" + }, + "editorial": { + "title": "Redaktionel", + "description": "Varm cremepalette, malerisk helt, raffineret magasinfølelse" } } }, @@ -3011,7 +3039,10 @@ "description": "Lavet med kernefunktioner, du nemt kan tilpasse - ingen kodning nødvendig.", "by_classroomio": "af ClassroomIO", "add": "Tilføj", - "upgrade": "Opgrader" + "upgrade": "Opgrader", + "apply_theme": "Anvend tema", + "preview": "Forhåndsvisning", + "card_menu_label": "Temamuligheder" }, "current": { "title": "Aktuelt tema", @@ -3952,6 +3983,54 @@ "quiz_me": "Quiz mig om dette", "summarize_section": "Opsummer dette afsnit", "what_to_review": "Hvad skal jeg gennemgå næste gang?" + }, + "run_running": "Genererende kursus", + "run_completed": "Kursusgenerering afsluttet", + "run_failed": "Kursusgenerering mislykkedes", + "run_canceled": "Kursusgenerering aflyst", + "run_waiting_for_worker": "Venter på baggrundsarbejderen", + "run_checkpoint": "Gemmer fremskridtskontrolpunkt", + "retry": "Prøv igen", + "run_monitor_aria": "Kursusgenerering i gang", + "run_summary": { + "completed": "Kørsel afsluttet.", + "completed_with_counts": "Kørsel afsluttet — oprettet {counts}.", + "failed": "Kørsel mislykkedes.", + "failed_with_reason": "Kørsel mislykkedes — {reason}.", + "canceled": "Kørsel annulleret.", + "canceled_with_counts": "Kørsel annulleret — oprettet {counts} før stop.", + "paused": "Løb sat på pause. Genoptag for at fortsætte.", + "paused_with_counts": "Kør på pause – oprettet {counts} indtil videre. Genoptag for at fortsætte." + }, + "changes_card": { + "summary_count": "{count} elementer blev ændret", + "expand": "Udvid ændringer", + "collapse": "Skjul ændringer", + "action": { + "created": "Oprettet", + "metadata_updated": "Metadata opdateret", + "content_written": "Indhold skrevet", + "questions_added": "+{count} spørgsmål", + "questions_updated": "{count} spørgsmål redigeret", + "section_added": "Blok tilføjet", + "section_edited": "Blok redigeret" + }, + "status": { + "completed": "Afsluttet", + "failed": "Mislykkedes", + "canceled": "Annulleret", + "paused": "Pause" + } + }, + "plan_view": { + "expand": "Udvid planen", + "collapse": "Kollaps plan" + }, + "run_monitor": { + "cancel": "Annuller kørsel", + "dismiss": "Afvis", + "now_doing": "Nu", + "input_disabled_hint": "Chatten er sat på pause, mens agenten er færdig med at bygge kurset. Annuller eller vent på, at det er fuldført." } }, "widgets": { diff --git a/apps/dashboard/src/lib/utils/translations/de.json b/apps/dashboard/src/lib/utils/translations/de.json index 75df8ad7..4589ad06 100644 --- a/apps/dashboard/src/lib/utils/translations/de.json +++ b/apps/dashboard/src/lib/utils/translations/de.json @@ -346,7 +346,11 @@ "transcription_started": "Die Transkription hat begonnen", "transcription_start_failed": "Die Transkription konnte nicht gestartet werden", "transcription_completed": "Transkription abgeschlossen", - "transcript_fetch_failed": "Das Transkript konnte nicht geladen werden" + "transcript_fetch_failed": "Das Transkript konnte nicht geladen werden", + "thumbnail_updated": "Miniaturbild aktualisiert", + "thumbnail_update_failed": "Miniaturansicht konnte nicht aktualisiert werden", + "thumbnail_regen_started": "Miniaturansichten werden neu generiert…", + "thumbnail_regen_failed": "Die Miniaturansichten konnten nicht neu generiert werden" }, "tags": { "group_created": "Tag-Gruppe erstellt", @@ -2059,7 +2063,8 @@ "overview_metrics": { "heading": "Kursschnappschuss", "description": "Die Teilnehmer werden für diesen Kurs nach Status gezählt." - } + }, + "empty_overview_description": "Halten Sie Ihre Mitarbeiter konform und verfolgen Sie, wer im Rückstand ist." } }, "search": { @@ -2164,9 +2169,7 @@ "depth": "Wie tief soll der Kurs gehen?", "topic": "Thema oder Nische", "expertise_level": "Zielkompetenzniveau", - "documentation_url": { - "hint": "Wir werden diese Website lesen, um den Kurs in Ihrem realen Produkt zu verankern." - } + "documentation_url": "Dokumentations-URL (optional)" }, "expert_on_x": { "title": "Werden Sie Experte für X", @@ -2340,7 +2343,8 @@ "login_activity_title": "Anmeldeaktivität für Studenten", "login_activity_description": "Aktivste Tage der Woche (letzte 90 Tage)", "login_activity_no_data": "Noch keine Login-Aktivität", - "login_activity_logins": "Anmeldungen" + "login_activity_logins": "Anmeldungen", + "open_academy": "Offene Akademie" }, "schedule": { "title": "Ihr Zeitplan", @@ -2621,7 +2625,8 @@ "actions": { "edit": "Bearbeiten", "where_used": "Wo verwendet", - "download": "Herunterladen" + "download": "Herunterladen", + "manage_thumbnails": "Miniaturansichten verwalten" }, "table": { "title": "Titel", @@ -2678,7 +2683,22 @@ "not_available": "N/A" }, "empty_description": "Das Asset existiert nicht oder ist in dieser Organisation nicht verfügbar.", - "page_subtitle": "Keep files, videos, and links for your courses in one library." + "page_subtitle": "Keep files, videos, and links for your courses in one library.", + "thumbnails": { + "manage_title": "Miniaturansichten verwalten", + "manage_description": "Sehen Sie sich das Video in der Vorschau an, wählen Sie eines der automatisch generierten Frames aus oder laden Sie Ihr eigenes hoch.", + "preview_unavailable": "Vorschau nicht verfügbar", + "generated": "Generierte Miniaturansichten", + "slot_start": "Starten", + "slot_middle": "Mitte", + "slot_end": "Ende", + "none_generated_warning": "Es wurden noch keine Miniaturansichten generiert. Versuchen Sie, ein benutzerdefiniertes Bild neu zu generieren oder hochzuladen.", + "upload_custom": "Laden Sie ein benutzerdefiniertes Miniaturbild hoch", + "custom_selected_hint": "Der benutzerdefinierte Upload wird gespeichert, wenn Sie Ihre Auswahl speichern.", + "custom_preview_alt": "Benutzerdefinierte Miniaturvorschau", + "regenerate": "Regenerieren", + "save_selection": "Miniaturansicht speichern" + } }, "settings": { "heading": "Einstellungen", @@ -3003,6 +3023,14 @@ "saas": { "title": "SaaS", "description": "Rahmen mit gepunkteten Linien, sanfte Akzente, modernes Produktgefühl" + }, + "vibrant": { + "title": "Lebendig", + "description": "Auffällige Primärpaneele, Held mit Sonnenuntergangsverlauf, energiegeladener Poster-Look" + }, + "editorial": { + "title": "Leitartikel", + "description": "Warme Creme-Palette, malerischer Held, raffiniertes Magazin-Feeling" } } }, @@ -3011,7 +3039,10 @@ "description": "Entwickelt mit Kernfunktionen, die Sie einfach anpassen können – keine Programmierung erforderlich.", "by_classroomio": "von ClassroomIO", "add": "Hinzufügen", - "upgrade": "Upgrade" + "upgrade": "Upgrade", + "apply_theme": "Theme anwenden", + "preview": "Vorschau", + "card_menu_label": "Themenoptionen" }, "current": { "title": "Aktuelles Thema", @@ -3952,6 +3983,54 @@ "quiz_me": "Befragen Sie mich dazu", "summarize_section": "Fassen Sie diesen Abschnitt zusammen", "what_to_review": "Was sollte ich als nächstes überprüfen?" + }, + "run_running": "Generierender Kurs", + "run_completed": "Kurserstellung abgeschlossen", + "run_failed": "Die Kursgenerierung ist fehlgeschlagen", + "run_canceled": "Kursgenerierung abgebrochen", + "run_waiting_for_worker": "Warten auf den Hintergrundarbeiter", + "run_checkpoint": "Fortschrittskontrollpunkt wird gespeichert", + "retry": "Versuchen Sie es noch einmal", + "run_monitor_aria": "Kursgenerierung läuft", + "run_summary": { + "completed": "Lauf abgeschlossen.", + "completed_with_counts": "Lauf abgeschlossen – {counts} erstellt.", + "failed": "Ausführung fehlgeschlagen.", + "failed_with_reason": "Ausführung fehlgeschlagen – {reason}.", + "canceled": "Lauf abgebrochen.", + "canceled_with_counts": "Lauf abgebrochen – {counts} vor dem Stoppen erstellt.", + "paused": "Lauf pausiert. Fortfahren, um fortzufahren.", + "paused_with_counts": "Ausführung pausiert – bisher {counts} erstellt. Fortfahren, um fortzufahren." + }, + "changes_card": { + "summary_count": "{count} Elemente geändert", + "expand": "Erweitern Sie Änderungen", + "collapse": "Änderungen reduzieren", + "action": { + "created": "Erstellt", + "metadata_updated": "Metadaten aktualisiert", + "content_written": "Inhalt geschrieben", + "questions_added": "+{count} Fragen", + "questions_updated": "{count} Fragen bearbeitet", + "section_added": "Block hinzugefügt", + "section_edited": "Block bearbeitet" + }, + "status": { + "completed": "Abgeschlossen", + "failed": "Fehlgeschlagen", + "canceled": "Abgesagt", + "paused": "Angehalten" + } + }, + "plan_view": { + "expand": "Plan erweitern", + "collapse": "Plan zusammenbrechen" + }, + "run_monitor": { + "cancel": "Lauf abbrechen", + "dismiss": "Entlassen", + "now_doing": "Jetzt", + "input_disabled_hint": "Der Chat wird angehalten, während der Agent mit der Erstellung des Kurses fertig ist. Brechen Sie den Vorgang ab oder warten Sie, bis der Vorgang abgeschlossen ist." } }, "widgets": { diff --git a/apps/dashboard/src/lib/utils/translations/en.json b/apps/dashboard/src/lib/utils/translations/en.json index dc163ad9..bb984dee 100644 --- a/apps/dashboard/src/lib/utils/translations/en.json +++ b/apps/dashboard/src/lib/utils/translations/en.json @@ -633,6 +633,14 @@ "terminal": { "title": "Terminal", "description": "Dark surface, ghosted wordmark, CLI-inspired catalog" + }, + "editorial": { + "title": "Editorial", + "description": "Warm cream palette, painterly hero, refined magazine feel" + }, + "vibrant": { + "title": "Vibrant", + "description": "Bold primary panels, sunset gradient hero, energetic poster look" } } }, @@ -657,7 +665,10 @@ "description": "Made with core features you can easily customize — no coding needed.", "by_classroomio": "by ClassroomIO", "add": "Add", - "upgrade": "Upgrade" + "upgrade": "Upgrade", + "apply_theme": "Apply Theme", + "preview": "Preview", + "card_menu_label": "Theme options" }, "editor": { "save": "Save", @@ -4288,6 +4299,12 @@ "plan_working": "Working", "plan_thinking": "Thinking through your request…", "plan_progress": "{completed} / {total} steps completed", + "run_running": "Generating course", + "run_completed": "Course generation complete", + "run_failed": "Course generation failed", + "run_canceled": "Course generation canceled", + "run_waiting_for_worker": "Waiting for the background worker", + "run_checkpoint": "Saving progress checkpoint", "plan_implement": "Implement Plan", "plan_ask_changes_placeholder": "Tell me what to change about the plan…", "plan_ask_changes_send_aria": "Send change request", @@ -4313,6 +4330,48 @@ "tokens_label": "tokens", "implementing_course_plan": "Implementing Course Plan", "resume": "Resume", + "retry": "Retry", + "run_monitor_aria": "Course generation in progress", + "run_monitor": { + "cancel": "Cancel run", + "dismiss": "Dismiss", + "now_doing": "Now", + "input_disabled_hint": "Chat is paused while the agent finishes building the course. Cancel or wait for it to complete." + }, + "plan_view": { + "expand": "Expand plan", + "collapse": "Collapse plan" + }, + "changes_card": { + "summary_count": "{count} items changed", + "expand": "Expand changes", + "collapse": "Collapse changes", + "status": { + "completed": "Completed", + "failed": "Failed", + "canceled": "Canceled", + "paused": "Paused" + }, + "action": { + "created": "Created", + "metadata_updated": "Metadata updated", + "content_written": "Content written", + "questions_added": "+{count} questions", + "questions_updated": "{count} questions edited", + "section_added": "Block added", + "section_edited": "Block edited" + } + }, + "run_summary": { + "completed": "Run completed.", + "completed_with_counts": "Run completed — created {counts}.", + "failed": "Run failed.", + "failed_with_reason": "Run failed — {reason}.", + "canceled": "Run canceled.", + "canceled_with_counts": "Run canceled — created {counts} before stopping.", + "paused": "Run paused. Resume to continue.", + "paused_with_counts": "Run paused — created {counts} so far. Resume to continue." + }, "stopped_content_kept": "Stopped. Created content has been kept.", "run_failed_after": "{action} did not finish successfully.", "tool": { diff --git a/apps/dashboard/src/lib/utils/translations/es.json b/apps/dashboard/src/lib/utils/translations/es.json index 2090ed1b..76faa4c4 100644 --- a/apps/dashboard/src/lib/utils/translations/es.json +++ b/apps/dashboard/src/lib/utils/translations/es.json @@ -346,7 +346,11 @@ "transcription_started": "Transcripción iniciada", "transcription_start_failed": "No se pudo iniciar la transcripción", "transcription_completed": "Transcripción finalizada", - "transcript_fetch_failed": "No se pudo cargar la transcripción" + "transcript_fetch_failed": "No se pudo cargar la transcripción", + "thumbnail_updated": "Miniatura actualizada", + "thumbnail_update_failed": "No se pudo actualizar la miniatura", + "thumbnail_regen_started": "Regenerando miniaturas…", + "thumbnail_regen_failed": "No se pudieron regenerar las miniaturas" }, "tags": { "group_created": "Grupo de etiquetas creado", @@ -2059,7 +2063,8 @@ "overview_metrics": { "heading": "Resumen del curso", "description": "El alumno cuenta por estado para este curso." - } + }, + "empty_overview_description": "Haga que sus empleados cumplan y realice un seguimiento de quién está detrás." } }, "search": { @@ -2164,9 +2169,7 @@ "depth": "¿Qué profundidad debe tener el curso?", "topic": "Tema o nicho", "expertise_level": "Nivel de experiencia objetivo", - "documentation_url": { - "hint": "Leeremos este sitio para basar el curso en su producto real." - } + "documentation_url": "URL de documentación (opcional)" }, "expert_on_x": { "title": "Conviértete en un experto en X", @@ -2340,7 +2343,8 @@ "login_activity_title": "Actividad de inicio de sesión del estudiante", "login_activity_description": "Días más activos de la semana (últimos 90 días)", "login_activity_no_data": "Aún no hay actividad de inicio de sesión", - "login_activity_logins": "inicios de sesión" + "login_activity_logins": "inicios de sesión", + "open_academy": "Academia Abierta" }, "schedule": { "title": "Tu horario", @@ -2621,7 +2625,8 @@ "actions": { "edit": "Editar", "where_used": "donde se usa", - "download": "Descargar" + "download": "Descargar", + "manage_thumbnails": "Administrar miniaturas" }, "table": { "title": "Título", @@ -2678,7 +2683,22 @@ "not_available": "N/A" }, "empty_description": "El activo no existe o no está disponible en esta organización.", - "page_subtitle": "Keep files, videos, and links for your courses in one library." + "page_subtitle": "Keep files, videos, and links for your courses in one library.", + "thumbnails": { + "manage_title": "Administrar miniaturas", + "manage_description": "Obtenga una vista previa del vídeo, elija uno de los fotogramas generados automáticamente o cargue el suyo propio.", + "preview_unavailable": "Vista previa no disponible", + "generated": "Miniaturas generadas", + "slot_start": "Empezar", + "slot_middle": "Medio", + "slot_end": "Fin", + "none_generated_warning": "Aún no se han generado miniaturas. Intente regenerar o cargar una imagen personalizada.", + "upload_custom": "Subir miniatura personalizada", + "custom_selected_hint": "La carga personalizada se guardará cuando guarde su selección.", + "custom_preview_alt": "Vista previa en miniatura personalizada", + "regenerate": "regenerar", + "save_selection": "Guardar miniatura" + } }, "settings": { "heading": "Configuración", @@ -3003,6 +3023,14 @@ "saas": { "title": "SaaS", "description": "Marco de líneas punteadas, detalles suaves, sensación de producto moderno." + }, + "vibrant": { + "title": "Vibrante", + "description": "Paneles primarios atrevidos, héroe degradado al atardecer, aspecto de póster enérgico" + }, + "editorial": { + "title": "Redacción", + "description": "Paleta de crema cálida, héroe pictórico, sensación refinada de revista" } } }, @@ -3011,7 +3039,10 @@ "description": "Creado con funciones principales que puedes personalizar fácilmente, sin necesidad de codificación.", "by_classroomio": "por ClassroomIO", "add": "Añadir", - "upgrade": "Actualizar" + "upgrade": "Actualizar", + "apply_theme": "Aplicar tema", + "preview": "Vista previa", + "card_menu_label": "Opciones de tema" }, "current": { "title": "Tema actual", @@ -3952,6 +3983,54 @@ "quiz_me": "Pregúntame sobre esto", "summarize_section": "Resumir esta sección", "what_to_review": "¿Qué debo revisar a continuación?" + }, + "run_running": "Generando curso", + "run_completed": "Generación del curso completa", + "run_failed": "Falló la generación del curso", + "run_canceled": "Generación de curso cancelada", + "run_waiting_for_worker": "Esperando al trabajador en segundo plano.", + "run_checkpoint": "Guardar punto de control de progreso", + "retry": "Reintentar", + "run_monitor_aria": "Generación de cursos en progreso", + "run_summary": { + "completed": "Ejecución completada.", + "completed_with_counts": "Ejecución completada: creada {counts}.", + "failed": "La ejecución falló.", + "failed_with_reason": "La ejecución falló: {motivo}.", + "canceled": "Ejecución cancelada.", + "canceled_with_counts": "Ejecución cancelada: creó {counts} antes de detenerse.", + "paused": "Ejecución pausada. Reanudar para continuar.", + "paused_with_counts": "Ejecución pausada: creado {counts} hasta el momento. Reanudar para continuar." + }, + "changes_card": { + "summary_count": "{count} elementos cambiados", + "expand": "Ampliar cambios", + "collapse": "Contraer cambios", + "action": { + "created": "Creado", + "metadata_updated": "Metadatos actualizados", + "content_written": "Contenido escrito", + "questions_added": "+{count} preguntas", + "questions_updated": "{count} preguntas editadas", + "section_added": "Bloque agregado", + "section_edited": "Bloque editado" + }, + "status": { + "completed": "Completado", + "failed": "Fallido", + "canceled": "Cancelado", + "paused": "En pausa" + } + }, + "plan_view": { + "expand": "Ampliar plano", + "collapse": "Plan de colapso" + }, + "run_monitor": { + "cancel": "Cancelar ejecución", + "dismiss": "Descartar", + "now_doing": "ahora", + "input_disabled_hint": "El chat se pausa mientras el agente termina de crear el curso. Cancele o espere a que se complete." } }, "widgets": { diff --git a/apps/dashboard/src/lib/utils/translations/fr.json b/apps/dashboard/src/lib/utils/translations/fr.json index 1bd9dc00..f9a89438 100644 --- a/apps/dashboard/src/lib/utils/translations/fr.json +++ b/apps/dashboard/src/lib/utils/translations/fr.json @@ -346,7 +346,11 @@ "transcription_started": "Transcription commencée", "transcription_start_failed": "Impossible de démarrer la transcription", "transcription_completed": "Transcription terminée", - "transcript_fetch_failed": "Impossible de charger la transcription" + "transcript_fetch_failed": "Impossible de charger la transcription", + "thumbnail_updated": "Miniature mise à jour", + "thumbnail_update_failed": "Échec de la mise à jour de la vignette", + "thumbnail_regen_started": "Régénération des vignettes…", + "thumbnail_regen_failed": "Échec de la régénération des miniatures" }, "tags": { "group_created": "Groupe de balises créé", @@ -2059,7 +2063,8 @@ "overview_metrics": { "heading": "Aperçu du cours", "description": "L'apprenant compte par statut pour ce cours." - } + }, + "empty_overview_description": "Gardez vos employés conformes et suivez qui est derrière." } }, "search": { @@ -2164,9 +2169,7 @@ "depth": "Jusqu’où doit aller le cours ?", "topic": "Sujet ou niche", "expertise_level": "Niveau d’expertise cible", - "documentation_url": { - "hint": "Nous lirons ce site pour ancrer le cours dans votre produit réel." - } + "documentation_url": "URL de la documentation (facultatif)" }, "expert_on_x": { "title": "Devenez un expert sur X", @@ -2340,7 +2343,8 @@ "login_activity_title": "Activité de connexion des étudiants", "login_activity_description": "Jours les plus actifs de la semaine (90 derniers jours)", "login_activity_no_data": "Aucune activité de connexion pour le moment", - "login_activity_logins": "connexions" + "login_activity_logins": "connexions", + "open_academy": "Académie ouverte" }, "schedule": { "title": "Votre emploi du temps", @@ -2621,7 +2625,8 @@ "actions": { "edit": "Modifier", "where_used": "Où utilisé", - "download": "Télécharger" + "download": "Télécharger", + "manage_thumbnails": "Gérer les vignettes" }, "table": { "title": "Titre", @@ -2678,7 +2683,22 @@ "not_available": "N/D" }, "empty_description": "L'actif n'existe pas ou n'est pas disponible dans cette organisation.", - "page_subtitle": "Keep files, videos, and links for your courses in one library." + "page_subtitle": "Keep files, videos, and links for your courses in one library.", + "thumbnails": { + "manage_title": "Gérer les vignettes", + "manage_description": "Prévisualisez la vidéo, choisissez l'une des images générées automatiquement ou téléchargez la vôtre.", + "preview_unavailable": "Aperçu indisponible", + "generated": "Miniatures générées", + "slot_start": "Commencer", + "slot_middle": "Milieu", + "slot_end": "Fin", + "none_generated_warning": "Aucune vignette générée pour l'instant. Essayez de régénérer ou de télécharger une image personnalisée.", + "upload_custom": "Télécharger une miniature personnalisée", + "custom_selected_hint": "Le téléchargement personnalisé sera enregistré lorsque vous enregistrerez votre sélection.", + "custom_preview_alt": "Aperçu miniature personnalisé", + "regenerate": "Régénérer", + "save_selection": "Enregistrer la vignette" + } }, "settings": { "heading": "Paramètres", @@ -3003,6 +3023,14 @@ "saas": { "title": "SaaS", "description": "Cadre en pointillés, accents doux, sensation de produit moderne" + }, + "vibrant": { + "title": "Vibrant", + "description": "Panneaux primaires audacieux, héros dégradé au coucher du soleil, look d'affiche énergique" + }, + "editorial": { + "title": "Éditorial", + "description": "Palette de crème chaude, héros pictural, sensation de magazine raffinée" } } }, @@ -3011,7 +3039,10 @@ "description": "Conçu avec des fonctionnalités de base que vous pouvez facilement personnaliser – aucun codage requis.", "by_classroomio": "par ClassroomIO", "add": "Ajouter", - "upgrade": "Mise à niveau" + "upgrade": "Mise à niveau", + "apply_theme": "Appliquer le thème", + "preview": "Aperçu", + "card_menu_label": "Options de thème" }, "current": { "title": "Thème actuel", @@ -3952,6 +3983,54 @@ "quiz_me": "Questionnez-moi à ce sujet", "summarize_section": "Résumer cette section", "what_to_review": "Que dois-je réviser ensuite ?" + }, + "run_running": "Génération de cours", + "run_completed": "Génération de cours terminée", + "run_failed": "La génération du cours a échoué", + "run_canceled": "Génération de cours annulée", + "run_waiting_for_worker": "En attendant le travailleur d'arrière-plan", + "run_checkpoint": "Enregistrement du point de contrôle de progression", + "retry": "Réessayer", + "run_monitor_aria": "Génération de cours en cours", + "run_summary": { + "completed": "Exécution terminée.", + "completed_with_counts": "Exécution terminée – créé {counts}.", + "failed": "L'exécution a échoué.", + "failed_with_reason": "Échec de l'exécution – {raison}.", + "canceled": "Exécution annulée.", + "canceled_with_counts": "Exécution annulée – créé {counts} avant de s'arrêter.", + "paused": "Exécution en pause. Reprendre pour continuer.", + "paused_with_counts": "Exécution en pause – créé {counts} jusqu'à présent. Reprendre pour continuer." + }, + "changes_card": { + "summary_count": "{count} éléments modifiés", + "expand": "Développer les modifications", + "collapse": "Réduire les modifications", + "action": { + "created": "Créé", + "metadata_updated": "Métadonnées mises à jour", + "content_written": "Contenu écrit", + "questions_added": "+{count} questions", + "questions_updated": "{count} questions modifiées", + "section_added": "Bloc ajouté", + "section_edited": "Bloc modifié" + }, + "status": { + "completed": "Terminé", + "failed": "Échec", + "canceled": "Annulé", + "paused": "En pause" + } + }, + "plan_view": { + "expand": "Élargir le plan", + "collapse": "Plan d'effondrement" + }, + "run_monitor": { + "cancel": "Annuler l'exécution", + "dismiss": "Rejeter", + "now_doing": "Maintenant", + "input_disabled_hint": "La conversation est suspendue pendant que l'agent termine la création du cours. Annulez ou attendez qu'il soit terminé." } }, "widgets": { diff --git a/apps/dashboard/src/lib/utils/translations/hi.json b/apps/dashboard/src/lib/utils/translations/hi.json index e24d3cfa..baa374c7 100644 --- a/apps/dashboard/src/lib/utils/translations/hi.json +++ b/apps/dashboard/src/lib/utils/translations/hi.json @@ -346,7 +346,11 @@ "transcription_started": "प्रतिलेखन प्रारंभ हुआ", "transcription_start_failed": "प्रतिलेखन प्रारंभ नहीं किया जा सका", "transcription_completed": "प्रतिलेखन समाप्त हुआ", - "transcript_fetch_failed": "प्रतिलेख लोड नहीं किया जा सका" + "transcript_fetch_failed": "प्रतिलेख लोड नहीं किया जा सका", + "thumbnail_updated": "थंबनेल अपडेट किया गया", + "thumbnail_update_failed": "थंबनेल अपडेट करने में विफल", + "thumbnail_regen_started": "थंबनेल पुनर्जीवित हो रहे हैं…", + "thumbnail_regen_failed": "थंबनेल पुन: उत्पन्न करने में विफल" }, "tags": { "group_created": "टैग समूह बनाया गया", @@ -2059,7 +2063,8 @@ "overview_metrics": { "heading": "कोर्स स्नैपशॉट", "description": "इस पाठ्यक्रम के लिए शिक्षार्थी की गणना उसकी स्थिति के आधार पर की जाती है।" - } + }, + "empty_overview_description": "अपने कर्मचारियों को आज्ञाकारी रखें और ट्रैक करें कि पीछे कौन है।" } }, "search": { @@ -2164,9 +2169,7 @@ "depth": "पाठ्यक्रम कितना गहरा होना चाहिए?", "topic": "विषय या आला", "expertise_level": "लक्ष्य विशेषज्ञता स्तर", - "documentation_url": { - "hint": "हम आपके वास्तविक उत्पाद में पाठ्यक्रम को आधार बनाने के लिए इस साइट को पढ़ेंगे।" - } + "documentation_url": "दस्तावेज़ीकरण URL (वैकल्पिक)" }, "expert_on_x": { "title": "एक्स पर विशेषज्ञ बनें", @@ -2340,7 +2343,8 @@ "login_activity_title": "छात्र लॉगिन गतिविधि", "login_activity_description": "सप्ताह के सर्वाधिक सक्रिय दिन (पिछले 90 दिन)", "login_activity_no_data": "अभी तक कोई लॉगिन गतिविधि नहीं", - "login_activity_logins": "लॉगिन" + "login_activity_logins": "लॉगिन", + "open_academy": "अकादमी खोलें" }, "schedule": { "title": "आपका शेड्यूल", @@ -2621,7 +2625,8 @@ "actions": { "edit": "संपादित करें", "where_used": "कहां उपयोग किया जाता है", - "download": "डाउनलोड करें" + "download": "डाउनलोड करें", + "manage_thumbnails": "थंबनेल प्रबंधित करें" }, "table": { "title": "शीर्षक", @@ -2678,7 +2683,22 @@ "not_available": "एन/ए" }, "empty_description": "संपत्ति इस संगठन में मौजूद नहीं है या उपलब्ध नहीं है.", - "page_subtitle": "Keep files, videos, and links for your courses in one library." + "page_subtitle": "Keep files, videos, and links for your courses in one library.", + "thumbnails": { + "manage_title": "थंबनेल प्रबंधित करें", + "manage_description": "वीडियो का पूर्वावलोकन करें, स्वतः-निर्मित फ़्रेमों में से एक चुनें, या अपना स्वयं का अपलोड करें।", + "preview_unavailable": "पूर्वावलोकन अनुपलब्ध", + "generated": "जेनरेट किए गए थंबनेल", + "slot_start": "प्रारंभ करें", + "slot_middle": "मध्य", + "slot_end": "ख़त्म", + "none_generated_warning": "अभी तक कोई थंबनेल उत्पन्न नहीं हुआ. एक कस्टम छवि पुनः बनाने या अपलोड करने का प्रयास करें।", + "upload_custom": "कस्टम थंबनेल अपलोड करें", + "custom_selected_hint": "जब आप अपना चयन सहेजेंगे तो कस्टम अपलोड सहेजा जाएगा।", + "custom_preview_alt": "कस्टम थंबनेल पूर्वावलोकन", + "regenerate": "पुनर्जीवित करें", + "save_selection": "थंबनेल सहेजें" + } }, "settings": { "heading": "सेटिंग्स", @@ -3003,6 +3023,14 @@ "saas": { "title": "सास", "description": "बिंदीदार रेखा फ्रेम, नरम लहजे, आधुनिक उत्पाद अनुभव" + }, + "vibrant": { + "title": "जीवंत", + "description": "बोल्ड प्राइमरी पैनल, सनसेट ग्रेडिएंट हीरो, ऊर्जावान पोस्टर लुक" + }, + "editorial": { + "title": "सम्पादकीय", + "description": "गर्म क्रीम पैलेट, चित्रकारी नायक, परिष्कृत पत्रिका अनुभव" } } }, @@ -3011,7 +3039,10 @@ "description": "मुख्य विशेषताओं के साथ बनाया गया जिसे आप आसानी से अनुकूलित कर सकते हैं - किसी कोडिंग की आवश्यकता नहीं है।", "by_classroomio": "क्लासरूमआईओ द्वारा", "add": "जोड़ें", - "upgrade": "अपग्रेड करें" + "upgrade": "अपग्रेड करें", + "apply_theme": "थीम लागू करें", + "preview": "पूर्वावलोकन", + "card_menu_label": "थीम विकल्प" }, "current": { "title": "वर्तमान विषय", @@ -3952,6 +3983,54 @@ "quiz_me": "इस पर मुझसे प्रश्नोत्तरी करें", "summarize_section": "इस अनुभाग को सारांशित करें", "what_to_review": "मुझे आगे क्या समीक्षा करनी चाहिए?" + }, + "run_running": "जनरेटिंग कोर्स", + "run_completed": "पाठ्यक्रम निर्माण पूर्ण", + "run_failed": "पाठ्यक्रम निर्माण विफल रहा", + "run_canceled": "पाठ्यक्रम निर्माण रद्द कर दिया गया", + "run_waiting_for_worker": "पृष्ठभूमि कार्यकर्ता की प्रतीक्षा की जा रही है", + "run_checkpoint": "प्रगति चेकप्वाइंट सहेजा जा रहा है", + "retry": "पुनः प्रयास करें", + "run_monitor_aria": "पाठ्यक्रम सृजन प्रगति पर है", + "run_summary": { + "completed": "रन पूरा हुआ.", + "completed_with_counts": "रन पूरा हुआ - {गिनती} बनाई गई।", + "failed": "रन विफल.", + "failed_with_reason": "रन विफल - {कारण}।", + "canceled": "चलाना रद्द कर दिया गया.", + "canceled_with_counts": "दौड़ रद्द कर दी गई - रुकने से पहले {गिनती} बनाई गई।", + "paused": "रन रुका हुआ. जारी रखने के लिए फिर से शुरू करें.", + "paused_with_counts": "रन रोका गया - अब तक {गिनती} बनाई गई। जारी रखने के लिए फिर से शुरू करें." + }, + "changes_card": { + "summary_count": "{गिनती} आइटम बदल गए", + "expand": "परिवर्तनों का विस्तार करें", + "collapse": "परिवर्तन संक्षिप्त करें", + "action": { + "created": "बनाया गया", + "metadata_updated": "मेटाडेटा अपडेट किया गया", + "content_written": "सामग्री लिखी गई", + "questions_added": "+{गिनती} प्रश्न", + "questions_updated": "{गिनती} प्रश्न संपादित", + "section_added": "ब्लॉक जोड़ा गया", + "section_edited": "ब्लॉक संपादित" + }, + "status": { + "completed": "पूरा हुआ", + "failed": "असफल", + "canceled": "रद्द कर दिया गया", + "paused": "रुका हुआ" + } + }, + "plan_view": { + "expand": "योजना का विस्तार करें", + "collapse": "संक्षिप्त योजना" + }, + "run_monitor": { + "cancel": "चलाएँ रद्द करें", + "dismiss": "ख़ारिज करें", + "now_doing": "अभी", + "input_disabled_hint": "जब एजेंट पाठ्यक्रम का निर्माण पूरा कर लेता है तो चैट रोक दी जाती है। रद्द करें या इसके पूरा होने तक प्रतीक्षा करें।" } }, "widgets": { diff --git a/apps/dashboard/src/lib/utils/translations/pl.json b/apps/dashboard/src/lib/utils/translations/pl.json index 6e98ea1f..70d573dc 100644 --- a/apps/dashboard/src/lib/utils/translations/pl.json +++ b/apps/dashboard/src/lib/utils/translations/pl.json @@ -346,7 +346,11 @@ "transcription_started": "Rozpoczęto transkrypcję", "transcription_start_failed": "Nie można rozpocząć transkrypcji", "transcription_completed": "Transkrypcja zakończona", - "transcript_fetch_failed": "Nie udało się wczytać transkrypcji" + "transcript_fetch_failed": "Nie udało się wczytać transkrypcji", + "thumbnail_updated": "Miniatura zaktualizowana", + "thumbnail_update_failed": "Nie udało się zaktualizować miniatury", + "thumbnail_regen_started": "Regeneruję miniatury…", + "thumbnail_regen_failed": "Nie udało się ponownie wygenerować miniatur" }, "tags": { "group_created": "Utworzono grupę tagów", @@ -2059,7 +2063,8 @@ "overview_metrics": { "heading": "Migawka kursu", "description": "Uczestnicy są zliczani według statusu tego kursu." - } + }, + "empty_overview_description": "Dbaj o przestrzeganie zasad przez swoich pracowników i śledź, kto ma zaległości." } }, "search": { @@ -2164,9 +2169,7 @@ "depth": "Jak głęboko powinien sięgać kurs?", "topic": "Temat lub nisza", "expertise_level": "Docelowy poziom wiedzy", - "documentation_url": { - "hint": "Przeczytamy tę witrynę, aby oprzeć kurs na Twoim prawdziwym produkcie." - } + "documentation_url": "Adres URL dokumentacji (opcjonalnie)" }, "expert_on_x": { "title": "Zostań ekspertem w dziedzinie X", @@ -2340,7 +2343,8 @@ "login_activity_title": "Aktywność logowania ucznia", "login_activity_description": "Najbardziej aktywne dni tygodnia (ostatnie 90 dni)", "login_activity_no_data": "Brak aktywności związanej z logowaniem", - "login_activity_logins": "logowania" + "login_activity_logins": "logowania", + "open_academy": "Otwarta Akademia" }, "schedule": { "title": "Twój harmonogram", @@ -2621,7 +2625,8 @@ "actions": { "edit": "Edytuj", "where_used": "Gdzie używany", - "download": "Pobierz" + "download": "Pobierz", + "manage_thumbnails": "Zarządzaj miniaturami" }, "table": { "title": "Tytuł", @@ -2678,7 +2683,22 @@ "not_available": "Nie dotyczy" }, "empty_description": "Zasób nie istnieje lub nie jest dostępny w tej organizacji.", - "page_subtitle": "Keep files, videos, and links for your courses in one library." + "page_subtitle": "Keep files, videos, and links for your courses in one library.", + "thumbnails": { + "manage_title": "Zarządzaj miniaturami", + "manage_description": "Obejrzyj podgląd wideo, wybierz jedną z automatycznie wygenerowanych klatek lub prześlij własną.", + "preview_unavailable": "Podgląd niedostępny", + "generated": "Wygenerowane miniatury", + "slot_start": "Zacznij", + "slot_middle": "Środek", + "slot_end": "Koniec", + "none_generated_warning": "Nie wygenerowano jeszcze żadnych miniatur. Spróbuj zregenerować lub prześlij niestandardowy obraz.", + "upload_custom": "Prześlij niestandardową miniaturę", + "custom_selected_hint": "Przesyłanie niestandardowe zostanie zapisane po zapisaniu wyboru.", + "custom_preview_alt": "Niestandardowy podgląd miniatur", + "regenerate": "Zregeneruj się", + "save_selection": "Zapisz miniaturę" + } }, "settings": { "heading": "Ustawienia", @@ -3003,6 +3023,14 @@ "saas": { "title": "SaaS", "description": "Ramka w postaci przerywanej linii, miękkie akcenty, nowoczesny wygląd produktu" + }, + "vibrant": { + "title": "Żywy", + "description": "Odważne panele główne, bohater gradientu zachodu słońca, energetyczny wygląd plakatu" + }, + "editorial": { + "title": "Redakcja", + "description": "Ciepła kremowa paleta, malarski bohater, wyrafinowany klimat magazynu" } } }, @@ -3011,7 +3039,10 @@ "description": "Zawiera podstawowe funkcje, które można łatwo dostosować — bez konieczności kodowania.", "by_classroomio": "przez ClassroomIO", "add": "Dodaj", - "upgrade": "Uaktualnij" + "upgrade": "Uaktualnij", + "apply_theme": "Zastosuj motyw", + "preview": "Podgląd", + "card_menu_label": "Opcje motywu" }, "current": { "title": "Aktualny temat", @@ -3952,6 +3983,54 @@ "quiz_me": "Przepytaj mnie na ten temat", "summarize_section": "Podsumuj tę sekcję", "what_to_review": "Co powinienem zrecenzować dalej?" + }, + "run_running": "Generowanie kursu", + "run_completed": "Zakończono generowanie kursu", + "run_failed": "Wygenerowanie kursu nie powiodło się", + "run_canceled": "Generowanie kursu anulowane", + "run_waiting_for_worker": "Czekam na pracownika w tle", + "run_checkpoint": "Zapisywanie punktu kontrolnego postępu", + "retry": "Spróbuj ponownie", + "run_monitor_aria": "Trwa generowanie kursu", + "run_summary": { + "completed": "Uruchomienie zakończone.", + "completed_with_counts": "Uruchomienie zakończone — utworzono {counts}.", + "failed": "Uruchomienie nie powiodło się.", + "failed_with_reason": "Uruchomienie nie powiodło się — {powód}.", + "canceled": "Bieg odwołany.", + "canceled_with_counts": "Uruchomienie anulowane — utworzono {counts} przed zatrzymaniem.", + "paused": "Uruchomienie wstrzymane. Wznów, aby kontynuować.", + "paused_with_counts": "Uruchomienie wstrzymane — do tej pory utworzono {counts}. Wznów, aby kontynuować." + }, + "changes_card": { + "summary_count": "Zmieniono {count} elementów", + "expand": "Rozwiń zmiany", + "collapse": "Zwiń zmiany", + "action": { + "created": "Utworzono", + "metadata_updated": "Metadane zaktualizowane", + "content_written": "Treść napisana", + "questions_added": "+{count} pytań", + "questions_updated": "Edytowano {count} pytań", + "section_added": "Dodano blok", + "section_edited": "Blok edytowany" + }, + "status": { + "completed": "Ukończono", + "failed": "Nie udało się", + "canceled": "Anulowano", + "paused": "Wstrzymano" + } + }, + "plan_view": { + "expand": "Rozwiń plan", + "collapse": "Zwiń plan" + }, + "run_monitor": { + "cancel": "Anuluj bieg", + "dismiss": "Odrzuć", + "now_doing": "Teraz", + "input_disabled_hint": "Czat zostaje wstrzymany, gdy agent kończy tworzenie kursu. Anuluj lub poczekaj na zakończenie." } }, "widgets": { diff --git a/apps/dashboard/src/lib/utils/translations/pt.json b/apps/dashboard/src/lib/utils/translations/pt.json index 298508d3..d79177ab 100644 --- a/apps/dashboard/src/lib/utils/translations/pt.json +++ b/apps/dashboard/src/lib/utils/translations/pt.json @@ -346,7 +346,11 @@ "transcription_started": "Transcrição iniciada", "transcription_start_failed": "Não foi possível iniciar a transcrição", "transcription_completed": "Transcrição concluída", - "transcript_fetch_failed": "Não foi possível carregar a transcrição" + "transcript_fetch_failed": "Não foi possível carregar a transcrição", + "thumbnail_updated": "Miniatura atualizada", + "thumbnail_update_failed": "Falha ao atualizar a miniatura", + "thumbnail_regen_started": "Regenerando miniaturas…", + "thumbnail_regen_failed": "Falha ao regenerar miniaturas" }, "tags": { "group_created": "Grupo de tags criado", @@ -2059,7 +2063,8 @@ "overview_metrics": { "heading": "Instantâneo do curso", "description": "Contagens de alunos por status para este curso." - } + }, + "empty_overview_description": "Mantenha seus funcionários em conformidade e monitore quem está por trás." } }, "search": { @@ -2164,9 +2169,7 @@ "depth": "Quão profundo o curso deve ir?", "topic": "Tópico ou nicho", "expertise_level": "Nível de especialização alvo", - "documentation_url": { - "hint": "Leremos este site para fundamentar o curso em seu produto real." - } + "documentation_url": "URL da documentação (opcional)" }, "expert_on_x": { "title": "Torne-se um especialista em X", @@ -2340,7 +2343,8 @@ "login_activity_title": "Atividade de login do aluno", "login_activity_description": "Dias mais ativos da semana (últimos 90 dias)", "login_activity_no_data": "Nenhuma atividade de login ainda", - "login_activity_logins": "logins" + "login_activity_logins": "logins", + "open_academy": "Academia Aberta" }, "schedule": { "title": "Sua programação", @@ -2621,7 +2625,8 @@ "actions": { "edit": "Editar", "where_used": "Onde usado", - "download": "Baixar" + "download": "Baixar", + "manage_thumbnails": "Gerenciar miniaturas" }, "table": { "title": "Título", @@ -2678,7 +2683,22 @@ "not_available": "N/A" }, "empty_description": "O ativo não existe ou não está disponível nesta organização.", - "page_subtitle": "Keep files, videos, and links for your courses in one library." + "page_subtitle": "Keep files, videos, and links for your courses in one library.", + "thumbnails": { + "manage_title": "Gerenciar miniaturas", + "manage_description": "Visualize o vídeo, escolha um dos quadros gerados automaticamente ou carregue o seu próprio.", + "preview_unavailable": "Visualização indisponível", + "generated": "Miniaturas geradas", + "slot_start": "Começar", + "slot_middle": "Meio", + "slot_end": "Fim", + "none_generated_warning": "Nenhuma miniatura gerada ainda. Tente regenerar ou fazer upload de uma imagem personalizada.", + "upload_custom": "Carregar miniatura personalizada", + "custom_selected_hint": "O upload personalizado será salvo quando você salvar sua seleção.", + "custom_preview_alt": "Pré-visualização de miniaturas personalizadas", + "regenerate": "Regenerar", + "save_selection": "Salvar miniatura" + } }, "settings": { "heading": "Configurações", @@ -3003,6 +3023,14 @@ "saas": { "title": "SaaS", "description": "Moldura de linha pontilhada, detalhes suaves, toque de produto moderno" + }, + "vibrant": { + "title": "Vibrante", + "description": "Painéis primários ousados, herói gradiente do pôr do sol, aparência de pôster energético" + }, + "editorial": { + "title": "Redação", + "description": "Paleta de creme quente, herói pictórico, sensação refinada de revista" } } }, @@ -3011,7 +3039,10 @@ "description": "Feito com recursos básicos que você pode personalizar facilmente – sem necessidade de codificação.", "by_classroomio": "da ClassroomIO", "add": "Adicionar", - "upgrade": "Atualizar" + "upgrade": "Atualizar", + "apply_theme": "Aplicar tema", + "preview": "Visualização", + "card_menu_label": "Opções de tema" }, "current": { "title": "Tema atual", @@ -3952,6 +3983,54 @@ "quiz_me": "Me questione sobre isso", "summarize_section": "Resuma esta seção", "what_to_review": "O que devo revisar a seguir?" + }, + "run_running": "Gerando curso", + "run_completed": "Geração do curso concluída", + "run_failed": "Falha na geração do curso", + "run_canceled": "Geração de curso cancelada", + "run_waiting_for_worker": "Esperando pelo trabalhador em segundo plano", + "run_checkpoint": "Salvando ponto de verificação de progresso", + "retry": "Tentar novamente", + "run_monitor_aria": "Geração de curso em andamento", + "run_summary": { + "completed": "Execução concluída.", + "completed_with_counts": "Execução concluída — criada {counts}.", + "failed": "Falha na execução.", + "failed_with_reason": "Falha na execução — {motivo}.", + "canceled": "Execução cancelada.", + "canceled_with_counts": "Execução cancelada — criou {counts} antes de parar.", + "paused": "Execução pausada. Retomar para continuar.", + "paused_with_counts": "Execução pausada — {counts} criados até agora. Retomar para continuar." + }, + "changes_card": { + "summary_count": "{count} itens alterados", + "expand": "Expandir alterações", + "collapse": "Recolher alterações", + "action": { + "created": "Criado", + "metadata_updated": "Metadados atualizados", + "content_written": "Conteúdo escrito", + "questions_added": "+{contar} perguntas", + "questions_updated": "{count} perguntas editadas", + "section_added": "Bloco adicionado", + "section_edited": "Bloco editado" + }, + "status": { + "completed": "Concluído", + "failed": "Falha", + "canceled": "Cancelado", + "paused": "Pausado" + } + }, + "plan_view": { + "expand": "Expandir plano", + "collapse": "Recolher plano" + }, + "run_monitor": { + "cancel": "Cancelar execução", + "dismiss": "Dispensar", + "now_doing": "Agora", + "input_disabled_hint": "O chat é pausado enquanto o agente termina de criar o curso. Cancele ou aguarde a conclusão." } }, "widgets": { diff --git a/apps/dashboard/src/lib/utils/translations/ru.json b/apps/dashboard/src/lib/utils/translations/ru.json index 4102f179..2e625175 100644 --- a/apps/dashboard/src/lib/utils/translations/ru.json +++ b/apps/dashboard/src/lib/utils/translations/ru.json @@ -346,7 +346,11 @@ "transcription_started": "Транскрипция началась", "transcription_start_failed": "Не удалось начать транскрипцию", "transcription_completed": "Транскрипция завершена", - "transcript_fetch_failed": "Не удалось загрузить расшифровку" + "transcript_fetch_failed": "Не удалось загрузить расшифровку", + "thumbnail_updated": "Значок обновлен.", + "thumbnail_update_failed": "Не удалось обновить миниатюру.", + "thumbnail_regen_started": "Восстановление миниатюр…", + "thumbnail_regen_failed": "Не удалось восстановить миниатюры." }, "tags": { "group_created": "Группа тегов создана.", @@ -2059,7 +2063,8 @@ "overview_metrics": { "heading": "Снимок курса", "description": "Учащиеся учитываются по статусу для этого курса." - } + }, + "empty_overview_description": "Следите за тем, чтобы ваши сотрудники соблюдали требования и отслеживали, кто отстает." } }, "search": { @@ -2164,9 +2169,7 @@ "depth": "Насколько глубоким должен быть курс?", "topic": "Тема или ниша", "expertise_level": "Целевой уровень экспертизы", - "documentation_url": { - "hint": "Мы прочитаем этот сайт, чтобы закрепить курс на вашем реальном продукте." - } + "documentation_url": "URL-адрес документации (необязательно)" }, "expert_on_x": { "title": "Станьте экспертом по X", @@ -2340,7 +2343,8 @@ "login_activity_title": "Активность входа учащихся", "login_activity_description": "Самые активные дни недели (последние 90 дней)", "login_activity_no_data": "Пока нет активности входа", - "login_activity_logins": "логины" + "login_activity_logins": "логины", + "open_academy": "Открытая Академия" }, "schedule": { "title": "Ваше расписание", @@ -2621,7 +2625,8 @@ "actions": { "edit": "Редактировать", "where_used": "Где используется", - "download": "Скачать" + "download": "Скачать", + "manage_thumbnails": "Управление миниатюрами" }, "table": { "title": "Название", @@ -2678,7 +2683,22 @@ "not_available": "Н/Д" }, "empty_description": "Актив не существует или недоступен в этой организации.", - "page_subtitle": "Keep files, videos, and links for your courses in one library." + "page_subtitle": "Keep files, videos, and links for your courses in one library.", + "thumbnails": { + "manage_title": "Управление миниатюрами", + "manage_description": "Просмотрите видео, выберите один из автоматически созданных кадров или загрузите свой собственный.", + "preview_unavailable": "Предварительный просмотр недоступен", + "generated": "Созданные миниатюры", + "slot_start": "Старт", + "slot_middle": "Средний", + "slot_end": "Конец", + "none_generated_warning": "Миниатюры еще не созданы. Попробуйте восстановить или загрузить собственное изображение.", + "upload_custom": "Загрузить собственный значок", + "custom_selected_hint": "Пользовательская загрузка будет сохранена, когда вы сохраните свой выбор.", + "custom_preview_alt": "Пользовательский предварительный просмотр миниатюр", + "regenerate": "Регенерировать", + "save_selection": "Сохранить миниатюру" + } }, "settings": { "heading": "Настройки", @@ -3003,6 +3023,14 @@ "saas": { "title": "SaaS", "description": "Пунктирная рамка, мягкие акценты, современный вид продукта" + }, + "vibrant": { + "title": "Яркий", + "description": "Яркие основные панели, герой градиента заката, энергичный вид плаката" + }, + "editorial": { + "title": "Редакционный", + "description": "Теплая кремовая палитра, живописный герой, изысканная журнальная атмосфера." } } }, @@ -3011,7 +3039,10 @@ "description": "Создан с использованием основных функций, которые можно легко настроить — кодирование не требуется.", "by_classroomio": "от ClassroomIO", "add": "Добавить", - "upgrade": "Обновление" + "upgrade": "Обновление", + "apply_theme": "Применить тему", + "preview": "Предварительный просмотр", + "card_menu_label": "Опции темы" }, "current": { "title": "Текущая тема", @@ -3952,6 +3983,54 @@ "quiz_me": "спроси меня об этом", "summarize_section": "Подведите итоги этого раздела", "what_to_review": "Что мне следует проверить дальше?" + }, + "run_running": "Генерация курса", + "run_completed": "Генерация курса завершена", + "run_failed": "Не удалось создать курс.", + "run_canceled": "Создание курса отменено.", + "run_waiting_for_worker": "Ожидание фонового работника", + "run_checkpoint": "Сохранение контрольной точки прогресса", + "retry": "Повторить попытку", + "run_monitor_aria": "Выполняется создание курса", + "run_summary": { + "completed": "Запуск завершен.", + "completed_with_counts": "Запуск завершен — создано {counts}.", + "failed": "Запуск не удался.", + "failed_with_reason": "Не удалось выполнить запуск — {причина}.", + "canceled": "Забег отменен.", + "canceled_with_counts": "Запуск отменен — перед остановкой создано {counts}.", + "paused": "Бег остановился. Возобновите, чтобы продолжить.", + "paused_with_counts": "Выполнение приостановлено — на данный момент создано {counts}. Возобновите, чтобы продолжить." + }, + "changes_card": { + "summary_count": "Изменено элементов: {count}", + "expand": "Развернуть изменения", + "collapse": "Свернуть изменения", + "action": { + "created": "Создано", + "metadata_updated": "Метаданные обновлены.", + "content_written": "Содержание написано", + "questions_added": "+{count} вопросов", + "questions_updated": "Отредактировано {count} вопросов", + "section_added": "Блок добавлен", + "section_edited": "Блок изменен" + }, + "status": { + "completed": "Завершено", + "failed": "Не удалось", + "canceled": "Отменено", + "paused": "Приостановлено" + } + }, + "plan_view": { + "expand": "Развернуть план", + "collapse": "Свернуть план" + }, + "run_monitor": { + "cancel": "Отменить запуск", + "dismiss": "Уволить", + "now_doing": "Сейчас", + "input_disabled_hint": "Чат приостановлен, пока агент завершает создание курса. Отмените или дождитесь завершения." } }, "widgets": { diff --git a/apps/dashboard/src/lib/utils/translations/vi.json b/apps/dashboard/src/lib/utils/translations/vi.json index 6ce6edaa..3311ed65 100644 --- a/apps/dashboard/src/lib/utils/translations/vi.json +++ b/apps/dashboard/src/lib/utils/translations/vi.json @@ -346,7 +346,11 @@ "transcription_started": "Đã bắt đầu phiên âm", "transcription_start_failed": "Không thể bắt đầu phiên âm", "transcription_completed": "Phiên âm đã hoàn tất", - "transcript_fetch_failed": "Không thể tải bản ghi" + "transcript_fetch_failed": "Không thể tải bản ghi", + "thumbnail_updated": "Đã cập nhật hình thu nhỏ", + "thumbnail_update_failed": "Không cập nhật được hình thu nhỏ", + "thumbnail_regen_started": "Đang tạo lại hình thu nhỏ…", + "thumbnail_regen_failed": "Không thể tạo lại hình thu nhỏ" }, "tags": { "group_created": "Đã tạo nhóm thẻ", @@ -2059,7 +2063,8 @@ "overview_metrics": { "heading": "Ảnh chụp nhanh khóa học", "description": "Người học đếm theo trạng thái cho khóa học này." - } + }, + "empty_overview_description": "Giúp nhân viên của bạn luôn tuân thủ và theo dõi xem ai đứng sau." } }, "search": { @@ -2164,9 +2169,7 @@ "depth": "Khóa học nên đi sâu đến mức nào?", "topic": "Chủ đề hoặc niche", "expertise_level": "Mục tiêu trình độ chuyên môn", - "documentation_url": { - "hint": "Chúng tôi sẽ đọc trang web này để xây dựng khóa học về sản phẩm thực của bạn." - } + "documentation_url": "URL tài liệu (tùy chọn)" }, "expert_on_x": { "title": "Trở thành chuyên gia về X", @@ -2340,7 +2343,8 @@ "login_activity_title": "Hoạt động đăng nhập của sinh viên", "login_activity_description": "Những ngày hoạt động tích cực nhất trong tuần (90 ngày qua)", "login_activity_no_data": "Chưa có hoạt động đăng nhập", - "login_activity_logins": "thông tin đăng nhập" + "login_activity_logins": "thông tin đăng nhập", + "open_academy": "Học viện mở" }, "schedule": { "title": "Lịch trình của bạn", @@ -2621,7 +2625,8 @@ "actions": { "edit": "Chỉnh sửa", "where_used": "Nơi sử dụng", - "download": "Tải xuống" + "download": "Tải xuống", + "manage_thumbnails": "Quản lý hình thu nhỏ" }, "table": { "title": "Tiêu đề", @@ -2678,7 +2683,22 @@ "not_available": "không áp dụng" }, "empty_description": "Tài sản không tồn tại hoặc không có sẵn trong tổ chức này.", - "page_subtitle": "Keep files, videos, and links for your courses in one library." + "page_subtitle": "Keep files, videos, and links for your courses in one library.", + "thumbnails": { + "manage_title": "Quản lý hình thu nhỏ", + "manage_description": "Xem trước video, chọn một trong các khung được tạo tự động hoặc tải lên khung của riêng bạn.", + "preview_unavailable": "Xem trước không có sẵn", + "generated": "Hình thu nhỏ được tạo", + "slot_start": "Bắt đầu", + "slot_middle": "giữa", + "slot_end": "Kết thúc", + "none_generated_warning": "Chưa có hình thu nhỏ nào được tạo. Hãy thử tạo lại hoặc tải lên một hình ảnh tùy chỉnh.", + "upload_custom": "Tải hình thu nhỏ tùy chỉnh lên", + "custom_selected_hint": "Tải lên tùy chỉnh sẽ được lưu khi bạn lưu lựa chọn của mình.", + "custom_preview_alt": "Xem trước hình thu nhỏ tùy chỉnh", + "regenerate": "tái sinh", + "save_selection": "Lưu hình thu nhỏ" + } }, "settings": { "heading": "Cài đặt", @@ -3003,6 +3023,14 @@ "saas": { "title": "SaaS", "description": "Khung nét chấm, điểm nhấn mềm mại, cảm giác sản phẩm hiện đại" + }, + "vibrant": { + "title": "Sống động", + "description": "Bảng điều khiển chính táo bạo, anh hùng chuyển màu hoàng hôn, giao diện poster tràn đầy năng lượng" + }, + "editorial": { + "title": "biên tập", + "description": "Bảng màu kem ấm áp, anh hùng họa sĩ, cảm giác tạp chí tinh tế" } } }, @@ -3011,7 +3039,10 @@ "description": "Được tạo bằng các tính năng cốt lõi, bạn có thể dễ dàng tùy chỉnh — không cần mã hóa.", "by_classroomio": "bởi ClassroomIO", "add": "Thêm", - "upgrade": "Nâng cấp" + "upgrade": "Nâng cấp", + "apply_theme": "Áp dụng chủ đề", + "preview": "Xem trước", + "card_menu_label": "Tùy chọn chủ đề" }, "current": { "title": "Chủ đề hiện tại", @@ -3952,6 +3983,54 @@ "quiz_me": "Đố tôi về điều này", "summarize_section": "Tóm tắt phần này", "what_to_review": "Tôi nên xem xét gì tiếp theo?" + }, + "run_running": "Tạo khóa học", + "run_completed": "Quá trình tạo khóa học đã hoàn tất", + "run_failed": "Tạo khóa học không thành công", + "run_canceled": "Việc tạo khóa học đã bị hủy", + "run_waiting_for_worker": "Đang chờ nhân viên nền", + "run_checkpoint": "Đang lưu điểm kiểm tra tiến độ", + "retry": "Thử lại", + "run_monitor_aria": "Đang tạo khóa học", + "run_summary": { + "completed": "Chạy xong.", + "completed_with_counts": "Đã hoàn thành chạy — đã tạo {counts}.", + "failed": "Chạy không thành công.", + "failed_with_reason": "Chạy không thành công — {reason}.", + "canceled": "Cuộc chạy bị hủy.", + "canceled_with_counts": "Cuộc chạy đã bị hủy — đã tạo {counts} trước khi dừng.", + "paused": "Chạy bị tạm dừng. Tiếp tục để tiếp tục.", + "paused_with_counts": "Cuộc chạy bị tạm dừng — đã tạo {counts} cho đến nay. Tiếp tục để tiếp tục." + }, + "changes_card": { + "summary_count": "{count} mục đã thay đổi", + "expand": "Mở rộng các thay đổi", + "collapse": "Thu gọn các thay đổi", + "action": { + "created": "Đã tạo", + "metadata_updated": "Đã cập nhật siêu dữ liệu", + "content_written": "Nội dung viết", + "questions_added": "+{đếm} câu hỏi", + "questions_updated": "{count} câu hỏi đã được chỉnh sửa", + "section_added": "Đã thêm khối", + "section_edited": "Đã chỉnh sửa khối" + }, + "status": { + "completed": "Đã hoàn thành", + "failed": "thất bại", + "canceled": "Đã hủy", + "paused": "Đã tạm dừng" + } + }, + "plan_view": { + "expand": "Mở rộng kế hoạch", + "collapse": "Thu gọn kế hoạch" + }, + "run_monitor": { + "cancel": "Hủy chạy", + "dismiss": "Loại bỏ", + "now_doing": "bây giờ", + "input_disabled_hint": "Cuộc trò chuyện bị tạm dừng trong khi nhân viên hỗ trợ hoàn tất việc xây dựng khóa học. Hủy bỏ hoặc đợi cho đến khi nó hoàn thành." } }, "widgets": { diff --git a/apps/dashboard/src/lib/utils/types/org.ts b/apps/dashboard/src/lib/utils/types/org.ts index 0ee2e8c8..5591586d 100644 --- a/apps/dashboard/src/lib/utils/types/org.ts +++ b/apps/dashboard/src/lib/utils/types/org.ts @@ -6,7 +6,9 @@ export type OrgLandingPageTheme = | 'tech' | 'studio' | 'corporate' - | 'terminal'; + | 'terminal' + | 'editorial' + | 'vibrant'; export interface OrgLandingPageNavItem { label: string; diff --git a/apps/dashboard/src/routes/(org-site)/course/[slug]/+page.svelte b/apps/dashboard/src/routes/(org-site)/course/[slug]/+page.svelte index fdee49d9..2acde2f4 100644 --- a/apps/dashboard/src/routes/(org-site)/course/[slug]/+page.svelte +++ b/apps/dashboard/src/routes/(org-site)/course/[slug]/+page.svelte @@ -8,7 +8,13 @@ import { user } from '$lib/utils/store/user'; import { CourseLandingPage } from '$features/ui'; import { PoweredBy } from '$features/ui'; - import { normalizeLandingPageSettings } from '$features/org/utils/landing-page'; + import { + importThemeNavHero, + normalizeLandingPageSettings, + themeHeaderShellClass, + themeStyle, + themeRendersNavInsideHero + } from '$features/org/utils/landing-page'; import { OrgLandingPageFooter } from '@cio/ui/custom/org-landing-page'; import type { Component } from 'svelte'; @@ -29,11 +35,9 @@ } ); - const shellClass = $derived( - landingSettings.theme === 'classic' - ? 'ui:min-h-screen ui:bg-muted/10 ui:text-foreground ui:font-sans' - : 'ui:min-h-screen ui:bg-background ui:text-foreground ui:font-sans' - ); + const shellClass = $derived(`ui:min-h-screen ${themeHeaderShellClass(landingSettings.theme)}`); + const shellStyle = $derived(themeStyle(landingSettings.theme)); + const navInsideHero = $derived(themeRendersNavInsideHero(landingSettings.theme)); const courseHero = $derived.by(() => { if (!data.course) { @@ -83,23 +87,9 @@ let HeroComponent = $state | null>(null); onMount(async () => { - const theme = landingSettings.theme; - - const [navMod, heroMod] = await Promise.all([ - theme === 'bold' - ? import('@cio/ui/custom/org-landing-page/bold/nav.svelte') - : theme === 'classic' - ? import('@cio/ui/custom/org-landing-page/classic/nav.svelte') - : import('@cio/ui/custom/org-landing-page/minimal/nav.svelte'), - theme === 'bold' - ? import('@cio/ui/custom/org-landing-page/bold/hero.svelte') - : theme === 'classic' - ? import('@cio/ui/custom/org-landing-page/classic/hero.svelte') - : import('@cio/ui/custom/org-landing-page/minimal/hero.svelte') - ]); - - NavComponent = navMod.default; - HeroComponent = heroMod.default; + const { NavComponent: nav, HeroComponent: hero } = await importThemeNavHero(landingSettings.theme); + NavComponent = nav; + HeroComponent = hero; }); @@ -112,17 +102,17 @@ {#if data.course} -
+
{#if NavComponent && HeroComponent} - {#if landingSettings.theme === 'minimal'} - + {#if navInsideHero} + {#snippet navigation()} {/snippet} {:else} - + {/if} {/if} diff --git a/apps/dashboard/src/routes/(org-site)/courses/+page.svelte b/apps/dashboard/src/routes/(org-site)/courses/+page.svelte index bd3c5ab2..6791e5cf 100644 --- a/apps/dashboard/src/routes/(org-site)/courses/+page.svelte +++ b/apps/dashboard/src/routes/(org-site)/courses/+page.svelte @@ -2,6 +2,8 @@ import { goto } from '$app/navigation'; import { page } from '$app/state'; import { resolve } from '$app/paths'; + import { onMount } from 'svelte'; + import type { Component } from 'svelte'; import { SvelteSet, SvelteURLSearchParams } from 'svelte/reactivity'; import { t } from '$lib/utils/functions/translations'; @@ -10,20 +12,18 @@ import { PoweredBy } from '$features/ui'; import LibraryBigIcon from '@lucide/svelte/icons/library-big'; - import { CourseCardList } from '$features/course/components'; - import { normalizeLandingPageSettings } from '$features/org/utils/landing-page'; - import { - BoldLandingHero, - BoldLandingNav, - ClassicLandingHero, - ClassicLandingNav, - MinimalLandingHero, - MinimalLandingNav, - OrgLandingPageFooter - } from '@cio/ui/custom/org-landing-page'; + importThemeCourseCard, + importThemeNavHero, + normalizeLandingPageSettings, + themeCourseGridClass, + themeHeaderShellClass, + themeStyle, + themeRendersNavInsideHero + } from '$features/org/utils/landing-page'; + + import { LandingButton, OrgLandingPageFooter } from '@cio/ui/custom/org-landing-page'; - import { Button } from '@cio/ui/base/button'; import { Checkbox } from '@cio/ui/base/checkbox'; import { Empty } from '@cio/ui/custom/empty'; import { Separator } from '@cio/ui/base/separator'; @@ -46,11 +46,33 @@ } ); - const shellClass = $derived( - landingSettings.theme === 'classic' - ? 'ui:min-h-screen ui:bg-muted/10 ui:text-foreground ui:font-sans' - : 'ui:min-h-screen ui:bg-background ui:text-foreground ui:font-sans' - ); + const shellClass = $derived(`ui:min-h-screen ${themeHeaderShellClass(landingSettings.theme)}`); + const shellStyle = $derived(themeStyle(landingSettings.theme)); + const navInsideHero = $derived(themeRendersNavInsideHero(landingSettings.theme)); + const courseGridClass = $derived(themeCourseGridClass(landingSettings.theme)); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let NavComponent = $state | null>(null); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let HeroComponent = $state | null>(null); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let CourseCardComponent = $state | null>(null); + + onMount(async () => { + const [navHero, cardComponent] = await Promise.all([ + importThemeNavHero(landingSettings.theme), + importThemeCourseCard(landingSettings.theme) + ]); + NavComponent = navHero.NavComponent; + HeroComponent = navHero.HeroComponent; + CourseCardComponent = cardComponent; + }); + + const heroProps = $derived({ + ...landingSettings.hero, + heading: t.get('public_courses.heading'), + image: '' + }); async function applyTagFilters(nextTags: string[]) { const params = new SvelteURLSearchParams(page.url.searchParams); @@ -119,54 +141,50 @@ -
- {#if landingSettings.theme === 'minimal'} - - {#snippet navigation()} - - {/snippet} - - {:else if landingSettings.theme === 'bold'} - - - {:else} - - +
+ {#if NavComponent && HeroComponent} + {#if navInsideHero} + + {#snippet navigation()} + + {/snippet} + + {:else} + + + {/if} {/if}
- +
-
diff --git a/apps/dashboard/static/templates/editorial.png b/apps/dashboard/static/templates/editorial.png new file mode 100644 index 00000000..e487eacc Binary files /dev/null and b/apps/dashboard/static/templates/editorial.png differ diff --git a/apps/dashboard/static/templates/vibrant.png b/apps/dashboard/static/templates/vibrant.png new file mode 100644 index 00000000..b62102eb Binary files /dev/null and b/apps/dashboard/static/templates/vibrant.png differ diff --git a/apps/jobs/src/processors/media/generate-thumbnail.ts b/apps/jobs/src/processors/media/generate-thumbnail.ts index 7ca0359c..7c80a717 100644 --- a/apps/jobs/src/processors/media/generate-thumbnail.ts +++ b/apps/jobs/src/processors/media/generate-thumbnail.ts @@ -2,7 +2,7 @@ import { mkdir, readFile } from 'node:fs/promises'; import path from 'node:path'; import { tmpdir } from 'node:os'; -import { getJobStep, updateAsset, updateMediaJob, upsertJobStep } from '@cio/db/queries'; +import { getAssetById, getJobStep, updateAsset, updateMediaJob, upsertJobStep } from '@cio/db/queries'; import { ZGenerateThumbnailPayload, type TGenerateThumbnailPayload } from '@cio/jobs/payloads/media'; import { @@ -14,25 +14,57 @@ import { } from '../../utils/storage'; import { errorMessage, throwIfCancelRequested } from '../../utils/cancel'; import { getStorageConfig } from '../../config/storage'; -import { ffmpegRun } from '../../utils/ffmpeg'; +import { ffmpegProbeLuma, ffmpegRun } from '../../utils/ffmpeg'; import { log } from '../../utils/logger'; const STEP_KEY = 'generate-thumbnail'; const DOMAIN = 'media'; +/** Frames whose mean luma (0-255) sits below this threshold are treated as blank/black. */ +const BLACK_LUMA_THRESHOLD = 8; +/** Slot order used for ranking; middle is preferred, then start, then end. */ +const SLOT_ORDER = ['middle', 'start', 'end'] as const; +type Slot = (typeof SLOT_ORDER)[number]; +/** Fallback time offsets (seconds) tried when a slot's primary frame is black. */ +const FALLBACK_OFFSETS = [0, -1.5, 1.5]; +const DEFAULT_DURATION_SECONDS = 10; + +interface CandidateUpload { + slot: Slot; + url: string; + meanLuma: number; + isBlack: boolean; +} interface ThumbnailResult { bucket: string; - key: string; + candidates: Array<{ slot: Slot; url: string }>; publicUrl: string | null; } -async function extractThumbnail(localPath: string, atSeconds: number, outputPath: string): Promise { +function clampToDuration(t: number, duration: number): number { + const upper = Math.max(0, duration - 0.1); + if (upper <= 0) return 0; + return Math.min(Math.max(t, 0), upper); +} + +function targetForSlot(slot: Slot, duration: number): number { + if (slot === 'start') return duration * 0.3; + if (slot === 'middle') return duration * 0.5; + return duration * 0.7; +} + +/** + * Use accurate seek (`-ss` after `-i`). Fast input-seek snaps to the nearest + * keyframe and is the most common source of black thumbnails for codecs whose + * first keyframe sits past the requested offset. + */ +async function extractFrame(localPath: string, atSeconds: number, outputPath: string): Promise { await ffmpegRun([ '-y', - '-ss', - String(atSeconds), '-i', localPath, + '-ss', + String(atSeconds), '-vframes', '1', '-vf', @@ -41,13 +73,52 @@ async function extractThumbnail(localPath: string, atSeconds: number, outputPath ]); } +async function captureSlot( + localPath: string, + slot: Slot, + baseSeconds: number, + duration: number, + outputPath: string +): Promise<{ buffer: Buffer; meanLuma: number; atSeconds: number } | null> { + for (const delta of FALLBACK_OFFSETS) { + const t = clampToDuration(baseSeconds + delta, duration); + try { + await extractFrame(localPath, t, outputPath); + const luma = await ffmpegProbeLuma(outputPath); + if (luma >= BLACK_LUMA_THRESHOLD) { + const buffer = await readFile(outputPath); + return { buffer, meanLuma: luma, atSeconds: t }; + } + log.warn('generate-thumbnail-black-frame', { slot, atSeconds: t, luma }); + } catch (error) { + log.warn('generate-thumbnail-slot-attempt-failed', { + slot, + atSeconds: t, + error: errorMessage(error) + }); + } + } + + // Every fallback was black or failed — keep the last frame so the user can + // still see what we produced, but flag it so the picker can warn. + try { + const buffer = await readFile(outputPath); + const luma = await ffmpegProbeLuma(outputPath).catch(() => 0); + return { buffer, meanLuma: luma, atSeconds: baseSeconds }; + } catch { + return null; + } +} + /** - * Render a single JPEG frame to use as the asset thumbnail. Replaces the - * browser-side thumbnail as the source of truth on the asset row. + * Render up to three JPEG candidate thumbnails (start / middle / end) and + * persist them on the asset. The chosen `thumbnailUrl` is the first non-black + * candidate in middle → start → end order. The browser-side preview is no + * longer the source of truth. */ export async function processGenerateThumbnail(rawData: unknown): Promise { const payload: TGenerateThumbnailPayload = ZGenerateThumbnailPayload.parse(rawData); - const { mediaJobId, assetId, storageKey, actorContext, atSeconds = 1.0 } = payload; + const { mediaJobId, assetId, storageKey, actorContext, markJobCompleteOnSuccess } = payload; const existing = await getJobStep(DOMAIN, mediaJobId, STEP_KEY); if (existing?.status === 'completed' && existing.result) { @@ -66,30 +137,54 @@ export async function processGenerateThumbnail(rawData: unknown): Promise 0 ? asset.durationSeconds : DEFAULT_DURATION_SECONDS; + let localPath: string | undefined; - let outputPath: string | undefined; + const slotOutputs: string[] = []; try { localPath = await downloadObjectToTempFile(videosBucket(), storageKey, 'thumb-source.bin'); const dir = path.join(tmpdir(), 'cio-jobs', 'thumbs'); await mkdir(dir, { recursive: true }); - outputPath = path.join(dir, `${mediaJobId}.jpg`); - await throwIfCancelRequested(mediaJobId); - await extractThumbnail(localPath, atSeconds, outputPath); + const uploads: CandidateUpload[] = []; + for (const slot of SLOT_ORDER) { + await throwIfCancelRequested(mediaJobId); + const outputPath = path.join(dir, `${mediaJobId}-${slot}.jpg`); + slotOutputs.push(outputPath); - const buffer = await readFile(outputPath); - const thumbKey = `thumbnails/${assetId}/${Date.now()}.jpg`; + const baseSeconds = targetForSlot(slot, duration); + const capture = await captureSlot(localPath, slot, baseSeconds, duration, outputPath); + if (!capture) continue; - await uploadBufferToBucket(mediaBucket(), thumbKey, buffer, 'image/jpeg', 'public, max-age=31536000'); + const key = `thumbnails/${assetId}/${slot}-${Date.now()}.jpg`; + await uploadBufferToBucket(mediaBucket(), key, capture.buffer, 'image/jpeg', 'public, max-age=31536000'); - const baseUrl = getStorageConfig().mediaPublicBaseUrl; - const publicUrl = baseUrl ? `${baseUrl.replace(/\/$/, '')}/${thumbKey}` : null; + const baseUrl = getStorageConfig().mediaPublicBaseUrl; + const url = baseUrl ? `${baseUrl.replace(/\/$/, '')}/${key}` : key; + uploads.push({ + slot, + url, + meanLuma: capture.meanLuma, + isBlack: capture.meanLuma < BLACK_LUMA_THRESHOLD + }); + } - const result: ThumbnailResult = { bucket: mediaBucket(), key: thumbKey, publicUrl }; + const candidateUrls = uploads.map((u) => u.url); + const chosen = uploads.find((u) => !u.isBlack) ?? null; + const publicUrl = chosen?.url ?? null; + + const result: ThumbnailResult = { + bucket: mediaBucket(), + candidates: uploads.map((u) => ({ slot: u.slot, url: u.url })), + publicUrl + }; await updateAsset(assetId, actorContext.organizationId, { - thumbnailUrl: publicUrl ?? undefined + thumbnailUrl: publicUrl, + thumbnailCandidates: candidateUrls }); await upsertJobStep({ @@ -101,9 +196,17 @@ export async function processGenerateThumbnail(rawData: unknown): Promise }); - await updateMediaJob(mediaJobId, { stage: 'thumbnailed', progressPercent: 60 }); + await updateMediaJob(mediaJobId, { + stage: 'thumbnailed', + progressPercent: markJobCompleteOnSuccess ? 100 : 60, + ...(markJobCompleteOnSuccess ? { status: 'completed' as const } : {}) + }); - log.info('generate-thumbnail-done', { mediaJobId, thumbKey }); + log.info('generate-thumbnail-done', { + mediaJobId, + candidateCount: uploads.length, + hasNonBlackChoice: Boolean(chosen) + }); return result; } catch (error) { log.error('generate-thumbnail-failed', { mediaJobId, error: errorMessage(error) }); @@ -118,6 +221,8 @@ export async function processGenerateThumbnail(rawData: unknown): Promise - import { faqs } from '$lib/utils/faqs'; + import { faqs as defaultFaqs } from '$lib/utils/faqs'; import { PageSignupCTA } from '$lib/components'; import * as Accordion from '@cio/ui/base/accordion'; + + /** + * @typedef {Object} FaqItem + * @property {string} question + * @property {string} answer + */ + + /** + * @typedef {Object} Props + * @property {FaqItem[]} [items] Override the global FAQ list + * @property {boolean} [hideCta] Hide the trailing PageSignupCTA when the page already has its own + * @property {string} [heading] + * @property {string} [subheading] + */ + + /** @type {Props} */ + let { items, hideCta = false, heading, subheading } = $props(); + + const entries = $derived(items ?? defaultFaqs);
-

Questions & Answers

+

{heading ?? 'Questions & Answers'}

- Can't find the answer you're looking for? - Shoot us an email and - we'll get back to you ASAP. + {#if subheading} + {subheading} + {:else} + Can't find the answer you're looking for? + Shoot us an email and we'll get back to you ASAP. + {/if}

- {#each faqs as faq, index} + {#each entries as faq, index} {faq.question} @@ -29,10 +52,12 @@
- + {#if !hideCta} + + {/if}
diff --git a/packages/jobs/src/enqueue/media.ts b/packages/jobs/src/enqueue/media.ts index aae08fdb..f4b573bd 100644 --- a/packages/jobs/src/enqueue/media.ts +++ b/packages/jobs/src/enqueue/media.ts @@ -118,6 +118,40 @@ export async function enqueueLessonVideoPipeline( }; } +/** + * Enqueue only generate-thumbnail (new `media_job` row required). Used by the + * "regenerate thumbnails" action from the media manager. Skips probe — the + * asset already has a duration from the original pipeline. + */ +export async function enqueueGenerateThumbnailOnly(input: { + mediaJobId: string; + assetId: string; + storageKey: string; + actorContext: TActorContext; +}): Promise { + const { mediaJobId, assetId, storageKey, actorContext } = input; + const sharedData = { + mediaJobId, + assetId, + storageKey, + actorContext, + markJobCompleteOnSuccess: true + }; + + const job = await getQueue(QUEUE_NAMES.media).add( + JOB_NAMES.media.generateThumbnail, + sharedData, + QUEUE_DEFAULTS[QUEUE_NAMES.media] + ); + + return { + rootJobId: job.id ?? '', + jobIds: { + [JOB_NAMES.media.generateThumbnail]: job.id ?? '' + } + }; +} + /** * Enqueue only extract-audio → transcribe-audio (new `media_job` row required). * Used for manual “generate transcript” when the initial pipeline had no OpenAI key. diff --git a/packages/jobs/src/payloads/media.ts b/packages/jobs/src/payloads/media.ts index 3b990290..e8dcd233 100644 --- a/packages/jobs/src/payloads/media.ts +++ b/packages/jobs/src/payloads/media.ts @@ -29,7 +29,13 @@ export const ZGenerateThumbnailPayload = z.object({ storageKey: z.string().min(1), actorContext: ZActorContext, /** Seconds into the video to grab. Defaults to 1.0 in the worker. */ - atSeconds: z.number().nonnegative().optional() + atSeconds: z.number().nonnegative().optional(), + /** + * When true, the processor marks the parent `media_job` as `completed` + * after a successful run. Set by `enqueueGenerateThumbnailOnly` because + * thumbnail is the only step and nothing else will flip status. + */ + markJobCompleteOnSuccess: z.boolean().optional() }); export type TGenerateThumbnailPayload = z.infer; diff --git a/packages/storybook/src/templates/org-landing-page/org-landing-page.stories.svelte b/packages/storybook/src/templates/org-landing-page/org-landing-page.stories.svelte index 950e8d7b..93067956 100644 --- a/packages/storybook/src/templates/org-landing-page/org-landing-page.stories.svelte +++ b/packages/storybook/src/templates/org-landing-page/org-landing-page.stories.svelte @@ -8,9 +8,11 @@ TechLandingPage, StudioLandingPage, CorporateLandingPage, - TerminalLandingPage + TerminalLandingPage, + EditorialLandingPage, + VibrantLandingPage, + mockOrgLandingPageProps as mockProps } from '@cio/ui/custom/org-landing-page'; - import { mockProps } from './fixtures'; const { Story } = defineMeta({ title: 'Templates/Org Landing Page', @@ -19,6 +21,27 @@ }, tags: ['autodocs'] }); + + /** Example of overriding UI chrome labels (e.g. via the dashboard's translations). */ + const localizedLabels = { + catalogEyebrow: 'Catálogo', + catalogHeading: 'Programas que empiezan esta temporada.', + catalogDescription: + 'Cohortes y a tu propio ritmo — cada programa termina con un proyecto revisado y un certificado.', + browseCoursesLabel: 'Ver todos →', + enrollLabel: 'Inscribirme', + freeLabel: 'Gratis', + featuredLabel: 'Destacado', + filterAllLabel: 'Todos', + startCourseLabel: 'Empezar curso', + resourcesEyebrow: 'Recursos', + embedEyebrow: 'Integración', + calloutEyebrow: 'Cuando quieras', + learnMoreLabel: 'Visitar', + lessonsLabel: (n) => (n === 1 ? '1 lección' : `${n} lecciones`), + exercisesLabel: (n) => (n === 1 ? '1 ejercicio' : `${n} ejercicios`), + enrolledLabel: (n) => `${n.toLocaleString()} inscritos` + }; @@ -52,3 +75,19 @@ + + + + + + + + + + + + + diff --git a/packages/ui/src/custom/media-player/players/plyr-player.svelte b/packages/ui/src/custom/media-player/players/plyr-player.svelte index b4b7f9c7..cbba3f44 100644 --- a/packages/ui/src/custom/media-player/players/plyr-player.svelte +++ b/packages/ui/src/custom/media-player/players/plyr-player.svelte @@ -160,7 +160,7 @@ {#if isYouTube && youtubeVideoId}
@@ -171,7 +171,7 @@ {src} {poster} {playsinline} - class="plyr-player ui:absolute ui:inset-0 ui:h-full ui:w-full ui:object-contain" + class="plyr-player ui:h-full ui:w-full ui:object-contain" > {#each tracks as track (track.src + track.srclang)} -
+
-
+
-

Latest Courses

+

{labels?.catalogHeading ?? 'Latest Courses'}

{#each courses as course, index (course.id)} - {@const courseTypeMeta = getCourseTypeLandingMeta(course)} - {@const primaryTag = getPrimaryCourseTag(course)} - - - - {#if course.logo} - {course.title} - {/if} - - {course.title} - {course.description} - -
- {#if course.duration} -
- - {course.duration} -
- {/if} - {#if courseTypeMeta} - {@const TypeIcon = courseTypeMeta.icon} -
- - {courseTypeMeta.label} -
- {/if} - {#if primaryTag} -
- - {#if primaryTag.color} - - {/if} - {primaryTag.name} -
- {/if} -
- -
- {course.price || formatCurrency(course.cost, course.currency)} - -
-
-
-
+ {/each}
@@ -140,7 +57,7 @@ class="ui:rounded-xl ui:px-8" disabled={disableCourseLinks} > - View more courses + {labels?.browseCoursesLabel ?? 'View more courses'}
{/if} @@ -148,11 +65,11 @@
- + - + - +
diff --git a/packages/ui/src/custom/org-landing-page/bold/course-card.svelte b/packages/ui/src/custom/org-landing-page/bold/course-card.svelte new file mode 100644 index 00000000..d3658d25 --- /dev/null +++ b/packages/ui/src/custom/org-landing-page/bold/course-card.svelte @@ -0,0 +1,99 @@ + + + + + {#if course.logo} + {course.title} + {/if} + + {course.title} + {course.description} + +
+ {#if course.duration} +
+ + {course.duration} +
+ {/if} + {#if courseTypeMeta} + {@const TypeIcon = courseTypeMeta.icon} +
+ + {courseTypeMeta.label} +
+ {/if} + {#if primaryTag} +
+ + {#if primaryTag.color} + + {/if} + {primaryTag.name} +
+ {/if} +
+ +
+ {course.price || formatCurrency(course.cost, course.currency)} + +
+
+
diff --git a/packages/ui/src/custom/org-landing-page/bold/hero.svelte b/packages/ui/src/custom/org-landing-page/bold/hero.svelte index 01f3438f..ad7e96cb 100644 --- a/packages/ui/src/custom/org-landing-page/bold/hero.svelte +++ b/packages/ui/src/custom/org-landing-page/bold/hero.svelte @@ -2,7 +2,6 @@ import type { OrgLandingPageProps } from '../types'; import SecondaryActionButton from '../secondary-action-button.svelte'; import { Button } from '../../../base/button'; - import { BlurFade } from '../../animation/blurfade'; import EditableLandingSection from '../editable-section.svelte'; interface Props { @@ -15,35 +14,27 @@
- -

{hero.heading}

-
- -

{hero.subheading}

-
- -
- - {#if hero.secondaryAction} - - {/if} -
-
+

{hero.heading}

+

{hero.subheading}

+
+ + {#if hero.secondaryAction} + + {/if} +
{#if hero.image} - -
- Hero -
+
+ Hero {/if}
diff --git a/packages/ui/src/custom/org-landing-page/bold/nav.svelte b/packages/ui/src/custom/org-landing-page/bold/nav.svelte index 8968bf8c..29bd9192 100644 --- a/packages/ui/src/custom/org-landing-page/bold/nav.svelte +++ b/packages/ui/src/custom/org-landing-page/bold/nav.svelte @@ -17,11 +17,13 @@ -
+
{#if logoUrl} {orgName} @@ -33,7 +35,7 @@ {#each navItems as item} {item.label} {/each} diff --git a/packages/ui/src/custom/org-landing-page/callout.svelte b/packages/ui/src/custom/org-landing-page/callout.svelte index 9ee85016..ae01cab6 100644 --- a/packages/ui/src/custom/org-landing-page/callout.svelte +++ b/packages/ui/src/custom/org-landing-page/callout.svelte @@ -1,21 +1,43 @@ @@ -93,7 +117,7 @@ {/if} @@ -101,45 +125,37 @@ {/if}
- {#if eyebrowLabels[variant]} - - {#if variant === 'studio'} -

- - {eyebrowLabels[variant]} -

- {:else} -

{eyebrowLabels[variant]}

- {/if} -
+ {#if resolvedEyebrow} + {#if variant === 'studio'} +

+ + {resolvedEyebrow} +

+ {:else} +

{resolvedEyebrow}

+ {/if} {/if} - -

{callout.heading}

-
+

{callout.heading}

{#if callout.description} - -

{callout.description}

-
+

{callout.description}

{/if} - - - +
diff --git a/packages/ui/src/custom/org-landing-page/classic.svelte b/packages/ui/src/custom/org-landing-page/classic.svelte index 4e223c05..4d1efeb5 100644 --- a/packages/ui/src/custom/org-landing-page/classic.svelte +++ b/packages/ui/src/custom/org-landing-page/classic.svelte @@ -6,12 +6,9 @@ import OrgLandingPageFooter from './landing-page-footer.svelte'; import ClassicNav from './classic/nav.svelte'; import ClassicHero from './classic/hero.svelte'; + import ClassicCourseCard from './classic/course-card.svelte'; import { Button } from '../../base/button'; - import * as Item from '../../base/item'; - import { BlurFade } from '../animation/blurfade'; - import { CourseCard } from '../course-card'; - import { calcCourseDiscount, formatExerciseCountLabel, getCourseTypeLandingMeta } from './landing-page-utils'; - import { cn } from '../../tools'; + import { themeStyle } from './theme-style'; let { orgName, @@ -25,11 +22,15 @@ embed, callout, links, - footer + footer, + labels }: OrgLandingPageProps = $props(); -
+
@@ -37,93 +38,15 @@
- -

Featured Programs

-
- -
-
+

+ {labels?.catalogHeading ?? 'Featured Programs'} +

+
{#each courses as course, index (course.id)} - {@const courseTypeMeta = getCourseTypeLandingMeta(course)} - {@const pricingData = { - cost: course.cost ?? 0, - discount: course.metadata?.discount ?? 0, - showDiscount: !!course.metadata?.showDiscount - }} - {@const displayCost = calcCourseDiscount(pricingData.discount, pricingData.cost, pricingData.showDiscount)} - {@const currencyFormatter = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: course.currency ?? 'USD' - })} - - - {#snippet tags()} - {#if course.tags && course.tags.length > 0} -
- {#each course.tags as tag (tag.id)} - - - {tag.name} - - {/each} -
- {/if} - {/snippet} - - {#snippet footer()} -
-
-

- {course.lessonCount || 0} lessons - & - {formatExerciseCountLabel(course.exerciseCount ?? 0)} -

-
- {#if course.price != null && String(course.price).length > 0} - {course.price} - {:else if !displayCost} - Free - {:else if pricingData.showDiscount} - - {currencyFormatter.format(displayCost)} - - {currencyFormatter.format(pricingData.cost)} - - - {:else} - {currencyFormatter.format(displayCost)} - {/if} -
-
-
- {/snippet} -
-
+ {/each}
@@ -136,18 +59,18 @@ class="ui:px-8" disabled={disableCourseLinks} > - View more courses + {labels?.browseCoursesLabel ?? 'View more courses'}
{/if}
- + - + - +
diff --git a/packages/ui/src/custom/org-landing-page/classic/course-card.svelte b/packages/ui/src/custom/org-landing-page/classic/course-card.svelte new file mode 100644 index 00000000..cdad4e26 --- /dev/null +++ b/packages/ui/src/custom/org-landing-page/classic/course-card.svelte @@ -0,0 +1,97 @@ + + + + {#snippet tags()} + {#if course.tags && course.tags.length > 0} +
+ {#each course.tags as tag (tag.id)} + + + {tag.name} + + {/each} +
+ {/if} + {/snippet} + + {#snippet footer()} +
+
+

+ {course.lessonCount || 0} lessons + & + {formatExerciseCountLabel(course.exerciseCount ?? 0)} +

+
+ {#if course.price != null && String(course.price).length > 0} + {course.price} + {:else if !displayCost} + {labels?.freeLabel ?? 'Free'} + {:else if pricingData.showDiscount} + + {currencyFormatter.format(displayCost)} + + {currencyFormatter.format(pricingData.cost)} + + + {:else} + {currencyFormatter.format(displayCost)} + {/if} +
+
+
+ {/snippet} +
diff --git a/packages/ui/src/custom/org-landing-page/classic/hero.svelte b/packages/ui/src/custom/org-landing-page/classic/hero.svelte index ce830bc5..70baa837 100644 --- a/packages/ui/src/custom/org-landing-page/classic/hero.svelte +++ b/packages/ui/src/custom/org-landing-page/classic/hero.svelte @@ -2,7 +2,6 @@ import type { OrgLandingPageProps } from '../types'; import SecondaryActionButton from '../secondary-action-button.svelte'; import { Button } from '../../../base/button'; - import { BlurFade } from '../../animation/blurfade'; import { BlurIn } from '../../animation/blurin'; import { DotPattern } from '../../animation/dot-pattern'; import EditableLandingSection from '../editable-section.svelte'; @@ -16,7 +15,7 @@
{#if hero.image}
@@ -30,28 +29,24 @@ > {hero.heading} - -

{hero.subheading}

-
- -
- - {#if hero.secondaryAction} - - {/if} -
-
+

{hero.subheading}

+
+ + {#if hero.secondaryAction} + + {/if} +
diff --git a/packages/ui/src/custom/org-landing-page/classic/nav.svelte b/packages/ui/src/custom/org-landing-page/classic/nav.svelte index 05715643..83f707f3 100644 --- a/packages/ui/src/custom/org-landing-page/classic/nav.svelte +++ b/packages/ui/src/custom/org-landing-page/classic/nav.svelte @@ -17,9 +17,12 @@ -
+
- + {#if logoUrl} {orgName} {/if} @@ -30,7 +33,7 @@ {#each navItems as item} {item.label} {/each} @@ -39,7 +42,7 @@ diff --git a/packages/ui/src/custom/org-landing-page/corporate.svelte b/packages/ui/src/custom/org-landing-page/corporate.svelte index 9e2cc35e..dd0ebd8a 100644 --- a/packages/ui/src/custom/org-landing-page/corporate.svelte +++ b/packages/ui/src/custom/org-landing-page/corporate.svelte @@ -6,9 +6,9 @@ import OrgLandingPageFooter from './landing-page-footer.svelte'; import CorporateNav from './corporate/nav.svelte'; import CorporateHero from './corporate/hero.svelte'; + import CorporateCourseCard from './corporate/course-card.svelte'; import { Button } from '../../base/button'; - import { BlurFade } from '../animation/blurfade'; - import { getCourseTypeLandingMeta } from './landing-page-utils'; + import { themeStyle } from './theme-style'; let { orgName, @@ -22,16 +22,15 @@ embed, callout, links, - footer + footer, + labels }: OrgLandingPageProps = $props(); - - function formatCurrency(cost?: number, currency = 'USD') { - if (!cost) return 'Free'; - return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cost); - } -
+
{#snippet navigation()} @@ -40,15 +39,17 @@ {#if courses.length > 0} -
+
-

- Catalog +

+ {labels?.catalogEyebrow ?? 'Catalog'}

- Programs your teams are taking + {labels?.catalogHeading ?? 'Programs your teams are taking'}

{#if hasMoreCourses} @@ -58,50 +59,16 @@ class="ui:rounded-none ui:font-medium" disabled={disableCourseLinks} > - Browse catalog → + {labels?.browseCoursesLabel ?? 'Browse catalog →'} {/if}
@@ -109,11 +76,11 @@ {/if}
- + - + - +
diff --git a/packages/ui/src/custom/org-landing-page/corporate/course-card.svelte b/packages/ui/src/custom/org-landing-page/corporate/course-card.svelte new file mode 100644 index 00000000..e88ff291 --- /dev/null +++ b/packages/ui/src/custom/org-landing-page/corporate/course-card.svelte @@ -0,0 +1,55 @@ + + + + {#if courseTypeMeta} + + {courseTypeMeta.label} + + {/if} +

+ {course.title} +

+

+ {course.description} +

+
+ + {course.price || formatCurrency(course.cost, course.currency)} + + +
+
diff --git a/packages/ui/src/custom/org-landing-page/corporate/hero.svelte b/packages/ui/src/custom/org-landing-page/corporate/hero.svelte index 5d6ea4a9..a0cf3615 100644 --- a/packages/ui/src/custom/org-landing-page/corporate/hero.svelte +++ b/packages/ui/src/custom/org-landing-page/corporate/hero.svelte @@ -3,7 +3,6 @@ import type { Snippet } from 'svelte'; import SecondaryActionButton from '../secondary-action-button.svelte'; import { Button } from '../../../base/button'; - import { BlurFade } from '../../animation/blurfade'; import EditableLandingSection from '../editable-section.svelte'; interface Props { @@ -22,85 +21,79 @@
- -
- - {orgName} - - - - Learning Report - -
-
+
+ + {orgName} + + + + Learning Report + +
- -

+ {hero.heading} +

+ +

+ {hero.subheading} +

+ +
+ - {#if hero.secondaryAction} - - {/if} -
-
+ {hero.primaryAction.label} + + {#if hero.secondaryAction} + + {/if} +
{#if stats.length > 0} {/if}
diff --git a/packages/ui/src/custom/org-landing-page/corporate/nav.svelte b/packages/ui/src/custom/org-landing-page/corporate/nav.svelte index 361a4d93..f105facd 100644 --- a/packages/ui/src/custom/org-landing-page/corporate/nav.svelte +++ b/packages/ui/src/custom/org-landing-page/corporate/nav.svelte @@ -17,12 +17,14 @@ -
+
{#if logoUrl} {orgName} @@ -35,7 +37,7 @@ {#each navItems as item (item.href + item.label)} {item.label} diff --git a/packages/ui/src/custom/org-landing-page/editorial.svelte b/packages/ui/src/custom/org-landing-page/editorial.svelte new file mode 100644 index 00000000..68cd292a --- /dev/null +++ b/packages/ui/src/custom/org-landing-page/editorial.svelte @@ -0,0 +1,184 @@ + + +
+
+ + {#snippet navigation()} + + {/snippet} + + + {#if courses.length > 0} +
+
+
+
+

+ {labels?.catalogEyebrow ?? 'Catalog'} +

+

+ {labels?.catalogHeading ?? 'Programs starting this season.'} +

+

+ {labels?.catalogDescription ?? + 'Cohort-based and self-paced — every program ends with a reviewed project and a certificate of completion.'} +

+
+ {#if hasMoreCourses} + + {/if} +
+ + {#if visibleFilterDefs.length > 1} +
+ {#each visibleFilterDefs as def (def.key)} + {@const isActive = activeFilter === def.key} + + {/each} +
+ {/if} + +
+ {#each filteredCourses as course, index (course.id)} + + {/each} +
+ + {#if hasMoreCourses} +
+ +
+ {/if} +
+
+ {/if} +
+ + + + + + + + +
diff --git a/packages/ui/src/custom/org-landing-page/editorial/course-card.svelte b/packages/ui/src/custom/org-landing-page/editorial/course-card.svelte new file mode 100644 index 00000000..112a9d64 --- /dev/null +++ b/packages/ui/src/custom/org-landing-page/editorial/course-card.svelte @@ -0,0 +1,122 @@ + + + +
+ + {#if courseTypeMeta} + + {courseTypeMeta.label} + + {/if} + {#if course.duration} + + {course.duration} + + {/if} +
+
+

+ {course.title} +

+

+ {course.description} +

+ + {#if course.lessonCount || course.totalStudents || course.level} +
+ {#if course.lessonCount} + {resolvedLessonsLabel(course.lessonCount)} + {/if} + {#if course.level} + {#if course.lessonCount} + + {/if} + {course.level} + {/if} + {#if course.totalStudents} + {#if course.lessonCount || course.level} + + {/if} + {resolvedEnrolledLabel(course.totalStudents)} + {/if} +
+ {/if} + +
+ + {course.price || formatCost(course.cost, course.currency)} + + +
+
+
diff --git a/packages/ui/src/custom/org-landing-page/editorial/hero.svelte b/packages/ui/src/custom/org-landing-page/editorial/hero.svelte new file mode 100644 index 00000000..5767c938 --- /dev/null +++ b/packages/ui/src/custom/org-landing-page/editorial/hero.svelte @@ -0,0 +1,185 @@ + + +{@render navigation()} + + +
+
+
+
+ {#if hero.stats && hero.stats.length > 0} +

+ {hero.stats[0].label} + {#if hero.stats[0].value} + + {hero.stats[0].value} + {/if} +

+ {/if} + +

+ {hero.heading} +

+ +

+ {hero.subheading} +

+ +
+ + {#if hero.secondaryAction} + + {/if} +
+
+ + {#if featured} + {@const cost = featured.cost ?? 0} + {@const seats = featured.totalStudents ?? 0} +
+ + +
+
+ + {featuredTypeMeta?.label ?? labels?.featuredLabel ?? 'Featured'} + + {#if featured.duration} + + {featured.duration} + {/if} +
+

+ {featured.title} +

+

+ {featured.description} +

+ +
+ {#each syllabusItems as item, idx (idx)} +
+ + {String(idx + 1).padStart(2, '0')} + + {item.label} + {item.duration} +
+ {/each} +
+ +
+ + {formatCost(cost, featured.currency)} + + {#if seats > 0} + {(labels?.enrolledLabel ?? ((n: number) => `${n.toLocaleString()} enrolled`))(seats)} + {/if} +
+
+
+ {/if} +
+
+
+
diff --git a/packages/ui/src/custom/org-landing-page/editorial/nav.svelte b/packages/ui/src/custom/org-landing-page/editorial/nav.svelte new file mode 100644 index 00000000..b390e25d --- /dev/null +++ b/packages/ui/src/custom/org-landing-page/editorial/nav.svelte @@ -0,0 +1,63 @@ + + + +
+
+
+ + {#if logoUrl} + {orgName} + {/if} + {orgName} + + + {#if navItems.length > 0} + + {/if} + +
+ {#if authAction} + + {/if} +
+
+
+
+
diff --git a/packages/ui/src/custom/org-landing-page/embed.svelte b/packages/ui/src/custom/org-landing-page/embed.svelte index 83314d6d..4c4330b6 100644 --- a/packages/ui/src/custom/org-landing-page/embed.svelte +++ b/packages/ui/src/custom/org-landing-page/embed.svelte @@ -1,10 +1,17 @@ {#if embed} @@ -12,82 +19,84 @@ {#if variant === 'terminal'}
- -

- - Embed -

-
- -

- {embed.title} -

-
+

+ + {labels?.embedEyebrow ?? 'Embed'} +

+

+ {embed.title} +

{#if embed.description?.trim()} - -

- {embed.description} -

-
+

+ {embed.description} +

{/if} {#if embed.secondaryAction?.label && embed.secondaryAction?.href} - -
- -
-
+
+ +
{/if} {#if embed.code} - -
-
- {@html embed.code} -
+
+
+ {@html embed.code}
- +
+ {/if} +
+
+ {:else if variant === 'editorial'} +
+
+

+ {embed.title} +

+ {#if embed.description?.trim()} +

+ {embed.description} +

+ {/if} + {#if embed.secondaryAction?.label && embed.secondaryAction?.href} + + {/if} + {#if embed.code} +
+ {@html embed.code} +
{/if}
{:else}
- -

{embed.title}

-
+

{embed.title}

{#if embed.description?.trim()} - -

- {embed.description} -

-
+

+ {embed.description} +

{/if} {#if embed.secondaryAction?.label && embed.secondaryAction?.href} - - - + {/if} {#if embed.code} - -
- {@html embed.code} -
-
+
+ {@html embed.code} +
{/if}
diff --git a/packages/storybook/src/templates/org-landing-page/fixtures.ts b/packages/ui/src/custom/org-landing-page/fixtures.ts similarity index 56% rename from packages/storybook/src/templates/org-landing-page/fixtures.ts rename to packages/ui/src/custom/org-landing-page/fixtures.ts index 747cd9ab..ee5fa562 100644 --- a/packages/storybook/src/templates/org-landing-page/fixtures.ts +++ b/packages/ui/src/custom/org-landing-page/fixtures.ts @@ -1,6 +1,6 @@ -import type { OrgLandingPageProps } from '@cio/ui/custom/org-landing-page/types'; +import type { OrgLandingPageProps } from './types'; -export const mockProps: OrgLandingPageProps = { +export const mockOrgLandingPageProps: OrgLandingPageProps = { orgName: 'Certifi Academy', logoUrl: 'https://ui-avatars.com/api/?name=CA&background=0f172a&color=fff', navItems: [ @@ -154,25 +154,100 @@ export const mockProps: OrgLandingPageProps = { brand: { socials: [ { platform: 'linkedin', href: 'https://linkedin.com/company/example' }, - { platform: 'x', href: 'https://x.com/example' } + { platform: 'x', href: 'https://x.com/example' }, + { platform: 'youtube', href: 'https://youtube.com/@example' }, + { platform: 'github', href: 'https://github.com/example' }, + { platform: 'instagram', href: 'https://instagram.com/example' }, + { platform: 'facebook', href: 'https://facebook.com/example' }, + { platform: 'tiktok', href: 'https://tiktok.com/@example' }, + { platform: 'website', href: 'https://example.com' } ] }, columns: [ { - id: 'col-company', - heading: 'Company', + id: 'col-product', + heading: 'Product', links: [ - { id: 'col-company-about', label: 'About', href: '#about' }, - { id: 'col-company-careers', label: 'Careers', href: '#careers' } - ], - cta: { label: 'Explore more', href: '#more' } + { id: 'col-product-overview', label: 'Overview', href: '#overview' }, + { id: 'col-product-features', label: 'Features', href: '#features' }, + { id: 'col-product-integrations', label: 'Integrations', href: '#integrations' }, + { id: 'col-product-automations', label: 'Automations', href: '#automations' }, + { id: 'col-product-certifications', label: 'Certifications', href: '#certifications' }, + { id: 'col-product-analytics', label: 'Analytics', href: '#analytics' }, + { id: 'col-product-mobile', label: 'Mobile app', href: '#mobile' }, + { id: 'col-product-changelog', label: 'Changelog', href: '#changelog' }, + { id: 'col-product-roadmap', label: 'Roadmap', href: '#roadmap' } + ] + }, + { + id: 'col-solutions', + heading: 'Solutions', + links: [ + { id: 'col-solutions-compliance', label: 'Compliance training', href: '#compliance' }, + { id: 'col-solutions-onboarding', label: 'Employee onboarding', href: '#onboarding' }, + { id: 'col-solutions-customer-ed', label: 'Customer education', href: '#customer-education' }, + { id: 'col-solutions-partner', label: 'Partner enablement', href: '#partner' }, + { id: 'col-solutions-sales', label: 'Sales enablement', href: '#sales' }, + { id: 'col-solutions-healthcare', label: 'Healthcare', href: '#healthcare' }, + { id: 'col-solutions-finance', label: 'Financial services', href: '#finance' }, + { id: 'col-solutions-government', label: 'Government', href: '#government' } + ] }, { id: 'col-resources', heading: 'Resources', links: [ - { id: 'col-resources-help', label: 'Help Center', href: '#help' }, - { id: 'col-resources-blog', label: 'Blog', href: '#blog' } + { id: 'col-resources-blog', label: 'Blog', href: '#blog' }, + { id: 'col-resources-help', label: 'Help center', href: '#help' }, + { id: 'col-resources-guides', label: 'Guides', href: '#guides' }, + { id: 'col-resources-webinars', label: 'Webinars', href: '#webinars' }, + { id: 'col-resources-podcast', label: 'Podcast', href: '#podcast' }, + { id: 'col-resources-templates', label: 'Templates', href: '#templates' }, + { id: 'col-resources-case-studies', label: 'Case studies', href: '#case-studies' }, + { id: 'col-resources-research', label: 'Research', href: '#research' }, + { id: 'col-resources-glossary', label: 'Glossary', href: '#glossary' } + ] + }, + { + id: 'col-developers', + heading: 'Developers', + links: [ + { id: 'col-developers-docs', label: 'Documentation', href: '#docs' }, + { id: 'col-developers-api', label: 'API reference', href: '#api' }, + { id: 'col-developers-sdks', label: 'SDKs', href: '#sdks' }, + { id: 'col-developers-webhooks', label: 'Webhooks', href: '#webhooks' }, + { id: 'col-developers-mcp', label: 'MCP server', href: '#mcp' }, + { id: 'col-developers-cli', label: 'CLI', href: '#cli' }, + { id: 'col-developers-status', label: 'System status', href: '#status' }, + { id: 'col-developers-changelog', label: 'API changelog', href: '#api-changelog' } + ] + }, + { + id: 'col-company', + heading: 'Company', + links: [ + { id: 'col-company-about', label: 'About', href: '#about' }, + { id: 'col-company-customers', label: 'Customers', href: '#customers' }, + { id: 'col-company-careers', label: 'Careers', href: '#careers' }, + { id: 'col-company-press', label: 'Press', href: '#press' }, + { id: 'col-company-partners', label: 'Partners', href: '#partners' }, + { id: 'col-company-contact', label: 'Contact', href: '#contact' }, + { id: 'col-company-events', label: 'Events', href: '#events' }, + { id: 'col-company-newsroom', label: 'Newsroom', href: '#newsroom' } + ] + }, + { + id: 'col-trust', + heading: 'Trust & Legal', + links: [ + { id: 'col-trust-security', label: 'Security', href: '#security' }, + { id: 'col-trust-soc2', label: 'SOC 2', href: '#soc2' }, + { id: 'col-trust-gdpr', label: 'GDPR', href: '#gdpr' }, + { id: 'col-trust-hipaa', label: 'HIPAA', href: '#hipaa' }, + { id: 'col-trust-iso', label: 'ISO 27001', href: '#iso' }, + { id: 'col-trust-dpa', label: 'Data processing addendum', href: '#dpa' }, + { id: 'col-trust-subprocessors', label: 'Subprocessors', href: '#subprocessors' }, + { id: 'col-trust-responsible-disclosure', label: 'Responsible disclosure', href: '#responsible-disclosure' } ] } ], @@ -181,6 +256,10 @@ export const mockProps: OrgLandingPageProps = { links: [ { id: 'bottom-terms', label: 'Terms of Service', href: '#terms' }, { id: 'bottom-privacy', label: 'Privacy Policy', href: '#privacy' }, + { id: 'bottom-cookies', label: 'Cookie Settings', href: '#cookies' }, + { id: 'bottom-acceptable-use', label: 'Acceptable Use', href: '#acceptable-use' }, + { id: 'bottom-accessibility', label: 'Accessibility', href: '#accessibility' }, + { id: 'bottom-sitemap', label: 'Sitemap', href: '#sitemap' }, { id: 'bottom-support', label: 'Support', href: '#support' } ] } diff --git a/packages/ui/src/custom/org-landing-page/index.ts b/packages/ui/src/custom/org-landing-page/index.ts index 97b2cd73..5aa0a987 100644 --- a/packages/ui/src/custom/org-landing-page/index.ts +++ b/packages/ui/src/custom/org-landing-page/index.ts @@ -6,6 +6,8 @@ export { default as TechLandingPage } from './tech.svelte'; export { default as StudioLandingPage } from './studio.svelte'; export { default as CorporateLandingPage } from './corporate.svelte'; export { default as TerminalLandingPage } from './terminal.svelte'; +export { default as EditorialLandingPage } from './editorial.svelte'; +export { default as VibrantLandingPage } from './vibrant.svelte'; export { default as BoldLandingNav } from './bold/nav.svelte'; export { default as BoldLandingHero } from './bold/hero.svelte'; export { default as ClassicLandingNav } from './classic/nav.svelte'; @@ -22,6 +24,21 @@ export { default as CorporateLandingNav } from './corporate/nav.svelte'; export { default as CorporateLandingHero } from './corporate/hero.svelte'; export { default as TerminalLandingNav } from './terminal/nav.svelte'; export { default as TerminalLandingHero } from './terminal/hero.svelte'; +export { default as EditorialLandingNav } from './editorial/nav.svelte'; +export { default as EditorialLandingHero } from './editorial/hero.svelte'; +export { default as VibrantLandingNav } from './vibrant/nav.svelte'; +export { default as VibrantLandingHero } from './vibrant/hero.svelte'; +export { default as MinimalLandingCourseCard } from './minimal/course-card.svelte'; +export { default as BoldLandingCourseCard } from './bold/course-card.svelte'; +export { default as ClassicLandingCourseCard } from './classic/course-card.svelte'; +export { default as SaasLandingCourseCard } from './saas/course-card.svelte'; +export { default as TechLandingCourseCard } from './tech/course-card.svelte'; +export { default as StudioLandingCourseCard } from './studio/course-card.svelte'; +export { default as CorporateLandingCourseCard } from './corporate/course-card.svelte'; +export { default as TerminalLandingCourseCard } from './terminal/course-card.svelte'; +export { default as EditorialLandingCourseCard } from './editorial/course-card.svelte'; +export { default as VibrantLandingCourseCard } from './vibrant/course-card.svelte'; +export { default as LandingButton } from './landing-button.svelte'; export { default as OrgLandingPageEmbed } from './embed.svelte'; export { default as OrgLandingPageCallout } from './callout.svelte'; export { default as OrgLandingPageLinks } from './links.svelte'; @@ -35,4 +52,6 @@ export { type LandingPageEditContext } from './edit-context'; export * from './landing-page-link-icons'; +export * from './theme-style'; export * from './types'; +export { mockOrgLandingPageProps } from './fixtures'; diff --git a/packages/ui/src/custom/org-landing-page/landing-button.svelte b/packages/ui/src/custom/org-landing-page/landing-button.svelte new file mode 100644 index 00000000..40a595a3 --- /dev/null +++ b/packages/ui/src/custom/org-landing-page/landing-button.svelte @@ -0,0 +1,61 @@ + + +{#if href && !disabled} + + {@render children()} + +{:else} + +{/if} diff --git a/packages/ui/src/custom/org-landing-page/landing-page-footer.svelte b/packages/ui/src/custom/org-landing-page/landing-page-footer.svelte index 002703a5..14ac4605 100644 --- a/packages/ui/src/custom/org-landing-page/landing-page-footer.svelte +++ b/packages/ui/src/custom/org-landing-page/landing-page-footer.svelte @@ -3,7 +3,6 @@ import { getFooterTokens } from './landing-page-footer.tokens'; import FooterSocialIcon from './footer-social-icon.svelte'; import ArrowRightIcon from '@lucide/svelte/icons/arrow-right'; - import { BlurFade } from '../animation/blurfade'; import EditableLandingSection from './editable-section.svelte'; interface Props { @@ -64,7 +63,7 @@ class="ui:grid ui:grid-cols-1 ui:gap-y-10 ui:gap-x-8 ui:sm:grid-cols-2 ui:md:gap-x-12 ui:md:gap-y-12 ui:md:grid-cols-[minmax(0,1.4fr)_repeat(auto-fit,minmax(140px,1fr))]" >
- + {#if logoUrl} {/if} @@ -79,7 +78,7 @@
  • {#each column.links as link (link.id)}
  • - {link.label} + {link.label}
  • {/each} {#if hasCta(column)} - + {column.cta!.label} @@ -119,11 +118,9 @@ {/snippet} {#if variant === 'minimal' || variant === 'bold' || variant === 'saas' || variant === 'tech' || variant === 'studio' || variant === 'corporate' || variant === 'terminal'} - - {#snippet children()} - {@render gridInner()} - {/snippet} - + {#snippet children()} + {@render gridInner()} + {/snippet} {:else} {@render gridInner()} {/if} @@ -139,7 +136,7 @@ {#if bottomLinks.length > 0}
    {#each bottomLinks as link (link.id)} - {link.label} + {link.label} {/each}
    {/if} @@ -147,7 +144,7 @@ {/if} {:else} {/if} diff --git a/packages/ui/src/custom/org-landing-page/landing-page-footer.tokens.ts b/packages/ui/src/custom/org-landing-page/landing-page-footer.tokens.ts index 60fb7019..5f5d85c5 100644 --- a/packages/ui/src/custom/org-landing-page/landing-page-footer.tokens.ts +++ b/packages/ui/src/custom/org-landing-page/landing-page-footer.tokens.ts @@ -23,82 +23,83 @@ export type FooterRegionTokens = { export function getFooterTokens(variant: OrgLandingPageTheme): FooterRegionTokens { if (variant === 'minimal') { return { - root: 'ui:border-t ui:border-border/50 ui:py-12 ui:px-4 ui:sm:px-6 ui:md:px-8 ui:md:py-16', + root: 'ui:border-t ui:border-[var(--landing-border)]/50 ui:py-12 ui:px-4 ui:sm:px-6 ui:md:px-8 ui:md:py-16', container: 'ui:max-w-[1200px] ui:mx-auto', compactRow: - 'ui:flex ui:flex-col ui:gap-3 ui:items-start ui:md:flex-row ui:md:items-center ui:md:justify-between ui:text-sm ui:text-muted-foreground', + 'ui:flex ui:flex-col ui:gap-3 ui:items-start ui:md:flex-row ui:md:items-center ui:md:justify-between ui:text-sm ui:text-[var(--landing-fg-muted)]', bottomRow: - 'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-10 ui:md:mt-12 ui:pt-6 ui:border-t ui:border-border/50 ui:text-sm ui:text-muted-foreground', + 'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-10 ui:md:mt-12 ui:pt-6 ui:border-t ui:border-[var(--landing-border)]/50 ui:text-sm ui:text-[var(--landing-fg-muted)]', brandName: - 'ui:font-semibold ui:text-xl ui:text-foreground ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words', - brandTagline: 'ui:text-sm ui:text-muted-foreground ui:break-words ui:mt-2', - columnHeading: 'ui:text-sm ui:font-semibold ui:text-foreground ui:mb-4', + 'ui:font-semibold ui:text-xl ui:text-[var(--landing-fg)] ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words', + brandTagline: 'ui:text-sm ui:text-[var(--landing-fg-muted)] ui:break-words ui:mt-2', + columnHeading: 'ui:text-sm ui:font-semibold ui:text-[var(--landing-fg)] ui:mb-4', columnLink: - 'ui:block ui:text-sm ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:break-words ui:py-1.5 ui:md:py-0', + 'ui:block ui:text-sm ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] ui:transition-colors ui:break-words ui:py-1.5 ui:md:py-0', columnCta: - 'ui:text-sm ui:font-semibold ui:text-foreground ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-4 ui:no-underline hover:ui:underline', + 'ui:text-sm ui:font-semibold ui:text-[var(--landing-fg)] ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-4 ui:no-underline hover:ui:underline', socialWrap: 'ui:flex ui:flex-wrap ui:gap-2 ui:mt-3 ui:list-none ui:m-0 ui:p-0', socialIcon: - 'ui:inline-flex ui:items-center ui:justify-center ui:p-1.5 ui:rounded-md ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:no-underline', + 'ui:inline-flex ui:items-center ui:justify-center ui:p-1.5 ui:rounded-md ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] ui:transition-colors ui:no-underline', bottomText: 'ui:break-words', bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-6 ui:gap-y-2 ui:justify-start ui:sm:justify-end', bottomLink: - 'ui:text-sm ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0', + 'ui:text-sm ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0', logoImg: 'ui:h-7 ui:md:h-8 ui:w-auto' }; } if (variant === 'tech') { return { - root: 'ui:bg-foreground ui:text-background ui:py-16 ui:px-4 ui:sm:px-6 ui:md:py-20', + root: 'ui:bg-[var(--landing-fg)] ui:text-[var(--landing-bg)] ui:py-16 ui:px-4 ui:sm:px-6 ui:md:py-20', container: 'ui:max-w-7xl ui:mx-auto', compactRow: 'ui:flex ui:flex-col ui:gap-3 ui:items-start ui:md:flex-row ui:md:items-center ui:md:justify-between ui:text-sm', bottomRow: - 'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-10 ui:pt-6 ui:border-t ui:border-background/15 ui:text-xs ui:text-background/55 ui:font-mono', + 'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-10 ui:pt-6 ui:border-t ui:border-[var(--landing-bg)]/15 ui:text-xs ui:text-[var(--landing-bg)]/55 ui:font-mono', brandName: - 'ui:font-bold ui:text-xl ui:tracking-tight ui:text-background ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words', - brandTagline: 'ui:text-sm ui:text-background/60 ui:break-words ui:mt-3 ui:max-w-[260px]', + 'ui:font-bold ui:text-xl ui:tracking-tight ui:text-[var(--landing-bg)] ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words', + brandTagline: 'ui:text-sm ui:text-[var(--landing-bg)]/60 ui:break-words ui:mt-3 ui:max-w-[260px]', columnHeading: - 'ui:text-[11px] ui:font-mono ui:font-semibold ui:uppercase ui:tracking-widest ui:text-background/60 ui:mb-4', + 'ui:text-[11px] ui:font-mono ui:font-semibold ui:uppercase ui:tracking-widest ui:text-[var(--landing-bg)]/60 ui:mb-4', columnLink: - 'ui:block ui:text-sm ui:text-background/75 ui:hover:text-background ui:transition-colors ui:break-words ui:py-1.5 ui:md:py-0', + 'ui:block ui:text-sm ui:text-[var(--landing-bg)]/75 ui:hover:text-[var(--landing-bg)] ui:transition-colors ui:break-words ui:py-1.5 ui:md:py-0', columnCta: - 'ui:text-sm ui:font-semibold ui:text-background ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-3 ui:no-underline hover:ui:underline', + 'ui:text-sm ui:font-semibold ui:text-[var(--landing-bg)] ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-3 ui:no-underline hover:ui:underline', socialWrap: 'ui:flex ui:flex-wrap ui:gap-2 ui:mt-4 ui:list-none ui:m-0 ui:p-0', socialIcon: - 'ui:inline-flex ui:items-center ui:justify-center ui:size-8 ui:border ui:border-background/15 ui:bg-background/5 ui:text-background/75 ui:hover:text-background ui:hover:bg-background/10 ui:transition-colors ui:no-underline', + 'ui:inline-flex ui:items-center ui:justify-center ui:size-8 ui:border ui:border-[var(--landing-bg)]/15 ui:bg-[var(--landing-bg)]/5 ui:text-[var(--landing-bg)]/75 ui:hover:text-[var(--landing-bg)] ui:hover:bg-[var(--landing-bg)]/10 ui:transition-colors ui:no-underline', bottomText: 'ui:break-words', bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-5 ui:gap-y-2 ui:justify-start ui:sm:justify-end', bottomLink: - 'ui:text-xs ui:font-mono ui:text-background/65 ui:hover:text-background ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0', + 'ui:text-xs ui:font-mono ui:text-[var(--landing-bg)]/65 ui:hover:text-[var(--landing-bg)] ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0', logoImg: 'ui:h-6 ui:md:h-7 ui:w-auto' }; } if (variant === 'studio') { return { - root: 'ui:bg-background ui:text-foreground ui:border-t ui:border-border ui:py-16 ui:px-4 ui:sm:px-6 ui:md:py-20', + root: 'ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:border-t ui:border-[var(--landing-border)] ui:py-16 ui:px-4 ui:sm:px-6 ui:md:py-20', container: 'ui:max-w-[1080px] ui:mx-auto', compactRow: - 'ui:flex ui:flex-col ui:gap-3 ui:items-start ui:md:flex-row ui:md:items-center ui:md:justify-between ui:text-sm ui:text-muted-foreground', + 'ui:flex ui:flex-col ui:gap-3 ui:items-start ui:md:flex-row ui:md:items-center ui:md:justify-between ui:text-sm ui:text-[var(--landing-fg-muted)]', bottomRow: - 'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-12 ui:pt-6 ui:border-t ui:border-border ui:text-xs ui:text-muted-foreground', + 'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-12 ui:pt-6 ui:border-t ui:border-[var(--landing-border)] ui:text-xs ui:text-[var(--landing-fg-muted)]', brandName: - 'ui:font-semibold ui:text-base ui:tracking-tight ui:text-foreground ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words', - brandTagline: 'ui:text-sm ui:text-muted-foreground ui:break-words ui:mt-3 ui:max-w-[240px] ui:leading-relaxed', - columnHeading: 'ui:text-xs ui:font-medium ui:text-muted-foreground ui:mb-3', + 'ui:font-semibold ui:text-base ui:tracking-tight ui:text-[var(--landing-fg)] ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words', + brandTagline: + 'ui:text-sm ui:text-[var(--landing-fg-muted)] ui:break-words ui:mt-3 ui:max-w-[240px] ui:leading-relaxed', + columnHeading: 'ui:text-xs ui:font-medium ui:text-[var(--landing-fg-muted)] ui:mb-3', columnLink: - 'ui:block ui:text-[13px] ui:text-foreground ui:hover:text-muted-foreground ui:transition-colors ui:break-words ui:py-1.5 ui:md:py-0', + 'ui:block ui:text-[13px] ui:text-[var(--landing-fg)] ui:hover:text-[var(--landing-fg-muted)] ui:transition-colors ui:break-words ui:py-1.5 ui:md:py-0', columnCta: - 'ui:text-sm ui:font-medium ui:text-primary ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-3 ui:no-underline hover:ui:underline', + 'ui:text-sm ui:font-medium ui:text-[var(--landing-accent)] ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-3 ui:no-underline hover:ui:underline', socialWrap: 'ui:flex ui:flex-wrap ui:gap-2 ui:mt-3 ui:list-none ui:m-0 ui:p-0', socialIcon: - 'ui:inline-flex ui:items-center ui:justify-center ui:size-7 ui:rounded-md ui:text-muted-foreground ui:hover:text-foreground ui:hover:bg-muted ui:transition-colors ui:no-underline', + 'ui:inline-flex ui:items-center ui:justify-center ui:size-7 ui:rounded-md ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] ui:hover:bg-[var(--landing-card-soft)] ui:transition-colors ui:no-underline', bottomText: 'ui:break-words', bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-5 ui:gap-y-2 ui:justify-start ui:sm:justify-end', bottomLink: - 'ui:text-xs ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0', + 'ui:text-xs ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0', logoImg: 'ui:h-6 ui:md:h-7 ui:w-auto' }; } @@ -132,108 +133,165 @@ export function getFooterTokens(variant: OrgLandingPageTheme): FooterRegionToken if (variant === 'corporate') { return { - root: 'ui:bg-background ui:border-t ui:border-border ui:py-12 ui:px-4 ui:sm:px-6 ui:md:py-16', + root: 'ui:bg-[var(--landing-bg)] ui:border-t ui:border-[var(--landing-border)] ui:py-12 ui:px-4 ui:sm:px-6 ui:md:py-16', container: 'ui:max-w-[1120px] ui:mx-auto', compactRow: - 'ui:flex ui:flex-col ui:gap-3 ui:items-start ui:md:flex-row ui:md:items-center ui:md:justify-between ui:text-sm ui:text-muted-foreground', + 'ui:flex ui:flex-col ui:gap-3 ui:items-start ui:md:flex-row ui:md:items-center ui:md:justify-between ui:text-sm ui:text-[var(--landing-fg-muted)]', bottomRow: - 'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-12 ui:pt-6 ui:border-t ui:border-border ui:text-xs ui:text-muted-foreground', + 'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-12 ui:pt-6 ui:border-t ui:border-[var(--landing-border)] ui:text-xs ui:text-[var(--landing-fg-muted)]', brandName: - 'ui:font-semibold ui:text-base ui:tracking-tight ui:text-foreground ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words', - brandTagline: 'ui:text-sm ui:text-muted-foreground ui:break-words ui:mt-3 ui:max-w-[260px] ui:leading-relaxed', - columnHeading: 'ui:text-xs ui:font-semibold ui:tracking-wider ui:text-foreground ui:mb-3 ui:uppercase', + 'ui:font-semibold ui:text-base ui:tracking-tight ui:text-[var(--landing-fg)] ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words', + brandTagline: + 'ui:text-sm ui:text-[var(--landing-fg-muted)] ui:break-words ui:mt-3 ui:max-w-[260px] ui:leading-relaxed', + columnHeading: 'ui:text-xs ui:font-semibold ui:tracking-wider ui:text-[var(--landing-fg)] ui:mb-3 ui:uppercase', columnLink: - 'ui:block ui:text-sm ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:break-words ui:py-1 ui:md:py-0', + 'ui:block ui:text-sm ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] ui:transition-colors ui:break-words ui:py-1 ui:md:py-0', columnCta: - 'ui:text-sm ui:font-medium ui:text-foreground ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-3 ui:no-underline ui:underline ui:underline-offset-4', + 'ui:text-sm ui:font-medium ui:text-[var(--landing-fg)] ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-3 ui:no-underline ui:underline ui:underline-offset-4', socialWrap: 'ui:flex ui:flex-wrap ui:gap-3 ui:mt-4 ui:list-none ui:m-0 ui:p-0', socialIcon: - 'ui:inline-flex ui:items-center ui:justify-center ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:no-underline', + 'ui:inline-flex ui:items-center ui:justify-center ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] ui:transition-colors ui:no-underline', bottomText: 'ui:break-words', bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-6 ui:gap-y-2 ui:justify-start ui:sm:justify-end', bottomLink: - 'ui:text-xs ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0', + 'ui:text-xs ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0', logoImg: 'ui:h-6 ui:md:h-7 ui:w-auto' }; } if (variant === 'saas') { return { - root: 'ui:bg-background ui:border-t ui:border-border ui:py-14 ui:px-4 ui:sm:px-6 ui:md:py-16', + root: 'ui:bg-[var(--landing-bg)] ui:border-t ui:border-[var(--landing-border)] ui:py-14 ui:px-4 ui:sm:px-6 ui:md:py-16', container: 'ui:max-w-[1180px] ui:mx-auto', compactRow: - 'ui:flex ui:flex-col ui:gap-3 ui:items-start ui:md:flex-row ui:md:items-center ui:md:justify-between ui:text-sm ui:text-muted-foreground', + 'ui:flex ui:flex-col ui:gap-3 ui:items-start ui:md:flex-row ui:md:items-center ui:md:justify-between ui:text-sm ui:text-[var(--landing-fg-muted)]', bottomRow: - 'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-10 ui:pt-6 ui:border-t ui:border-border ui:text-xs ui:text-muted-foreground', + 'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-10 ui:pt-6 ui:border-t ui:border-[var(--landing-border)] ui:text-xs ui:text-[var(--landing-fg-muted)]', brandName: - 'ui:font-semibold ui:text-[15px] ui:tracking-tight ui:text-foreground ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words', - brandTagline: 'ui:text-sm ui:text-muted-foreground ui:break-words ui:mt-3 ui:max-w-[260px]', - columnHeading: 'ui:text-sm ui:font-bold ui:text-foreground ui:mb-3', + 'ui:font-semibold ui:text-[15px] ui:tracking-tight ui:text-[var(--landing-fg)] ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words', + brandTagline: 'ui:text-sm ui:text-[var(--landing-fg-muted)] ui:break-words ui:mt-3 ui:max-w-[260px]', + columnHeading: 'ui:text-sm ui:font-bold ui:text-[var(--landing-fg)] ui:mb-3', columnLink: - 'ui:block ui:text-sm ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:break-words ui:py-1.5 ui:md:py-0', + 'ui:block ui:text-sm ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] ui:transition-colors ui:break-words ui:py-1.5 ui:md:py-0', columnCta: - 'ui:text-sm ui:font-semibold ui:text-primary ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-3 ui:no-underline hover:ui:underline', + 'ui:text-sm ui:font-semibold ui:text-[var(--landing-accent)] ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-3 ui:no-underline hover:ui:underline', socialWrap: 'ui:flex ui:flex-wrap ui:gap-2 ui:mt-4 ui:list-none ui:m-0 ui:p-0', socialIcon: - 'ui:inline-flex ui:items-center ui:justify-center ui:size-8 ui:rounded-full ui:border ui:border-border ui:bg-muted/40 ui:text-muted-foreground ui:hover:text-foreground ui:hover:bg-muted ui:transition-colors ui:no-underline', + 'ui:inline-flex ui:items-center ui:justify-center ui:size-8 ui:rounded-full ui:border ui:border-[var(--landing-border)] ui:bg-[var(--landing-card-soft)]/40 ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] ui:hover:bg-[var(--landing-card-soft)] ui:transition-colors ui:no-underline', bottomText: 'ui:break-words', bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-5 ui:gap-y-2 ui:justify-start ui:sm:justify-end', bottomLink: - 'ui:text-xs ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0', + 'ui:text-xs ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0', + logoImg: 'ui:h-6 ui:md:h-7 ui:w-auto' + }; + } + + if (variant === 'vibrant') { + return { + root: 'ui:bg-[var(--landing-card-soft)]/40 ui:text-[var(--landing-fg)] ui:py-20 ui:px-4 ui:sm:px-6 ui:md:py-24', + container: 'ui:max-w-[1320px] ui:mx-auto', + compactRow: + 'ui:flex ui:flex-col ui:gap-3 ui:items-start ui:md:flex-row ui:md:items-center ui:md:justify-between ui:text-sm ui:text-[var(--landing-fg-muted)]', + bottomRow: + 'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-14 ui:pt-6 ui:border-t ui:border-[var(--landing-border)]/60 ui:text-sm ui:text-[var(--landing-fg-muted)]', + brandName: + 'ui:font-bold ui:text-[42px] ui:md:text-[48px] ui:tracking-tight ui:text-[var(--landing-fg)] ui:flex ui:items-center ui:gap-3 ui:no-underline ui:break-words', + brandTagline: + 'ui:text-base ui:text-[var(--landing-fg-muted)] ui:break-words ui:mt-4 ui:max-w-[280px] ui:leading-relaxed', + columnHeading: + 'ui:text-xs ui:font-medium ui:tracking-[0.08em] ui:uppercase ui:text-[var(--landing-fg-muted)] ui:mb-5', + columnLink: + 'ui:block ui:text-lg ui:font-medium ui:text-[var(--landing-fg)] ui:hover:text-[var(--landing-accent)] ui:transition-colors ui:break-words ui:py-1.5 ui:md:py-1 ui:no-underline', + columnCta: + 'ui:text-sm ui:font-medium ui:text-[var(--landing-accent)] ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-3 ui:no-underline hover:ui:underline', + socialWrap: 'ui:flex ui:flex-wrap ui:gap-3 ui:mt-4 ui:list-none ui:m-0 ui:p-0', + socialIcon: + 'ui:inline-flex ui:items-center ui:justify-center ui:size-9 ui:rounded-md ui:border ui:border-[var(--landing-border)] ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:hover:text-[var(--landing-accent)] ui:transition-colors ui:no-underline', + bottomText: 'ui:break-words', + bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-5 ui:gap-y-2 ui:justify-start ui:sm:justify-end', + bottomLink: + 'ui:text-sm ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0 ui:no-underline', + logoImg: 'ui:h-12 ui:md:h-14 ui:w-auto' + }; + } + + if (variant === 'editorial') { + return { + root: 'ui:bg-[#ebeae3] ui:text-[#1a1a1a] ui:py-14 ui:px-4 ui:sm:px-6 ui:md:py-16', + container: 'ui:max-w-[1240px] ui:mx-auto', + compactRow: + 'ui:flex ui:flex-col ui:gap-3 ui:items-start ui:md:flex-row ui:md:items-center ui:md:justify-between ui:text-sm ui:text-[#76746c]', + bottomRow: + 'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-12 ui:pt-6 ui:border-t ui:border-[#d9d8d0] ui:text-xs ui:text-[#76746c]', + brandName: + 'ui:font-semibold ui:text-base ui:tracking-tight ui:text-[#1a1a1a] ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words', + brandTagline: 'ui:text-sm ui:text-[#76746c] ui:break-words ui:mt-3 ui:max-w-[260px] ui:leading-relaxed', + columnHeading: 'ui:text-[13px] ui:font-normal ui:text-[#76746c] ui:mb-3', + columnLink: + 'ui:block ui:text-sm ui:text-[#2c2b29] ui:hover:text-[#1a1a1a] ui:transition-colors ui:break-words ui:py-1 ui:md:py-0 ui:no-underline', + columnCta: + 'ui:text-sm ui:font-medium ui:text-[#c25237] ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-3 ui:no-underline hover:ui:underline', + socialWrap: 'ui:flex ui:flex-wrap ui:gap-3 ui:mt-4 ui:list-none ui:m-0 ui:p-0', + socialIcon: + 'ui:inline-flex ui:items-center ui:justify-center ui:text-[#76746c] ui:hover:text-[#1a1a1a] ui:transition-colors ui:no-underline', + bottomText: 'ui:break-words', + bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-6 ui:gap-y-2 ui:justify-start ui:sm:justify-end', + bottomLink: + 'ui:text-xs ui:text-[#76746c] ui:hover:text-[#1a1a1a] ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0 ui:no-underline', logoImg: 'ui:h-6 ui:md:h-7 ui:w-auto' }; } if (variant === 'bold') { return { - root: 'ui:bg-foreground ui:text-background/60 ui:py-14 ui:px-4 ui:sm:px-6 ui:md:py-20', + root: 'ui:bg-[var(--landing-fg)] ui:text-[var(--landing-bg)]/60 ui:py-14 ui:px-4 ui:sm:px-6 ui:md:py-20', container: 'ui:max-w-7xl ui:mx-auto', compactRow: 'ui:flex ui:flex-col ui:gap-3 ui:items-start ui:md:flex-row ui:md:items-center ui:md:justify-between ui:text-sm', bottomRow: - 'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-10 ui:md:mt-12 ui:pt-6 ui:border-t ui:border-background/15 ui:text-xs ui:text-background/50', + 'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-10 ui:md:mt-12 ui:pt-6 ui:border-t ui:border-[var(--landing-bg)]/15 ui:text-xs ui:text-[var(--landing-bg)]/50', brandName: - 'ui:font-black ui:text-2xl ui:tracking-tighter ui:text-background ui:flex ui:items-center ui:gap-3 ui:no-underline ui:break-words', - brandTagline: 'ui:text-sm ui:text-background/60 ui:break-words ui:mt-2', - columnHeading: 'ui:text-xs ui:font-bold ui:uppercase ui:tracking-widest ui:text-background ui:mb-4', + 'ui:font-black ui:text-2xl ui:tracking-tighter ui:text-[var(--landing-bg)] ui:flex ui:items-center ui:gap-3 ui:no-underline ui:break-words', + brandTagline: 'ui:text-sm ui:text-[var(--landing-bg)]/60 ui:break-words ui:mt-2', + columnHeading: 'ui:text-xs ui:font-bold ui:uppercase ui:tracking-widest ui:text-[var(--landing-bg)] ui:mb-4', columnLink: - 'ui:block ui:text-sm ui:font-medium ui:text-background/70 ui:hover:text-background ui:transition-colors ui:break-words ui:py-1.5 ui:md:py-0', + 'ui:block ui:text-sm ui:font-medium ui:text-[var(--landing-bg)]/70 ui:hover:text-[var(--landing-bg)] ui:transition-colors ui:break-words ui:py-1.5 ui:md:py-0', columnCta: - 'ui:text-sm ui:font-bold ui:text-background ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-4 ui:no-underline hover:ui:underline', + 'ui:text-sm ui:font-bold ui:text-[var(--landing-bg)] ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-4 ui:no-underline hover:ui:underline', socialWrap: 'ui:flex ui:flex-wrap ui:gap-2 ui:mt-3 ui:list-none ui:m-0 ui:p-0', socialIcon: - 'ui:inline-flex ui:items-center ui:justify-center ui:p-1.5 ui:rounded-md ui:text-background/70 ui:hover:text-background ui:transition-colors ui:no-underline', + 'ui:inline-flex ui:items-center ui:justify-center ui:p-1.5 ui:rounded-md ui:text-[var(--landing-bg)]/70 ui:hover:text-[var(--landing-bg)] ui:transition-colors ui:no-underline', bottomText: 'ui:break-words', bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-6 ui:gap-y-2 ui:justify-start ui:sm:justify-end', bottomLink: - 'ui:text-xs ui:font-medium ui:text-background/70 ui:hover:text-background ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0', + 'ui:text-xs ui:font-medium ui:text-[var(--landing-bg)]/70 ui:hover:text-[var(--landing-bg)] ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0', logoImg: 'ui:h-7 ui:md:h-8 ui:w-auto ui:grayscale ui:brightness-200' }; } return { - root: 'ui:bg-foreground ui:text-background ui:py-12 ui:px-4 ui:sm:px-6 ui:lg:px-8 ui:md:py-16', + root: 'ui:bg-[var(--landing-fg)] ui:text-[var(--landing-bg)] ui:py-12 ui:px-4 ui:sm:px-6 ui:lg:px-8 ui:md:py-16', container: 'ui:max-w-7xl ui:mx-auto', compactRow: 'ui:flex ui:flex-col ui:gap-3 ui:items-start ui:md:flex-row ui:md:items-center ui:md:justify-between ui:text-sm', bottomRow: - 'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-10 ui:md:mt-12 ui:pt-6 ui:text-sm ui:text-background/70', - bottomHairline: 'ui:h-px ui:w-16 ui:md:w-24 ui:bg-background/30 ui:mx-auto ui:mb-6', + 'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-10 ui:md:mt-12 ui:pt-6 ui:text-sm ui:text-[var(--landing-bg)]/70', + bottomHairline: 'ui:h-px ui:w-16 ui:md:w-24 ui:bg-[var(--landing-bg)]/30 ui:mx-auto ui:mb-6', brandName: - 'ui:text-xl ui:font-bold ui:tracking-wide ui:text-background ui:flex ui:items-center ui:gap-3 ui:no-underline ui:break-words', - brandTagline: 'ui:text-sm ui:text-background/70 ui:break-words ui:mt-2', - columnHeading: 'ui:text-sm ui:font-semibold ui:text-background ui:mb-4', + 'ui:text-xl ui:font-bold ui:tracking-wide ui:text-[var(--landing-bg)] ui:flex ui:items-center ui:gap-3 ui:no-underline ui:break-words', + brandTagline: 'ui:text-sm ui:text-[var(--landing-bg)]/70 ui:break-words ui:mt-2', + columnHeading: 'ui:text-sm ui:font-semibold ui:text-[var(--landing-bg)] ui:mb-4', columnLink: - 'ui:block ui:text-sm ui:font-medium ui:text-background/70 ui:hover:text-background ui:transition-colors ui:break-words ui:py-1.5 ui:md:py-0', + 'ui:block ui:text-sm ui:font-medium ui:text-[var(--landing-bg)]/70 ui:hover:text-[var(--landing-bg)] ui:transition-colors ui:break-words ui:py-1.5 ui:md:py-0', columnCta: - 'ui:text-sm ui:font-semibold ui:text-background ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-4 ui:no-underline hover:ui:underline', + 'ui:text-sm ui:font-semibold ui:text-[var(--landing-bg)] ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-4 ui:no-underline hover:ui:underline', socialWrap: 'ui:flex ui:flex-wrap ui:gap-2 ui:mt-3 ui:list-none ui:m-0 ui:p-0', socialIcon: - 'ui:inline-flex ui:items-center ui:justify-center ui:p-1.5 ui:rounded-md ui:text-background/70 ui:hover:text-background ui:transition-colors ui:no-underline', + 'ui:inline-flex ui:items-center ui:justify-center ui:p-1.5 ui:rounded-md ui:text-[var(--landing-bg)]/70 ui:hover:text-[var(--landing-bg)] ui:transition-colors ui:no-underline', bottomText: 'ui:break-words', bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-6 ui:gap-y-2 ui:justify-start ui:sm:justify-end', bottomLink: - 'ui:text-sm ui:font-medium ui:text-background/70 ui:hover:text-background ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0', + 'ui:text-sm ui:font-medium ui:text-[var(--landing-bg)]/70 ui:hover:text-[var(--landing-bg)] ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0', logoImg: 'ui:h-7 ui:md:h-8 ui:w-auto' }; } diff --git a/packages/ui/src/custom/org-landing-page/landing-page-utils.ts b/packages/ui/src/custom/org-landing-page/landing-page-utils.ts index 5c696593..7e3dfd21 100644 --- a/packages/ui/src/custom/org-landing-page/landing-page-utils.ts +++ b/packages/ui/src/custom/org-landing-page/landing-page-utils.ts @@ -72,3 +72,15 @@ export function calcCourseDiscount(percent = 0, cost: number, showDiscount: bool const discountedPrice = cost - discountAmount; return Math.round(discountedPrice); } + +export function defaultLessonsLabel(count: number): string { + return count === 1 ? '1 lesson' : `${count.toLocaleString()} lessons`; +} + +export function defaultExercisesLabel(count: number): string { + return count === 1 ? '1 exercise' : `${count.toLocaleString()} exercises`; +} + +export function defaultEnrolledLabel(count: number): string { + return `${count.toLocaleString()} enrolled`; +} diff --git a/packages/ui/src/custom/org-landing-page/links.svelte b/packages/ui/src/custom/org-landing-page/links.svelte index cb8821fc..e2c1418d 100644 --- a/packages/ui/src/custom/org-landing-page/links.svelte +++ b/packages/ui/src/custom/org-landing-page/links.svelte @@ -1,14 +1,23 @@ -
    +
    {#snippet navigation()} @@ -44,68 +40,10 @@
    -

    Our Courses

    +

    {labels?.catalogHeading ?? 'Our Courses'}

    @@ -117,7 +55,7 @@ size="lg" disabled={disableCourseLinks} > - View more courses + {labels?.browseCoursesLabel ?? 'View more courses'}
    {/if} @@ -125,11 +63,11 @@
    - + - + - +
    diff --git a/packages/ui/src/custom/org-landing-page/minimal/course-card.svelte b/packages/ui/src/custom/org-landing-page/minimal/course-card.svelte new file mode 100644 index 00000000..7af895e5 --- /dev/null +++ b/packages/ui/src/custom/org-landing-page/minimal/course-card.svelte @@ -0,0 +1,82 @@ + + + + + + {course.title} + + {course.description} + + +
    + {#if course.duration} +
    + + {course.duration} +
    + {/if} + {#if courseTypeMeta} + {@const TypeIcon = courseTypeMeta.icon} +
    + + {courseTypeMeta.label} +
    + {/if} + {#if primaryTag} +
    + + {#if primaryTag.color} + + {/if} + {primaryTag.name} +
    + {/if} +
    + {course.price || formatCurrency(course.cost, course.currency)} +
    +
    +
    +
    +
    diff --git a/packages/ui/src/custom/org-landing-page/minimal/hero.svelte b/packages/ui/src/custom/org-landing-page/minimal/hero.svelte index dd60ca5f..e70e1758 100644 --- a/packages/ui/src/custom/org-landing-page/minimal/hero.svelte +++ b/packages/ui/src/custom/org-landing-page/minimal/hero.svelte @@ -3,7 +3,6 @@ import type { Snippet } from 'svelte'; import SecondaryActionButton from '../secondary-action-button.svelte'; import { Button } from '../../../base/button'; - import { BlurFade } from '../../animation/blurfade'; import { DotPattern } from '../../animation/dot-pattern'; import { ShinnyText } from '../../animation/shinny-text'; import EditableLandingSection from '../editable-section.svelte'; @@ -18,14 +17,14 @@ const heroGradientBackground = ` radial-gradient( circle at top center, - color-mix(in oklab, var(--primary) 24%, transparent) 0%, + color-mix(in oklab, var(--landing-accent) 24%, transparent) 0%, transparent 58% ), linear-gradient( 135deg, - color-mix(in oklab, var(--primary) 12%, var(--background)) 0%, - color-mix(in oklab, var(--primary) 6%, var(--background)) 48%, - var(--background) 100% + color-mix(in oklab, var(--landing-accent) 12%, var(--landing-bg)) 0%, + color-mix(in oklab, var(--landing-accent) 6%, var(--landing-bg)) 48%, + var(--landing-bg) 100% ) `; @@ -41,36 +40,30 @@
    - -

    {hero.heading}

    -
    - - {hero.heading} + + {hero.subheading} + +
    + - {#if hero.secondaryAction} - - {/if} -
    -
    + {hero.primaryAction.label} + + {#if hero.secondaryAction} + + {/if} +
    diff --git a/packages/ui/src/custom/org-landing-page/minimal/nav.svelte b/packages/ui/src/custom/org-landing-page/minimal/nav.svelte index ea2a9944..2138eee9 100644 --- a/packages/ui/src/custom/org-landing-page/minimal/nav.svelte +++ b/packages/ui/src/custom/org-landing-page/minimal/nav.svelte @@ -18,18 +18,18 @@ const navigationGradientBackground = ` linear-gradient( 135deg, - color-mix(in oklab, var(--primary) 16%, var(--background)) 0%, - color-mix(in oklab, var(--primary) 8%, var(--background)) 100% + color-mix(in oklab, var(--landing-accent) 16%, var(--landing-bg)) 0%, + color-mix(in oklab, var(--landing-accent) 8%, var(--landing-bg)) 100% ) `;
    - + {#if logoUrl} {orgName} {/if} @@ -38,7 +38,9 @@