feat: preview templates, thumbnail features, 2 new org templates
This commit is contained in:
@@ -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/<theme>.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 `<LandingButton variant="primary|secondary|tertiary">` 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.
|
||||||
@@ -432,13 +432,11 @@ const agentCoreRouter = new Hono()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role === AgentRole.TEACHER && !modelDescriptor.isFree) {
|
const isOrgPaid = role === AgentRole.TEACHER ? await isOrgOnPaidPlan(orgId) : false;
|
||||||
const isPaid = await isOrgOnPaidPlan(orgId);
|
|
||||||
|
|
||||||
if (!isPaid) {
|
if (role === AgentRole.TEACHER && !modelDescriptor.isFree && !isOrgPaid) {
|
||||||
throw new AppError('Upgrade required to use this AI model', 'UPGRADE_REQUIRED', 403);
|
throw new AppError('Upgrade required to use this AI model', 'UPGRADE_REQUIRED', 403);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Students go through tutor policy (workspace toggle, pool, per-learner cap).
|
// Students go through tutor policy (workspace toggle, pool, per-learner cap).
|
||||||
// Teachers continue to use the existing pool-only check.
|
// Teachers continue to use the existing pool-only check.
|
||||||
@@ -520,7 +518,8 @@ const agentCoreRouter = new Hono()
|
|||||||
// approved plan/active template) is sent as a user-turn message instead so
|
// approved plan/active template) is sent as a user-turn message instead so
|
||||||
// it doesn't invalidate the tools+system cache.
|
// it doesn't invalidate the tools+system cache.
|
||||||
const systemPrompt = buildSystemPrompt(agentContext, {
|
const systemPrompt = buildSystemPrompt(agentContext, {
|
||||||
tutorSettings: studentPolicy?.settings
|
tutorSettings: studentPolicy?.settings,
|
||||||
|
isOrgOnPaidPlan: isOrgPaid
|
||||||
});
|
});
|
||||||
|
|
||||||
const contextMessageText = buildContextMessage(agentContext, {
|
const contextMessageText = buildContextMessage(agentContext, {
|
||||||
@@ -531,7 +530,7 @@ const agentCoreRouter = new Hono()
|
|||||||
const agentTools =
|
const agentTools =
|
||||||
role === AgentRole.STUDENT
|
role === AgentRole.STUDENT
|
||||||
? buildStudentAgentTools(orgId, user.id, courseId, studentPolicy!.settings)
|
? 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({
|
const contextManaged = await buildModelContextMessages({
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -569,27 +568,7 @@ const agentCoreRouter = new Hono()
|
|||||||
]
|
]
|
||||||
: convertedMessages;
|
: convertedMessages;
|
||||||
|
|
||||||
console.log(
|
console.log(`[agent.chat] user=${user.id} messages=${modelMessages.length}`);
|
||||||
'[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
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cache the growing conversation prefix: each turn reads the prior
|
// Cache the growing conversation prefix: each turn reads the prior
|
||||||
// transcript at ~0.1x cost instead of reprocessing it at full price.
|
// transcript at ~0.1x cost instead of reprocessing it at full price.
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export const agentRunsRouter = new Hono()
|
|||||||
courseId: data.courseId,
|
courseId: data.courseId,
|
||||||
conversationId: data.conversationId,
|
conversationId: data.conversationId,
|
||||||
phase: data.phase,
|
phase: data.phase,
|
||||||
|
modelId: data.model,
|
||||||
approvedPlan: data.approvedPlan,
|
approvedPlan: data.approvedPlan,
|
||||||
executionCursor: data.executionCursor,
|
executionCursor: data.executionCursor,
|
||||||
sourceIds: data.sourceIds,
|
sourceIds: data.sourceIds,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getMediaJobSteps,
|
getMediaJobSteps,
|
||||||
listMediaJobEnvelopes,
|
listMediaJobEnvelopes,
|
||||||
listMediaJobsForAssetEnvelopes,
|
listMediaJobsForAssetEnvelopes,
|
||||||
|
startThumbnailRegenJob,
|
||||||
startTranscriptionOnlyMediaJob
|
startTranscriptionOnlyMediaJob
|
||||||
} from '@api/services/jobs';
|
} 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(
|
.post(
|
||||||
'/media/:jobId/cancel',
|
'/media/:jobId/cancel',
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
import { getMediaTranscriptByAsset } from '@cio/db/queries';
|
import { getMediaTranscriptByAsset } from '@cio/db/queries';
|
||||||
|
|
||||||
import { generateTranscriptVttPresignedUrl, TRANSCRIPT_VTT_PRESIGN_SECONDS } from '@api/utils/s3';
|
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}$/;
|
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) {
|
export async function deleteAssetService(orgId: string, assetId: string) {
|
||||||
try {
|
try {
|
||||||
await getAssetService(orgId, assetId);
|
await getAssetService(orgId, assetId);
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import {
|
|||||||
hasActiveMediaJobForAsset
|
hasActiveMediaJobForAsset
|
||||||
} from '@cio/db/queries';
|
} from '@cio/db/queries';
|
||||||
import { getAssetById } from '@cio/db/queries/assets';
|
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 { logRedisUnavailableOnce } from '@api/utils/redis/redis';
|
||||||
import type { TMediaJob } from '@db/types';
|
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<TMediaJob> {
|
||||||
|
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 {
|
export interface StartMediaJobInput {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
assetId: string;
|
assetId: string;
|
||||||
|
|||||||
@@ -718,6 +718,31 @@ export class LessonApi extends BaseApiWithErrors {
|
|||||||
this.isDirty = true;
|
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<string, unknown>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
videos[videoIndex] = {
|
||||||
|
...target,
|
||||||
|
metadata: { ...metadata, thumbnailUrl }
|
||||||
|
};
|
||||||
|
|
||||||
|
this.lesson = { ...this.lesson, videos };
|
||||||
|
this.isDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a document from lesson materials
|
* Deletes a document from lesson materials
|
||||||
*/
|
*/
|
||||||
|
|||||||
+7
-2
@@ -3,7 +3,7 @@
|
|||||||
import { Image } from '$features/ui';
|
import { Image } from '$features/ui';
|
||||||
import VideoIcon from '@lucide/svelte/icons/video';
|
import VideoIcon from '@lucide/svelte/icons/video';
|
||||||
import { ExternalLinkIcon, HoverableItem } from '@cio/ui/custom/moving-icons';
|
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 VideoCardDropdown from './video-card-dropdown.svelte';
|
||||||
import {
|
import {
|
||||||
getVideoThumbnailUrl,
|
getVideoThumbnailUrl,
|
||||||
@@ -138,7 +138,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if isEditMode}
|
{#if isEditMode}
|
||||||
<VideoCardDropdown {video} {onRemove} menuPlacement="inline" />
|
<VideoCardDropdown
|
||||||
|
{video}
|
||||||
|
{onRemove}
|
||||||
|
onThumbnailSaved={(url) => lessonApi.updateLessonVideoThumbnail(index, url)}
|
||||||
|
menuPlacement="inline"
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="ui:text-muted-foreground mt-0.5 line-clamp-1 text-sm leading-snug">
|
<p class="ui:text-muted-foreground mt-0.5 line-clamp-1 text-sm leading-snug">
|
||||||
|
|||||||
+66
-1
@@ -1,8 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||||
|
import CaptionsIcon from '@lucide/svelte/icons/captions';
|
||||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||||
|
import EyeIcon from '@lucide/svelte/icons/eye';
|
||||||
|
import ImageIcon from '@lucide/svelte/icons/image';
|
||||||
|
import Trash2Icon from '@lucide/svelte/icons/trash-2';
|
||||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||||
import { mediaApi } from '$features/media/api';
|
import { mediaApi } from '$features/media/api';
|
||||||
|
import { ManageThumbnailsDialog } from '$features/media/components';
|
||||||
|
import type { OrganizationAsset } from '$features/media/utils';
|
||||||
import { JobPoller, jobsApi, type MediaJobEnvelope } from '$features/jobs';
|
import { JobPoller, jobsApi, type MediaJobEnvelope } from '$features/jobs';
|
||||||
import { sidePanel } from '$features/side-panel';
|
import { sidePanel } from '$features/side-panel';
|
||||||
import { snackbar } from '$features/ui/snackbar/store';
|
import { snackbar } from '$features/ui/snackbar/store';
|
||||||
@@ -15,11 +21,16 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
video: LessonVideo;
|
video: LessonVideo;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
onThumbnailSaved?: (thumbnailUrl: string) => void;
|
||||||
/** `inline` aligns with the title row (YouTube-style). `corner` is top-right overlay on the nearest `relative` ancestor. */
|
/** `inline` aligns with the title row (YouTube-style). `corner` is top-right overlay on the nearest `relative` ancestor. */
|
||||||
menuPlacement?: 'inline' | 'corner';
|
menuPlacement?: 'inline' | 'corner';
|
||||||
}
|
}
|
||||||
|
|
||||||
let { video, onRemove, menuPlacement = 'inline' }: Props = $props();
|
let { video, onRemove, onThumbnailSaved, menuPlacement = 'inline' }: Props = $props();
|
||||||
|
|
||||||
|
let manageThumbsOpen = $state(false);
|
||||||
|
let manageThumbsAsset = $state<OrganizationAsset | null>(null);
|
||||||
|
let isLoadingAsset = $state(false);
|
||||||
|
|
||||||
let isTranscribing = $state(false);
|
let isTranscribing = $state(false);
|
||||||
let hasTranscript = $state(false);
|
let hasTranscript = $state(false);
|
||||||
@@ -29,6 +40,7 @@
|
|||||||
const assetId = $derived((video as LessonVideo & { assetId?: string }).assetId ?? null);
|
const assetId = $derived((video as LessonVideo & { assetId?: string }).assetId ?? null);
|
||||||
|
|
||||||
const canGenerateTranscript = $derived(video.type === 'upload' && !!assetId);
|
const canGenerateTranscript = $derived(video.type === 'upload' && !!assetId);
|
||||||
|
const canManageThumbnails = $derived(video.type === 'upload' && !!assetId);
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
activePoller?.stop();
|
activePoller?.stop();
|
||||||
@@ -116,6 +128,31 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleManageThumbnails() {
|
||||||
|
if (!assetId || isLoadingAsset) return;
|
||||||
|
|
||||||
|
isLoadingAsset = true;
|
||||||
|
try {
|
||||||
|
const asset = await mediaApi.refreshAsset(assetId);
|
||||||
|
if (!asset) {
|
||||||
|
snackbar.error('snackbar.media_manager.list_failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
manageThumbsAsset = asset;
|
||||||
|
manageThumbsOpen = true;
|
||||||
|
} finally {
|
||||||
|
isLoadingAsset = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleThumbnailSaved(asset: OrganizationAsset) {
|
||||||
|
manageThumbsAsset = asset;
|
||||||
|
if (asset.thumbnailUrl) {
|
||||||
|
onThumbnailSaved?.(asset.thumbnailUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const generateLabel = $derived(
|
const generateLabel = $derived(
|
||||||
isTranscribing
|
isTranscribing
|
||||||
? t.get('course.navItem.lessons.materials.tabs.video.generate_transcript_in_progress')
|
? t.get('course.navItem.lessons.materials.tabs.video.generate_transcript_in_progress')
|
||||||
@@ -142,6 +179,19 @@
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content align="end">
|
<DropdownMenu.Content align="end">
|
||||||
|
{#if canManageThumbnails}
|
||||||
|
<DropdownMenu.Item
|
||||||
|
disabled={isLoadingAsset}
|
||||||
|
onclick={() => {
|
||||||
|
void handleManageThumbnails();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<ImageIcon size={14} />
|
||||||
|
{$t('media_manager.actions.manage_thumbnails')}
|
||||||
|
</span>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{/if}
|
||||||
{#if canGenerateTranscript}
|
{#if canGenerateTranscript}
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
disabled={isTranscribing}
|
disabled={isTranscribing}
|
||||||
@@ -149,7 +199,10 @@
|
|||||||
void handleGenerateTranscript();
|
void handleGenerateTranscript();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<CaptionsIcon size={14} />
|
||||||
{generateLabel}
|
{generateLabel}
|
||||||
|
</span>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
{#if hasTranscript}
|
{#if hasTranscript}
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
@@ -157,12 +210,24 @@
|
|||||||
void handleViewTranscript();
|
void handleViewTranscript();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<EyeIcon size={14} />
|
||||||
{$t('course.navItem.lessons.materials.tabs.video.view_transcript')}
|
{$t('course.navItem.lessons.materials.tabs.video.view_transcript')}
|
||||||
|
</span>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<DropdownMenu.Item class="ui:text-red-600" onclick={onRemove}>
|
<DropdownMenu.Item class="ui:text-red-600" onclick={onRemove}>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<Trash2Icon size={14} />
|
||||||
{$t('course.navItem.lessons.materials.tabs.video.remove_video')}
|
{$t('course.navItem.lessons.materials.tabs.video.remove_video')}
|
||||||
|
</span>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
|
|
||||||
|
<ManageThumbnailsDialog
|
||||||
|
bind:open={manageThumbsOpen}
|
||||||
|
asset={manageThumbsAsset}
|
||||||
|
onThumbnailSaved={handleThumbnailSaved}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import type {
|
|||||||
GetYouTubeMetadataRequest,
|
GetYouTubeMetadataRequest,
|
||||||
ListAssetsRequest,
|
ListAssetsRequest,
|
||||||
OrganizationAsset,
|
OrganizationAsset,
|
||||||
|
RegenerateAssetThumbnailRequest,
|
||||||
|
SelectAssetThumbnailRequest,
|
||||||
UpdateAssetData,
|
UpdateAssetData,
|
||||||
UpdateAssetRequest,
|
UpdateAssetRequest,
|
||||||
YouTubeMetadata
|
YouTubeMetadata
|
||||||
@@ -404,6 +406,67 @@ export class MediaApi extends BaseApiWithErrors {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Persist the chosen thumbnail URL (must be a generated candidate or a fresh upload). */
|
||||||
|
async selectThumbnail(assetId: string, thumbnailUrl: string): Promise<OrganizationAsset | null> {
|
||||||
|
let updated: OrganizationAsset | null = null;
|
||||||
|
await this.execute<SelectAssetThumbnailRequest>({
|
||||||
|
requestFn: () =>
|
||||||
|
classroomio.organization.assets[':assetId'].thumbnail.$put({
|
||||||
|
param: { assetId },
|
||||||
|
json: { thumbnailUrl }
|
||||||
|
}),
|
||||||
|
logContext: 'selecting asset thumbnail',
|
||||||
|
onSuccess: (response) => {
|
||||||
|
updated = response.data;
|
||||||
|
this.assets = this.assets.map((asset) => (asset.id === response.data.id ? response.data : asset));
|
||||||
|
snackbar.success('snackbar.media_manager.thumbnail_updated');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
snackbar.error('snackbar.media_manager.thumbnail_update_failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enqueue a fresh generate-thumbnail job and return the media_job id. */
|
||||||
|
async regenerateThumbnails(assetId: string): Promise<string | null> {
|
||||||
|
let jobId: string | null = null;
|
||||||
|
await this.execute<RegenerateAssetThumbnailRequest>({
|
||||||
|
requestFn: () =>
|
||||||
|
classroomio.jobs.media.asset[':assetId']['regenerate-thumbnail'].$post({
|
||||||
|
param: { assetId }
|
||||||
|
}),
|
||||||
|
logContext: 'regenerating asset thumbnail',
|
||||||
|
onSuccess: (response) => {
|
||||||
|
jobId = response.data.id;
|
||||||
|
snackbar.success('snackbar.media_manager.thumbnail_regen_started');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
snackbar.error('snackbar.media_manager.thumbnail_regen_failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return jobId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshAsset(assetId: string): Promise<OrganizationAsset | null> {
|
||||||
|
let updated: OrganizationAsset | null = null;
|
||||||
|
await this.execute<(typeof classroomio.organization.assets)[':assetId']['$get']>({
|
||||||
|
requestFn: () =>
|
||||||
|
classroomio.organization.assets[':assetId'].$get({
|
||||||
|
param: { assetId }
|
||||||
|
}),
|
||||||
|
logContext: 'refreshing asset',
|
||||||
|
onSuccess: (response) => {
|
||||||
|
updated = response.data;
|
||||||
|
this.assets = this.assets.map((asset) => (asset.id === response.data.id ? response.data : asset));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
/** Enqueue extract-audio + Whisper for an existing upload. */
|
/** Enqueue extract-audio + Whisper for an existing upload. */
|
||||||
async generateTranscript(assetId: string): Promise<boolean> {
|
async generateTranscript(assetId: string): Promise<boolean> {
|
||||||
let ok = false;
|
let ok = false;
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
|
|
||||||
import DownloadIcon from '@lucide/svelte/icons/download';
|
import DownloadIcon from '@lucide/svelte/icons/download';
|
||||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||||
|
import ImageIcon from '@lucide/svelte/icons/image';
|
||||||
|
import LinkIcon from '@lucide/svelte/icons/link';
|
||||||
|
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||||
|
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +26,7 @@
|
|||||||
onEdit?: (asset: OrganizationAsset) => void;
|
onEdit?: (asset: OrganizationAsset) => void;
|
||||||
onUsage?: (asset: OrganizationAsset) => void;
|
onUsage?: (asset: OrganizationAsset) => void;
|
||||||
onDownload?: (asset: OrganizationAsset) => void;
|
onDownload?: (asset: OrganizationAsset) => void;
|
||||||
|
onManageThumbnails?: (asset: OrganizationAsset) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -30,13 +34,14 @@
|
|||||||
downloadingAssetId = null,
|
downloadingAssetId = null,
|
||||||
onEdit = () => {},
|
onEdit = () => {},
|
||||||
onUsage = () => {},
|
onUsage = () => {},
|
||||||
onDownload = () => {}
|
onDownload = () => {},
|
||||||
|
onManageThumbnails = () => {}
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Item.Root
|
<Item.Root
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="group relative max-w-[300px] cursor-pointer p-4!"
|
class="group relative w-full! max-w-75 cursor-pointer p-4!"
|
||||||
onclick={() => onEdit(asset)}
|
onclick={() => onEdit(asset)}
|
||||||
onkeydown={(event) => {
|
onkeydown={(event) => {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
@@ -57,10 +62,24 @@
|
|||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content align="end" onclick={(event) => event.stopPropagation()}>
|
<DropdownMenu.Content align="end" onclick={(event) => event.stopPropagation()}>
|
||||||
<DropdownMenu.Item onclick={() => onEdit(asset)}>
|
<DropdownMenu.Item onclick={() => onEdit(asset)}>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<PencilIcon size={14} />
|
||||||
{$t('media_manager.actions.edit')}
|
{$t('media_manager.actions.edit')}
|
||||||
|
</span>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
{#if asset.kind === 'video'}
|
||||||
|
<DropdownMenu.Item onclick={() => onManageThumbnails(asset)}>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<ImageIcon size={14} />
|
||||||
|
{$t('media_manager.actions.manage_thumbnails')}
|
||||||
|
</span>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{/if}
|
||||||
<DropdownMenu.Item onclick={() => onUsage(asset)}>
|
<DropdownMenu.Item onclick={() => onUsage(asset)}>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<LinkIcon size={14} />
|
||||||
{$t('media_manager.actions.where_used')}
|
{$t('media_manager.actions.where_used')}
|
||||||
|
</span>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item onclick={() => onDownload(asset)} disabled={downloadingAssetId === asset.id}>
|
<DropdownMenu.Item onclick={() => onDownload(asset)} disabled={downloadingAssetId === asset.id}>
|
||||||
<span class="flex items-center gap-2">
|
<span class="flex items-center gap-2">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export { default as AssetCard } from './asset-card.svelte';
|
export { default as AssetCard } from './asset-card.svelte';
|
||||||
export { default as AssetUsageDialog } from './asset-usage-dialog.svelte';
|
export { default as AssetUsageDialog } from './asset-usage-dialog.svelte';
|
||||||
export { default as EditAssetDialog } from './edit-asset-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 MediaFilters } from './media-filters.svelte';
|
||||||
export { default as StorageCards } from './storage-cards.svelte';
|
export { default as StorageCards } from './storage-cards.svelte';
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '@cio/ui/base/button';
|
||||||
|
import * as Dialog from '@cio/ui/base/dialog';
|
||||||
|
import * as FileDropZone from '@cio/ui/custom/file-drop-zone';
|
||||||
|
import type { FileRejectedReason } from '@cio/ui/custom/file-drop-zone';
|
||||||
|
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||||
|
import CheckIcon from '@lucide/svelte/icons/check';
|
||||||
|
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
|
||||||
|
|
||||||
|
import { mediaApi } from '$features/media/api';
|
||||||
|
import type { OrganizationAsset } from '$features/media/utils';
|
||||||
|
import { snackbar } from '$features/ui/snackbar/store';
|
||||||
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
import { uploadImage } from '$lib/utils/services/upload';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open?: boolean;
|
||||||
|
asset?: OrganizationAsset | null;
|
||||||
|
onThumbnailSaved?: (asset: OrganizationAsset) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const THUMBNAIL_MAX_SIZE = 5 * FileDropZone.MEGABYTE;
|
||||||
|
const REGEN_POLL_INTERVAL_MS = 2000;
|
||||||
|
const REGEN_POLL_TIMEOUT_MS = 60_000;
|
||||||
|
const SLOT_KEYS = ['start', 'middle', 'end'] as const;
|
||||||
|
type Slot = (typeof SLOT_KEYS)[number];
|
||||||
|
|
||||||
|
let { open = $bindable(false), asset = null, onThumbnailSaved }: Props = $props();
|
||||||
|
|
||||||
|
let selectedUrl = $state<string | null>(null);
|
||||||
|
let customUrl = $state<string | null>(null);
|
||||||
|
let videoUrl = $state<string | null>(null);
|
||||||
|
let isVideoLoading = $state(false);
|
||||||
|
let isUploading = $state(false);
|
||||||
|
let isSaving = $state(false);
|
||||||
|
let isRegenerating = $state(false);
|
||||||
|
|
||||||
|
const candidates = $derived((asset?.thumbnailCandidates ?? []) as string[]);
|
||||||
|
const currentThumbnail = $derived(asset?.thumbnailUrl ?? null);
|
||||||
|
const hasSelection = $derived(Boolean(selectedUrl) && selectedUrl !== currentThumbnail);
|
||||||
|
const allCandidatesMissing = $derived(candidates.length === 0 && !isRegenerating);
|
||||||
|
|
||||||
|
function slotForIndex(index: number): Slot {
|
||||||
|
return SLOT_KEYS[Math.min(index, SLOT_KEYS.length - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open || !asset) return;
|
||||||
|
|
||||||
|
selectedUrl = asset.thumbnailUrl ?? candidates[0] ?? null;
|
||||||
|
customUrl = null;
|
||||||
|
void loadVideoPreview(asset);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadVideoPreview(target: OrganizationAsset) {
|
||||||
|
if (target.kind !== 'video') {
|
||||||
|
videoUrl = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isVideoLoading = true;
|
||||||
|
try {
|
||||||
|
videoUrl = await mediaApi.getVideoPlaybackUrl(target);
|
||||||
|
} finally {
|
||||||
|
isVideoLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetState() {
|
||||||
|
selectedUrl = null;
|
||||||
|
customUrl = null;
|
||||||
|
videoUrl = null;
|
||||||
|
isUploading = false;
|
||||||
|
isSaving = false;
|
||||||
|
isRegenerating = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCandidate(url: string) {
|
||||||
|
selectedUrl = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCustomUpload(files: File[]) {
|
||||||
|
const file = files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
isUploading = true;
|
||||||
|
try {
|
||||||
|
const url = await uploadImage(file);
|
||||||
|
customUrl = url;
|
||||||
|
selectedUrl = url;
|
||||||
|
snackbar.success('snackbar.media_manager.thumbnail_uploaded');
|
||||||
|
} catch {
|
||||||
|
snackbar.error('snackbar.media_manager.thumbnail_upload_failed');
|
||||||
|
} finally {
|
||||||
|
isUploading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUploadRejected({ reason }: { reason: FileRejectedReason }) {
|
||||||
|
if (reason === 'Maximum file size exceeded') {
|
||||||
|
snackbar.error('snackbar.media_manager.thumbnail_too_large');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
snackbar.error('snackbar.media_manager.thumbnail_invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollUntilRegenComplete(assetId: string) {
|
||||||
|
const deadline = Date.now() + REGEN_POLL_TIMEOUT_MS;
|
||||||
|
const initialCandidates = candidates.slice().sort().join('|');
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, REGEN_POLL_INTERVAL_MS));
|
||||||
|
const next = await mediaApi.refreshAsset(assetId);
|
||||||
|
if (!next) continue;
|
||||||
|
|
||||||
|
const nextCandidates = ((next.thumbnailCandidates ?? []) as string[]).slice().sort().join('|');
|
||||||
|
if (nextCandidates && nextCandidates !== initialCandidates) {
|
||||||
|
asset = next;
|
||||||
|
selectedUrl = next.thumbnailUrl ?? ((next.thumbnailCandidates ?? [])[0] as string | undefined) ?? null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function regenerate() {
|
||||||
|
if (!asset || isRegenerating) return;
|
||||||
|
|
||||||
|
isRegenerating = true;
|
||||||
|
try {
|
||||||
|
const jobId = await mediaApi.regenerateThumbnails(asset.id);
|
||||||
|
if (!jobId) return;
|
||||||
|
|
||||||
|
await pollUntilRegenComplete(asset.id);
|
||||||
|
} finally {
|
||||||
|
isRegenerating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!asset || !selectedUrl || !hasSelection) return;
|
||||||
|
|
||||||
|
isSaving = true;
|
||||||
|
try {
|
||||||
|
const updated = await mediaApi.selectThumbnail(asset.id, selectedUrl);
|
||||||
|
if (!updated) return;
|
||||||
|
asset = updated;
|
||||||
|
onThumbnailSaved?.(updated);
|
||||||
|
open = false;
|
||||||
|
} finally {
|
||||||
|
isSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root
|
||||||
|
bind:open
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
if (!isOpen) resetState();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog.Content class="max-h-[85vh] w-[95%] max-w-3xl overflow-y-auto">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>{$t('media_manager.thumbnails.manage_title')}</Dialog.Title>
|
||||||
|
<Dialog.Description>{$t('media_manager.thumbnails.manage_description')}</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
{#if asset}
|
||||||
|
<div class="space-y-5 p-1">
|
||||||
|
<div class="ui:bg-muted/40 aspect-video w-full overflow-hidden rounded-md border">
|
||||||
|
{#if isVideoLoading}
|
||||||
|
<Skeleton class="h-full w-full" />
|
||||||
|
{:else if videoUrl}
|
||||||
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
|
<video controls preload="metadata" src={videoUrl} class="h-full w-full bg-black"></video>
|
||||||
|
{:else}
|
||||||
|
<div class="ui:text-muted-foreground flex h-full w-full items-center justify-center text-sm">
|
||||||
|
{$t('media_manager.thumbnails.preview_unavailable')}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-sm font-medium">{$t('media_manager.thumbnails.generated')}</p>
|
||||||
|
{#if allCandidatesMissing}
|
||||||
|
<p class="ui:text-muted-foreground text-xs">
|
||||||
|
{$t('media_manager.thumbnails.none_generated_warning')}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
|
{#each SLOT_KEYS as slotKey, index (slotKey)}
|
||||||
|
{@const url = candidates[index] ?? null}
|
||||||
|
{@const isSelected = url !== null && url === selectedUrl}
|
||||||
|
{#if isRegenerating}
|
||||||
|
<Skeleton class="aspect-video w-full rounded-md" />
|
||||||
|
{:else if url}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => selectCandidate(url)}
|
||||||
|
class={`ui:focus-visible:ring-ring relative aspect-video w-full overflow-hidden rounded-md border text-left transition focus-visible:ring-2 focus-visible:outline-none ${
|
||||||
|
isSelected ? 'ring-primary border-primary ring-2' : 'hover:border-primary/50'
|
||||||
|
}`}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={$t(`media_manager.thumbnails.slot_${slotForIndex(index)}`)}
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="ui:bg-background/85 absolute bottom-1 left-1 rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase"
|
||||||
|
>
|
||||||
|
{$t(`media_manager.thumbnails.slot_${slotForIndex(index)}`)}
|
||||||
|
</span>
|
||||||
|
{#if isSelected}
|
||||||
|
<span
|
||||||
|
class="ui:bg-primary ui:text-primary-foreground absolute top-1 right-1 flex h-5 w-5 items-center justify-center rounded-full"
|
||||||
|
>
|
||||||
|
<CheckIcon class="size-3" />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="ui:bg-muted/30 ui:text-muted-foreground flex aspect-video w-full items-center justify-center rounded-md border border-dashed text-xs"
|
||||||
|
>
|
||||||
|
{$t(`media_manager.thumbnails.slot_${slotForIndex(index)}`)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-sm font-medium">{$t('media_manager.thumbnails.upload_custom')}</p>
|
||||||
|
<div class={isUploading ? 'ui:pointer-events-none ui:opacity-50' : ''}>
|
||||||
|
<FileDropZone.Root
|
||||||
|
accept={FileDropZone.ACCEPT_IMAGE}
|
||||||
|
maxFiles={1}
|
||||||
|
fileCount={0}
|
||||||
|
maxFileSize={THUMBNAIL_MAX_SIZE}
|
||||||
|
onUpload={handleCustomUpload}
|
||||||
|
onFileRejected={handleUploadRejected}
|
||||||
|
>
|
||||||
|
<FileDropZone.Trigger
|
||||||
|
label={$t('media_manager.form.thumbnail_drop_label')}
|
||||||
|
formatMaxSize={() => $t('media_manager.form.thumbnail_drop_size')}
|
||||||
|
/>
|
||||||
|
</FileDropZone.Root>
|
||||||
|
</div>
|
||||||
|
{#if customUrl}
|
||||||
|
<div class="flex items-center gap-3 rounded-md border p-2">
|
||||||
|
<img
|
||||||
|
src={customUrl}
|
||||||
|
alt={$t('media_manager.thumbnails.custom_preview_alt')}
|
||||||
|
class="h-16 w-24 rounded object-cover"
|
||||||
|
/>
|
||||||
|
<p class="ui:text-muted-foreground text-xs">
|
||||||
|
{$t('media_manager.thumbnails.custom_selected_hint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Dialog.Footer class="ui:gap-2 flex-col sm:flex-row sm:justify-between">
|
||||||
|
<Button variant="outline" onclick={regenerate} loading={isRegenerating} disabled={isSaving}>
|
||||||
|
<RefreshCcwIcon class="size-4" />
|
||||||
|
{$t('media_manager.thumbnails.regenerate')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button variant="outline" onclick={() => (open = false)} disabled={isSaving || isRegenerating}>
|
||||||
|
{$t('media_manager.edit.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onclick={save} loading={isSaving} disabled={!hasSelection || isRegenerating || isUploading}>
|
||||||
|
{$t('media_manager.thumbnails.save_selection')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
import Link2Icon from '@lucide/svelte/icons/link-2';
|
import Link2Icon from '@lucide/svelte/icons/link-2';
|
||||||
|
|
||||||
import { ActivityCard } from '$features/ui';
|
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 type { AssetStorageSummary } from '$features/media/utils';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
|
||||||
@@ -14,19 +14,23 @@
|
|||||||
|
|
||||||
let { storageSummary }: Props = $props();
|
let { storageSummary }: Props = $props();
|
||||||
|
|
||||||
|
function bytesDescription(bytes: number | null | undefined, fallbackKey: string): string {
|
||||||
|
return formatBytesSecondary(bytes) ?? t.get(fallbackKey);
|
||||||
|
}
|
||||||
|
|
||||||
const storageCards = $derived([
|
const storageCards = $derived([
|
||||||
{
|
{
|
||||||
icon: FileIcon,
|
icon: FileIcon,
|
||||||
title: t.get('media_manager.storage.total_bytes'),
|
title: t.get('media_manager.storage.total_bytes'),
|
||||||
percentage: formatBytes(storageSummary?.totalBytes ?? 0),
|
percentage: formatBytes(storageSummary?.totalBytes ?? 0),
|
||||||
description: t.get('media_manager.storage.total_bytes'),
|
description: bytesDescription(storageSummary?.totalBytes, 'media_manager.storage.total_bytes'),
|
||||||
hidePercentage: true
|
hidePercentage: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: FileVideoIcon,
|
icon: FileVideoIcon,
|
||||||
title: t.get('media_manager.storage.internal_bytes'),
|
title: t.get('media_manager.storage.internal_bytes'),
|
||||||
percentage: formatBytes(storageSummary?.internalBytes ?? 0),
|
percentage: formatBytes(storageSummary?.internalBytes ?? 0),
|
||||||
description: t.get('media_manager.storage.internal_bytes'),
|
description: bytesDescription(storageSummary?.internalBytes, 'media_manager.storage.internal_bytes'),
|
||||||
hidePercentage: true
|
hidePercentage: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,7 +7,14 @@
|
|||||||
|
|
||||||
import type { TAssetUpdate } from '@cio/utils/validation/assets';
|
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 { mediaApi } from '$features/media/api';
|
||||||
import type { AssetKindFilter, AssetStatusFilter, AssetUsageGraph, OrganizationAsset } from '$features/media/utils';
|
import type { AssetKindFilter, AssetStatusFilter, AssetUsageGraph, OrganizationAsset } from '$features/media/utils';
|
||||||
import { snackbar } from '$features/ui/snackbar/store';
|
import { snackbar } from '$features/ui/snackbar/store';
|
||||||
@@ -28,6 +35,7 @@
|
|||||||
let isRefreshing = $state(false);
|
let isRefreshing = $state(false);
|
||||||
let editOpen = $state(false);
|
let editOpen = $state(false);
|
||||||
let usageOpen = $state(false);
|
let usageOpen = $state(false);
|
||||||
|
let manageThumbsOpen = $state(false);
|
||||||
let isSavingAsset = $state(false);
|
let isSavingAsset = $state(false);
|
||||||
let isUsageLoading = $state(false);
|
let isUsageLoading = $state(false);
|
||||||
let downloadingAssetId = $state<string | null>(null);
|
let downloadingAssetId = $state<string | null>(null);
|
||||||
@@ -66,6 +74,11 @@
|
|||||||
editOpen = true;
|
editOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openManageThumbnails(asset: OrganizationAsset) {
|
||||||
|
selectedAsset = asset;
|
||||||
|
manageThumbsOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
async function saveAsset(fields: TAssetUpdate) {
|
async function saveAsset(fields: TAssetUpdate) {
|
||||||
if (!selectedAsset) return;
|
if (!selectedAsset) return;
|
||||||
|
|
||||||
@@ -148,7 +161,14 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<Item.Group class="grid! w-full grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
<Item.Group class="grid! w-full grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{#each assets as asset (asset.id)}
|
{#each assets as asset (asset.id)}
|
||||||
<AssetCard {asset} {downloadingAssetId} onEdit={openEditAsset} onUsage={openUsage} onDownload={downloadAsset} />
|
<AssetCard
|
||||||
|
{asset}
|
||||||
|
{downloadingAssetId}
|
||||||
|
onEdit={openEditAsset}
|
||||||
|
onUsage={openUsage}
|
||||||
|
onDownload={downloadAsset}
|
||||||
|
onManageThumbnails={openManageThumbnails}
|
||||||
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</Item.Group>
|
</Item.Group>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -181,6 +201,8 @@
|
|||||||
|
|
||||||
<EditAssetDialog bind:open={editOpen} asset={selectedAsset} isSaving={isSavingAsset} onSave={saveAsset} />
|
<EditAssetDialog bind:open={editOpen} asset={selectedAsset} isSaving={isSavingAsset} onSave={saveAsset} />
|
||||||
|
|
||||||
|
<ManageThumbnailsDialog bind:open={manageThumbsOpen} asset={selectedAsset} />
|
||||||
|
|
||||||
<AssetUsageDialog
|
<AssetUsageDialog
|
||||||
bind:open={usageOpen}
|
bind:open={usageOpen}
|
||||||
{selectedAsset}
|
{selectedAsset}
|
||||||
|
|||||||
@@ -19,6 +19,42 @@ export function formatBytes(bytes: number | null | undefined): string {
|
|||||||
return `${size.toFixed(size >= 100 || exponent === 0 ? 0 : 2)} ${units[exponent]}`;
|
return `${size.toFixed(size >= 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 {
|
export function mapAssetProviderToLessonVideoType(provider: string): LessonVideoType {
|
||||||
if (provider === 'youtube') return 'youtube';
|
if (provider === 'youtube') return 'youtube';
|
||||||
if (provider === 'google_drive') return 'google_drive';
|
if (provider === 'google_drive') return 'google_drive';
|
||||||
|
|||||||
@@ -57,5 +57,14 @@ export type GetAssetTranscriptResponse = InferResponseType<GetAssetTranscriptReq
|
|||||||
export type GetAssetTranscriptSuccess = Extract<GetAssetTranscriptResponse, { success: true }>;
|
export type GetAssetTranscriptSuccess = Extract<GetAssetTranscriptResponse, { success: true }>;
|
||||||
export type AssetTranscriptPayload = GetAssetTranscriptSuccess['data'];
|
export type AssetTranscriptPayload = GetAssetTranscriptSuccess['data'];
|
||||||
|
|
||||||
|
export type SelectAssetThumbnailRequest = (typeof classroomio.organization)['assets'][':assetId']['thumbnail']['$put'];
|
||||||
|
export type SelectAssetThumbnailResponse = InferResponseType<SelectAssetThumbnailRequest>;
|
||||||
|
export type SelectAssetThumbnailSuccess = Extract<SelectAssetThumbnailResponse, { success: true }>;
|
||||||
|
|
||||||
|
export type RegenerateAssetThumbnailRequest =
|
||||||
|
(typeof classroomio.jobs.media.asset)[':assetId']['regenerate-thumbnail']['$post'];
|
||||||
|
export type RegenerateAssetThumbnailResponse = InferResponseType<RegenerateAssetThumbnailRequest>;
|
||||||
|
export type RegenerateAssetThumbnailSuccess = Extract<RegenerateAssetThumbnailResponse, { success: true }>;
|
||||||
|
|
||||||
export type AssetKindFilter = 'all' | 'video' | 'document' | 'image' | 'audio' | 'other';
|
export type AssetKindFilter = 'all' | 'video' | 'document' | 'image' | 'audio' | 'other';
|
||||||
export type AssetStatusFilter = 'all' | 'active' | 'archived';
|
export type AssetStatusFilter = 'all' | 'active' | 'archived';
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import {
|
|||||||
CorporateLandingHero,
|
CorporateLandingHero,
|
||||||
CorporateLandingNav,
|
CorporateLandingNav,
|
||||||
CorporateLandingPage,
|
CorporateLandingPage,
|
||||||
|
EditorialLandingHero,
|
||||||
|
EditorialLandingNav,
|
||||||
|
EditorialLandingPage,
|
||||||
MinimalLandingHero,
|
MinimalLandingHero,
|
||||||
MinimalLandingNav,
|
MinimalLandingNav,
|
||||||
MinimalLandingPage,
|
MinimalLandingPage,
|
||||||
@@ -22,7 +25,10 @@ import {
|
|||||||
TechLandingPage,
|
TechLandingPage,
|
||||||
TerminalLandingHero,
|
TerminalLandingHero,
|
||||||
TerminalLandingNav,
|
TerminalLandingNav,
|
||||||
TerminalLandingPage
|
TerminalLandingPage,
|
||||||
|
VibrantLandingHero,
|
||||||
|
VibrantLandingNav,
|
||||||
|
VibrantLandingPage
|
||||||
} from '@cio/ui/custom/org-landing-page';
|
} from '@cio/ui/custom/org-landing-page';
|
||||||
|
|
||||||
export const landingPageThemeComponents = {
|
export const landingPageThemeComponents = {
|
||||||
@@ -33,7 +39,9 @@ export const landingPageThemeComponents = {
|
|||||||
tech: TechLandingPage,
|
tech: TechLandingPage,
|
||||||
studio: StudioLandingPage,
|
studio: StudioLandingPage,
|
||||||
corporate: CorporateLandingPage,
|
corporate: CorporateLandingPage,
|
||||||
terminal: TerminalLandingPage
|
terminal: TerminalLandingPage,
|
||||||
|
editorial: EditorialLandingPage,
|
||||||
|
vibrant: VibrantLandingPage
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const landingPageNavComponents = {
|
export const landingPageNavComponents = {
|
||||||
@@ -44,7 +52,9 @@ export const landingPageNavComponents = {
|
|||||||
tech: TechLandingNav,
|
tech: TechLandingNav,
|
||||||
studio: StudioLandingNav,
|
studio: StudioLandingNav,
|
||||||
corporate: CorporateLandingNav,
|
corporate: CorporateLandingNav,
|
||||||
terminal: TerminalLandingNav
|
terminal: TerminalLandingNav,
|
||||||
|
editorial: EditorialLandingNav,
|
||||||
|
vibrant: VibrantLandingNav
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const landingPageHeroComponents = {
|
export const landingPageHeroComponents = {
|
||||||
@@ -55,5 +65,7 @@ export const landingPageHeroComponents = {
|
|||||||
tech: TechLandingHero,
|
tech: TechLandingHero,
|
||||||
studio: StudioLandingHero,
|
studio: StudioLandingHero,
|
||||||
corporate: CorporateLandingHero,
|
corporate: CorporateLandingHero,
|
||||||
terminal: TerminalLandingHero
|
terminal: TerminalLandingHero,
|
||||||
|
editorial: EditorialLandingHero,
|
||||||
|
vibrant: VibrantLandingHero
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ export const landingPageThemes = [
|
|||||||
'tech',
|
'tech',
|
||||||
'studio',
|
'studio',
|
||||||
'corporate',
|
'corporate',
|
||||||
'terminal'
|
'terminal',
|
||||||
|
'editorial',
|
||||||
|
'vibrant'
|
||||||
] as const satisfies OrgLandingPageTheme[];
|
] as const satisfies OrgLandingPageTheme[];
|
||||||
|
|
||||||
export const defaultLandingPageHero: OrgLandingPageHero = {
|
export const defaultLandingPageHero: OrgLandingPageHero = {
|
||||||
@@ -724,7 +726,154 @@ export function importThemeComponent(theme: LandingPageThemeKey) {
|
|||||||
return import('@cio/ui/custom/org-landing-page/corporate.svelte');
|
return import('@cio/ui/custom/org-landing-page/corporate.svelte');
|
||||||
case 'terminal':
|
case 'terminal':
|
||||||
return import('@cio/ui/custom/org-landing-page/terminal.svelte');
|
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:
|
default:
|
||||||
return import('@cio/ui/custom/org-landing-page/minimal.svelte');
|
return import('@cio/ui/custom/org-landing-page/minimal.svelte');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NAV_SNIPPET_THEMES: ReadonlySet<LandingPageThemeKey> = 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import XIcon from '@lucide/svelte/icons/x';
|
||||||
|
import * as Dialog from '@cio/ui/base/dialog';
|
||||||
|
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||||
|
import { mockOrgLandingPageProps } from '@cio/ui/custom/org-landing-page';
|
||||||
|
import { landingPageThemeComponents } from '$features/org/utils/landing-page-components';
|
||||||
|
import { landingPageThemes } from '$features/org/utils/landing-page';
|
||||||
|
|
||||||
|
type LandingPageTheme = (typeof landingPageThemes)[number];
|
||||||
|
|
||||||
|
const validThemes = new Set<LandingPageTheme>(landingPageThemes);
|
||||||
|
|
||||||
|
const previewTheme = $derived.by<LandingPageTheme | null>(() => {
|
||||||
|
const value = page.url.searchParams.get('preview');
|
||||||
|
return value && validThemes.has(value as LandingPageTheme) ? (value as LandingPageTheme) : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const open = $derived(previewTheme !== null);
|
||||||
|
|
||||||
|
const ThemeComponent = $derived(
|
||||||
|
previewTheme ? (landingPageThemeComponents[previewTheme] ?? landingPageThemeComponents.minimal) : null
|
||||||
|
);
|
||||||
|
|
||||||
|
const previewProps = $derived({
|
||||||
|
...mockOrgLandingPageProps,
|
||||||
|
embed: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
function closePreview() {
|
||||||
|
const nextUrl = new URL(page.url);
|
||||||
|
nextUrl.searchParams.delete('preview');
|
||||||
|
goto(`${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`, {
|
||||||
|
replaceState: false,
|
||||||
|
keepFocus: true,
|
||||||
|
noScroll: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(nextOpen: boolean) {
|
||||||
|
if (!nextOpen && open) {
|
||||||
|
closePreview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root {open} onOpenChange={handleOpenChange}>
|
||||||
|
<Dialog.Content
|
||||||
|
showCloseButton={false}
|
||||||
|
class="ui:w-[calc(100vw-2rem)] ui:h-[calc(100vh-2rem)] ui:max-w-[calc(100vw-2rem)] ui:max-h-[calc(100vh-2rem)] ui:overflow-hidden ui:p-0 ui:gap-0 ui:sm:max-w-[calc(100vw-2rem)]"
|
||||||
|
>
|
||||||
|
<Dialog.Title class="ui:sr-only">Theme preview</Dialog.Title>
|
||||||
|
<Dialog.Description class="ui:sr-only"
|
||||||
|
>Preview the selected landing page theme with sample content.</Dialog.Description
|
||||||
|
>
|
||||||
|
|
||||||
|
<div class="absolute top-3 right-3 z-[9999]">
|
||||||
|
<IconButton variant="secondary" aria-label="Close preview" onclick={closePreview}>
|
||||||
|
<XIcon size={16} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-full w-full overflow-y-auto">
|
||||||
|
{#if ThemeComponent}
|
||||||
|
<ThemeComponent {...previewProps} disableCourseLinks={true} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
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 LayoutTemplateIcon from '@lucide/svelte/icons/layout-template';
|
||||||
|
import PaintbrushIcon from '@lucide/svelte/icons/paintbrush';
|
||||||
import ZapIcon from '@lucide/svelte/icons/zap';
|
import ZapIcon from '@lucide/svelte/icons/zap';
|
||||||
|
|
||||||
import { currentOrg, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
|
import { currentOrg, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
|
||||||
@@ -10,12 +13,19 @@
|
|||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { user } from '$lib/utils/store/user';
|
import { user } from '$lib/utils/store/user';
|
||||||
import { basePath } from '$lib/utils/store/app';
|
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 { Button } from '@cio/ui/base/button';
|
||||||
import { Badge } from '@cio/ui/base/badge';
|
import { Badge } from '@cio/ui/base/badge';
|
||||||
import * as Card from '@cio/ui/base/card';
|
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 { PremiumIcon } from '@cio/ui/custom/moving-icons';
|
||||||
|
|
||||||
|
import ThemePreviewDialog from '$features/settings/components/theme-preview-dialog.svelte';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createDefaultLandingPageSettings,
|
createDefaultLandingPageSettings,
|
||||||
landingPageThemes,
|
landingPageThemes,
|
||||||
@@ -86,6 +96,18 @@
|
|||||||
preview: '/templates/terminal.png',
|
preview: '/templates/terminal.png',
|
||||||
titleKey: 'settings.landing_page.theme.cards.terminal.title',
|
titleKey: 'settings.landing_page.theme.cards.terminal.title',
|
||||||
descriptionKey: 'settings.landing_page.theme.cards.terminal.description'
|
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<{
|
] as const satisfies ReadonlyArray<{
|
||||||
value: LandingPageTheme;
|
value: LandingPageTheme;
|
||||||
@@ -125,6 +147,37 @@
|
|||||||
goto(resolve(`${$currentOrgPath}/settings/landingpage/edit?theme=${theme}`, {}));
|
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 navCount = $derived(normalized.navItems?.length ?? 0);
|
||||||
const heroHeading = $derived(normalized.hero?.heading?.trim() || t.get('settings.landing_page.current.hero_empty'));
|
const heroHeading = $derived(normalized.hero?.heading?.trim() || t.get('settings.landing_page.current.hero_empty'));
|
||||||
const navCountLabel = $derived(
|
const navCountLabel = $derived(
|
||||||
@@ -227,12 +280,31 @@
|
|||||||
<div class="ui:border-border bg-background relative overflow-hidden rounded-xl border">
|
<div class="ui:border-border bg-background relative overflow-hidden rounded-xl border">
|
||||||
{#if isLocked}
|
{#if isLocked}
|
||||||
<div
|
<div
|
||||||
class="absolute top-2 right-2 z-10 flex items-center gap-1 rounded-full bg-blue-600 px-2 py-0.5 text-xs font-medium text-white shadow-sm"
|
class="absolute top-2 left-2 z-10 flex items-center gap-1 rounded-full bg-blue-600 px-2 py-0.5 text-xs font-medium text-white shadow-sm"
|
||||||
>
|
>
|
||||||
<PremiumIcon size={14} color="white" />
|
<PremiumIcon size={14} color="white" />
|
||||||
{$t('settings.landing_page.gallery.upgrade')}
|
{$t('settings.landing_page.gallery.upgrade')}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger class="absolute top-2 right-2 z-10">
|
||||||
|
<IconButton variant="secondary" aria-label={$t('settings.landing_page.gallery.card_menu_label')}>
|
||||||
|
<EllipsisVerticalIcon size={16} />
|
||||||
|
</IconButton>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content align="end">
|
||||||
|
<DropdownMenu.Item onclick={() => handlePreviewTheme(themeCard.value)}>
|
||||||
|
<EyeIcon size={16} class="mr-2" />
|
||||||
|
{$t('settings.landing_page.gallery.preview')}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{#if !isLocked}
|
||||||
|
<DropdownMenu.Item onclick={() => handleApplyTheme(themeCard.value)}>
|
||||||
|
<PaintbrushIcon size={16} class="mr-2" />
|
||||||
|
{$t('settings.landing_page.gallery.apply_theme')}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{/if}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
<div class="bg-background aspect-[4/3] w-full overflow-hidden">
|
<div class="bg-background aspect-[4/3] w-full overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src={themeCard.preview}
|
src={themeCard.preview}
|
||||||
@@ -257,3 +329,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ThemePreviewDialog />
|
||||||
|
|||||||
@@ -18,7 +18,13 @@
|
|||||||
import { MagicCard } from '@cio/ui/custom/animation/magic-card';
|
import { MagicCard } from '@cio/ui/custom/animation/magic-card';
|
||||||
|
|
||||||
import { getLectureNo } from '$features/course/utils/functions';
|
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 { currentOrg } from '$lib/utils/store/org';
|
||||||
import { basePath } from '$lib/utils/store/app';
|
import { basePath } from '$lib/utils/store/app';
|
||||||
import { user } from '$lib/utils/store/user';
|
import { user } from '$lib/utils/store/user';
|
||||||
@@ -57,11 +63,9 @@
|
|||||||
|
|
||||||
const landingSettings = $derived(normalizeLandingPageSettings($currentOrg.landingpage));
|
const landingSettings = $derived(normalizeLandingPageSettings($currentOrg.landingpage));
|
||||||
|
|
||||||
const heroShellClass = $derived(
|
const heroShellClass = $derived(`ui:w-full ${themeHeaderShellClass(landingSettings.theme)}`);
|
||||||
landingSettings.theme === 'classic'
|
const heroShellStyle = $derived(themeStyle(landingSettings.theme));
|
||||||
? 'ui:w-full ui:bg-muted/10 ui:text-foreground ui:font-sans'
|
const navInsideHero = $derived(themeRendersNavInsideHero(landingSettings.theme));
|
||||||
: 'ui:w-full ui:bg-background ui:text-foreground ui:font-sans'
|
|
||||||
);
|
|
||||||
|
|
||||||
const orgNameForNav = $derived($currentOrg.name || get(courseData, 'org.name', '') || 'ClassroomIO');
|
const orgNameForNav = $derived($currentOrg.name || get(courseData, 'org.name', '') || 'ClassroomIO');
|
||||||
const logoUrlForNav = $derived($currentOrg.avatarUrl || undefined);
|
const logoUrlForNav = $derived($currentOrg.avatarUrl || undefined);
|
||||||
@@ -115,21 +119,10 @@
|
|||||||
const theme = landingSettings.theme;
|
const theme = landingSettings.theme;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
void Promise.all([
|
void importThemeNavHero(theme).then(({ NavComponent: nav, HeroComponent: hero }) => {
|
||||||
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]) => {
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
NavComponent = navMod.default;
|
NavComponent = nav;
|
||||||
HeroComponent = heroMod.default;
|
HeroComponent = hero;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -202,10 +195,10 @@
|
|||||||
|
|
||||||
<div class="flex w-full flex-col items-center bg-white dark:bg-black">
|
<div class="flex w-full flex-col items-center bg-white dark:bg-black">
|
||||||
{#if showMarketingHero}
|
{#if showMarketingHero}
|
||||||
<div class={heroShellClass}>
|
<div class={heroShellClass} style={heroShellStyle}>
|
||||||
{#if NavComponent && HeroComponent}
|
{#if NavComponent && HeroComponent}
|
||||||
{#if landingSettings.theme === 'minimal'}
|
{#if navInsideHero}
|
||||||
<HeroComponent hero={courseHero}>
|
<HeroComponent hero={courseHero} orgName={orgNameForNav}>
|
||||||
{#snippet navigation()}
|
{#snippet navigation()}
|
||||||
<NavComponent
|
<NavComponent
|
||||||
orgName={orgNameForNav}
|
orgName={orgNameForNav}
|
||||||
@@ -222,7 +215,7 @@
|
|||||||
navItems={landingSettings.navItems}
|
navItems={landingSettings.navItems}
|
||||||
{authAction}
|
{authAction}
|
||||||
/>
|
/>
|
||||||
<HeroComponent hero={courseHero} />
|
<HeroComponent hero={courseHero} orgName={orgNameForNav} />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import { currentOrg } from '$lib/utils/store/org';
|
import { currentOrg } from '$lib/utils/store/org';
|
||||||
import { setupProgressApi } from '$features/setup/api/setup-progress.svelte';
|
import { setupProgressApi } from '$features/setup/api/setup-progress.svelte';
|
||||||
import AppSetup from './app-setup.svelte';
|
import AppSetup from './app-setup.svelte';
|
||||||
|
import VisitOrgSiteBtn from '$features/ui/visit-org-site-btn.svelte';
|
||||||
|
|
||||||
const siteName = $derived($currentOrg.siteName);
|
const siteName = $derived($currentOrg.siteName);
|
||||||
|
|
||||||
@@ -36,6 +37,8 @@
|
|||||||
<span class="grow"></span>
|
<span class="grow"></span>
|
||||||
|
|
||||||
<AppSetup />
|
<AppSetup />
|
||||||
|
<VisitOrgSiteBtn variant="outline" labelKey="dashboard.open_academy" />
|
||||||
|
|
||||||
<Search />
|
<Search />
|
||||||
|
|
||||||
<Popover.Root>
|
<Popover.Root>
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import { QUESTION_TYPE_KEY, QUESTION_TYPE_REGISTRY, type QuestionTypeKey } from '@cio/question-types';
|
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 { PREMIUM_QUESTION_TYPE_KEYS } from '@cio/question-types';
|
||||||
export const PREMIUM_QUESTION_TYPE_KEYS = new Set<QuestionTypeKey>([
|
|
||||||
QUESTION_TYPE_KEY.FILE_UPLOAD,
|
|
||||||
QUESTION_TYPE_KEY.ORDERING,
|
|
||||||
QUESTION_TYPE_KEY.LINK,
|
|
||||||
QUESTION_TYPE_KEY.STAR,
|
|
||||||
QUESTION_TYPE_KEY.VIDEO_RECORDING
|
|
||||||
]);
|
|
||||||
|
|
||||||
const QUESTION_TYPE_LABEL_KEYS: Partial<Record<QuestionTypeKey, string>> = {
|
const QUESTION_TYPE_LABEL_KEYS: Partial<Record<QuestionTypeKey, string>> = {
|
||||||
[QUESTION_TYPE_KEY.RADIO]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.single',
|
[QUESTION_TYPE_KEY.RADIO]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.single',
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { Button, type ButtonVariant } from '@cio/ui/base/button';
|
import { Button, type ButtonVariant } from '@cio/ui/base/button';
|
||||||
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
|
import SquareArrowOutUpRight from '@lucide/svelte/icons/square-arrow-out-up-right';
|
||||||
|
|
||||||
import { TENANT_ROOT_DOMAIN } from '@cio/utils/constants';
|
import { TENANT_ROOT_DOMAIN } from '@cio/utils/constants';
|
||||||
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
|
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
|
||||||
import { isMobile } from '$lib/utils/store/useMobile';
|
import { isMobile } from '$lib/utils/store/useMobile';
|
||||||
@@ -45,13 +46,15 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button {href} target="_blank" {variant} class={`ml-2 ${className}`.trim()}>
|
<Button {href} target="_blank" {variant} class={`&_span:hidden! md:&_span:block ml-2 ${className}`.trim()}>
|
||||||
{#if !$isMobile}
|
{#if !$isMobile}
|
||||||
|
<span>
|
||||||
{#if isLMS}
|
{#if isLMS}
|
||||||
{$t('dashboard.visit_site')}
|
{$t('dashboard.visit_site')}
|
||||||
{:else}
|
{:else}
|
||||||
{$t(labelKey)}
|
{$t(labelKey)}
|
||||||
{/if}
|
{/if}
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<ArrowUpRightIcon class="custom" />
|
<SquareArrowOutUpRight class="custom" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -9,4 +9,3 @@ export const authServerClient = createAuthClient({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
console.log('baseURL', baseURL);
|
console.log('baseURL', baseURL);
|
||||||
console.log('authServerClient', authServerClient);
|
|
||||||
|
|||||||
@@ -346,7 +346,11 @@
|
|||||||
"transcription_started": "Transskription startede",
|
"transcription_started": "Transskription startede",
|
||||||
"transcription_start_failed": "Kunne ikke starte transskription",
|
"transcription_start_failed": "Kunne ikke starte transskription",
|
||||||
"transcription_completed": "Transskription afsluttet",
|
"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": {
|
"tags": {
|
||||||
"group_created": "Taggruppe oprettet",
|
"group_created": "Taggruppe oprettet",
|
||||||
@@ -2059,7 +2063,8 @@
|
|||||||
"overview_metrics": {
|
"overview_metrics": {
|
||||||
"heading": "Kursus øjebliksbillede",
|
"heading": "Kursus øjebliksbillede",
|
||||||
"description": "Eleven tæller efter status for dette kursus."
|
"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": {
|
"search": {
|
||||||
@@ -2164,9 +2169,7 @@
|
|||||||
"depth": "Hvor dybt skal kurset gå?",
|
"depth": "Hvor dybt skal kurset gå?",
|
||||||
"topic": "Emne eller niche",
|
"topic": "Emne eller niche",
|
||||||
"expertise_level": "Mål ekspertiseniveau",
|
"expertise_level": "Mål ekspertiseniveau",
|
||||||
"documentation_url": {
|
"documentation_url": "Dokumentations-URL (valgfrit)"
|
||||||
"hint": "Vi læser dette websted for at basere kurset på dit rigtige produkt."
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"expert_on_x": {
|
"expert_on_x": {
|
||||||
"title": "Bliv ekspert på X",
|
"title": "Bliv ekspert på X",
|
||||||
@@ -2340,7 +2343,8 @@
|
|||||||
"login_activity_title": "Student login aktivitet",
|
"login_activity_title": "Student login aktivitet",
|
||||||
"login_activity_description": "Ugens mest aktive dage (sidste 90 dage)",
|
"login_activity_description": "Ugens mest aktive dage (sidste 90 dage)",
|
||||||
"login_activity_no_data": "Ingen login-aktivitet endnu",
|
"login_activity_no_data": "Ingen login-aktivitet endnu",
|
||||||
"login_activity_logins": "logins"
|
"login_activity_logins": "logins",
|
||||||
|
"open_academy": "Åbent Akademi"
|
||||||
},
|
},
|
||||||
"schedule": {
|
"schedule": {
|
||||||
"title": "Dit skema",
|
"title": "Dit skema",
|
||||||
@@ -2621,7 +2625,8 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"edit": "Rediger",
|
"edit": "Rediger",
|
||||||
"where_used": "Hvor brugt",
|
"where_used": "Hvor brugt",
|
||||||
"download": "Download"
|
"download": "Download",
|
||||||
|
"manage_thumbnails": "Administrer thumbnails"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"title": "Titel",
|
"title": "Titel",
|
||||||
@@ -2678,7 +2683,22 @@
|
|||||||
"not_available": "N/A"
|
"not_available": "N/A"
|
||||||
},
|
},
|
||||||
"empty_description": "Aktivet findes ikke eller er ikke tilgængeligt i denne organisation.",
|
"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": {
|
"settings": {
|
||||||
"heading": "Indstillinger",
|
"heading": "Indstillinger",
|
||||||
@@ -3003,6 +3023,14 @@
|
|||||||
"saas": {
|
"saas": {
|
||||||
"title": "SaaS",
|
"title": "SaaS",
|
||||||
"description": "Stiplet ramme, bløde accenter, moderne produktfølelse"
|
"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.",
|
"description": "Lavet med kernefunktioner, du nemt kan tilpasse - ingen kodning nødvendig.",
|
||||||
"by_classroomio": "af ClassroomIO",
|
"by_classroomio": "af ClassroomIO",
|
||||||
"add": "Tilføj",
|
"add": "Tilføj",
|
||||||
"upgrade": "Opgrader"
|
"upgrade": "Opgrader",
|
||||||
|
"apply_theme": "Anvend tema",
|
||||||
|
"preview": "Forhåndsvisning",
|
||||||
|
"card_menu_label": "Temamuligheder"
|
||||||
},
|
},
|
||||||
"current": {
|
"current": {
|
||||||
"title": "Aktuelt tema",
|
"title": "Aktuelt tema",
|
||||||
@@ -3952,6 +3983,54 @@
|
|||||||
"quiz_me": "Quiz mig om dette",
|
"quiz_me": "Quiz mig om dette",
|
||||||
"summarize_section": "Opsummer dette afsnit",
|
"summarize_section": "Opsummer dette afsnit",
|
||||||
"what_to_review": "Hvad skal jeg gennemgå næste gang?"
|
"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": {
|
"widgets": {
|
||||||
|
|||||||
@@ -346,7 +346,11 @@
|
|||||||
"transcription_started": "Die Transkription hat begonnen",
|
"transcription_started": "Die Transkription hat begonnen",
|
||||||
"transcription_start_failed": "Die Transkription konnte nicht gestartet werden",
|
"transcription_start_failed": "Die Transkription konnte nicht gestartet werden",
|
||||||
"transcription_completed": "Transkription abgeschlossen",
|
"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": {
|
"tags": {
|
||||||
"group_created": "Tag-Gruppe erstellt",
|
"group_created": "Tag-Gruppe erstellt",
|
||||||
@@ -2059,7 +2063,8 @@
|
|||||||
"overview_metrics": {
|
"overview_metrics": {
|
||||||
"heading": "Kursschnappschuss",
|
"heading": "Kursschnappschuss",
|
||||||
"description": "Die Teilnehmer werden für diesen Kurs nach Status gezählt."
|
"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": {
|
"search": {
|
||||||
@@ -2164,9 +2169,7 @@
|
|||||||
"depth": "Wie tief soll der Kurs gehen?",
|
"depth": "Wie tief soll der Kurs gehen?",
|
||||||
"topic": "Thema oder Nische",
|
"topic": "Thema oder Nische",
|
||||||
"expertise_level": "Zielkompetenzniveau",
|
"expertise_level": "Zielkompetenzniveau",
|
||||||
"documentation_url": {
|
"documentation_url": "Dokumentations-URL (optional)"
|
||||||
"hint": "Wir werden diese Website lesen, um den Kurs in Ihrem realen Produkt zu verankern."
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"expert_on_x": {
|
"expert_on_x": {
|
||||||
"title": "Werden Sie Experte für X",
|
"title": "Werden Sie Experte für X",
|
||||||
@@ -2340,7 +2343,8 @@
|
|||||||
"login_activity_title": "Anmeldeaktivität für Studenten",
|
"login_activity_title": "Anmeldeaktivität für Studenten",
|
||||||
"login_activity_description": "Aktivste Tage der Woche (letzte 90 Tage)",
|
"login_activity_description": "Aktivste Tage der Woche (letzte 90 Tage)",
|
||||||
"login_activity_no_data": "Noch keine Login-Aktivität",
|
"login_activity_no_data": "Noch keine Login-Aktivität",
|
||||||
"login_activity_logins": "Anmeldungen"
|
"login_activity_logins": "Anmeldungen",
|
||||||
|
"open_academy": "Offene Akademie"
|
||||||
},
|
},
|
||||||
"schedule": {
|
"schedule": {
|
||||||
"title": "Ihr Zeitplan",
|
"title": "Ihr Zeitplan",
|
||||||
@@ -2621,7 +2625,8 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"edit": "Bearbeiten",
|
"edit": "Bearbeiten",
|
||||||
"where_used": "Wo verwendet",
|
"where_used": "Wo verwendet",
|
||||||
"download": "Herunterladen"
|
"download": "Herunterladen",
|
||||||
|
"manage_thumbnails": "Miniaturansichten verwalten"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"title": "Titel",
|
"title": "Titel",
|
||||||
@@ -2678,7 +2683,22 @@
|
|||||||
"not_available": "N/A"
|
"not_available": "N/A"
|
||||||
},
|
},
|
||||||
"empty_description": "Das Asset existiert nicht oder ist in dieser Organisation nicht verfügbar.",
|
"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": {
|
"settings": {
|
||||||
"heading": "Einstellungen",
|
"heading": "Einstellungen",
|
||||||
@@ -3003,6 +3023,14 @@
|
|||||||
"saas": {
|
"saas": {
|
||||||
"title": "SaaS",
|
"title": "SaaS",
|
||||||
"description": "Rahmen mit gepunkteten Linien, sanfte Akzente, modernes Produktgefühl"
|
"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.",
|
"description": "Entwickelt mit Kernfunktionen, die Sie einfach anpassen können – keine Programmierung erforderlich.",
|
||||||
"by_classroomio": "von ClassroomIO",
|
"by_classroomio": "von ClassroomIO",
|
||||||
"add": "Hinzufügen",
|
"add": "Hinzufügen",
|
||||||
"upgrade": "Upgrade"
|
"upgrade": "Upgrade",
|
||||||
|
"apply_theme": "Theme anwenden",
|
||||||
|
"preview": "Vorschau",
|
||||||
|
"card_menu_label": "Themenoptionen"
|
||||||
},
|
},
|
||||||
"current": {
|
"current": {
|
||||||
"title": "Aktuelles Thema",
|
"title": "Aktuelles Thema",
|
||||||
@@ -3952,6 +3983,54 @@
|
|||||||
"quiz_me": "Befragen Sie mich dazu",
|
"quiz_me": "Befragen Sie mich dazu",
|
||||||
"summarize_section": "Fassen Sie diesen Abschnitt zusammen",
|
"summarize_section": "Fassen Sie diesen Abschnitt zusammen",
|
||||||
"what_to_review": "Was sollte ich als nächstes überprüfen?"
|
"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": {
|
"widgets": {
|
||||||
|
|||||||
@@ -633,6 +633,14 @@
|
|||||||
"terminal": {
|
"terminal": {
|
||||||
"title": "Terminal",
|
"title": "Terminal",
|
||||||
"description": "Dark surface, ghosted wordmark, CLI-inspired catalog"
|
"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.",
|
"description": "Made with core features you can easily customize — no coding needed.",
|
||||||
"by_classroomio": "by ClassroomIO",
|
"by_classroomio": "by ClassroomIO",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"upgrade": "Upgrade"
|
"upgrade": "Upgrade",
|
||||||
|
"apply_theme": "Apply Theme",
|
||||||
|
"preview": "Preview",
|
||||||
|
"card_menu_label": "Theme options"
|
||||||
},
|
},
|
||||||
"editor": {
|
"editor": {
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
@@ -4288,6 +4299,12 @@
|
|||||||
"plan_working": "Working",
|
"plan_working": "Working",
|
||||||
"plan_thinking": "Thinking through your request…",
|
"plan_thinking": "Thinking through your request…",
|
||||||
"plan_progress": "{completed} / {total} steps completed",
|
"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_implement": "Implement Plan",
|
||||||
"plan_ask_changes_placeholder": "Tell me what to change about the plan…",
|
"plan_ask_changes_placeholder": "Tell me what to change about the plan…",
|
||||||
"plan_ask_changes_send_aria": "Send change request",
|
"plan_ask_changes_send_aria": "Send change request",
|
||||||
@@ -4313,6 +4330,48 @@
|
|||||||
"tokens_label": "tokens",
|
"tokens_label": "tokens",
|
||||||
"implementing_course_plan": "Implementing Course Plan",
|
"implementing_course_plan": "Implementing Course Plan",
|
||||||
"resume": "Resume",
|
"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.",
|
"stopped_content_kept": "Stopped. Created content has been kept.",
|
||||||
"run_failed_after": "{action} did not finish successfully.",
|
"run_failed_after": "{action} did not finish successfully.",
|
||||||
"tool": {
|
"tool": {
|
||||||
|
|||||||
@@ -346,7 +346,11 @@
|
|||||||
"transcription_started": "Transcripción iniciada",
|
"transcription_started": "Transcripción iniciada",
|
||||||
"transcription_start_failed": "No se pudo iniciar la transcripción",
|
"transcription_start_failed": "No se pudo iniciar la transcripción",
|
||||||
"transcription_completed": "Transcripción finalizada",
|
"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": {
|
"tags": {
|
||||||
"group_created": "Grupo de etiquetas creado",
|
"group_created": "Grupo de etiquetas creado",
|
||||||
@@ -2059,7 +2063,8 @@
|
|||||||
"overview_metrics": {
|
"overview_metrics": {
|
||||||
"heading": "Resumen del curso",
|
"heading": "Resumen del curso",
|
||||||
"description": "El alumno cuenta por estado para este 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": {
|
"search": {
|
||||||
@@ -2164,9 +2169,7 @@
|
|||||||
"depth": "¿Qué profundidad debe tener el curso?",
|
"depth": "¿Qué profundidad debe tener el curso?",
|
||||||
"topic": "Tema o nicho",
|
"topic": "Tema o nicho",
|
||||||
"expertise_level": "Nivel de experiencia objetivo",
|
"expertise_level": "Nivel de experiencia objetivo",
|
||||||
"documentation_url": {
|
"documentation_url": "URL de documentación (opcional)"
|
||||||
"hint": "Leeremos este sitio para basar el curso en su producto real."
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"expert_on_x": {
|
"expert_on_x": {
|
||||||
"title": "Conviértete en un experto en 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_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_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_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": {
|
"schedule": {
|
||||||
"title": "Tu horario",
|
"title": "Tu horario",
|
||||||
@@ -2621,7 +2625,8 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
"where_used": "donde se usa",
|
"where_used": "donde se usa",
|
||||||
"download": "Descargar"
|
"download": "Descargar",
|
||||||
|
"manage_thumbnails": "Administrar miniaturas"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"title": "Título",
|
"title": "Título",
|
||||||
@@ -2678,7 +2683,22 @@
|
|||||||
"not_available": "N/A"
|
"not_available": "N/A"
|
||||||
},
|
},
|
||||||
"empty_description": "El activo no existe o no está disponible en esta organización.",
|
"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": {
|
"settings": {
|
||||||
"heading": "Configuración",
|
"heading": "Configuración",
|
||||||
@@ -3003,6 +3023,14 @@
|
|||||||
"saas": {
|
"saas": {
|
||||||
"title": "SaaS",
|
"title": "SaaS",
|
||||||
"description": "Marco de líneas punteadas, detalles suaves, sensación de producto moderno."
|
"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.",
|
"description": "Creado con funciones principales que puedes personalizar fácilmente, sin necesidad de codificación.",
|
||||||
"by_classroomio": "por ClassroomIO",
|
"by_classroomio": "por ClassroomIO",
|
||||||
"add": "Añadir",
|
"add": "Añadir",
|
||||||
"upgrade": "Actualizar"
|
"upgrade": "Actualizar",
|
||||||
|
"apply_theme": "Aplicar tema",
|
||||||
|
"preview": "Vista previa",
|
||||||
|
"card_menu_label": "Opciones de tema"
|
||||||
},
|
},
|
||||||
"current": {
|
"current": {
|
||||||
"title": "Tema actual",
|
"title": "Tema actual",
|
||||||
@@ -3952,6 +3983,54 @@
|
|||||||
"quiz_me": "Pregúntame sobre esto",
|
"quiz_me": "Pregúntame sobre esto",
|
||||||
"summarize_section": "Resumir esta sección",
|
"summarize_section": "Resumir esta sección",
|
||||||
"what_to_review": "¿Qué debo revisar a continuació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": {
|
"widgets": {
|
||||||
|
|||||||
@@ -346,7 +346,11 @@
|
|||||||
"transcription_started": "Transcription commencée",
|
"transcription_started": "Transcription commencée",
|
||||||
"transcription_start_failed": "Impossible de démarrer la transcription",
|
"transcription_start_failed": "Impossible de démarrer la transcription",
|
||||||
"transcription_completed": "Transcription terminée",
|
"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": {
|
"tags": {
|
||||||
"group_created": "Groupe de balises créé",
|
"group_created": "Groupe de balises créé",
|
||||||
@@ -2059,7 +2063,8 @@
|
|||||||
"overview_metrics": {
|
"overview_metrics": {
|
||||||
"heading": "Aperçu du cours",
|
"heading": "Aperçu du cours",
|
||||||
"description": "L'apprenant compte par statut pour ce 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": {
|
"search": {
|
||||||
@@ -2164,9 +2169,7 @@
|
|||||||
"depth": "Jusqu’où doit aller le cours ?",
|
"depth": "Jusqu’où doit aller le cours ?",
|
||||||
"topic": "Sujet ou niche",
|
"topic": "Sujet ou niche",
|
||||||
"expertise_level": "Niveau d’expertise cible",
|
"expertise_level": "Niveau d’expertise cible",
|
||||||
"documentation_url": {
|
"documentation_url": "URL de la documentation (facultatif)"
|
||||||
"hint": "Nous lirons ce site pour ancrer le cours dans votre produit réel."
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"expert_on_x": {
|
"expert_on_x": {
|
||||||
"title": "Devenez un expert sur X",
|
"title": "Devenez un expert sur X",
|
||||||
@@ -2340,7 +2343,8 @@
|
|||||||
"login_activity_title": "Activité de connexion des étudiants",
|
"login_activity_title": "Activité de connexion des étudiants",
|
||||||
"login_activity_description": "Jours les plus actifs de la semaine (90 derniers jours)",
|
"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_no_data": "Aucune activité de connexion pour le moment",
|
||||||
"login_activity_logins": "connexions"
|
"login_activity_logins": "connexions",
|
||||||
|
"open_academy": "Académie ouverte"
|
||||||
},
|
},
|
||||||
"schedule": {
|
"schedule": {
|
||||||
"title": "Votre emploi du temps",
|
"title": "Votre emploi du temps",
|
||||||
@@ -2621,7 +2625,8 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"edit": "Modifier",
|
"edit": "Modifier",
|
||||||
"where_used": "Où utilisé",
|
"where_used": "Où utilisé",
|
||||||
"download": "Télécharger"
|
"download": "Télécharger",
|
||||||
|
"manage_thumbnails": "Gérer les vignettes"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"title": "Titre",
|
"title": "Titre",
|
||||||
@@ -2678,7 +2683,22 @@
|
|||||||
"not_available": "N/D"
|
"not_available": "N/D"
|
||||||
},
|
},
|
||||||
"empty_description": "L'actif n'existe pas ou n'est pas disponible dans cette organisation.",
|
"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": {
|
"settings": {
|
||||||
"heading": "Paramètres",
|
"heading": "Paramètres",
|
||||||
@@ -3003,6 +3023,14 @@
|
|||||||
"saas": {
|
"saas": {
|
||||||
"title": "SaaS",
|
"title": "SaaS",
|
||||||
"description": "Cadre en pointillés, accents doux, sensation de produit moderne"
|
"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.",
|
"description": "Conçu avec des fonctionnalités de base que vous pouvez facilement personnaliser – aucun codage requis.",
|
||||||
"by_classroomio": "par ClassroomIO",
|
"by_classroomio": "par ClassroomIO",
|
||||||
"add": "Ajouter",
|
"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": {
|
"current": {
|
||||||
"title": "Thème actuel",
|
"title": "Thème actuel",
|
||||||
@@ -3952,6 +3983,54 @@
|
|||||||
"quiz_me": "Questionnez-moi à ce sujet",
|
"quiz_me": "Questionnez-moi à ce sujet",
|
||||||
"summarize_section": "Résumer cette section",
|
"summarize_section": "Résumer cette section",
|
||||||
"what_to_review": "Que dois-je réviser ensuite ?"
|
"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": {
|
"widgets": {
|
||||||
|
|||||||
@@ -346,7 +346,11 @@
|
|||||||
"transcription_started": "प्रतिलेखन प्रारंभ हुआ",
|
"transcription_started": "प्रतिलेखन प्रारंभ हुआ",
|
||||||
"transcription_start_failed": "प्रतिलेखन प्रारंभ नहीं किया जा सका",
|
"transcription_start_failed": "प्रतिलेखन प्रारंभ नहीं किया जा सका",
|
||||||
"transcription_completed": "प्रतिलेखन समाप्त हुआ",
|
"transcription_completed": "प्रतिलेखन समाप्त हुआ",
|
||||||
"transcript_fetch_failed": "प्रतिलेख लोड नहीं किया जा सका"
|
"transcript_fetch_failed": "प्रतिलेख लोड नहीं किया जा सका",
|
||||||
|
"thumbnail_updated": "थंबनेल अपडेट किया गया",
|
||||||
|
"thumbnail_update_failed": "थंबनेल अपडेट करने में विफल",
|
||||||
|
"thumbnail_regen_started": "थंबनेल पुनर्जीवित हो रहे हैं…",
|
||||||
|
"thumbnail_regen_failed": "थंबनेल पुन: उत्पन्न करने में विफल"
|
||||||
},
|
},
|
||||||
"tags": {
|
"tags": {
|
||||||
"group_created": "टैग समूह बनाया गया",
|
"group_created": "टैग समूह बनाया गया",
|
||||||
@@ -2059,7 +2063,8 @@
|
|||||||
"overview_metrics": {
|
"overview_metrics": {
|
||||||
"heading": "कोर्स स्नैपशॉट",
|
"heading": "कोर्स स्नैपशॉट",
|
||||||
"description": "इस पाठ्यक्रम के लिए शिक्षार्थी की गणना उसकी स्थिति के आधार पर की जाती है।"
|
"description": "इस पाठ्यक्रम के लिए शिक्षार्थी की गणना उसकी स्थिति के आधार पर की जाती है।"
|
||||||
}
|
},
|
||||||
|
"empty_overview_description": "अपने कर्मचारियों को आज्ञाकारी रखें और ट्रैक करें कि पीछे कौन है।"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -2164,9 +2169,7 @@
|
|||||||
"depth": "पाठ्यक्रम कितना गहरा होना चाहिए?",
|
"depth": "पाठ्यक्रम कितना गहरा होना चाहिए?",
|
||||||
"topic": "विषय या आला",
|
"topic": "विषय या आला",
|
||||||
"expertise_level": "लक्ष्य विशेषज्ञता स्तर",
|
"expertise_level": "लक्ष्य विशेषज्ञता स्तर",
|
||||||
"documentation_url": {
|
"documentation_url": "दस्तावेज़ीकरण URL (वैकल्पिक)"
|
||||||
"hint": "हम आपके वास्तविक उत्पाद में पाठ्यक्रम को आधार बनाने के लिए इस साइट को पढ़ेंगे।"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"expert_on_x": {
|
"expert_on_x": {
|
||||||
"title": "एक्स पर विशेषज्ञ बनें",
|
"title": "एक्स पर विशेषज्ञ बनें",
|
||||||
@@ -2340,7 +2343,8 @@
|
|||||||
"login_activity_title": "छात्र लॉगिन गतिविधि",
|
"login_activity_title": "छात्र लॉगिन गतिविधि",
|
||||||
"login_activity_description": "सप्ताह के सर्वाधिक सक्रिय दिन (पिछले 90 दिन)",
|
"login_activity_description": "सप्ताह के सर्वाधिक सक्रिय दिन (पिछले 90 दिन)",
|
||||||
"login_activity_no_data": "अभी तक कोई लॉगिन गतिविधि नहीं",
|
"login_activity_no_data": "अभी तक कोई लॉगिन गतिविधि नहीं",
|
||||||
"login_activity_logins": "लॉगिन"
|
"login_activity_logins": "लॉगिन",
|
||||||
|
"open_academy": "अकादमी खोलें"
|
||||||
},
|
},
|
||||||
"schedule": {
|
"schedule": {
|
||||||
"title": "आपका शेड्यूल",
|
"title": "आपका शेड्यूल",
|
||||||
@@ -2621,7 +2625,8 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"edit": "संपादित करें",
|
"edit": "संपादित करें",
|
||||||
"where_used": "कहां उपयोग किया जाता है",
|
"where_used": "कहां उपयोग किया जाता है",
|
||||||
"download": "डाउनलोड करें"
|
"download": "डाउनलोड करें",
|
||||||
|
"manage_thumbnails": "थंबनेल प्रबंधित करें"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"title": "शीर्षक",
|
"title": "शीर्षक",
|
||||||
@@ -2678,7 +2683,22 @@
|
|||||||
"not_available": "एन/ए"
|
"not_available": "एन/ए"
|
||||||
},
|
},
|
||||||
"empty_description": "संपत्ति इस संगठन में मौजूद नहीं है या उपलब्ध नहीं है.",
|
"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": {
|
"settings": {
|
||||||
"heading": "सेटिंग्स",
|
"heading": "सेटिंग्स",
|
||||||
@@ -3003,6 +3023,14 @@
|
|||||||
"saas": {
|
"saas": {
|
||||||
"title": "सास",
|
"title": "सास",
|
||||||
"description": "बिंदीदार रेखा फ्रेम, नरम लहजे, आधुनिक उत्पाद अनुभव"
|
"description": "बिंदीदार रेखा फ्रेम, नरम लहजे, आधुनिक उत्पाद अनुभव"
|
||||||
|
},
|
||||||
|
"vibrant": {
|
||||||
|
"title": "जीवंत",
|
||||||
|
"description": "बोल्ड प्राइमरी पैनल, सनसेट ग्रेडिएंट हीरो, ऊर्जावान पोस्टर लुक"
|
||||||
|
},
|
||||||
|
"editorial": {
|
||||||
|
"title": "सम्पादकीय",
|
||||||
|
"description": "गर्म क्रीम पैलेट, चित्रकारी नायक, परिष्कृत पत्रिका अनुभव"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3011,7 +3039,10 @@
|
|||||||
"description": "मुख्य विशेषताओं के साथ बनाया गया जिसे आप आसानी से अनुकूलित कर सकते हैं - किसी कोडिंग की आवश्यकता नहीं है।",
|
"description": "मुख्य विशेषताओं के साथ बनाया गया जिसे आप आसानी से अनुकूलित कर सकते हैं - किसी कोडिंग की आवश्यकता नहीं है।",
|
||||||
"by_classroomio": "क्लासरूमआईओ द्वारा",
|
"by_classroomio": "क्लासरूमआईओ द्वारा",
|
||||||
"add": "जोड़ें",
|
"add": "जोड़ें",
|
||||||
"upgrade": "अपग्रेड करें"
|
"upgrade": "अपग्रेड करें",
|
||||||
|
"apply_theme": "थीम लागू करें",
|
||||||
|
"preview": "पूर्वावलोकन",
|
||||||
|
"card_menu_label": "थीम विकल्प"
|
||||||
},
|
},
|
||||||
"current": {
|
"current": {
|
||||||
"title": "वर्तमान विषय",
|
"title": "वर्तमान विषय",
|
||||||
@@ -3952,6 +3983,54 @@
|
|||||||
"quiz_me": "इस पर मुझसे प्रश्नोत्तरी करें",
|
"quiz_me": "इस पर मुझसे प्रश्नोत्तरी करें",
|
||||||
"summarize_section": "इस अनुभाग को सारांशित करें",
|
"summarize_section": "इस अनुभाग को सारांशित करें",
|
||||||
"what_to_review": "मुझे आगे क्या समीक्षा करनी चाहिए?"
|
"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": {
|
"widgets": {
|
||||||
|
|||||||
@@ -346,7 +346,11 @@
|
|||||||
"transcription_started": "Rozpoczęto transkrypcję",
|
"transcription_started": "Rozpoczęto transkrypcję",
|
||||||
"transcription_start_failed": "Nie można rozpocząć transkrypcji",
|
"transcription_start_failed": "Nie można rozpocząć transkrypcji",
|
||||||
"transcription_completed": "Transkrypcja zakończona",
|
"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": {
|
"tags": {
|
||||||
"group_created": "Utworzono grupę tagów",
|
"group_created": "Utworzono grupę tagów",
|
||||||
@@ -2059,7 +2063,8 @@
|
|||||||
"overview_metrics": {
|
"overview_metrics": {
|
||||||
"heading": "Migawka kursu",
|
"heading": "Migawka kursu",
|
||||||
"description": "Uczestnicy są zliczani według statusu tego 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": {
|
"search": {
|
||||||
@@ -2164,9 +2169,7 @@
|
|||||||
"depth": "Jak głęboko powinien sięgać kurs?",
|
"depth": "Jak głęboko powinien sięgać kurs?",
|
||||||
"topic": "Temat lub nisza",
|
"topic": "Temat lub nisza",
|
||||||
"expertise_level": "Docelowy poziom wiedzy",
|
"expertise_level": "Docelowy poziom wiedzy",
|
||||||
"documentation_url": {
|
"documentation_url": "Adres URL dokumentacji (opcjonalnie)"
|
||||||
"hint": "Przeczytamy tę witrynę, aby oprzeć kurs na Twoim prawdziwym produkcie."
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"expert_on_x": {
|
"expert_on_x": {
|
||||||
"title": "Zostań ekspertem w dziedzinie X",
|
"title": "Zostań ekspertem w dziedzinie X",
|
||||||
@@ -2340,7 +2343,8 @@
|
|||||||
"login_activity_title": "Aktywność logowania ucznia",
|
"login_activity_title": "Aktywność logowania ucznia",
|
||||||
"login_activity_description": "Najbardziej aktywne dni tygodnia (ostatnie 90 dni)",
|
"login_activity_description": "Najbardziej aktywne dni tygodnia (ostatnie 90 dni)",
|
||||||
"login_activity_no_data": "Brak aktywności związanej z logowaniem",
|
"login_activity_no_data": "Brak aktywności związanej z logowaniem",
|
||||||
"login_activity_logins": "logowania"
|
"login_activity_logins": "logowania",
|
||||||
|
"open_academy": "Otwarta Akademia"
|
||||||
},
|
},
|
||||||
"schedule": {
|
"schedule": {
|
||||||
"title": "Twój harmonogram",
|
"title": "Twój harmonogram",
|
||||||
@@ -2621,7 +2625,8 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"edit": "Edytuj",
|
"edit": "Edytuj",
|
||||||
"where_used": "Gdzie używany",
|
"where_used": "Gdzie używany",
|
||||||
"download": "Pobierz"
|
"download": "Pobierz",
|
||||||
|
"manage_thumbnails": "Zarządzaj miniaturami"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"title": "Tytuł",
|
"title": "Tytuł",
|
||||||
@@ -2678,7 +2683,22 @@
|
|||||||
"not_available": "Nie dotyczy"
|
"not_available": "Nie dotyczy"
|
||||||
},
|
},
|
||||||
"empty_description": "Zasób nie istnieje lub nie jest dostępny w tej organizacji.",
|
"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": {
|
"settings": {
|
||||||
"heading": "Ustawienia",
|
"heading": "Ustawienia",
|
||||||
@@ -3003,6 +3023,14 @@
|
|||||||
"saas": {
|
"saas": {
|
||||||
"title": "SaaS",
|
"title": "SaaS",
|
||||||
"description": "Ramka w postaci przerywanej linii, miękkie akcenty, nowoczesny wygląd produktu"
|
"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.",
|
"description": "Zawiera podstawowe funkcje, które można łatwo dostosować — bez konieczności kodowania.",
|
||||||
"by_classroomio": "przez ClassroomIO",
|
"by_classroomio": "przez ClassroomIO",
|
||||||
"add": "Dodaj",
|
"add": "Dodaj",
|
||||||
"upgrade": "Uaktualnij"
|
"upgrade": "Uaktualnij",
|
||||||
|
"apply_theme": "Zastosuj motyw",
|
||||||
|
"preview": "Podgląd",
|
||||||
|
"card_menu_label": "Opcje motywu"
|
||||||
},
|
},
|
||||||
"current": {
|
"current": {
|
||||||
"title": "Aktualny temat",
|
"title": "Aktualny temat",
|
||||||
@@ -3952,6 +3983,54 @@
|
|||||||
"quiz_me": "Przepytaj mnie na ten temat",
|
"quiz_me": "Przepytaj mnie na ten temat",
|
||||||
"summarize_section": "Podsumuj tę sekcję",
|
"summarize_section": "Podsumuj tę sekcję",
|
||||||
"what_to_review": "Co powinienem zrecenzować dalej?"
|
"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": {
|
"widgets": {
|
||||||
|
|||||||
@@ -346,7 +346,11 @@
|
|||||||
"transcription_started": "Transcrição iniciada",
|
"transcription_started": "Transcrição iniciada",
|
||||||
"transcription_start_failed": "Não foi possível iniciar a transcrição",
|
"transcription_start_failed": "Não foi possível iniciar a transcrição",
|
||||||
"transcription_completed": "Transcrição concluída",
|
"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": {
|
"tags": {
|
||||||
"group_created": "Grupo de tags criado",
|
"group_created": "Grupo de tags criado",
|
||||||
@@ -2059,7 +2063,8 @@
|
|||||||
"overview_metrics": {
|
"overview_metrics": {
|
||||||
"heading": "Instantâneo do curso",
|
"heading": "Instantâneo do curso",
|
||||||
"description": "Contagens de alunos por status para este 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": {
|
"search": {
|
||||||
@@ -2164,9 +2169,7 @@
|
|||||||
"depth": "Quão profundo o curso deve ir?",
|
"depth": "Quão profundo o curso deve ir?",
|
||||||
"topic": "Tópico ou nicho",
|
"topic": "Tópico ou nicho",
|
||||||
"expertise_level": "Nível de especialização alvo",
|
"expertise_level": "Nível de especialização alvo",
|
||||||
"documentation_url": {
|
"documentation_url": "URL da documentação (opcional)"
|
||||||
"hint": "Leremos este site para fundamentar o curso em seu produto real."
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"expert_on_x": {
|
"expert_on_x": {
|
||||||
"title": "Torne-se um especialista em X",
|
"title": "Torne-se um especialista em X",
|
||||||
@@ -2340,7 +2343,8 @@
|
|||||||
"login_activity_title": "Atividade de login do aluno",
|
"login_activity_title": "Atividade de login do aluno",
|
||||||
"login_activity_description": "Dias mais ativos da semana (últimos 90 dias)",
|
"login_activity_description": "Dias mais ativos da semana (últimos 90 dias)",
|
||||||
"login_activity_no_data": "Nenhuma atividade de login ainda",
|
"login_activity_no_data": "Nenhuma atividade de login ainda",
|
||||||
"login_activity_logins": "logins"
|
"login_activity_logins": "logins",
|
||||||
|
"open_academy": "Academia Aberta"
|
||||||
},
|
},
|
||||||
"schedule": {
|
"schedule": {
|
||||||
"title": "Sua programação",
|
"title": "Sua programação",
|
||||||
@@ -2621,7 +2625,8 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
"where_used": "Onde usado",
|
"where_used": "Onde usado",
|
||||||
"download": "Baixar"
|
"download": "Baixar",
|
||||||
|
"manage_thumbnails": "Gerenciar miniaturas"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"title": "Título",
|
"title": "Título",
|
||||||
@@ -2678,7 +2683,22 @@
|
|||||||
"not_available": "N/A"
|
"not_available": "N/A"
|
||||||
},
|
},
|
||||||
"empty_description": "O ativo não existe ou não está disponível nesta organização.",
|
"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": {
|
"settings": {
|
||||||
"heading": "Configurações",
|
"heading": "Configurações",
|
||||||
@@ -3003,6 +3023,14 @@
|
|||||||
"saas": {
|
"saas": {
|
||||||
"title": "SaaS",
|
"title": "SaaS",
|
||||||
"description": "Moldura de linha pontilhada, detalhes suaves, toque de produto moderno"
|
"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.",
|
"description": "Feito com recursos básicos que você pode personalizar facilmente – sem necessidade de codificação.",
|
||||||
"by_classroomio": "da ClassroomIO",
|
"by_classroomio": "da ClassroomIO",
|
||||||
"add": "Adicionar",
|
"add": "Adicionar",
|
||||||
"upgrade": "Atualizar"
|
"upgrade": "Atualizar",
|
||||||
|
"apply_theme": "Aplicar tema",
|
||||||
|
"preview": "Visualização",
|
||||||
|
"card_menu_label": "Opções de tema"
|
||||||
},
|
},
|
||||||
"current": {
|
"current": {
|
||||||
"title": "Tema atual",
|
"title": "Tema atual",
|
||||||
@@ -3952,6 +3983,54 @@
|
|||||||
"quiz_me": "Me questione sobre isso",
|
"quiz_me": "Me questione sobre isso",
|
||||||
"summarize_section": "Resuma esta seção",
|
"summarize_section": "Resuma esta seção",
|
||||||
"what_to_review": "O que devo revisar a seguir?"
|
"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": {
|
"widgets": {
|
||||||
|
|||||||
@@ -346,7 +346,11 @@
|
|||||||
"transcription_started": "Транскрипция началась",
|
"transcription_started": "Транскрипция началась",
|
||||||
"transcription_start_failed": "Не удалось начать транскрипцию",
|
"transcription_start_failed": "Не удалось начать транскрипцию",
|
||||||
"transcription_completed": "Транскрипция завершена",
|
"transcription_completed": "Транскрипция завершена",
|
||||||
"transcript_fetch_failed": "Не удалось загрузить расшифровку"
|
"transcript_fetch_failed": "Не удалось загрузить расшифровку",
|
||||||
|
"thumbnail_updated": "Значок обновлен.",
|
||||||
|
"thumbnail_update_failed": "Не удалось обновить миниатюру.",
|
||||||
|
"thumbnail_regen_started": "Восстановление миниатюр…",
|
||||||
|
"thumbnail_regen_failed": "Не удалось восстановить миниатюры."
|
||||||
},
|
},
|
||||||
"tags": {
|
"tags": {
|
||||||
"group_created": "Группа тегов создана.",
|
"group_created": "Группа тегов создана.",
|
||||||
@@ -2059,7 +2063,8 @@
|
|||||||
"overview_metrics": {
|
"overview_metrics": {
|
||||||
"heading": "Снимок курса",
|
"heading": "Снимок курса",
|
||||||
"description": "Учащиеся учитываются по статусу для этого курса."
|
"description": "Учащиеся учитываются по статусу для этого курса."
|
||||||
}
|
},
|
||||||
|
"empty_overview_description": "Следите за тем, чтобы ваши сотрудники соблюдали требования и отслеживали, кто отстает."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -2164,9 +2169,7 @@
|
|||||||
"depth": "Насколько глубоким должен быть курс?",
|
"depth": "Насколько глубоким должен быть курс?",
|
||||||
"topic": "Тема или ниша",
|
"topic": "Тема или ниша",
|
||||||
"expertise_level": "Целевой уровень экспертизы",
|
"expertise_level": "Целевой уровень экспертизы",
|
||||||
"documentation_url": {
|
"documentation_url": "URL-адрес документации (необязательно)"
|
||||||
"hint": "Мы прочитаем этот сайт, чтобы закрепить курс на вашем реальном продукте."
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"expert_on_x": {
|
"expert_on_x": {
|
||||||
"title": "Станьте экспертом по X",
|
"title": "Станьте экспертом по X",
|
||||||
@@ -2340,7 +2343,8 @@
|
|||||||
"login_activity_title": "Активность входа учащихся",
|
"login_activity_title": "Активность входа учащихся",
|
||||||
"login_activity_description": "Самые активные дни недели (последние 90 дней)",
|
"login_activity_description": "Самые активные дни недели (последние 90 дней)",
|
||||||
"login_activity_no_data": "Пока нет активности входа",
|
"login_activity_no_data": "Пока нет активности входа",
|
||||||
"login_activity_logins": "логины"
|
"login_activity_logins": "логины",
|
||||||
|
"open_academy": "Открытая Академия"
|
||||||
},
|
},
|
||||||
"schedule": {
|
"schedule": {
|
||||||
"title": "Ваше расписание",
|
"title": "Ваше расписание",
|
||||||
@@ -2621,7 +2625,8 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"edit": "Редактировать",
|
"edit": "Редактировать",
|
||||||
"where_used": "Где используется",
|
"where_used": "Где используется",
|
||||||
"download": "Скачать"
|
"download": "Скачать",
|
||||||
|
"manage_thumbnails": "Управление миниатюрами"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"title": "Название",
|
"title": "Название",
|
||||||
@@ -2678,7 +2683,22 @@
|
|||||||
"not_available": "Н/Д"
|
"not_available": "Н/Д"
|
||||||
},
|
},
|
||||||
"empty_description": "Актив не существует или недоступен в этой организации.",
|
"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": {
|
"settings": {
|
||||||
"heading": "Настройки",
|
"heading": "Настройки",
|
||||||
@@ -3003,6 +3023,14 @@
|
|||||||
"saas": {
|
"saas": {
|
||||||
"title": "SaaS",
|
"title": "SaaS",
|
||||||
"description": "Пунктирная рамка, мягкие акценты, современный вид продукта"
|
"description": "Пунктирная рамка, мягкие акценты, современный вид продукта"
|
||||||
|
},
|
||||||
|
"vibrant": {
|
||||||
|
"title": "Яркий",
|
||||||
|
"description": "Яркие основные панели, герой градиента заката, энергичный вид плаката"
|
||||||
|
},
|
||||||
|
"editorial": {
|
||||||
|
"title": "Редакционный",
|
||||||
|
"description": "Теплая кремовая палитра, живописный герой, изысканная журнальная атмосфера."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3011,7 +3039,10 @@
|
|||||||
"description": "Создан с использованием основных функций, которые можно легко настроить — кодирование не требуется.",
|
"description": "Создан с использованием основных функций, которые можно легко настроить — кодирование не требуется.",
|
||||||
"by_classroomio": "от ClassroomIO",
|
"by_classroomio": "от ClassroomIO",
|
||||||
"add": "Добавить",
|
"add": "Добавить",
|
||||||
"upgrade": "Обновление"
|
"upgrade": "Обновление",
|
||||||
|
"apply_theme": "Применить тему",
|
||||||
|
"preview": "Предварительный просмотр",
|
||||||
|
"card_menu_label": "Опции темы"
|
||||||
},
|
},
|
||||||
"current": {
|
"current": {
|
||||||
"title": "Текущая тема",
|
"title": "Текущая тема",
|
||||||
@@ -3952,6 +3983,54 @@
|
|||||||
"quiz_me": "спроси меня об этом",
|
"quiz_me": "спроси меня об этом",
|
||||||
"summarize_section": "Подведите итоги этого раздела",
|
"summarize_section": "Подведите итоги этого раздела",
|
||||||
"what_to_review": "Что мне следует проверить дальше?"
|
"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": {
|
"widgets": {
|
||||||
|
|||||||
@@ -346,7 +346,11 @@
|
|||||||
"transcription_started": "Đã bắt đầu phiên âm",
|
"transcription_started": "Đã bắt đầu phiên âm",
|
||||||
"transcription_start_failed": "Không thể 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",
|
"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": {
|
"tags": {
|
||||||
"group_created": "Đã tạo nhóm thẻ",
|
"group_created": "Đã tạo nhóm thẻ",
|
||||||
@@ -2059,7 +2063,8 @@
|
|||||||
"overview_metrics": {
|
"overview_metrics": {
|
||||||
"heading": "Ảnh chụp nhanh khóa học",
|
"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."
|
"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": {
|
"search": {
|
||||||
@@ -2164,9 +2169,7 @@
|
|||||||
"depth": "Khóa học nên đi sâu đến mức nào?",
|
"depth": "Khóa học nên đi sâu đến mức nào?",
|
||||||
"topic": "Chủ đề hoặc niche",
|
"topic": "Chủ đề hoặc niche",
|
||||||
"expertise_level": "Mục tiêu trình độ chuyên môn",
|
"expertise_level": "Mục tiêu trình độ chuyên môn",
|
||||||
"documentation_url": {
|
"documentation_url": "URL tài liệu (tùy chọn)"
|
||||||
"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."
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"expert_on_x": {
|
"expert_on_x": {
|
||||||
"title": "Trở thành chuyên gia về 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_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_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_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": {
|
"schedule": {
|
||||||
"title": "Lịch trình của bạn",
|
"title": "Lịch trình của bạn",
|
||||||
@@ -2621,7 +2625,8 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"edit": "Chỉnh sửa",
|
"edit": "Chỉnh sửa",
|
||||||
"where_used": "Nơi sử dụng",
|
"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": {
|
"table": {
|
||||||
"title": "Tiêu đề",
|
"title": "Tiêu đề",
|
||||||
@@ -2678,7 +2683,22 @@
|
|||||||
"not_available": "không áp dụng"
|
"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.",
|
"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": {
|
"settings": {
|
||||||
"heading": "Cài đặt",
|
"heading": "Cài đặt",
|
||||||
@@ -3003,6 +3023,14 @@
|
|||||||
"saas": {
|
"saas": {
|
||||||
"title": "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"
|
"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.",
|
"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",
|
"by_classroomio": "bởi ClassroomIO",
|
||||||
"add": "Thêm",
|
"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": {
|
"current": {
|
||||||
"title": "Chủ đề hiện tại",
|
"title": "Chủ đề hiện tại",
|
||||||
@@ -3952,6 +3983,54 @@
|
|||||||
"quiz_me": "Đố tôi về điều này",
|
"quiz_me": "Đố tôi về điều này",
|
||||||
"summarize_section": "Tóm tắt phần 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?"
|
"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": {
|
"widgets": {
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ export type OrgLandingPageTheme =
|
|||||||
| 'tech'
|
| 'tech'
|
||||||
| 'studio'
|
| 'studio'
|
||||||
| 'corporate'
|
| 'corporate'
|
||||||
| 'terminal';
|
| 'terminal'
|
||||||
|
| 'editorial'
|
||||||
|
| 'vibrant';
|
||||||
|
|
||||||
export interface OrgLandingPageNavItem {
|
export interface OrgLandingPageNavItem {
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -8,7 +8,13 @@
|
|||||||
import { user } from '$lib/utils/store/user';
|
import { user } from '$lib/utils/store/user';
|
||||||
import { CourseLandingPage } from '$features/ui';
|
import { CourseLandingPage } from '$features/ui';
|
||||||
import { PoweredBy } 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 { OrgLandingPageFooter } from '@cio/ui/custom/org-landing-page';
|
||||||
import type { Component } from 'svelte';
|
import type { Component } from 'svelte';
|
||||||
@@ -29,11 +35,9 @@
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const shellClass = $derived(
|
const shellClass = $derived(`ui:min-h-screen ${themeHeaderShellClass(landingSettings.theme)}`);
|
||||||
landingSettings.theme === 'classic'
|
const shellStyle = $derived(themeStyle(landingSettings.theme));
|
||||||
? 'ui:min-h-screen ui:bg-muted/10 ui:text-foreground ui:font-sans'
|
const navInsideHero = $derived(themeRendersNavInsideHero(landingSettings.theme));
|
||||||
: 'ui:min-h-screen ui:bg-background ui:text-foreground ui:font-sans'
|
|
||||||
);
|
|
||||||
|
|
||||||
const courseHero = $derived.by(() => {
|
const courseHero = $derived.by(() => {
|
||||||
if (!data.course) {
|
if (!data.course) {
|
||||||
@@ -83,23 +87,9 @@
|
|||||||
let HeroComponent = $state<Component<any> | null>(null);
|
let HeroComponent = $state<Component<any> | null>(null);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const theme = landingSettings.theme;
|
const { NavComponent: nav, HeroComponent: hero } = await importThemeNavHero(landingSettings.theme);
|
||||||
|
NavComponent = nav;
|
||||||
const [navMod, heroMod] = await Promise.all([
|
HeroComponent = hero;
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -112,17 +102,17 @@
|
|||||||
{#if data.course}
|
{#if data.course}
|
||||||
<PoweredBy />
|
<PoweredBy />
|
||||||
|
|
||||||
<main class={shellClass}>
|
<main class={shellClass} style={shellStyle}>
|
||||||
{#if NavComponent && HeroComponent}
|
{#if NavComponent && HeroComponent}
|
||||||
{#if landingSettings.theme === 'minimal'}
|
{#if navInsideHero}
|
||||||
<HeroComponent hero={courseHero}>
|
<HeroComponent hero={courseHero} {orgName}>
|
||||||
{#snippet navigation()}
|
{#snippet navigation()}
|
||||||
<NavComponent {orgName} {logoUrl} navItems={landingSettings.navItems} {authAction} />
|
<NavComponent {orgName} {logoUrl} navItems={landingSettings.navItems} {authAction} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</HeroComponent>
|
</HeroComponent>
|
||||||
{:else}
|
{:else}
|
||||||
<NavComponent {orgName} {logoUrl} navItems={landingSettings.navItems} {authAction} />
|
<NavComponent {orgName} {logoUrl} navItems={landingSettings.navItems} {authAction} />
|
||||||
<HeroComponent hero={courseHero} />
|
<HeroComponent hero={courseHero} {orgName} />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { Component } from 'svelte';
|
||||||
import { SvelteSet, SvelteURLSearchParams } from 'svelte/reactivity';
|
import { SvelteSet, SvelteURLSearchParams } from 'svelte/reactivity';
|
||||||
|
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
@@ -10,20 +12,18 @@
|
|||||||
|
|
||||||
import { PoweredBy } from '$features/ui';
|
import { PoweredBy } from '$features/ui';
|
||||||
import LibraryBigIcon from '@lucide/svelte/icons/library-big';
|
import LibraryBigIcon from '@lucide/svelte/icons/library-big';
|
||||||
import { CourseCardList } from '$features/course/components';
|
|
||||||
import { normalizeLandingPageSettings } from '$features/org/utils/landing-page';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BoldLandingHero,
|
importThemeCourseCard,
|
||||||
BoldLandingNav,
|
importThemeNavHero,
|
||||||
ClassicLandingHero,
|
normalizeLandingPageSettings,
|
||||||
ClassicLandingNav,
|
themeCourseGridClass,
|
||||||
MinimalLandingHero,
|
themeHeaderShellClass,
|
||||||
MinimalLandingNav,
|
themeStyle,
|
||||||
OrgLandingPageFooter
|
themeRendersNavInsideHero
|
||||||
} from '@cio/ui/custom/org-landing-page';
|
} 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 { Checkbox } from '@cio/ui/base/checkbox';
|
||||||
import { Empty } from '@cio/ui/custom/empty';
|
import { Empty } from '@cio/ui/custom/empty';
|
||||||
import { Separator } from '@cio/ui/base/separator';
|
import { Separator } from '@cio/ui/base/separator';
|
||||||
@@ -46,11 +46,33 @@
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const shellClass = $derived(
|
const shellClass = $derived(`ui:min-h-screen ${themeHeaderShellClass(landingSettings.theme)}`);
|
||||||
landingSettings.theme === 'classic'
|
const shellStyle = $derived(themeStyle(landingSettings.theme));
|
||||||
? 'ui:min-h-screen ui:bg-muted/10 ui:text-foreground ui:font-sans'
|
const navInsideHero = $derived(themeRendersNavInsideHero(landingSettings.theme));
|
||||||
: 'ui:min-h-screen ui:bg-background ui:text-foreground ui:font-sans'
|
const courseGridClass = $derived(themeCourseGridClass(landingSettings.theme));
|
||||||
);
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let NavComponent = $state<Component<any> | null>(null);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let HeroComponent = $state<Component<any> | null>(null);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let CourseCardComponent = $state<Component<any> | 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[]) {
|
async function applyTagFilters(nextTags: string[]) {
|
||||||
const params = new SvelteURLSearchParams(page.url.searchParams);
|
const params = new SvelteURLSearchParams(page.url.searchParams);
|
||||||
@@ -119,54 +141,50 @@
|
|||||||
|
|
||||||
<PoweredBy />
|
<PoweredBy />
|
||||||
|
|
||||||
<main class={shellClass}>
|
<main class={shellClass} style={shellStyle}>
|
||||||
{#if landingSettings.theme === 'minimal'}
|
{#if NavComponent && HeroComponent}
|
||||||
<MinimalLandingHero hero={{ ...landingSettings.hero, heading: $t('public_courses.heading') }}>
|
{#if navInsideHero}
|
||||||
|
<HeroComponent hero={heroProps} orgName={data.org.name}>
|
||||||
{#snippet navigation()}
|
{#snippet navigation()}
|
||||||
<MinimalLandingNav
|
<NavComponent
|
||||||
orgName={data.org.name}
|
orgName={data.org.name}
|
||||||
logoUrl={data.org.avatarUrl ?? undefined}
|
logoUrl={data.org.avatarUrl ?? undefined}
|
||||||
navItems={landingSettings.navItems}
|
navItems={landingSettings.navItems}
|
||||||
{authAction}
|
{authAction}
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</MinimalLandingHero>
|
</HeroComponent>
|
||||||
{:else if landingSettings.theme === 'bold'}
|
|
||||||
<BoldLandingNav
|
|
||||||
orgName={data.org.name}
|
|
||||||
logoUrl={data.org.avatarUrl ?? undefined}
|
|
||||||
navItems={landingSettings.navItems}
|
|
||||||
{authAction}
|
|
||||||
/>
|
|
||||||
<BoldLandingHero hero={{ ...landingSettings.hero, heading: $t('public_courses.heading') }} />
|
|
||||||
{:else}
|
{:else}
|
||||||
<ClassicLandingNav
|
<NavComponent
|
||||||
orgName={data.org.name}
|
orgName={data.org.name}
|
||||||
logoUrl={data.org.avatarUrl ?? undefined}
|
logoUrl={data.org.avatarUrl ?? undefined}
|
||||||
navItems={landingSettings.navItems}
|
navItems={landingSettings.navItems}
|
||||||
{authAction}
|
{authAction}
|
||||||
/>
|
/>
|
||||||
<ClassicLandingHero hero={{ ...landingSettings.hero, heading: $t('public_courses.heading') }} />
|
<HeroComponent hero={heroProps} orgName={data.org.name} />
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<section class="mx-auto w-full max-w-7xl px-4 py-8 md:px-6">
|
<section class="mx-auto w-full max-w-7xl px-4 py-8 md:px-6">
|
||||||
<div class="mb-6 flex items-center justify-end gap-4">
|
<div class="mb-6 flex items-center justify-end gap-4">
|
||||||
<Button variant="outline" onclick={clearFilters} disabled={selectedTags.length === 0}>
|
<LandingButton variant="secondary" onclick={clearFilters} disabled={selectedTags.length === 0}>
|
||||||
{$t('public_courses.clear_filters')}
|
{$t('public_courses.clear_filters')}
|
||||||
</Button>
|
</LandingButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-8 lg:grid-cols-[280px_minmax(0,1fr)]">
|
<div class="grid gap-8 lg:grid-cols-[280px_minmax(0,1fr)]">
|
||||||
<aside class="ui:bg-background space-y-4 rounded-lg border p-4">
|
<aside
|
||||||
|
class="ui:bg-[var(--landing-card)] ui:text-[var(--landing-fg)] ui:border-[var(--landing-border)] space-y-4 rounded-lg border p-4"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-base font-semibold">{$t('public_courses.filters.title')}</h2>
|
<h2 class="text-base font-semibold">{$t('public_courses.filters.title')}</h2>
|
||||||
<p class="ui:text-muted-foreground text-xs">{$t('public_courses.filters.help')}</p>
|
<p class="ui:text-[var(--landing-fg-muted)] text-xs">{$t('public_courses.filters.help')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator class="ui:bg-[var(--landing-border)]" />
|
||||||
|
|
||||||
{#if displayTagGroups.length === 0}
|
{#if displayTagGroups.length === 0}
|
||||||
<p class="ui:text-muted-foreground text-sm">{$t('public_courses.filters.empty')}</p>
|
<p class="ui:text-[var(--landing-fg-muted)] text-sm">{$t('public_courses.filters.empty')}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
{#each displayTagGroups as group (group.id)}
|
{#each displayTagGroups as group (group.id)}
|
||||||
@@ -174,14 +192,14 @@
|
|||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<h3 class="text-sm font-semibold">{group.name}</h3>
|
<h3 class="text-sm font-semibold">{group.name}</h3>
|
||||||
{#if group.description}
|
{#if group.description}
|
||||||
<p class="ui:text-muted-foreground text-xs">{group.description}</p>
|
<p class="ui:text-[var(--landing-fg-muted)] text-xs">{group.description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{#each group.tags as tag (tag.id)}
|
{#each group.tags as tag (tag.id)}
|
||||||
<label
|
<label
|
||||||
class="hover:ui:bg-muted/30 flex cursor-pointer items-center justify-between rounded-md border px-3 py-2"
|
class="ui:border-[var(--landing-border)] ui:hover:bg-[var(--landing-card-soft)] flex cursor-pointer items-center justify-between rounded-md border px-3 py-2"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -195,7 +213,7 @@
|
|||||||
></span>
|
></span>
|
||||||
<span class="text-sm">{tag.name}</span>
|
<span class="text-sm">{tag.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="ui:text-muted-foreground text-xs">{tag.courseCount}</span>
|
<span class="ui:text-[var(--landing-fg-muted)] text-xs">{tag.courseCount}</span>
|
||||||
</label>
|
</label>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -213,8 +231,12 @@
|
|||||||
description={$t('public_courses.empty.description')}
|
description={$t('public_courses.empty.description')}
|
||||||
variant="page"
|
variant="page"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else if CourseCardComponent}
|
||||||
<CourseCardList courses={data.courses} isOnLandingPage={true} />
|
<div class={courseGridClass}>
|
||||||
|
{#each data.courses as course, index (course.id)}
|
||||||
|
<CourseCardComponent {course} {index} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 441 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 444 KiB |
@@ -2,7 +2,7 @@ import { mkdir, readFile } from 'node:fs/promises';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { tmpdir } from 'node:os';
|
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 { ZGenerateThumbnailPayload, type TGenerateThumbnailPayload } from '@cio/jobs/payloads/media';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -14,25 +14,57 @@ import {
|
|||||||
} from '../../utils/storage';
|
} from '../../utils/storage';
|
||||||
import { errorMessage, throwIfCancelRequested } from '../../utils/cancel';
|
import { errorMessage, throwIfCancelRequested } from '../../utils/cancel';
|
||||||
import { getStorageConfig } from '../../config/storage';
|
import { getStorageConfig } from '../../config/storage';
|
||||||
import { ffmpegRun } from '../../utils/ffmpeg';
|
import { ffmpegProbeLuma, ffmpegRun } from '../../utils/ffmpeg';
|
||||||
import { log } from '../../utils/logger';
|
import { log } from '../../utils/logger';
|
||||||
|
|
||||||
const STEP_KEY = 'generate-thumbnail';
|
const STEP_KEY = 'generate-thumbnail';
|
||||||
const DOMAIN = 'media';
|
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 {
|
interface ThumbnailResult {
|
||||||
bucket: string;
|
bucket: string;
|
||||||
key: string;
|
candidates: Array<{ slot: Slot; url: string }>;
|
||||||
publicUrl: string | null;
|
publicUrl: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function extractThumbnail(localPath: string, atSeconds: number, outputPath: string): Promise<void> {
|
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<void> {
|
||||||
await ffmpegRun([
|
await ffmpegRun([
|
||||||
'-y',
|
'-y',
|
||||||
'-ss',
|
|
||||||
String(atSeconds),
|
|
||||||
'-i',
|
'-i',
|
||||||
localPath,
|
localPath,
|
||||||
|
'-ss',
|
||||||
|
String(atSeconds),
|
||||||
'-vframes',
|
'-vframes',
|
||||||
'1',
|
'1',
|
||||||
'-vf',
|
'-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
|
* Render up to three JPEG candidate thumbnails (start / middle / end) and
|
||||||
* browser-side thumbnail as the source of truth on the asset row.
|
* 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<ThumbnailResult> {
|
export async function processGenerateThumbnail(rawData: unknown): Promise<ThumbnailResult> {
|
||||||
const payload: TGenerateThumbnailPayload = ZGenerateThumbnailPayload.parse(rawData);
|
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);
|
const existing = await getJobStep(DOMAIN, mediaJobId, STEP_KEY);
|
||||||
if (existing?.status === 'completed' && existing.result) {
|
if (existing?.status === 'completed' && existing.result) {
|
||||||
@@ -66,30 +137,54 @@ export async function processGenerateThumbnail(rawData: unknown): Promise<Thumbn
|
|||||||
attempt: (existing?.attempt ?? 0) + 1
|
attempt: (existing?.attempt ?? 0) + 1
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const asset = await getAssetById(assetId, actorContext.organizationId);
|
||||||
|
const duration =
|
||||||
|
asset?.durationSeconds && asset.durationSeconds > 0 ? asset.durationSeconds : DEFAULT_DURATION_SECONDS;
|
||||||
|
|
||||||
let localPath: string | undefined;
|
let localPath: string | undefined;
|
||||||
let outputPath: string | undefined;
|
const slotOutputs: string[] = [];
|
||||||
try {
|
try {
|
||||||
localPath = await downloadObjectToTempFile(videosBucket(), storageKey, 'thumb-source.bin');
|
localPath = await downloadObjectToTempFile(videosBucket(), storageKey, 'thumb-source.bin');
|
||||||
|
|
||||||
const dir = path.join(tmpdir(), 'cio-jobs', 'thumbs');
|
const dir = path.join(tmpdir(), 'cio-jobs', 'thumbs');
|
||||||
await mkdir(dir, { recursive: true });
|
await mkdir(dir, { recursive: true });
|
||||||
outputPath = path.join(dir, `${mediaJobId}.jpg`);
|
|
||||||
|
|
||||||
|
const uploads: CandidateUpload[] = [];
|
||||||
|
for (const slot of SLOT_ORDER) {
|
||||||
await throwIfCancelRequested(mediaJobId);
|
await throwIfCancelRequested(mediaJobId);
|
||||||
await extractThumbnail(localPath, atSeconds, outputPath);
|
const outputPath = path.join(dir, `${mediaJobId}-${slot}.jpg`);
|
||||||
|
slotOutputs.push(outputPath);
|
||||||
|
|
||||||
const buffer = await readFile(outputPath);
|
const baseSeconds = targetForSlot(slot, duration);
|
||||||
const thumbKey = `thumbnails/${assetId}/${Date.now()}.jpg`;
|
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 baseUrl = getStorageConfig().mediaPublicBaseUrl;
|
||||||
const publicUrl = baseUrl ? `${baseUrl.replace(/\/$/, '')}/${thumbKey}` : null;
|
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, {
|
await updateAsset(assetId, actorContext.organizationId, {
|
||||||
thumbnailUrl: publicUrl ?? undefined
|
thumbnailUrl: publicUrl,
|
||||||
|
thumbnailCandidates: candidateUrls
|
||||||
});
|
});
|
||||||
|
|
||||||
await upsertJobStep({
|
await upsertJobStep({
|
||||||
@@ -101,9 +196,17 @@ export async function processGenerateThumbnail(rawData: unknown): Promise<Thumbn
|
|||||||
result: result as unknown as Record<string, unknown>
|
result: result as unknown as Record<string, unknown>
|
||||||
});
|
});
|
||||||
|
|
||||||
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;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('generate-thumbnail-failed', { mediaJobId, error: errorMessage(error) });
|
log.error('generate-thumbnail-failed', { mediaJobId, error: errorMessage(error) });
|
||||||
@@ -118,6 +221,8 @@ export async function processGenerateThumbnail(rawData: unknown): Promise<Thumbn
|
|||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
await safeUnlink(localPath);
|
await safeUnlink(localPath);
|
||||||
await safeUnlink(outputPath);
|
for (const out of slotOutputs) {
|
||||||
|
await safeUnlink(out);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,47 @@
|
|||||||
<script>
|
<script>
|
||||||
import { faqs } from '$lib/utils/faqs';
|
import { faqs as defaultFaqs } from '$lib/utils/faqs';
|
||||||
import { PageSignupCTA } from '$lib/components';
|
import { PageSignupCTA } from '$lib/components';
|
||||||
import * as Accordion from '@cio/ui/base/accordion';
|
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);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mt-10 mb-20 flex flex-col items-center justify-center gap-6">
|
<div class="mt-10 mb-20 flex flex-col items-center justify-center gap-6">
|
||||||
<header class="space-y-2 text-center">
|
<header class="space-y-2 text-center">
|
||||||
<h1 class="mt-2 text-xl md:text-4xl">Questions & Answers</h1>
|
<h1 class="mt-2 text-xl md:text-4xl">{heading ?? 'Questions & Answers'}</h1>
|
||||||
<p
|
<p
|
||||||
class="ui:text-muted-foreground text-sm font-normal tracking-normal md:text-lg lg:px-[20%] lg:text-xl lg:tracking-wide"
|
class="ui:text-muted-foreground text-sm font-normal tracking-normal md:text-lg lg:px-[20%] lg:text-xl lg:tracking-wide"
|
||||||
>
|
>
|
||||||
|
{#if subheading}
|
||||||
|
{subheading}
|
||||||
|
{:else}
|
||||||
Can't find the answer you're looking for?
|
Can't find the answer you're looking for?
|
||||||
<a class="text-blue-700 hover:underline" href="mailto:help@classroomio.com" target="_blank">Shoot us an email</a> and
|
<a class="text-blue-700 hover:underline" href="mailto:help@classroomio.com" target="_blank">Shoot us an email</a
|
||||||
we'll get back to you ASAP.
|
> and we'll get back to you ASAP.
|
||||||
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="mx-auto w-full max-w-3xl px-2 lg:m-0">
|
<div class="mx-auto w-full max-w-3xl px-2 lg:m-0">
|
||||||
<Accordion.Root type="single" class="w-full">
|
<Accordion.Root type="single" class="w-full">
|
||||||
{#each faqs as faq, index}
|
{#each entries as faq, index}
|
||||||
<Accordion.Item value="faq-{index}">
|
<Accordion.Item value="faq-{index}">
|
||||||
<Accordion.Trigger>{faq.question}</Accordion.Trigger>
|
<Accordion.Trigger>{faq.question}</Accordion.Trigger>
|
||||||
<Accordion.Content>
|
<Accordion.Content>
|
||||||
@@ -29,10 +52,12 @@
|
|||||||
</Accordion.Root>
|
</Accordion.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if !hideCta}
|
||||||
<PageSignupCTA
|
<PageSignupCTA
|
||||||
header="Your Custom Academy – Up & Running in Minutes!"
|
header="Your Custom Academy – Up & Running in Minutes!"
|
||||||
subText="Try before you buy. No credit card required."
|
subText="Try before you buy. No credit card required."
|
||||||
btnLabel="Book a demo"
|
btnLabel="Book a demo"
|
||||||
link="classroomio/demo"
|
link="classroomio/demo"
|
||||||
/>
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<EnqueueLessonVideoPipelineResult> {
|
||||||
|
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).
|
* Enqueue only extract-audio → transcribe-audio (new `media_job` row required).
|
||||||
* Used for manual “generate transcript” when the initial pipeline had no OpenAI key.
|
* Used for manual “generate transcript” when the initial pipeline had no OpenAI key.
|
||||||
|
|||||||
@@ -29,7 +29,13 @@ export const ZGenerateThumbnailPayload = z.object({
|
|||||||
storageKey: z.string().min(1),
|
storageKey: z.string().min(1),
|
||||||
actorContext: ZActorContext,
|
actorContext: ZActorContext,
|
||||||
/** Seconds into the video to grab. Defaults to 1.0 in the worker. */
|
/** 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<typeof ZGenerateThumbnailPayload>;
|
export type TGenerateThumbnailPayload = z.infer<typeof ZGenerateThumbnailPayload>;
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,11 @@
|
|||||||
TechLandingPage,
|
TechLandingPage,
|
||||||
StudioLandingPage,
|
StudioLandingPage,
|
||||||
CorporateLandingPage,
|
CorporateLandingPage,
|
||||||
TerminalLandingPage
|
TerminalLandingPage,
|
||||||
|
EditorialLandingPage,
|
||||||
|
VibrantLandingPage,
|
||||||
|
mockOrgLandingPageProps as mockProps
|
||||||
} from '@cio/ui/custom/org-landing-page';
|
} from '@cio/ui/custom/org-landing-page';
|
||||||
import { mockProps } from './fixtures';
|
|
||||||
|
|
||||||
const { Story } = defineMeta({
|
const { Story } = defineMeta({
|
||||||
title: 'Templates/Org Landing Page',
|
title: 'Templates/Org Landing Page',
|
||||||
@@ -19,6 +21,27 @@
|
|||||||
},
|
},
|
||||||
tags: ['autodocs']
|
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`
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Story name="Minimal">
|
<Story name="Minimal">
|
||||||
@@ -52,3 +75,19 @@
|
|||||||
<Story name="Terminal">
|
<Story name="Terminal">
|
||||||
<TerminalLandingPage {...mockProps} />
|
<TerminalLandingPage {...mockProps} />
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
|
<Story name="Editorial">
|
||||||
|
<EditorialLandingPage {...mockProps} />
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story name="Vibrant">
|
||||||
|
<VibrantLandingPage {...mockProps} />
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Demonstrates the OrgLandingPageLabels override surface. Every UI-chrome
|
||||||
|
string is replaced via the `labels` prop; per-template defaults disappear.
|
||||||
|
-->
|
||||||
|
<Story name="Editorial (localized labels)">
|
||||||
|
<EditorialLandingPage {...mockProps} labels={localizedLabels} />
|
||||||
|
</Story>
|
||||||
|
|||||||
@@ -160,7 +160,7 @@
|
|||||||
{#if isYouTube && youtubeVideoId}
|
{#if isYouTube && youtubeVideoId}
|
||||||
<div
|
<div
|
||||||
bind:this={containerElement}
|
bind:this={containerElement}
|
||||||
class="plyr__video-embed ui:absolute ui:inset-0 ui:h-full ui:w-full"
|
class="plyr__video-embed ui:h-full ui:w-full"
|
||||||
data-plyr-provider="youtube"
|
data-plyr-provider="youtube"
|
||||||
data-plyr-embed-id={youtubeVideoId}
|
data-plyr-embed-id={youtubeVideoId}
|
||||||
></div>
|
></div>
|
||||||
@@ -171,7 +171,7 @@
|
|||||||
{src}
|
{src}
|
||||||
{poster}
|
{poster}
|
||||||
{playsinline}
|
{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)}
|
{#each tracks as track (track.src + track.srclang)}
|
||||||
<track
|
<track
|
||||||
|
|||||||
@@ -6,14 +6,10 @@
|
|||||||
import OrgLandingPageFooter from './landing-page-footer.svelte';
|
import OrgLandingPageFooter from './landing-page-footer.svelte';
|
||||||
import BoldNav from './bold/nav.svelte';
|
import BoldNav from './bold/nav.svelte';
|
||||||
import BoldHero from './bold/hero.svelte';
|
import BoldHero from './bold/hero.svelte';
|
||||||
|
import BoldCourseCard from './bold/course-card.svelte';
|
||||||
import { Button } from '../../base/button';
|
import { Button } from '../../base/button';
|
||||||
import * as Card from '../../base/card';
|
|
||||||
import { BlurFade } from '../animation/blurfade';
|
|
||||||
import { BorderBeam } from '../animation/border-beam';
|
|
||||||
import { DotPattern } from '../animation/dot-pattern';
|
import { DotPattern } from '../animation/dot-pattern';
|
||||||
import ClockIcon from '@lucide/svelte/icons/clock';
|
import { themeStyle } from './theme-style';
|
||||||
import TagIcon from '@lucide/svelte/icons/tag';
|
|
||||||
import { getCourseTypeLandingMeta, getPrimaryCourseTag } from './landing-page-utils';
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
orgName,
|
orgName,
|
||||||
@@ -27,108 +23,29 @@
|
|||||||
embed,
|
embed,
|
||||||
callout,
|
callout,
|
||||||
links,
|
links,
|
||||||
footer
|
footer,
|
||||||
|
labels
|
||||||
}: OrgLandingPageProps = $props();
|
}: OrgLandingPageProps = $props();
|
||||||
|
|
||||||
function formatCurrency(cost?: number, currency = 'USD') {
|
|
||||||
if (!cost) return 'Free';
|
|
||||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cost);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="ui:min-h-screen ui:bg-background ui:text-foreground ui:font-sans">
|
<div
|
||||||
|
class="ui:min-h-screen ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:font-sans"
|
||||||
|
style={themeStyle('bold')}
|
||||||
|
>
|
||||||
<BoldNav {orgName} {logoUrl} {navItems} {authAction} />
|
<BoldNav {orgName} {logoUrl} {navItems} {authAction} />
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<BoldHero {hero} />
|
<BoldHero {hero} />
|
||||||
|
|
||||||
<section class="ui:relative ui:bg-muted/30 ui:px-6 ui:overflow-hidden ui:mb-12">
|
<section class="ui:relative ui:bg-[var(--landing-card-soft)]/30 ui:px-6 ui:overflow-hidden ui:mb-12">
|
||||||
<DotPattern class="ui:opacity-[0.15]" />
|
<DotPattern class="ui:opacity-[0.15]" />
|
||||||
<div class="ui:relative ui:max-w-7xl ui:mx-auto">
|
<div class="ui:relative ui:max-w-7xl ui:mx-auto">
|
||||||
<div class="ui:flex ui:items-end ui:justify-between ui:mb-12">
|
<div class="ui:flex ui:items-end ui:justify-between ui:mb-12">
|
||||||
<h2 class="ui:text-4xl ui:font-black ui:tracking-tight">Latest Courses</h2>
|
<h2 class="ui:text-4xl ui:font-black ui:tracking-tight">{labels?.catalogHeading ?? 'Latest Courses'}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-8">
|
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-8">
|
||||||
{#each courses as course, index (course.id)}
|
{#each courses as course, index (course.id)}
|
||||||
{@const courseTypeMeta = getCourseTypeLandingMeta(course)}
|
<BoldCourseCard {course} {disableCourseLinks} {labels} />
|
||||||
{@const primaryTag = getPrimaryCourseTag(course)}
|
|
||||||
<BlurFade delay={0.1 * index} once={true}>
|
|
||||||
<Card.Root
|
|
||||||
class="ui:group ui:relative ui:overflow-hidden ui:bg-background ui:rounded-3xl ui:p-4 ui:flex-col ui:sm:flex-row ui:gap-6 ui:duration-300 ui:h-full ui:border-border/50 {disableCourseLinks
|
|
||||||
? ''
|
|
||||||
: 'ui:hover:shadow-xl ui:hover:shadow-primary/5 ui:transition-all'}"
|
|
||||||
>
|
|
||||||
<BorderBeam
|
|
||||||
size={120}
|
|
||||||
duration={8}
|
|
||||||
colorFrom="var(--primary)"
|
|
||||||
colorTo="color-mix(in oklab, var(--primary) 65%, white)"
|
|
||||||
/>
|
|
||||||
{#if course.logo}
|
|
||||||
<img
|
|
||||||
src={course.logo}
|
|
||||||
alt={course.title}
|
|
||||||
class="ui:w-full ui:sm:w-48 ui:h-48 ui:object-cover ui:rounded-2xl ui:group-hover:scale-[1.02] ui:transition-transform ui:duration-500"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
<Card.Content class="ui:p-2 ui:flex ui:flex-col ui:justify-center ui:flex-1">
|
|
||||||
<Card.Title class="ui:text-2xl ui:font-bold ui:mb-2">{course.title}</Card.Title>
|
|
||||||
<Card.Description class="ui:mb-6 ui:line-clamp-2">{course.description}</Card.Description>
|
|
||||||
|
|
||||||
<div class="ui:flex ui:items-center ui:gap-4 ui:text-sm ui:font-semibold ui:mb-6">
|
|
||||||
{#if course.duration}
|
|
||||||
<div
|
|
||||||
class="ui:flex ui:items-center ui:gap-1.5 ui:text-muted-foreground ui:bg-muted ui:px-2.5 ui:py-1 ui:rounded-md"
|
|
||||||
>
|
|
||||||
<ClockIcon class="ui:size-3.5" />
|
|
||||||
<span>{course.duration}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if courseTypeMeta}
|
|
||||||
{@const TypeIcon = courseTypeMeta.icon}
|
|
||||||
<div
|
|
||||||
class="ui:flex ui:items-center ui:gap-1.5 ui:text-foreground ui:bg-muted ui:px-2.5 ui:py-1 ui:rounded-md"
|
|
||||||
>
|
|
||||||
<TypeIcon class={courseTypeMeta.iconClass} />
|
|
||||||
<span>{courseTypeMeta.label}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if primaryTag}
|
|
||||||
<div
|
|
||||||
class="ui:flex ui:items-center ui:gap-1.5 ui:text-foreground ui:bg-muted ui:px-2.5 ui:py-1 ui:rounded-md"
|
|
||||||
>
|
|
||||||
<TagIcon class="ui:size-3.5 ui:text-primary" />
|
|
||||||
{#if primaryTag.color}
|
|
||||||
<span
|
|
||||||
class="ui:inline-block ui:h-2 ui:w-2 ui:rounded-full"
|
|
||||||
style={`background-color: ${primaryTag.color}`}
|
|
||||||
aria-hidden="true"
|
|
||||||
></span>
|
|
||||||
{/if}
|
|
||||||
<span>{primaryTag.name}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ui:mt-auto ui:flex ui:items-center ui:justify-between">
|
|
||||||
<span class="ui:font-black ui:text-lg"
|
|
||||||
>{course.price || formatCurrency(course.cost, course.currency)}</span
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
href={disableCourseLinks ? undefined : course.link}
|
|
||||||
variant="outline"
|
|
||||||
class="ui:rounded-lg ui:font-bold ui:text-sm {disableCourseLinks
|
|
||||||
? 'ui:pointer-events-none'
|
|
||||||
: 'ui:hover:bg-primary ui:hover:text-primary-foreground ui:group-hover:bg-primary ui:group-hover:text-primary-foreground ui:transition-colors'}"
|
|
||||||
aria-disabled={disableCourseLinks}
|
|
||||||
tabindex={disableCourseLinks ? -1 : undefined}
|
|
||||||
>
|
|
||||||
Details
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card.Content>
|
|
||||||
</Card.Root>
|
|
||||||
</BlurFade>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -140,7 +57,7 @@
|
|||||||
class="ui:rounded-xl ui:px-8"
|
class="ui:rounded-xl ui:px-8"
|
||||||
disabled={disableCourseLinks}
|
disabled={disableCourseLinks}
|
||||||
>
|
>
|
||||||
View more courses
|
{labels?.browseCoursesLabel ?? 'View more courses'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -148,11 +65,11 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<OrgLandingPageLinks {links} variant="bold" />
|
<OrgLandingPageLinks {links} {labels} variant="bold" />
|
||||||
|
|
||||||
<OrgLandingPageEmbed {embed} variant="bold" />
|
<OrgLandingPageEmbed {embed} {labels} variant="bold" />
|
||||||
|
|
||||||
<OrgLandingPageCallout {callout} variant="bold" />
|
<OrgLandingPageCallout {callout} {labels} variant="bold" />
|
||||||
|
|
||||||
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="bold" />
|
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="bold" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { CourseItem, OrgLandingPageLabels } from '../types';
|
||||||
|
import * as Card from '../../../base/card';
|
||||||
|
import { Button } from '../../../base/button';
|
||||||
|
import { BorderBeam } from '../../animation/border-beam';
|
||||||
|
import ClockIcon from '@lucide/svelte/icons/clock';
|
||||||
|
import TagIcon from '@lucide/svelte/icons/tag';
|
||||||
|
import { getCourseTypeLandingMeta, getPrimaryCourseTag } from '../landing-page-utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
course: CourseItem;
|
||||||
|
disableCourseLinks?: boolean;
|
||||||
|
labels?: OrgLandingPageLabels;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { course, disableCourseLinks = false, labels }: Props = $props();
|
||||||
|
|
||||||
|
const courseTypeMeta = $derived(getCourseTypeLandingMeta(course));
|
||||||
|
const primaryTag = $derived(getPrimaryCourseTag(course));
|
||||||
|
|
||||||
|
function formatCurrency(cost?: number, currency = 'USD') {
|
||||||
|
if (!cost) return labels?.freeLabel ?? 'Free';
|
||||||
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cost);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card.Root
|
||||||
|
class="ui:group ui:relative ui:overflow-hidden ui:bg-[var(--landing-bg)] ui:rounded-3xl ui:p-4 ui:flex-col ui:sm:flex-row ui:gap-6 ui:duration-300 ui:h-full ui:border-[var(--landing-border)]/50 {disableCourseLinks
|
||||||
|
? ''
|
||||||
|
: 'ui:hover:shadow-xl ui:hover:shadow-primary/5 ui:transition-all'}"
|
||||||
|
>
|
||||||
|
<BorderBeam
|
||||||
|
size={120}
|
||||||
|
duration={8}
|
||||||
|
colorFrom="var(--landing-accent)"
|
||||||
|
colorTo="color-mix(in oklab, var(--landing-accent) 65%, white)"
|
||||||
|
/>
|
||||||
|
{#if course.logo}
|
||||||
|
<img
|
||||||
|
src={course.logo}
|
||||||
|
alt={course.title}
|
||||||
|
class="ui:w-full ui:sm:w-48 ui:h-48 ui:object-cover ui:rounded-2xl ui:group-hover:scale-[1.02] ui:transition-transform ui:duration-500"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<Card.Content class="ui:p-2 ui:flex ui:flex-col ui:justify-center ui:flex-1">
|
||||||
|
<Card.Title class="ui:text-2xl ui:font-bold ui:mb-2">{course.title}</Card.Title>
|
||||||
|
<Card.Description class="ui:mb-6 ui:line-clamp-2">{course.description}</Card.Description>
|
||||||
|
|
||||||
|
<div class="ui:flex ui:items-center ui:gap-4 ui:text-sm ui:font-semibold ui:mb-6">
|
||||||
|
{#if course.duration}
|
||||||
|
<div
|
||||||
|
class="ui:flex ui:items-center ui:gap-1.5 ui:text-[var(--landing-fg-muted)] ui:bg-[var(--landing-card-soft)] ui:px-2.5 ui:py-1 ui:rounded-md"
|
||||||
|
>
|
||||||
|
<ClockIcon class="ui:size-3.5" />
|
||||||
|
<span>{course.duration}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if courseTypeMeta}
|
||||||
|
{@const TypeIcon = courseTypeMeta.icon}
|
||||||
|
<div
|
||||||
|
class="ui:flex ui:items-center ui:gap-1.5 ui:text-[var(--landing-fg)] ui:bg-[var(--landing-card-soft)] ui:px-2.5 ui:py-1 ui:rounded-md"
|
||||||
|
>
|
||||||
|
<TypeIcon class={courseTypeMeta.iconClass} />
|
||||||
|
<span>{courseTypeMeta.label}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if primaryTag}
|
||||||
|
<div
|
||||||
|
class="ui:flex ui:items-center ui:gap-1.5 ui:text-[var(--landing-fg)] ui:bg-[var(--landing-card-soft)] ui:px-2.5 ui:py-1 ui:rounded-md"
|
||||||
|
>
|
||||||
|
<TagIcon class="ui:size-3.5 ui:text-[var(--landing-accent)]" />
|
||||||
|
{#if primaryTag.color}
|
||||||
|
<span
|
||||||
|
class="ui:inline-block ui:h-2 ui:w-2 ui:rounded-full"
|
||||||
|
style={`background-color: ${primaryTag.color}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
|
<span>{primaryTag.name}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ui:mt-auto ui:flex ui:items-center ui:justify-between">
|
||||||
|
<span class="ui:font-black ui:text-lg">{course.price || formatCurrency(course.cost, course.currency)}</span>
|
||||||
|
<Button
|
||||||
|
href={disableCourseLinks ? undefined : course.link}
|
||||||
|
variant="outline"
|
||||||
|
class="ui:rounded-lg ui:font-bold ui:text-sm {disableCourseLinks
|
||||||
|
? 'ui:pointer-events-none'
|
||||||
|
: 'ui:hover:bg-[var(--landing-accent)] ui:hover:text-[var(--landing-accent-fg)] ui:group-hover:bg-[var(--landing-accent)] ui:group-hover:text-[var(--landing-accent-fg)] ui:transition-colors'}"
|
||||||
|
aria-disabled={disableCourseLinks}
|
||||||
|
tabindex={disableCourseLinks ? -1 : undefined}
|
||||||
|
>
|
||||||
|
{labels?.enrollLabel ?? 'Details'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
import type { OrgLandingPageProps } from '../types';
|
import type { OrgLandingPageProps } from '../types';
|
||||||
import SecondaryActionButton from '../secondary-action-button.svelte';
|
import SecondaryActionButton from '../secondary-action-button.svelte';
|
||||||
import { Button } from '../../../base/button';
|
import { Button } from '../../../base/button';
|
||||||
import { BlurFade } from '../../animation/blurfade';
|
|
||||||
import EditableLandingSection from '../editable-section.svelte';
|
import EditableLandingSection from '../editable-section.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -15,19 +14,14 @@
|
|||||||
<EditableLandingSection sectionKey="hero">
|
<EditableLandingSection sectionKey="hero">
|
||||||
<section class="ui:py-10 ui:px-6 ui:max-w-7xl ui:mx-auto ui:grid ui:lg:grid-cols-2 ui:gap-16 ui:items-center">
|
<section class="ui:py-10 ui:px-6 ui:max-w-7xl ui:mx-auto ui:grid ui:lg:grid-cols-2 ui:gap-16 ui:items-center">
|
||||||
<div>
|
<div>
|
||||||
<BlurFade delay={0} once={true} class="ui:mb-6">
|
|
||||||
<h1 class="ui:text-5xl ui:lg:text-7xl ui:font-black ui:leading-[1.1] ui:tracking-tighter">{hero.heading}</h1>
|
<h1 class="ui:text-5xl ui:lg:text-7xl ui:font-black ui:leading-[1.1] ui:tracking-tighter">{hero.heading}</h1>
|
||||||
</BlurFade>
|
<p class="ui:text-xl ui:text-[var(--landing-fg-muted)] ui:mb-10 ui:leading-relaxed">{hero.subheading}</p>
|
||||||
<BlurFade delay={0.15} once={true}>
|
|
||||||
<p class="ui:text-xl ui:text-muted-foreground ui:mb-10 ui:leading-relaxed">{hero.subheading}</p>
|
|
||||||
</BlurFade>
|
|
||||||
<BlurFade delay={0.3} once={true}>
|
|
||||||
<div class="ui:flex ui:flex-wrap ui:gap-4">
|
<div class="ui:flex ui:flex-wrap ui:gap-4">
|
||||||
<Button
|
<Button
|
||||||
href={hero.primaryAction.href}
|
href={hero.primaryAction.href}
|
||||||
disabled={hero.primaryAction.disabled ?? false}
|
disabled={hero.primaryAction.disabled ?? false}
|
||||||
size="lg"
|
size="lg"
|
||||||
class="ui:rounded-xl ui:px-8 ui:py-6 ui:text-base ui:font-bold ui:shadow-lg ui:shadow-primary/20 ui:hover:-translate-y-1 ui:transition-all"
|
class="ui:rounded-xl ui:px-8 ui:py-6 ui:text-base ui:font-bold ui:shadow-lg ui:shadow-[var(--landing-accent)]/20 ui:hover:-translate-y-1 ui:transition-all"
|
||||||
>
|
>
|
||||||
{hero.primaryAction.label}
|
{hero.primaryAction.label}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -35,15 +29,12 @@
|
|||||||
<SecondaryActionButton href={hero.secondaryAction.href} label={hero.secondaryAction.label} variant="bold" />
|
<SecondaryActionButton href={hero.secondaryAction.href} label={hero.secondaryAction.label} variant="bold" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</BlurFade>
|
|
||||||
</div>
|
</div>
|
||||||
{#if hero.image}
|
{#if hero.image}
|
||||||
<BlurFade delay={0.2} once={true} class="ui:relative ui:hidden ui:lg:block">
|
|
||||||
<div
|
<div
|
||||||
class="ui:absolute ui:inset-0 ui:bg-primary/5 ui:rounded-3xl ui:transform ui:translate-x-4 ui:translate-y-4"
|
class="ui:absolute ui:inset-0 ui:bg-[var(--landing-accent)]/5 ui:rounded-3xl ui:transform ui:translate-x-4 ui:translate-y-4"
|
||||||
></div>
|
></div>
|
||||||
<img src={hero.image} alt="Hero" class="ui:relative ui:rounded-3xl ui:w-full ui:h-[500px] ui:object-cover" />
|
<img src={hero.image} alt="Hero" class="ui:relative ui:rounded-3xl ui:w-full ui:h-[500px] ui:object-cover" />
|
||||||
</BlurFade>
|
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
</EditableLandingSection>
|
</EditableLandingSection>
|
||||||
|
|||||||
@@ -17,11 +17,13 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<EditableLandingSection sectionKey="navigation">
|
<EditableLandingSection sectionKey="navigation">
|
||||||
<header class="ui:sticky ui:top-0 ui:z-10 ui:bg-background/90 ui:backdrop-blur-xl ui:border-b ui:border-border/40">
|
<header
|
||||||
|
class="ui:sticky ui:top-0 ui:z-10 ui:bg-[var(--landing-bg)]/90 ui:backdrop-blur-xl ui:border-b ui:border-[var(--landing-border)]/40"
|
||||||
|
>
|
||||||
<div class="ui:flex ui:items-center ui:justify-between ui:px-6 ui:py-5 ui:max-w-7xl ui:mx-auto">
|
<div class="ui:flex ui:items-center ui:justify-between ui:px-6 ui:py-5 ui:max-w-7xl ui:mx-auto">
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
class="ui:font-black ui:text-2xl ui:tracking-tighter ui:flex ui:items-center ui:gap-3 ui:no-underline"
|
class="ui:font-black ui:text-2xl ui:tracking-tighter ui:flex ui:items-center ui:gap-3 ui:no-underline ui:cursor-pointer"
|
||||||
>
|
>
|
||||||
{#if logoUrl}
|
{#if logoUrl}
|
||||||
<img src={logoUrl} alt={orgName} class="ui:h-8 ui:w-auto" />
|
<img src={logoUrl} alt={orgName} class="ui:h-8 ui:w-auto" />
|
||||||
@@ -33,7 +35,7 @@
|
|||||||
{#each navItems as item}
|
{#each navItems as item}
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class="ui:text-sm ui:font-bold ui:uppercase ui:tracking-widest ui:hover:text-primary ui:transition-colors"
|
class="ui:text-sm ui:font-bold ui:uppercase ui:tracking-widest ui:hover:text-[var(--landing-accent)] ui:transition-colors ui:cursor-pointer"
|
||||||
>{item.label}</a
|
>{item.label}</a
|
||||||
>
|
>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,21 +1,43 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { LandingPageCallout, OrgLandingPageTheme } from './types';
|
import type { LandingPageCallout, OrgLandingPageLabels, OrgLandingPageTheme } from './types';
|
||||||
import { Button } from '../../base/button';
|
import { Button } from '../../base/button';
|
||||||
import { BlurFade } from '../animation/blurfade';
|
|
||||||
import EditableLandingSection from './editable-section.svelte';
|
import EditableLandingSection from './editable-section.svelte';
|
||||||
|
|
||||||
let { callout, variant = 'minimal' }: { callout?: LandingPageCallout; variant?: OrgLandingPageTheme } = $props();
|
let {
|
||||||
|
callout,
|
||||||
|
variant = 'minimal',
|
||||||
|
labels
|
||||||
|
}: {
|
||||||
|
callout?: LandingPageCallout;
|
||||||
|
variant?: OrgLandingPageTheme;
|
||||||
|
labels?: OrgLandingPageLabels;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
/** Per-variant default eyebrow used when labels.calloutEyebrow is not provided. */
|
||||||
|
const defaultEyebrow = $derived<string | null>(
|
||||||
|
variant === 'saas'
|
||||||
|
? 'Get started'
|
||||||
|
: variant === 'tech'
|
||||||
|
? '// get started'
|
||||||
|
: variant === 'studio' || variant === 'corporate' || variant === 'editorial'
|
||||||
|
? 'Ready when you are'
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
const resolvedEyebrow = $derived(labels?.calloutEyebrow ?? defaultEyebrow);
|
||||||
|
|
||||||
const sectionClasses: Record<string, string> = {
|
const sectionClasses: Record<string, string> = {
|
||||||
minimal: 'ui:py-20 ui:px-6',
|
minimal: 'ui:py-20 ui:px-6',
|
||||||
bold: 'ui:py-24 ui:px-6 ui:bg-foreground ui:text-background',
|
bold: 'ui:py-24 ui:px-6 ui:bg-[var(--landing-fg)] ui:text-[var(--landing-bg)]',
|
||||||
classic: 'ui:py-20 ui:px-6 ui:bg-foreground ui:text-background',
|
classic: 'ui:py-20 ui:px-6 ui:bg-[var(--landing-fg)] ui:text-[var(--landing-bg)]',
|
||||||
saas: 'ui:py-24 ui:md:py-28 ui:px-6 ui:relative ui:overflow-hidden',
|
saas: 'ui:py-24 ui:md:py-28 ui:px-6 ui:relative ui:overflow-hidden',
|
||||||
tech: 'ui:py-24 ui:px-6 ui:bg-foreground ui:text-background',
|
tech: 'ui:py-24 ui:px-6 ui:bg-[var(--landing-fg)] ui:text-[var(--landing-bg)]',
|
||||||
studio:
|
studio:
|
||||||
'ui:py-32 ui:px-6 ui:relative ui:overflow-hidden ui:bg-background ui:text-foreground ui:border-t ui:border-border',
|
'ui:py-32 ui:px-6 ui:relative ui:overflow-hidden ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:border-t ui:border-[var(--landing-border)]',
|
||||||
corporate: 'ui:py-20 ui:px-6 ui:bg-muted/40 ui:text-foreground ui:border-t ui:border-border',
|
corporate:
|
||||||
terminal: 'ui:py-20 ui:px-6 ui:border-t ui:border-[#1c1f28] ui:text-[#e9eaed] ui:bg-[#06070a]'
|
'ui:py-20 ui:px-6 ui:bg-[var(--landing-card-soft)]/40 ui:text-[var(--landing-fg)] ui:border-t ui:border-[var(--landing-border)]',
|
||||||
|
terminal: 'ui:py-20 ui:px-6 ui:border-t ui:border-[#1c1f28] ui:text-[#e9eaed] ui:bg-[#06070a]',
|
||||||
|
editorial: 'ui:py-32 ui:md:py-40 ui:px-6 ui:bg-[#e2e1d9] ui:text-[#1a1a1a]',
|
||||||
|
vibrant: 'ui:py-32 ui:md:py-40 ui:px-6 ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:text-center'
|
||||||
};
|
};
|
||||||
|
|
||||||
const headingClasses: Record<string, string> = {
|
const headingClasses: Record<string, string> = {
|
||||||
@@ -26,59 +48,61 @@
|
|||||||
tech: 'ui:text-4xl ui:lg:text-5xl ui:font-extrabold ui:tracking-tight',
|
tech: 'ui:text-4xl ui:lg:text-5xl ui:font-extrabold ui:tracking-tight',
|
||||||
studio: 'ui:text-5xl ui:lg:text-6xl ui:font-semibold ui:tracking-tight ui:leading-[1.02]',
|
studio: 'ui:text-5xl ui:lg:text-6xl ui:font-semibold ui:tracking-tight ui:leading-[1.02]',
|
||||||
corporate: 'ui:text-3xl ui:lg:text-4xl ui:font-semibold ui:tracking-tight',
|
corporate: 'ui:text-3xl ui:lg:text-4xl ui:font-semibold ui:tracking-tight',
|
||||||
terminal: 'ui:text-[22px] ui:font-semibold ui:tracking-tight'
|
terminal: 'ui:text-[22px] ui:font-semibold ui:tracking-tight',
|
||||||
|
editorial: 'ui:text-4xl ui:md:text-5xl ui:lg:text-[56px] ui:font-medium ui:tracking-tight ui:leading-[1.05]',
|
||||||
|
vibrant: 'ui:text-5xl ui:md:text-6xl ui:lg:text-[80px] ui:font-medium ui:tracking-tight ui:leading-[1.02]'
|
||||||
};
|
};
|
||||||
|
|
||||||
const descriptionClasses: Record<string, string> = {
|
const descriptionClasses: Record<string, string> = {
|
||||||
minimal: 'ui:text-lg ui:text-foreground/70',
|
minimal: 'ui:text-lg ui:text-[var(--landing-fg)]/70',
|
||||||
bold: 'ui:text-xl ui:text-background/70',
|
bold: 'ui:text-xl ui:text-[var(--landing-bg)]/70',
|
||||||
classic: 'ui:text-lg ui:text-background/80',
|
classic: 'ui:text-lg ui:text-[var(--landing-bg)]/80',
|
||||||
saas: 'ui:text-base ui:md:text-lg ui:text-muted-foreground ui:max-w-xl ui:mx-auto',
|
saas: 'ui:text-base ui:md:text-lg ui:text-[var(--landing-fg-muted)] ui:max-w-xl ui:mx-auto',
|
||||||
tech: 'ui:text-lg ui:text-background/70 ui:font-mono',
|
tech: 'ui:text-lg ui:text-[var(--landing-bg)]/70 ui:font-mono',
|
||||||
studio: 'ui:text-base ui:text-muted-foreground ui:max-w-xl ui:mx-auto',
|
studio: 'ui:text-base ui:text-[var(--landing-fg-muted)] ui:max-w-xl ui:mx-auto',
|
||||||
corporate: 'ui:text-base ui:text-muted-foreground ui:max-w-xl ui:mx-auto',
|
corporate: 'ui:text-base ui:text-[var(--landing-fg-muted)] ui:max-w-xl ui:mx-auto',
|
||||||
terminal: 'ui:text-sm ui:max-w-md ui:mx-auto ui:text-[#9da1ab]'
|
terminal: 'ui:text-sm ui:max-w-md ui:mx-auto ui:text-[#9da1ab]',
|
||||||
|
editorial: 'ui:text-base ui:md:text-lg ui:text-[#76746c] ui:max-w-xl ui:mx-auto',
|
||||||
|
vibrant: 'ui:text-lg ui:text-[var(--landing-fg-muted)] ui:max-w-xl ui:mx-auto'
|
||||||
};
|
};
|
||||||
|
|
||||||
const buttonClasses: Record<string, string> = {
|
const buttonClasses: Record<string, string> = {
|
||||||
minimal: 'ui:rounded-full',
|
minimal: 'ui:rounded-full',
|
||||||
bold: 'ui:rounded-xl ui:px-8 ui:py-6 ui:text-base ui:font-bold ui:shadow-lg ui:shadow-primary/20',
|
bold: 'ui:rounded-xl ui:px-8 ui:py-6 ui:text-base ui:font-bold ui:shadow-lg ui:shadow-[var(--landing-accent)]/20',
|
||||||
classic: 'ui:px-8 ui:font-semibold',
|
classic: 'ui:px-8 ui:font-semibold',
|
||||||
saas: 'ui:rounded-full ui:px-7 ui:font-semibold ui:shadow-md ui:shadow-primary/20',
|
saas: 'ui:rounded-full ui:px-7 ui:font-semibold ui:shadow-md ui:shadow-[var(--landing-accent)]/20',
|
||||||
tech: 'ui:rounded-none ui:px-8 ui:font-semibold',
|
tech: 'ui:rounded-none ui:px-8 ui:font-semibold',
|
||||||
studio: 'ui:rounded-md ui:px-5 ui:font-medium ui:shadow-lg ui:shadow-primary/25',
|
studio: 'ui:rounded-md ui:px-5 ui:font-medium ui:shadow-lg ui:shadow-[var(--landing-accent)]/25',
|
||||||
corporate: 'ui:rounded-none ui:px-8 ui:font-medium',
|
corporate: 'ui:rounded-none ui:px-8 ui:font-medium',
|
||||||
terminal:
|
terminal:
|
||||||
'ui:rounded-full ui:px-4 ui:font-mono ui:text-[13px] ui:bg-[#0f1218] ui:text-[#e9eaed] ui:border ui:border-[#262a35] ui:hover:bg-[#14171f]'
|
'ui:rounded-full ui:px-4 ui:font-mono ui:text-[13px] ui:bg-[#0f1218] ui:text-[#e9eaed] ui:border ui:border-[#262a35] ui:hover:bg-[#14171f]',
|
||||||
|
editorial:
|
||||||
|
'ui:rounded-full ui:px-6 ui:font-medium ui:bg-[#1a1a1a] ui:text-[#fafaf5] ui:hover:bg-[#2c2b29] ui:transition-colors',
|
||||||
|
vibrant:
|
||||||
|
'ui:rounded-md ui:px-7 ui:py-3 ui:text-base ui:font-medium ui:bg-[var(--landing-accent)] ui:text-[var(--landing-accent-fg)] ui:hover:bg-[var(--landing-accent)]/90 ui:transition-colors'
|
||||||
};
|
};
|
||||||
|
|
||||||
const eyebrowClasses: Record<string, string> = {
|
const eyebrowClasses: Record<string, string> = {
|
||||||
minimal: '',
|
minimal: '',
|
||||||
bold: '',
|
bold: '',
|
||||||
classic: '',
|
classic: '',
|
||||||
saas: 'ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-primary ui:mb-1',
|
saas: 'ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-[var(--landing-accent)] ui:mb-1',
|
||||||
tech: 'ui:font-mono ui:text-xs ui:tracking-widest ui:uppercase ui:text-background/70 ui:mb-1',
|
tech: 'ui:font-mono ui:text-xs ui:tracking-widest ui:uppercase ui:text-[var(--landing-bg)]/70 ui:mb-1',
|
||||||
studio: 'ui:text-sm ui:text-muted-foreground ui:inline-flex ui:items-center ui:gap-2 ui:mb-2',
|
studio: 'ui:text-sm ui:text-[var(--landing-fg-muted)] ui:inline-flex ui:items-center ui:gap-2 ui:mb-2',
|
||||||
corporate: 'ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-foreground ui:mb-1',
|
corporate: 'ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-[var(--landing-fg)] ui:mb-1',
|
||||||
terminal: ''
|
terminal: '',
|
||||||
|
editorial: 'ui:text-sm ui:text-[#76746c] ui:mb-1',
|
||||||
|
vibrant: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
const eyebrowLabels: Record<string, string | null> = {
|
// Default eyebrow per variant is computed above as `defaultEyebrow`, resolved
|
||||||
minimal: null,
|
// into `resolvedEyebrow` (which prefers labels.calloutEyebrow when provided).
|
||||||
bold: null,
|
|
||||||
classic: null,
|
|
||||||
saas: 'Get started',
|
|
||||||
tech: '// get started',
|
|
||||||
studio: 'Ready when you are',
|
|
||||||
corporate: 'Ready when you are',
|
|
||||||
terminal: null
|
|
||||||
};
|
|
||||||
|
|
||||||
const minimalBackground = `
|
const minimalBackground = `
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
135deg,
|
135deg,
|
||||||
color-mix(in oklab, var(--primary) 10%, var(--background)) 0%,
|
color-mix(in oklab, var(--landing-accent) 10%, var(--landing-bg)) 0%,
|
||||||
color-mix(in oklab, var(--primary) 4%, var(--background)) 100%
|
color-mix(in oklab, var(--landing-accent) 4%, var(--landing-bg)) 100%
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
</script>
|
</script>
|
||||||
@@ -93,7 +117,7 @@
|
|||||||
<!-- soft radial glow at the bottom -->
|
<!-- soft radial glow at the bottom -->
|
||||||
<div
|
<div
|
||||||
class="ui:pointer-events-none ui:absolute ui:left-1/2 ui:bottom-[-220px] ui:size-[800px] ui:-translate-x-1/2"
|
class="ui:pointer-events-none ui:absolute ui:left-1/2 ui:bottom-[-220px] ui:size-[800px] ui:-translate-x-1/2"
|
||||||
style="background: radial-gradient(ellipse at center, color-mix(in oklab, var(--primary) 28%, transparent) 0%, transparent 60%); filter: blur(40px);"
|
style="background: radial-gradient(ellipse at center, color-mix(in oklab, var(--landing-accent) 28%, transparent) 0%, transparent 60%); filter: blur(40px);"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></div>
|
></div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -101,36 +125,29 @@
|
|||||||
<!-- soft accent ring; the parent .frame already provides the dot grid + side lines -->
|
<!-- soft accent ring; the parent .frame already provides the dot grid + side lines -->
|
||||||
<div
|
<div
|
||||||
class="ui:pointer-events-none ui:absolute ui:top-1/2 ui:left-1/2 ui:size-[640px] ui:-translate-x-1/2 ui:-translate-y-1/2 ui:opacity-60"
|
class="ui:pointer-events-none ui:absolute ui:top-1/2 ui:left-1/2 ui:size-[640px] ui:-translate-x-1/2 ui:-translate-y-1/2 ui:opacity-60"
|
||||||
style="background: radial-gradient(ellipse at center, color-mix(in oklab, var(--primary) 14%, transparent) 0%, transparent 60%);"
|
style="background: radial-gradient(ellipse at center, color-mix(in oklab, var(--landing-accent) 14%, transparent) 0%, transparent 60%);"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></div>
|
></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="ui:relative ui:mx-auto ui:flex ui:max-w-3xl ui:flex-col ui:items-center ui:gap-6 ui:text-center">
|
<div class="ui:relative ui:mx-auto ui:flex ui:max-w-3xl ui:flex-col ui:items-center ui:gap-6 ui:text-center">
|
||||||
{#if eyebrowLabels[variant]}
|
{#if resolvedEyebrow}
|
||||||
<BlurFade delay={0} once={true}>
|
|
||||||
{#if variant === 'studio'}
|
{#if variant === 'studio'}
|
||||||
<p class={eyebrowClasses[variant]}>
|
<p class={eyebrowClasses[variant]}>
|
||||||
<span class="ui:size-1.5 ui:rounded-full ui:bg-primary"></span>
|
<span class="ui:size-1.5 ui:rounded-full ui:bg-[var(--landing-accent)]"></span>
|
||||||
{eyebrowLabels[variant]}
|
{resolvedEyebrow}
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class={eyebrowClasses[variant]}>{eyebrowLabels[variant]}</p>
|
<p class={eyebrowClasses[variant]}>{resolvedEyebrow}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</BlurFade>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<BlurFade delay={0.05} once={true}>
|
|
||||||
<h2 class={headingClasses[variant]}>{callout.heading}</h2>
|
<h2 class={headingClasses[variant]}>{callout.heading}</h2>
|
||||||
</BlurFade>
|
|
||||||
|
|
||||||
{#if callout.description}
|
{#if callout.description}
|
||||||
<BlurFade delay={0.15} once={true}>
|
|
||||||
<p class={descriptionClasses[variant]}>{callout.description}</p>
|
<p class={descriptionClasses[variant]}>{callout.description}</p>
|
||||||
</BlurFade>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<BlurFade delay={0.3} once={true}>
|
|
||||||
<Button
|
<Button
|
||||||
href={callout.action.href}
|
href={callout.action.href}
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -139,7 +156,6 @@
|
|||||||
>
|
>
|
||||||
{callout.action.label}
|
{callout.action.label}
|
||||||
</Button>
|
</Button>
|
||||||
</BlurFade>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</EditableLandingSection>
|
</EditableLandingSection>
|
||||||
|
|||||||
@@ -6,12 +6,9 @@
|
|||||||
import OrgLandingPageFooter from './landing-page-footer.svelte';
|
import OrgLandingPageFooter from './landing-page-footer.svelte';
|
||||||
import ClassicNav from './classic/nav.svelte';
|
import ClassicNav from './classic/nav.svelte';
|
||||||
import ClassicHero from './classic/hero.svelte';
|
import ClassicHero from './classic/hero.svelte';
|
||||||
|
import ClassicCourseCard from './classic/course-card.svelte';
|
||||||
import { Button } from '../../base/button';
|
import { Button } from '../../base/button';
|
||||||
import * as Item from '../../base/item';
|
import { themeStyle } from './theme-style';
|
||||||
import { BlurFade } from '../animation/blurfade';
|
|
||||||
import { CourseCard } from '../course-card';
|
|
||||||
import { calcCourseDiscount, formatExerciseCountLabel, getCourseTypeLandingMeta } from './landing-page-utils';
|
|
||||||
import { cn } from '../../tools';
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
orgName,
|
orgName,
|
||||||
@@ -25,11 +22,15 @@
|
|||||||
embed,
|
embed,
|
||||||
callout,
|
callout,
|
||||||
links,
|
links,
|
||||||
footer
|
footer,
|
||||||
|
labels
|
||||||
}: OrgLandingPageProps = $props();
|
}: OrgLandingPageProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="ui:min-h-screen ui:bg-muted/10 ui:text-foreground ui:font-sans">
|
<div
|
||||||
|
class="ui:min-h-screen ui:bg-[var(--landing-bg-section)] ui:text-[var(--landing-fg)] ui:font-sans"
|
||||||
|
style={themeStyle('classic')}
|
||||||
|
>
|
||||||
<ClassicNav {orgName} {logoUrl} {navItems} {authAction} />
|
<ClassicNav {orgName} {logoUrl} {navItems} {authAction} />
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
@@ -37,93 +38,15 @@
|
|||||||
|
|
||||||
<section class="ui:py-10 ui:px-6 ui:lg:px-8 ui:max-w-7xl ui:mx-auto">
|
<section class="ui:py-10 ui:px-6 ui:lg:px-8 ui:max-w-7xl ui:mx-auto">
|
||||||
<div class="ui:text-center ui:mb-8">
|
<div class="ui:text-center ui:mb-8">
|
||||||
<BlurFade delay={0} once={true}>
|
<h2 class="ui:text-3xl ui:font-bold ui:text-[var(--landing-fg)]">
|
||||||
<h2 class="ui:text-3xl ui:font-bold ui:text-foreground">Featured Programs</h2>
|
{labels?.catalogHeading ?? 'Featured Programs'}
|
||||||
</BlurFade>
|
</h2>
|
||||||
<BlurFade delay={0.15} once={true}>
|
<div class="ui:mt-6 ui:h-px ui:w-24 ui:bg-[var(--landing-border)] ui:mx-auto"></div>
|
||||||
<div class="ui:mt-6 ui:h-px ui:w-24 ui:bg-border ui:mx-auto"></div>
|
|
||||||
</BlurFade>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ui:grid ui:grid-cols-1 ui:sm:grid-cols-2 ui:lg:grid-cols-3 ui:gap-8 ui:justify-items-center">
|
<div class="ui:grid ui:grid-cols-1 ui:sm:grid-cols-2 ui:lg:grid-cols-3 ui:gap-8 ui:justify-items-center">
|
||||||
{#each courses as course, index (course.id)}
|
{#each courses as course, index (course.id)}
|
||||||
{@const courseTypeMeta = getCourseTypeLandingMeta(course)}
|
<ClassicCourseCard {course} {disableCourseLinks} {labels} />
|
||||||
{@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'
|
|
||||||
})}
|
|
||||||
<BlurFade delay={0.1 * index} once={true} class="ui:w-full ui:flex ui:justify-center">
|
|
||||||
<CourseCard
|
|
||||||
href={disableCourseLinks
|
|
||||||
? undefined
|
|
||||||
: course.link || (course.slug ? `/course/${course.slug}` : undefined)}
|
|
||||||
bannerImage={course.logo || ''}
|
|
||||||
bannerAlt={course.title}
|
|
||||||
title={course.title}
|
|
||||||
description={course.description}
|
|
||||||
typeBadge={courseTypeMeta
|
|
||||||
? {
|
|
||||||
label: courseTypeMeta.label,
|
|
||||||
icon: courseTypeMeta.icon,
|
|
||||||
iconClass: courseTypeMeta.iconClass
|
|
||||||
}
|
|
||||||
: undefined}
|
|
||||||
class={cn('ui:group ui:relative', !disableCourseLinks && 'ui:hover:shadow-md ui:transition-shadow')}
|
|
||||||
>
|
|
||||||
{#snippet tags()}
|
|
||||||
{#if course.tags && course.tags.length > 0}
|
|
||||||
<div class="ui:flex ui:flex-wrap ui:gap-1 ui:min-w-0">
|
|
||||||
{#each course.tags as tag (tag.id)}
|
|
||||||
<Item.SubDescription
|
|
||||||
class="ui:border-border ui:inline-flex ui:shrink-0 ui:items-center ui:gap-1.5 ui:rounded-full ui:border ui:px-2"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="ui:bg-primary/60 ui:inline-block ui:h-1.5 ui:w-1.5 ui:shrink-0 ui:rounded-full"
|
|
||||||
style={tag.color ? `background-color: ${tag.color}` : undefined}
|
|
||||||
aria-hidden="true"
|
|
||||||
></span>
|
|
||||||
<span>{tag.name}</span>
|
|
||||||
</Item.SubDescription>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#snippet footer()}
|
|
||||||
<div class="ui:flex ui:justify-between ui:w-full">
|
|
||||||
<div class="ui:w-[80%]">
|
|
||||||
<p class="ui:text-xs ui:pl-2 ui:dark:text-white">
|
|
||||||
<span>{course.lessonCount || 0} lessons</span>
|
|
||||||
&
|
|
||||||
<span>{formatExerciseCountLabel(course.exerciseCount ?? 0)}</span>
|
|
||||||
</p>
|
|
||||||
<div class="ui:py-2 ui:text-xs">
|
|
||||||
{#if course.price != null && String(course.price).length > 0}
|
|
||||||
<span class="ui:px-2">{course.price}</span>
|
|
||||||
{:else if !displayCost}
|
|
||||||
<span class="ui:px-2">Free</span>
|
|
||||||
{:else if pricingData.showDiscount}
|
|
||||||
<span class="ui:px-2">
|
|
||||||
{currencyFormatter.format(displayCost)}
|
|
||||||
<span class="line-through">
|
|
||||||
{currencyFormatter.format(pricingData.cost)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<span class="ui:px-2">{currencyFormatter.format(displayCost)}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</CourseCard>
|
|
||||||
</BlurFade>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -136,18 +59,18 @@
|
|||||||
class="ui:px-8"
|
class="ui:px-8"
|
||||||
disabled={disableCourseLinks}
|
disabled={disableCourseLinks}
|
||||||
>
|
>
|
||||||
View more courses
|
{labels?.browseCoursesLabel ?? 'View more courses'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<OrgLandingPageLinks {links} variant="classic" />
|
<OrgLandingPageLinks {links} {labels} variant="classic" />
|
||||||
|
|
||||||
<OrgLandingPageEmbed {embed} variant="classic" />
|
<OrgLandingPageEmbed {embed} {labels} variant="classic" />
|
||||||
|
|
||||||
<OrgLandingPageCallout {callout} variant="classic" />
|
<OrgLandingPageCallout {callout} {labels} variant="classic" />
|
||||||
|
|
||||||
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="classic" />
|
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="classic" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { CourseItem, OrgLandingPageLabels } from '../types';
|
||||||
|
import * as Item from '../../../base/item';
|
||||||
|
import { CourseCard } from '../../course-card';
|
||||||
|
import { calcCourseDiscount, formatExerciseCountLabel, getCourseTypeLandingMeta } from '../landing-page-utils';
|
||||||
|
import { cn } from '../../../tools';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
course: CourseItem;
|
||||||
|
disableCourseLinks?: boolean;
|
||||||
|
labels?: OrgLandingPageLabels;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { course, disableCourseLinks = false, labels }: Props = $props();
|
||||||
|
|
||||||
|
const courseTypeMeta = $derived(getCourseTypeLandingMeta(course));
|
||||||
|
const pricingData = $derived({
|
||||||
|
cost: course.cost ?? 0,
|
||||||
|
discount: course.metadata?.discount ?? 0,
|
||||||
|
showDiscount: !!course.metadata?.showDiscount
|
||||||
|
});
|
||||||
|
const displayCost = $derived(calcCourseDiscount(pricingData.discount, pricingData.cost, pricingData.showDiscount));
|
||||||
|
const currencyFormatter = $derived(
|
||||||
|
new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: course.currency ?? 'USD'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const href = $derived.by(() => {
|
||||||
|
if (disableCourseLinks) return undefined;
|
||||||
|
return course.link || (course.slug ? `/course/${course.slug}` : undefined);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CourseCard
|
||||||
|
{href}
|
||||||
|
bannerImage={course.logo || ''}
|
||||||
|
bannerAlt={course.title}
|
||||||
|
title={course.title}
|
||||||
|
description={course.description}
|
||||||
|
typeBadge={courseTypeMeta
|
||||||
|
? {
|
||||||
|
label: courseTypeMeta.label,
|
||||||
|
icon: courseTypeMeta.icon,
|
||||||
|
iconClass: courseTypeMeta.iconClass
|
||||||
|
}
|
||||||
|
: undefined}
|
||||||
|
class={cn('ui:group ui:relative', !disableCourseLinks && 'ui:cursor-pointer ui:hover:shadow-md ui:transition-shadow')}
|
||||||
|
>
|
||||||
|
{#snippet tags()}
|
||||||
|
{#if course.tags && course.tags.length > 0}
|
||||||
|
<div class="ui:flex ui:flex-wrap ui:gap-1 ui:min-w-0">
|
||||||
|
{#each course.tags as tag (tag.id)}
|
||||||
|
<Item.SubDescription
|
||||||
|
class="ui:border-[var(--landing-border)] ui:inline-flex ui:shrink-0 ui:items-center ui:gap-1.5 ui:rounded-full ui:border ui:px-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="ui:bg-[var(--landing-accent)]/60 ui:inline-block ui:h-1.5 ui:w-1.5 ui:shrink-0 ui:rounded-full"
|
||||||
|
style={tag.color ? `background-color: ${tag.color}` : undefined}
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
<span>{tag.name}</span>
|
||||||
|
</Item.SubDescription>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet footer()}
|
||||||
|
<div class="ui:flex ui:justify-between ui:w-full">
|
||||||
|
<div class="ui:w-[80%]">
|
||||||
|
<p class="ui:text-xs ui:pl-2 ui:dark:text-white">
|
||||||
|
<span>{course.lessonCount || 0} lessons</span>
|
||||||
|
&
|
||||||
|
<span>{formatExerciseCountLabel(course.exerciseCount ?? 0)}</span>
|
||||||
|
</p>
|
||||||
|
<div class="ui:py-2 ui:text-xs">
|
||||||
|
{#if course.price != null && String(course.price).length > 0}
|
||||||
|
<span class="ui:px-2">{course.price}</span>
|
||||||
|
{:else if !displayCost}
|
||||||
|
<span class="ui:px-2">{labels?.freeLabel ?? 'Free'}</span>
|
||||||
|
{:else if pricingData.showDiscount}
|
||||||
|
<span class="ui:px-2">
|
||||||
|
{currencyFormatter.format(displayCost)}
|
||||||
|
<span class="line-through">
|
||||||
|
{currencyFormatter.format(pricingData.cost)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="ui:px-2">{currencyFormatter.format(displayCost)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</CourseCard>
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
import type { OrgLandingPageProps } from '../types';
|
import type { OrgLandingPageProps } from '../types';
|
||||||
import SecondaryActionButton from '../secondary-action-button.svelte';
|
import SecondaryActionButton from '../secondary-action-button.svelte';
|
||||||
import { Button } from '../../../base/button';
|
import { Button } from '../../../base/button';
|
||||||
import { BlurFade } from '../../animation/blurfade';
|
|
||||||
import { BlurIn } from '../../animation/blurin';
|
import { BlurIn } from '../../animation/blurin';
|
||||||
import { DotPattern } from '../../animation/dot-pattern';
|
import { DotPattern } from '../../animation/dot-pattern';
|
||||||
import EditableLandingSection from '../editable-section.svelte';
|
import EditableLandingSection from '../editable-section.svelte';
|
||||||
@@ -16,7 +15,7 @@
|
|||||||
|
|
||||||
<EditableLandingSection sectionKey="hero">
|
<EditableLandingSection sectionKey="hero">
|
||||||
<section
|
<section
|
||||||
class="ui:relative ui:bg-foreground ui:text-background ui:overflow-hidden ui:py-24 ui:sm:py-32 ui:px-6 ui:lg:px-8"
|
class="ui:relative ui:bg-[var(--landing-fg)] ui:text-[var(--landing-bg)] ui:overflow-hidden ui:py-24 ui:sm:py-32 ui:px-6 ui:lg:px-8"
|
||||||
>
|
>
|
||||||
{#if hero.image}
|
{#if hero.image}
|
||||||
<div class="ui:absolute ui:inset-0 ui:opacity-10">
|
<div class="ui:absolute ui:inset-0 ui:opacity-10">
|
||||||
@@ -30,10 +29,7 @@
|
|||||||
>
|
>
|
||||||
{hero.heading}
|
{hero.heading}
|
||||||
</BlurIn>
|
</BlurIn>
|
||||||
<BlurFade delay={0.15} once={true}>
|
<p class="ui:text-lg ui:sm:text-xl ui:text-[var(--landing-bg)]/80 ui:mb-10">{hero.subheading}</p>
|
||||||
<p class="ui:text-lg ui:sm:text-xl ui:text-background/80 ui:mb-10">{hero.subheading}</p>
|
|
||||||
</BlurFade>
|
|
||||||
<BlurFade delay={0.3} once={true}>
|
|
||||||
<div class="ui:flex ui:flex-col ui:sm:flex-row ui:justify-center ui:gap-4">
|
<div class="ui:flex ui:flex-col ui:sm:flex-row ui:justify-center ui:gap-4">
|
||||||
<Button
|
<Button
|
||||||
href={hero.primaryAction.href}
|
href={hero.primaryAction.href}
|
||||||
@@ -51,7 +47,6 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</BlurFade>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</EditableLandingSection>
|
</EditableLandingSection>
|
||||||
|
|||||||
@@ -17,9 +17,12 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<EditableLandingSection sectionKey="navigation">
|
<EditableLandingSection sectionKey="navigation">
|
||||||
<header class="ui:bg-foreground ui:text-background">
|
<header class="ui:bg-[var(--landing-fg)] ui:text-[var(--landing-bg)]">
|
||||||
<div class="ui:flex ui:items-center ui:justify-between ui:px-6 ui:lg:px-8 ui:h-20 ui:max-w-7xl ui:mx-auto">
|
<div class="ui:flex ui:items-center ui:justify-between ui:px-6 ui:lg:px-8 ui:h-20 ui:max-w-7xl ui:mx-auto">
|
||||||
<a href="/" class="ui:flex ui:items-center ui:gap-3 ui:text-xl ui:font-bold ui:tracking-wide ui:no-underline">
|
<a
|
||||||
|
href="/"
|
||||||
|
class="ui:flex ui:items-center ui:gap-3 ui:text-xl ui:font-bold ui:tracking-wide ui:no-underline ui:cursor-pointer"
|
||||||
|
>
|
||||||
{#if logoUrl}
|
{#if logoUrl}
|
||||||
<img src={logoUrl} alt={orgName} class="ui:h-8 ui:w-auto" />
|
<img src={logoUrl} alt={orgName} class="ui:h-8 ui:w-auto" />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -30,7 +33,7 @@
|
|||||||
{#each navItems as item}
|
{#each navItems as item}
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class="ui:text-sm ui:font-medium ui:text-background/70 ui:hover:text-background ui:transition-colors"
|
class="ui:text-sm ui:font-medium ui:text-[var(--landing-bg)]/70 ui:hover:text-[var(--landing-bg)] ui:transition-colors ui:cursor-pointer"
|
||||||
>{item.label}</a
|
>{item.label}</a
|
||||||
>
|
>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -39,7 +42,7 @@
|
|||||||
<Button
|
<Button
|
||||||
href={authAction.href}
|
href={authAction.href}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="ui:bg-transparent ui:text-background ui:border-background/40 ui:hover:bg-background/10 ui:hover:text-background ui:hover:border-background/60"
|
class="ui:bg-transparent ui:text-[var(--landing-bg)] ui:border-[var(--landing-bg)]/40 ui:hover:bg-[var(--landing-bg)]/10 ui:hover:text-[var(--landing-bg)] ui:hover:border-[var(--landing-bg)]/60"
|
||||||
>
|
>
|
||||||
{authAction.label}
|
{authAction.label}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
import OrgLandingPageFooter from './landing-page-footer.svelte';
|
import OrgLandingPageFooter from './landing-page-footer.svelte';
|
||||||
import CorporateNav from './corporate/nav.svelte';
|
import CorporateNav from './corporate/nav.svelte';
|
||||||
import CorporateHero from './corporate/hero.svelte';
|
import CorporateHero from './corporate/hero.svelte';
|
||||||
|
import CorporateCourseCard from './corporate/course-card.svelte';
|
||||||
import { Button } from '../../base/button';
|
import { Button } from '../../base/button';
|
||||||
import { BlurFade } from '../animation/blurfade';
|
import { themeStyle } from './theme-style';
|
||||||
import { getCourseTypeLandingMeta } from './landing-page-utils';
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
orgName,
|
orgName,
|
||||||
@@ -22,16 +22,15 @@
|
|||||||
embed,
|
embed,
|
||||||
callout,
|
callout,
|
||||||
links,
|
links,
|
||||||
footer
|
footer,
|
||||||
|
labels
|
||||||
}: OrgLandingPageProps = $props();
|
}: OrgLandingPageProps = $props();
|
||||||
|
|
||||||
function formatCurrency(cost?: number, currency = 'USD') {
|
|
||||||
if (!cost) return 'Free';
|
|
||||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cost);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="ui:min-h-screen ui:bg-background ui:text-foreground ui:font-sans">
|
<div
|
||||||
|
class="ui:min-h-screen ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:font-sans"
|
||||||
|
style={themeStyle('corporate')}
|
||||||
|
>
|
||||||
<main>
|
<main>
|
||||||
<CorporateHero {orgName} {hero}>
|
<CorporateHero {orgName} {hero}>
|
||||||
{#snippet navigation()}
|
{#snippet navigation()}
|
||||||
@@ -40,15 +39,17 @@
|
|||||||
</CorporateHero>
|
</CorporateHero>
|
||||||
|
|
||||||
{#if courses.length > 0}
|
{#if courses.length > 0}
|
||||||
<section class="ui:py-24 ui:px-6 ui:border-t ui:border-border">
|
<section class="ui:py-24 ui:px-6 ui:border-t ui:border-[var(--landing-border)]">
|
||||||
<div class="ui:max-w-[1120px] ui:mx-auto">
|
<div class="ui:max-w-[1120px] ui:mx-auto">
|
||||||
<div class="ui:flex ui:items-end ui:justify-between ui:flex-wrap ui:gap-4 ui:mb-10">
|
<div class="ui:flex ui:items-end ui:justify-between ui:flex-wrap ui:gap-4 ui:mb-10">
|
||||||
<div>
|
<div>
|
||||||
<p class="ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-foreground ui:mb-3">
|
<p
|
||||||
Catalog
|
class="ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-[var(--landing-fg)] ui:mb-3"
|
||||||
|
>
|
||||||
|
{labels?.catalogEyebrow ?? 'Catalog'}
|
||||||
</p>
|
</p>
|
||||||
<h2 class="ui:text-3xl ui:lg:text-4xl ui:font-semibold ui:tracking-tight ui:m-0 ui:max-w-xl">
|
<h2 class="ui:text-3xl ui:lg:text-4xl ui:font-semibold ui:tracking-tight ui:m-0 ui:max-w-xl">
|
||||||
Programs your teams are taking
|
{labels?.catalogHeading ?? 'Programs your teams are taking'}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
{#if hasMoreCourses}
|
{#if hasMoreCourses}
|
||||||
@@ -58,50 +59,16 @@
|
|||||||
class="ui:rounded-none ui:font-medium"
|
class="ui:rounded-none ui:font-medium"
|
||||||
disabled={disableCourseLinks}
|
disabled={disableCourseLinks}
|
||||||
>
|
>
|
||||||
Browse catalog →
|
{labels?.browseCoursesLabel ?? 'Browse catalog →'}
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:border-t ui:border-l ui:border-border"
|
class="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)]"
|
||||||
>
|
>
|
||||||
{#each courses as course, index (course.id)}
|
{#each courses as course, index (course.id)}
|
||||||
{@const courseTypeMeta = getCourseTypeLandingMeta(course)}
|
<CorporateCourseCard {course} {disableCourseLinks} {labels} />
|
||||||
<BlurFade delay={0.06 * index} once={true}>
|
|
||||||
<a
|
|
||||||
href={disableCourseLinks
|
|
||||||
? undefined
|
|
||||||
: course.link || (course.slug ? `/course/${course.slug}` : undefined)}
|
|
||||||
class="ui:flex ui:flex-col ui:h-full ui:p-6 ui:gap-2 ui:border-r ui:border-b ui:border-border ui:bg-background ui:transition-colors ui:no-underline {disableCourseLinks
|
|
||||||
? 'ui:cursor-default'
|
|
||||||
: 'ui:hover:bg-muted/30'}"
|
|
||||||
aria-disabled={disableCourseLinks}
|
|
||||||
tabindex={disableCourseLinks ? -1 : undefined}
|
|
||||||
>
|
|
||||||
{#if courseTypeMeta}
|
|
||||||
<span
|
|
||||||
class="ui:text-[11px] ui:font-semibold ui:tracking-widest ui:uppercase ui:text-muted-foreground ui:mb-1"
|
|
||||||
>
|
|
||||||
{courseTypeMeta.label}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
<h3 class="ui:text-lg ui:font-semibold ui:tracking-tight ui:leading-snug ui:m-0">
|
|
||||||
{course.title}
|
|
||||||
</h3>
|
|
||||||
<p class="ui:text-sm ui:text-muted-foreground ui:leading-relaxed ui:line-clamp-3 ui:flex-1 ui:m-0">
|
|
||||||
{course.description}
|
|
||||||
</p>
|
|
||||||
<div class="ui:flex ui:items-center ui:justify-between ui:pt-4 ui:mt-3 ui:border-t ui:border-border">
|
|
||||||
<span class="ui:text-sm ui:font-semibold">
|
|
||||||
{course.price || formatCurrency(course.cost, course.currency)}
|
|
||||||
</span>
|
|
||||||
<span class="ui:text-sm ui:text-foreground ui:underline ui:underline-offset-4" aria-hidden="true">
|
|
||||||
Enroll →
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</BlurFade>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,11 +76,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<OrgLandingPageLinks {links} variant="corporate" />
|
<OrgLandingPageLinks {links} {labels} variant="corporate" />
|
||||||
|
|
||||||
<OrgLandingPageEmbed {embed} variant="corporate" />
|
<OrgLandingPageEmbed {embed} {labels} variant="corporate" />
|
||||||
|
|
||||||
<OrgLandingPageCallout {callout} variant="corporate" />
|
<OrgLandingPageCallout {callout} {labels} variant="corporate" />
|
||||||
|
|
||||||
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="corporate" />
|
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="corporate" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { CourseItem, OrgLandingPageLabels } from '../types';
|
||||||
|
import { getCourseTypeLandingMeta } from '../landing-page-utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
course: CourseItem;
|
||||||
|
disableCourseLinks?: boolean;
|
||||||
|
labels?: OrgLandingPageLabels;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { course, disableCourseLinks = false, labels }: Props = $props();
|
||||||
|
|
||||||
|
const courseTypeMeta = $derived(getCourseTypeLandingMeta(course));
|
||||||
|
|
||||||
|
const href = $derived.by(() => {
|
||||||
|
if (disableCourseLinks) return undefined;
|
||||||
|
return course.link || (course.slug ? `/course/${course.slug}` : undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatCurrency(cost?: number, currency = 'USD') {
|
||||||
|
if (!cost) return labels?.freeLabel ?? 'Free';
|
||||||
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cost);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a
|
||||||
|
{href}
|
||||||
|
class="ui:flex ui:flex-col ui:h-full ui:p-6 ui:gap-2 ui:border-r ui:border-b ui:border-[var(--landing-border)] ui:bg-[var(--landing-bg)] ui:transition-colors ui:no-underline {disableCourseLinks
|
||||||
|
? 'ui:cursor-default'
|
||||||
|
: 'ui:cursor-pointer ui:hover:bg-[var(--landing-card-soft)]/30'}"
|
||||||
|
aria-disabled={disableCourseLinks}
|
||||||
|
tabindex={disableCourseLinks ? -1 : undefined}
|
||||||
|
>
|
||||||
|
{#if courseTypeMeta}
|
||||||
|
<span
|
||||||
|
class="ui:text-[11px] ui:font-semibold ui:tracking-widest ui:uppercase ui:text-[var(--landing-fg-muted)] ui:mb-1"
|
||||||
|
>
|
||||||
|
{courseTypeMeta.label}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<h3 class="ui:text-lg ui:font-semibold ui:tracking-tight ui:leading-snug ui:m-0">
|
||||||
|
{course.title}
|
||||||
|
</h3>
|
||||||
|
<p class="ui:text-sm ui:text-[var(--landing-fg-muted)] ui:leading-relaxed ui:line-clamp-3 ui:flex-1 ui:m-0">
|
||||||
|
{course.description}
|
||||||
|
</p>
|
||||||
|
<div class="ui:flex ui:items-center ui:justify-between ui:pt-4 ui:mt-3 ui:border-t ui:border-[var(--landing-border)]">
|
||||||
|
<span class="ui:text-sm ui:font-semibold">
|
||||||
|
{course.price || formatCurrency(course.cost, course.currency)}
|
||||||
|
</span>
|
||||||
|
<span class="ui:text-sm ui:text-[var(--landing-fg)] ui:underline ui:underline-offset-4" aria-hidden="true">
|
||||||
|
{labels?.enrollLabel ?? 'Enroll →'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import SecondaryActionButton from '../secondary-action-button.svelte';
|
import SecondaryActionButton from '../secondary-action-button.svelte';
|
||||||
import { Button } from '../../../base/button';
|
import { Button } from '../../../base/button';
|
||||||
import { BlurFade } from '../../animation/blurfade';
|
|
||||||
import EditableLandingSection from '../editable-section.svelte';
|
import EditableLandingSection from '../editable-section.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -22,35 +21,32 @@
|
|||||||
<EditableLandingSection sectionKey="hero">
|
<EditableLandingSection sectionKey="hero">
|
||||||
<section class="ui:px-6 ui:pt-12 ui:pb-24 ui:md:pt-16 ui:md:pb-28">
|
<section class="ui:px-6 ui:pt-12 ui:pb-24 ui:md:pt-16 ui:md:pb-28">
|
||||||
<div class="ui:max-w-[1120px] ui:mx-auto">
|
<div class="ui:max-w-[1120px] ui:mx-auto">
|
||||||
<BlurFade delay={0} once={true}>
|
<div class="ui:flex ui:items-center ui:gap-4 ui:pb-5 ui:mb-10 ui:border-b ui:border-[var(--landing-border)]">
|
||||||
<div class="ui:flex ui:items-center ui:gap-4 ui:pb-5 ui:mb-10 ui:border-b ui:border-border">
|
<span class="ui:text-[11px] ui:font-semibold ui:tracking-[0.2em] ui:uppercase ui:text-[var(--landing-fg)]">
|
||||||
<span class="ui:text-[11px] ui:font-semibold ui:tracking-[0.2em] ui:uppercase ui:text-foreground">
|
|
||||||
{orgName}
|
{orgName}
|
||||||
</span>
|
</span>
|
||||||
<span class="ui:h-px ui:flex-1 ui:bg-border" aria-hidden="true"></span>
|
<span class="ui:h-px ui:flex-1 ui:bg-[var(--landing-border)]" aria-hidden="true"></span>
|
||||||
<span class="ui:text-[11px] ui:font-semibold ui:tracking-[0.2em] ui:uppercase ui:text-muted-foreground">
|
<span
|
||||||
|
class="ui:text-[11px] ui:font-semibold ui:tracking-[0.2em] ui:uppercase ui:text-[var(--landing-fg-muted)]"
|
||||||
|
>
|
||||||
Learning Report
|
Learning Report
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</BlurFade>
|
|
||||||
|
|
||||||
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-12 ui:gap-x-8 ui:gap-y-12">
|
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-12 ui:gap-x-8 ui:gap-y-12">
|
||||||
<div class="ui:md:col-span-7">
|
<div class="ui:md:col-span-7">
|
||||||
<BlurFade delay={0.08} once={true}>
|
|
||||||
<h1
|
<h1
|
||||||
class="ui:text-4xl ui:md:text-5xl ui:lg:text-[56px] ui:font-semibold ui:tracking-tight ui:leading-[1.05] ui:mb-6 ui:text-foreground"
|
class="ui:text-4xl ui:md:text-5xl ui:lg:text-[56px] ui:font-semibold ui:tracking-tight ui:leading-[1.05] ui:mb-6 ui:text-[var(--landing-fg)]"
|
||||||
>
|
>
|
||||||
{hero.heading}
|
{hero.heading}
|
||||||
</h1>
|
</h1>
|
||||||
</BlurFade>
|
|
||||||
|
|
||||||
<BlurFade delay={0.18} once={true}>
|
<p
|
||||||
<p class="ui:text-base ui:md:text-lg ui:text-muted-foreground ui:max-w-xl ui:mb-8 ui:leading-relaxed">
|
class="ui:text-base ui:md:text-lg ui:text-[var(--landing-fg-muted)] ui:max-w-xl ui:mb-8 ui:leading-relaxed"
|
||||||
|
>
|
||||||
{hero.subheading}
|
{hero.subheading}
|
||||||
</p>
|
</p>
|
||||||
</BlurFade>
|
|
||||||
|
|
||||||
<BlurFade delay={0.28} once={true}>
|
|
||||||
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-3">
|
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-3">
|
||||||
<Button
|
<Button
|
||||||
href={hero.primaryAction.href}
|
href={hero.primaryAction.href}
|
||||||
@@ -67,14 +63,12 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</BlurFade>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if stats.length > 0}
|
{#if stats.length > 0}
|
||||||
<aside class="ui:md:col-span-4 ui:md:col-start-9 ui:md:border-l ui:md:border-border ui:md:pl-8">
|
<aside class="ui:md:col-span-4 ui:md:col-start-9 ui:md:border-l ui:md:border-border ui:md:pl-8">
|
||||||
<BlurFade delay={0.32} once={true}>
|
|
||||||
<p
|
<p
|
||||||
class="ui:text-[11px] ui:font-semibold ui:tracking-[0.2em] ui:uppercase ui:text-muted-foreground ui:mb-6"
|
class="ui:text-[11px] ui:font-semibold ui:tracking-[0.2em] ui:uppercase ui:text-[var(--landing-fg-muted)] ui:mb-6"
|
||||||
>
|
>
|
||||||
At a glance
|
At a glance
|
||||||
</p>
|
</p>
|
||||||
@@ -82,25 +76,24 @@
|
|||||||
<dl class="ui:flex ui:flex-col">
|
<dl class="ui:flex ui:flex-col">
|
||||||
{#each stats as stat, index (`${stat.label}-${index}`)}
|
{#each stats as stat, index (`${stat.label}-${index}`)}
|
||||||
<div
|
<div
|
||||||
class="ui:flex ui:items-baseline ui:justify-between ui:gap-4 ui:py-4 ui:border-t ui:border-border ui:first:border-t-0"
|
class="ui:flex ui:items-baseline ui:justify-between ui:gap-4 ui:py-4 ui:border-t ui:border-[var(--landing-border)] ui:first:border-t-0"
|
||||||
>
|
>
|
||||||
<div class="ui:flex ui:items-baseline ui:gap-3 ui:min-w-0">
|
<div class="ui:flex ui:items-baseline ui:gap-3 ui:min-w-0">
|
||||||
<span class="ui:text-[11px] ui:font-mono ui:text-muted-foreground ui:tabular-nums">
|
<span class="ui:text-[11px] ui:font-mono ui:text-[var(--landing-fg-muted)] ui:tabular-nums">
|
||||||
{String(index + 1).padStart(2, '0')}
|
{String(index + 1).padStart(2, '0')}
|
||||||
</span>
|
</span>
|
||||||
<dt class="ui:text-sm ui:text-foreground ui:truncate">
|
<dt class="ui:text-sm ui:text-[var(--landing-fg)] ui:truncate">
|
||||||
{stat.label}
|
{stat.label}
|
||||||
</dt>
|
</dt>
|
||||||
</div>
|
</div>
|
||||||
<dd
|
<dd
|
||||||
class="ui:text-2xl ui:md:text-3xl ui:font-semibold ui:tracking-tight ui:tabular-nums ui:text-foreground ui:m-0"
|
class="ui:text-2xl ui:md:text-3xl ui:font-semibold ui:tracking-tight ui:tabular-nums ui:text-[var(--landing-fg)] ui:m-0"
|
||||||
>
|
>
|
||||||
{stat.value}
|
{stat.value}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</dl>
|
</dl>
|
||||||
</BlurFade>
|
|
||||||
</aside>
|
</aside>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,12 +17,14 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<EditableLandingSection sectionKey="navigation">
|
<EditableLandingSection sectionKey="navigation">
|
||||||
<header class="ui:sticky ui:top-0 ui:z-50 ui:bg-background/95 ui:backdrop-blur-md ui:border-b ui:border-border">
|
<header
|
||||||
|
class="ui:sticky ui:top-0 ui:z-50 ui:bg-[var(--landing-bg)]/95 ui:backdrop-blur-md ui:border-b ui:border-[var(--landing-border)]"
|
||||||
|
>
|
||||||
<div class="ui:max-w-[1120px] ui:mx-auto ui:px-6">
|
<div class="ui:max-w-[1120px] ui:mx-auto ui:px-6">
|
||||||
<div class="ui:flex ui:items-center ui:justify-between ui:h-14">
|
<div class="ui:flex ui:items-center ui:justify-between ui:h-14">
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
class="ui:flex ui:items-center ui:gap-2 ui:font-semibold ui:text-[15px] ui:tracking-tight ui:text-foreground ui:no-underline"
|
class="ui:flex ui:items-center ui:gap-2 ui:font-semibold ui:text-[15px] ui:tracking-tight ui:text-[var(--landing-fg)] ui:no-underline ui:cursor-pointer"
|
||||||
>
|
>
|
||||||
{#if logoUrl}
|
{#if logoUrl}
|
||||||
<img src={logoUrl} alt={orgName} class="ui:h-6 ui:w-auto" />
|
<img src={logoUrl} alt={orgName} class="ui:h-6 ui:w-auto" />
|
||||||
@@ -35,7 +37,7 @@
|
|||||||
{#each navItems as item (item.href + item.label)}
|
{#each navItems as item (item.href + item.label)}
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class="ui:text-sm ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:no-underline"
|
class="ui:text-sm ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] ui:transition-colors ui:no-underline ui:cursor-pointer"
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { OrgLandingPageProps, CourseItem } from './types';
|
||||||
|
import OrgLandingPageEmbed from './embed.svelte';
|
||||||
|
import OrgLandingPageLinks from './links.svelte';
|
||||||
|
import OrgLandingPageCallout from './callout.svelte';
|
||||||
|
import OrgLandingPageFooter from './landing-page-footer.svelte';
|
||||||
|
import EditorialNav from './editorial/nav.svelte';
|
||||||
|
import EditorialHero from './editorial/hero.svelte';
|
||||||
|
import EditorialCourseCard from './editorial/course-card.svelte';
|
||||||
|
import { themeStyle } from './theme-style';
|
||||||
|
import { Button } from '../../base/button';
|
||||||
|
import { getCourseTypeLandingMeta } from './landing-page-utils';
|
||||||
|
|
||||||
|
let {
|
||||||
|
orgName,
|
||||||
|
logoUrl,
|
||||||
|
navItems,
|
||||||
|
authAction,
|
||||||
|
hero,
|
||||||
|
courses,
|
||||||
|
hasMoreCourses = false,
|
||||||
|
disableCourseLinks = false,
|
||||||
|
embed,
|
||||||
|
callout,
|
||||||
|
links,
|
||||||
|
footer,
|
||||||
|
labels
|
||||||
|
}: OrgLandingPageProps = $props();
|
||||||
|
|
||||||
|
function formatCost(cost?: number, currency = 'USD'): string {
|
||||||
|
if (!cost) return labels?.freeLabel ?? 'Free';
|
||||||
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency, maximumFractionDigits: 0 }).format(cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilterKey = 'ALL' | 'LIVE_CLASS' | 'SELF_PACED' | 'COMPLIANCE' | 'PUBLIC';
|
||||||
|
|
||||||
|
const filterDefs: Array<{ key: FilterKey; label: string }> = $derived([
|
||||||
|
{ key: 'ALL', label: labels?.filterAllLabel ?? 'All' },
|
||||||
|
{
|
||||||
|
key: 'LIVE_CLASS',
|
||||||
|
label: getCourseTypeLandingMeta({ id: '', title: '', description: '', type: 'LIVE_CLASS' })?.label ?? 'Live class'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'SELF_PACED',
|
||||||
|
label: getCourseTypeLandingMeta({ id: '', title: '', description: '', type: 'SELF_PACED' })?.label ?? 'Self-paced'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'COMPLIANCE',
|
||||||
|
label: getCourseTypeLandingMeta({ id: '', title: '', description: '', type: 'COMPLIANCE' })?.label ?? 'Compliance'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'PUBLIC',
|
||||||
|
label: getCourseTypeLandingMeta({ id: '', title: '', description: '', type: 'PUBLIC' })?.label ?? 'Public'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
let activeFilter: FilterKey = $state('ALL');
|
||||||
|
|
||||||
|
const filterCounts = $derived.by(() => {
|
||||||
|
const counts: Record<FilterKey, number> = {
|
||||||
|
ALL: courses.length,
|
||||||
|
LIVE_CLASS: 0,
|
||||||
|
SELF_PACED: 0,
|
||||||
|
COMPLIANCE: 0,
|
||||||
|
PUBLIC: 0
|
||||||
|
};
|
||||||
|
for (const course of courses) {
|
||||||
|
const t = course.type?.trim().toUpperCase();
|
||||||
|
if (t === 'LIVE_CLASS' || t === 'SELF_PACED' || t === 'COMPLIANCE' || t === 'PUBLIC') {
|
||||||
|
counts[t]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleFilterDefs = $derived(filterDefs.filter((f) => f.key === 'ALL' || filterCounts[f.key] > 0));
|
||||||
|
|
||||||
|
const filteredCourses = $derived(
|
||||||
|
activeFilter === 'ALL' ? courses : courses.filter((c) => c.type?.trim().toUpperCase() === activeFilter)
|
||||||
|
);
|
||||||
|
|
||||||
|
function courseHref(course: CourseItem): string | undefined {
|
||||||
|
if (disableCourseLinks) return undefined;
|
||||||
|
return course.link || (course.slug ? `/course/${course.slug}` : undefined);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="ui:min-h-screen ui:font-sans ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)]"
|
||||||
|
style={themeStyle('editorial')}
|
||||||
|
>
|
||||||
|
<main>
|
||||||
|
<EditorialHero {hero} {courses} {labels}>
|
||||||
|
{#snippet navigation()}
|
||||||
|
<EditorialNav {orgName} {logoUrl} {navItems} {authAction} />
|
||||||
|
{/snippet}
|
||||||
|
</EditorialHero>
|
||||||
|
|
||||||
|
{#if courses.length > 0}
|
||||||
|
<section id="courses" class="ui:py-10 ui:px-6 ui:md:px-8 ui:bg-[var(--landing-bg)]">
|
||||||
|
<div class="ui:max-w-[1240px] ui:mx-auto">
|
||||||
|
<div class="ui:flex ui:flex-col ui:md:flex-row ui:md:items-end ui:justify-between ui:gap-6 ui:mb-8">
|
||||||
|
<div>
|
||||||
|
<p class="ui:text-[13px] ui:m-0 ui:mb-2 ui:text-[var(--landing-fg-muted)]">
|
||||||
|
{labels?.catalogEyebrow ?? 'Catalog'}
|
||||||
|
</p>
|
||||||
|
<h2
|
||||||
|
class="ui:text-3xl ui:md:text-[40px] ui:font-medium ui:tracking-tight ui:m-0 ui:leading-[1.1] ui:text-[var(--landing-fg)]"
|
||||||
|
style="letter-spacing: -0.025em;"
|
||||||
|
>
|
||||||
|
{labels?.catalogHeading ?? 'Programs starting this season.'}
|
||||||
|
</h2>
|
||||||
|
<p class="ui:text-base ui:mt-3 ui:max-w-xl ui:m-0 ui:text-[var(--landing-fg-muted)]">
|
||||||
|
{labels?.catalogDescription ??
|
||||||
|
'Cohort-based and self-paced — every program ends with a reviewed project and a certificate of completion.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{#if hasMoreCourses}
|
||||||
|
<Button
|
||||||
|
href={disableCourseLinks ? undefined : '/courses'}
|
||||||
|
variant="outline"
|
||||||
|
class="ui:rounded-full ui:px-5 ui:font-medium ui:bg-transparent ui:border-[var(--landing-border)] ui:text-[var(--landing-fg)] ui:hover:bg-[var(--landing-bg-section)]"
|
||||||
|
disabled={disableCourseLinks}
|
||||||
|
>
|
||||||
|
{labels?.browseCoursesLabel ?? 'Browse all courses →'}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if visibleFilterDefs.length > 1}
|
||||||
|
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-1.5 ui:mb-8">
|
||||||
|
{#each visibleFilterDefs as def (def.key)}
|
||||||
|
{@const isActive = activeFilter === def.key}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ui:inline-flex ui:items-center ui:gap-1.5 ui:px-3.5 ui:py-1.5 ui:rounded-full ui:text-[13.5px] ui:transition-colors ui:border ui:cursor-pointer"
|
||||||
|
style={isActive
|
||||||
|
? 'background: var(--landing-fg); color: var(--landing-bg); border-color: var(--landing-fg);'
|
||||||
|
: 'background: transparent; color: var(--landing-fg); border-color: var(--landing-border);'}
|
||||||
|
onclick={() => (activeFilter = def.key)}
|
||||||
|
>
|
||||||
|
{def.label}
|
||||||
|
<span
|
||||||
|
class="ui:text-[11.5px] ui:tabular-nums"
|
||||||
|
style={isActive ? 'opacity: 0.6;' : 'color: var(--landing-fg-faint);'}
|
||||||
|
>
|
||||||
|
{filterCounts[def.key]}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-4">
|
||||||
|
{#each filteredCourses as course, index (course.id)}
|
||||||
|
<EditorialCourseCard {course} {index} {disableCourseLinks} {labels} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if hasMoreCourses}
|
||||||
|
<div class="ui:text-center ui:mt-10">
|
||||||
|
<Button
|
||||||
|
href={disableCourseLinks ? undefined : '/courses'}
|
||||||
|
size="lg"
|
||||||
|
class="ui:rounded-full ui:px-6 ui:font-medium ui:bg-[var(--landing-fg)] ui:text-[var(--landing-bg)] ui:hover:opacity-90"
|
||||||
|
disabled={disableCourseLinks}
|
||||||
|
>
|
||||||
|
{labels?.browseCoursesLabel ?? 'View all courses'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<OrgLandingPageLinks {links} {labels} variant="editorial" />
|
||||||
|
|
||||||
|
<OrgLandingPageEmbed {embed} {labels} variant="editorial" />
|
||||||
|
|
||||||
|
<OrgLandingPageCallout {callout} {labels} variant="editorial" />
|
||||||
|
|
||||||
|
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="editorial" />
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { CourseItem, OrgLandingPageLabels } from '../types';
|
||||||
|
import { defaultEnrolledLabel, defaultLessonsLabel, getCourseTypeLandingMeta } from '../landing-page-utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
course: CourseItem;
|
||||||
|
disableCourseLinks?: boolean;
|
||||||
|
labels?: OrgLandingPageLabels;
|
||||||
|
/** Index in the parent grid; used to cycle the painterly thumb gradient when the course has no logo. */
|
||||||
|
index?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { course, disableCourseLinks = false, labels, index = 0 }: Props = $props();
|
||||||
|
|
||||||
|
const thumbGradients = [
|
||||||
|
'radial-gradient(60% 80% at 30% 30%, #c4b78b 0%, transparent 60%), linear-gradient(135deg, #8a9460 0%, #c4a878 100%)',
|
||||||
|
'radial-gradient(50% 70% at 70% 40%, #d4a48a 0%, transparent 60%), linear-gradient(135deg, #6f5a4a 0%, #b08868 100%)',
|
||||||
|
'radial-gradient(60% 80% at 30% 30%, #8aa4b8 0%, transparent 60%), linear-gradient(135deg, #3a4858 0%, #6a7e92 100%)',
|
||||||
|
'radial-gradient(60% 80% at 40% 60%, #b8a4a4 0%, transparent 60%), linear-gradient(135deg, #5e4848 0%, #a87a78 100%)',
|
||||||
|
'radial-gradient(60% 80% at 50% 50%, #c4c08a 0%, transparent 60%), linear-gradient(135deg, #6a6840 0%, #a8a468 100%)',
|
||||||
|
'radial-gradient(60% 80% at 30% 30%, #aab8c4 0%, transparent 60%), linear-gradient(135deg, #4a5868 0%, #788a9a 100%)'
|
||||||
|
];
|
||||||
|
|
||||||
|
const courseTypeMeta = $derived(getCourseTypeLandingMeta(course));
|
||||||
|
const resolvedLessonsLabel = $derived(labels?.lessonsLabel ?? defaultLessonsLabel);
|
||||||
|
const resolvedEnrolledLabel = $derived(labels?.enrolledLabel ?? defaultEnrolledLabel);
|
||||||
|
|
||||||
|
const thumbBg = $derived(
|
||||||
|
course.logo ? `center / cover no-repeat url('${course.logo}')` : thumbGradients[index % thumbGradients.length]
|
||||||
|
);
|
||||||
|
|
||||||
|
const href = $derived.by(() => {
|
||||||
|
if (disableCourseLinks) return undefined;
|
||||||
|
return course.link || (course.slug ? `/course/${course.slug}` : undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatCost(cost?: number, currency = 'USD'): string {
|
||||||
|
if (!cost) return labels?.freeLabel ?? 'Free';
|
||||||
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency, maximumFractionDigits: 0 }).format(cost);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a
|
||||||
|
{href}
|
||||||
|
class="ui:flex ui:flex-col ui:h-full ui:overflow-hidden ui:rounded-lg ui:no-underline ui:transition-transform ui:hover:-translate-y-0.5 ui:bg-[var(--landing-card-soft)] ui:text-[var(--landing-fg)] {disableCourseLinks
|
||||||
|
? 'ui:cursor-default'
|
||||||
|
: 'ui:cursor-pointer'}"
|
||||||
|
aria-disabled={disableCourseLinks}
|
||||||
|
tabindex={disableCourseLinks ? -1 : undefined}
|
||||||
|
>
|
||||||
|
<div class="ui:relative ui:h-[168px] ui:overflow-hidden" style="background: {thumbBg};">
|
||||||
|
<div
|
||||||
|
class="ui:pointer-events-none ui:absolute ui:inset-0"
|
||||||
|
style="background-image: repeating-linear-gradient(45deg, rgba(0,0,0,0.05) 0px, rgba(0,0,0,0.05) 1px, transparent 1px, transparent 5px); mix-blend-mode: overlay;"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
|
{#if courseTypeMeta}
|
||||||
|
<span
|
||||||
|
class="ui:absolute ui:top-3.5 ui:left-3.5 ui:inline-flex ui:items-center ui:gap-1.5 ui:rounded-full ui:px-2.5 ui:py-0.5 ui:text-[11px] ui:font-medium ui:text-[var(--landing-fg)]"
|
||||||
|
style="background: color-mix(in srgb, var(--landing-card) 95%, transparent);"
|
||||||
|
>
|
||||||
|
{courseTypeMeta.label}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if course.duration}
|
||||||
|
<span
|
||||||
|
class="ui:absolute ui:bottom-3.5 ui:right-3.5 ui:rounded-full ui:px-2.5 ui:py-0.5 ui:text-[11px] ui:tabular-nums"
|
||||||
|
style="background: color-mix(in srgb, var(--landing-fg) 70%, transparent); color: var(--landing-bg);"
|
||||||
|
>
|
||||||
|
{course.duration}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="ui:flex ui:flex-col ui:flex-1 ui:p-5 ui:md:p-6">
|
||||||
|
<h3
|
||||||
|
class="ui:text-[19px] ui:font-medium ui:tracking-tight ui:leading-[1.25] ui:m-0 ui:mb-2 ui:text-[var(--landing-fg)]"
|
||||||
|
style="letter-spacing: -0.015em;"
|
||||||
|
>
|
||||||
|
{course.title}
|
||||||
|
</h3>
|
||||||
|
<p class="ui:text-sm ui:leading-relaxed ui:line-clamp-3 ui:m-0 ui:mb-4 ui:flex-1 ui:text-[var(--landing-fg-muted)]">
|
||||||
|
{course.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if course.lessonCount || course.totalStudents || course.level}
|
||||||
|
<div
|
||||||
|
class="ui:flex ui:flex-wrap ui:items-center ui:gap-x-3 ui:gap-y-1 ui:text-[12.5px] ui:mb-4 ui:text-[var(--landing-fg-muted)]"
|
||||||
|
>
|
||||||
|
{#if course.lessonCount}
|
||||||
|
<span>{resolvedLessonsLabel(course.lessonCount)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if course.level}
|
||||||
|
{#if course.lessonCount}
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
{/if}
|
||||||
|
<span>{course.level}</span>
|
||||||
|
{/if}
|
||||||
|
{#if course.totalStudents}
|
||||||
|
{#if course.lessonCount || course.level}
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
{/if}
|
||||||
|
<span>{resolvedEnrolledLabel(course.totalStudents)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="ui:flex ui:items-center ui:justify-between ui:pt-4 ui:mt-auto"
|
||||||
|
style="border-top: 1px solid var(--landing-border);"
|
||||||
|
>
|
||||||
|
<span class="ui:text-base ui:font-semibold ui:text-[var(--landing-fg)]" style="letter-spacing: -0.01em;">
|
||||||
|
{course.price || formatCost(course.cost, course.currency)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="ui:inline-flex ui:items-center ui:gap-1 ui:text-sm ui:font-medium ui:text-[var(--landing-accent)]"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{labels?.enrollLabel ?? 'Enroll'} <span>→</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { OrgLandingPageProps } from '../types';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import SecondaryActionButton from '../secondary-action-button.svelte';
|
||||||
|
import { Button } from '../../../base/button';
|
||||||
|
import EditableLandingSection from '../editable-section.svelte';
|
||||||
|
import { getCourseTypeLandingMeta } from '../landing-page-utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
hero: OrgLandingPageProps['hero'];
|
||||||
|
courses: OrgLandingPageProps['courses'];
|
||||||
|
labels?: OrgLandingPageProps['labels'];
|
||||||
|
navigation: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { hero, courses = [], labels, navigation }: Props = $props();
|
||||||
|
|
||||||
|
const featured = $derived(courses[0]);
|
||||||
|
const featuredTypeMeta = $derived(featured ? getCourseTypeLandingMeta(featured) : undefined);
|
||||||
|
const syllabusItems = $derived(
|
||||||
|
featured
|
||||||
|
? [
|
||||||
|
{ label: 'Foundations', duration: '42 min' },
|
||||||
|
{ label: 'Core concepts', duration: '1h 12m' },
|
||||||
|
{ label: 'Hands-on practice', duration: '58 min' },
|
||||||
|
{ label: 'Capstone project', duration: '2h 30m' }
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
|
||||||
|
function formatCost(cost?: number, currency = 'USD'): string {
|
||||||
|
if (!cost) return labels?.freeLabel ?? 'Free';
|
||||||
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency, maximumFractionDigits: 0 }).format(cost);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{@render navigation()}
|
||||||
|
|
||||||
|
<EditableLandingSection sectionKey="hero">
|
||||||
|
<section class="ui:px-6 ui:md:px-8 ui:pt-16 ui:md:pt-20 ui:pb-12 ui:bg-[var(--landing-bg)]">
|
||||||
|
<div class="ui:max-w-[1240px] ui:mx-auto">
|
||||||
|
<div
|
||||||
|
class="ui:grid ui:grid-cols-1 ui:gap-12 ui:lg:gap-14 ui:items-start {featured
|
||||||
|
? 'ui:lg:grid-cols-[1fr_1.05fr]'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
<div class={featured ? '' : 'ui:text-center'}>
|
||||||
|
{#if hero.stats && hero.stats.length > 0}
|
||||||
|
<p class="ui:text-[13px] ui:mb-5 ui:text-[var(--landing-fg-muted)]">
|
||||||
|
{hero.stats[0].label}
|
||||||
|
{#if hero.stats[0].value}
|
||||||
|
<span class="ui:mx-2" aria-hidden="true">·</span>
|
||||||
|
<span class="ui:text-[var(--landing-fg)]">{hero.stats[0].value}</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<h1
|
||||||
|
class="ui:text-[44px] ui:md:text-[56px] ui:lg:text-[64px] ui:font-medium ui:tracking-tight ui:leading-[1.02] ui:m-0 ui:mb-5 ui:text-[var(--landing-fg)]"
|
||||||
|
style="letter-spacing: -0.03em;"
|
||||||
|
>
|
||||||
|
{hero.heading}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p
|
||||||
|
class="ui:text-lg ui:md:text-xl ui:leading-[1.35] ui:max-w-xl ui:m-0 ui:mb-9 ui:text-[var(--landing-fg-muted)] {featured
|
||||||
|
? ''
|
||||||
|
: 'ui:mx-auto'}"
|
||||||
|
style="letter-spacing: -0.012em;"
|
||||||
|
>
|
||||||
|
{hero.subheading}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-2.5 {featured ? '' : 'ui:justify-center'}">
|
||||||
|
<Button
|
||||||
|
href={hero.primaryAction.href}
|
||||||
|
disabled={hero.primaryAction.disabled ?? false}
|
||||||
|
size="lg"
|
||||||
|
class="ui:rounded-full ui:px-6 ui:font-medium ui:bg-[var(--landing-fg)] ui:text-[var(--landing-bg)] ui:hover:opacity-90"
|
||||||
|
>
|
||||||
|
{hero.primaryAction.label}
|
||||||
|
</Button>
|
||||||
|
{#if hero.secondaryAction}
|
||||||
|
<SecondaryActionButton
|
||||||
|
href={hero.secondaryAction.href}
|
||||||
|
label={hero.secondaryAction.label}
|
||||||
|
variant="editorial"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if featured}
|
||||||
|
{@const cost = featured.cost ?? 0}
|
||||||
|
{@const seats = featured.totalStudents ?? 0}
|
||||||
|
<div
|
||||||
|
class="ui:relative ui:overflow-hidden ui:rounded-xl"
|
||||||
|
style="
|
||||||
|
height: 480px;
|
||||||
|
background:
|
||||||
|
radial-gradient(60% 80% at 20% 30%, #b8a877 0%, transparent 60%),
|
||||||
|
radial-gradient(50% 60% at 80% 70%, #6b7a4a 0%, transparent 65%),
|
||||||
|
radial-gradient(40% 50% at 50% 50%, #d4c89a 0%, transparent 70%),
|
||||||
|
linear-gradient(135deg, #8a9460 0%, #c4a878 50%, #8b7a4f 100%);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="ui:pointer-events-none ui:absolute ui:inset-0"
|
||||||
|
style="
|
||||||
|
background-image:
|
||||||
|
repeating-linear-gradient(45deg, rgba(0,0,0,0.06) 0px, rgba(0,0,0,0.06) 1px, transparent 1px, transparent 4px),
|
||||||
|
repeating-linear-gradient(-45deg, rgba(255,255,255,0.05) 0px, rgba(255,255,255,0.05) 1px, transparent 1px, transparent 6px);
|
||||||
|
mix-blend-mode: overlay;
|
||||||
|
"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="ui:absolute ui:left-1/2 ui:top-1/2 ui:-translate-x-1/2 ui:-translate-y-1/2 ui:w-[84%] ui:overflow-hidden ui:rounded-[10px] ui:bg-[var(--landing-card)]"
|
||||||
|
style="
|
||||||
|
border: 1px solid var(--landing-border);
|
||||||
|
box-shadow: 0 24px 60px -20px rgba(0,0,0,0.28);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="ui:flex ui:items-center ui:gap-2 ui:px-5 ui:pt-4 ui:text-[var(--landing-fg-muted)]">
|
||||||
|
<span
|
||||||
|
class="ui:inline-flex ui:items-center ui:gap-1.5 ui:rounded-full ui:px-2.5 ui:py-0.5 ui:text-[11.5px] ui:font-medium ui:bg-[var(--landing-bg-section)] ui:text-[var(--landing-fg)]"
|
||||||
|
>
|
||||||
|
{featuredTypeMeta?.label ?? labels?.featuredLabel ?? 'Featured'}
|
||||||
|
</span>
|
||||||
|
{#if featured.duration}
|
||||||
|
<span class="ui:text-[12px]" aria-hidden="true">·</span>
|
||||||
|
<span class="ui:text-[12px]">{featured.duration}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<h3
|
||||||
|
class="ui:m-0 ui:mx-5 ui:mt-2 ui:mb-2 ui:text-[22px] ui:font-medium ui:tracking-tight ui:text-[var(--landing-fg)]"
|
||||||
|
style="letter-spacing: -0.02em;"
|
||||||
|
>
|
||||||
|
{featured.title}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
class="ui:m-0 ui:mx-5 ui:mb-4 ui:text-[13px] ui:line-clamp-2 ui:text-[var(--landing-fg-muted)]"
|
||||||
|
style="line-height: 1.5;"
|
||||||
|
>
|
||||||
|
{featured.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="ui:px-5 ui:pb-4">
|
||||||
|
{#each syllabusItems as item, idx (idx)}
|
||||||
|
<div
|
||||||
|
class="ui:flex ui:items-center ui:gap-2.5 ui:py-1.5 ui:text-[13px]"
|
||||||
|
style={idx === 0 ? '' : 'border-top: 1px solid var(--landing-border-soft);'}
|
||||||
|
>
|
||||||
|
<span class="ui:text-[11px] ui:w-4 ui:tabular-nums ui:text-[var(--landing-fg-faint)]">
|
||||||
|
{String(idx + 1).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
<span class="ui:flex-1 ui:text-[var(--landing-fg)]">{item.label}</span>
|
||||||
|
<span class="ui:text-[11.5px] ui:tabular-nums ui:text-[var(--landing-fg-muted)]"
|
||||||
|
>{item.duration}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="ui:flex ui:items-center ui:justify-between ui:px-5 ui:py-3 ui:bg-[var(--landing-bg)]"
|
||||||
|
style="border-top: 1px solid var(--landing-border-soft);"
|
||||||
|
>
|
||||||
|
<span class="ui:text-sm ui:font-semibold ui:text-[var(--landing-fg)]" style="letter-spacing: -0.01em;">
|
||||||
|
{formatCost(cost, featured.currency)}
|
||||||
|
</span>
|
||||||
|
{#if seats > 0}
|
||||||
|
<span class="ui:text-[12.5px] ui:text-[var(--landing-fg-muted)]"
|
||||||
|
>{(labels?.enrolledLabel ?? ((n: number) => `${n.toLocaleString()} enrolled`))(seats)}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</EditableLandingSection>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { NavItem } from '../types';
|
||||||
|
import { Button } from '../../../base/button';
|
||||||
|
import EditableLandingSection from '../editable-section.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
orgName: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
navItems: NavItem[];
|
||||||
|
authAction?: {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let { orgName, logoUrl, navItems, authAction }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<EditableLandingSection sectionKey="navigation">
|
||||||
|
<header
|
||||||
|
class="ui:sticky ui:top-0 ui:z-50 ui:backdrop-blur-md"
|
||||||
|
style="background: color-mix(in srgb, var(--landing-bg) 85%, transparent); border-bottom: 1px solid var(--landing-border-soft);"
|
||||||
|
>
|
||||||
|
<div class="ui:max-w-[1240px] ui:mx-auto ui:px-6 ui:md:px-8">
|
||||||
|
<div class="ui:flex ui:items-center ui:justify-between ui:h-16">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="ui:flex ui:items-center ui:gap-2.5 ui:font-semibold ui:text-[15px] ui:tracking-tight ui:no-underline ui:text-[var(--landing-fg)] ui:cursor-pointer"
|
||||||
|
>
|
||||||
|
{#if logoUrl}
|
||||||
|
<img src={logoUrl} alt={orgName} class="ui:h-6 ui:w-auto" />
|
||||||
|
{/if}
|
||||||
|
<span>{orgName}</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{#if navItems.length > 0}
|
||||||
|
<nav class="ui:hidden ui:md:flex ui:gap-8">
|
||||||
|
{#each navItems as item (item.href + item.label)}
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
class="ui:text-sm ui:transition-colors ui:no-underline ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] ui:cursor-pointer"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="ui:flex ui:items-center ui:gap-2.5">
|
||||||
|
{#if authAction}
|
||||||
|
<Button
|
||||||
|
href={authAction.href}
|
||||||
|
size="sm"
|
||||||
|
class="ui:rounded-full ui:px-4 ui:font-medium ui:bg-[var(--landing-fg)] ui:text-[var(--landing-bg)] ui:hover:opacity-90"
|
||||||
|
>
|
||||||
|
{authAction.label}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</EditableLandingSection>
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { OrgLandingPageTheme, LandingPageEmbed } from './types';
|
import type { OrgLandingPageLabels, OrgLandingPageTheme, LandingPageEmbed } from './types';
|
||||||
import SecondaryActionButton from './secondary-action-button.svelte';
|
import SecondaryActionButton from './secondary-action-button.svelte';
|
||||||
import { BlurFade } from '../animation/blurfade';
|
|
||||||
import EditableLandingSection from './editable-section.svelte';
|
import EditableLandingSection from './editable-section.svelte';
|
||||||
|
|
||||||
let { embed, variant = 'minimal' }: { embed?: LandingPageEmbed; variant?: OrgLandingPageTheme } = $props();
|
let {
|
||||||
|
embed,
|
||||||
|
variant = 'minimal',
|
||||||
|
labels
|
||||||
|
}: {
|
||||||
|
embed?: LandingPageEmbed;
|
||||||
|
variant?: OrgLandingPageTheme;
|
||||||
|
labels?: OrgLandingPageLabels;
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if embed}
|
{#if embed}
|
||||||
@@ -12,46 +19,33 @@
|
|||||||
{#if variant === 'terminal'}
|
{#if variant === 'terminal'}
|
||||||
<section class="ui:py-24 ui:px-6" style="border-top: 1px solid #1c1f28; background: #06070a;">
|
<section class="ui:py-24 ui:px-6" style="border-top: 1px solid #1c1f28; background: #06070a;">
|
||||||
<div class="ui:mx-auto ui:max-w-[1120px]">
|
<div class="ui:mx-auto ui:max-w-[1120px]">
|
||||||
<BlurFade delay={0} once={true}>
|
|
||||||
<p
|
<p
|
||||||
class="ui:font-mono ui:text-[11px] ui:tracking-[0.12em] ui:uppercase ui:mb-3 ui:inline-flex ui:items-center ui:gap-2"
|
class="ui:font-mono ui:text-[11px] ui:tracking-[0.12em] ui:uppercase ui:mb-3 ui:inline-flex ui:items-center ui:gap-2"
|
||||||
style="color: var(--primary);"
|
style="color: var(--landing-accent);"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="ui:size-1.5 ui:rounded-full"
|
class="ui:size-1.5 ui:rounded-full"
|
||||||
style="background: var(--primary); box-shadow: 0 0 12px var(--primary);"
|
style="background: var(--landing-accent); box-shadow: 0 0 12px var(--landing-accent);"
|
||||||
></span>
|
></span>
|
||||||
Embed
|
{labels?.embedEyebrow ?? 'Embed'}
|
||||||
</p>
|
</p>
|
||||||
</BlurFade>
|
|
||||||
<BlurFade delay={0.08} once={true}>
|
|
||||||
<h3
|
<h3
|
||||||
class="ui:text-[36px] ui:font-semibold ui:tracking-tight ui:m-0 ui:mb-4"
|
class="ui:text-[36px] ui:font-semibold ui:tracking-tight ui:m-0 ui:mb-4"
|
||||||
style="color: #e9eaed; line-height: 1.1; letter-spacing: -0.025em;"
|
style="color: #e9eaed; line-height: 1.1; letter-spacing: -0.025em;"
|
||||||
>
|
>
|
||||||
{embed.title}
|
{embed.title}
|
||||||
</h3>
|
</h3>
|
||||||
</BlurFade>
|
|
||||||
{#if embed.description?.trim()}
|
{#if embed.description?.trim()}
|
||||||
<BlurFade delay={0.16} once={true}>
|
|
||||||
<p class="ui:max-w-xl ui:m-0 ui:mb-6 ui:text-[15px]" style="color: #9da1ab;">
|
<p class="ui:max-w-xl ui:m-0 ui:mb-6 ui:text-[15px]" style="color: #9da1ab;">
|
||||||
{embed.description}
|
{embed.description}
|
||||||
</p>
|
</p>
|
||||||
</BlurFade>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if embed.secondaryAction?.label && embed.secondaryAction?.href}
|
{#if embed.secondaryAction?.label && embed.secondaryAction?.href}
|
||||||
<BlurFade delay={0.22} once={true}>
|
|
||||||
<div class="ui:mb-8">
|
<div class="ui:mb-8">
|
||||||
<SecondaryActionButton
|
<SecondaryActionButton href={embed.secondaryAction.href} label={embed.secondaryAction.label} {variant} />
|
||||||
href={embed.secondaryAction.href}
|
|
||||||
label={embed.secondaryAction.label}
|
|
||||||
{variant}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</BlurFade>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if embed.code}
|
{#if embed.code}
|
||||||
<BlurFade delay={0.3} once={true}>
|
|
||||||
<div
|
<div
|
||||||
class="ui:rounded-xl ui:overflow-hidden ui:max-w-[820px]"
|
class="ui:rounded-xl ui:overflow-hidden ui:max-w-[820px]"
|
||||||
style="border: 1px solid #262a35; background: #0f1218; box-shadow: 0 30px 90px -30px rgba(0,0,0,0.7);"
|
style="border: 1px solid #262a35; background: #0f1218; box-shadow: 0 30px 90px -30px rgba(0,0,0,0.7);"
|
||||||
@@ -60,34 +54,49 @@
|
|||||||
{@html embed.code}
|
{@html embed.code}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</BlurFade>
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{:else if variant === 'editorial'}
|
||||||
|
<section class="ui:py-24 ui:md:py-28 ui:px-6 ui:md:px-8 ui:bg-[#f4f3ed]">
|
||||||
|
<div class="ui:mx-auto ui:max-w-[1240px] ui:flex ui:flex-col ui:items-center ui:gap-6 ui:text-center">
|
||||||
|
<h3
|
||||||
|
class="ui:text-3xl ui:md:text-[40px] ui:font-medium ui:tracking-tight ui:m-0 ui:text-[#1a1a1a]"
|
||||||
|
style="line-height: 1.1; letter-spacing: -0.025em;"
|
||||||
|
>
|
||||||
|
{embed.title}
|
||||||
|
</h3>
|
||||||
|
{#if embed.description?.trim()}
|
||||||
|
<p class="ui:max-w-xl ui:text-center ui:text-base ui:leading-relaxed ui:text-[#76746c] ui:m-0">
|
||||||
|
{embed.description}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if embed.secondaryAction?.label && embed.secondaryAction?.href}
|
||||||
|
<SecondaryActionButton href={embed.secondaryAction.href} label={embed.secondaryAction.label} {variant} />
|
||||||
|
{/if}
|
||||||
|
{#if embed.code}
|
||||||
|
<div class="ui:flex ui:w-full ui:justify-center">
|
||||||
|
{@html embed.code}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{:else}
|
{:else}
|
||||||
<section class="ui:py-12 ui:px-6 ui:md:px-8">
|
<section class="ui:py-12 ui:px-6 ui:md:px-8">
|
||||||
<div class="ui:mx-auto ui:flex ui:max-w-7xl ui:flex-col ui:items-center ui:gap-6 ui:text-center">
|
<div class="ui:mx-auto ui:flex ui:max-w-7xl ui:flex-col ui:items-center ui:gap-6 ui:text-center">
|
||||||
<BlurFade delay={0} once={true}>
|
|
||||||
<h3 class="ui:text-center ui:text-2xl ui:font-semibold ui:tracking-tight">{embed.title}</h3>
|
<h3 class="ui:text-center ui:text-2xl ui:font-semibold ui:tracking-tight">{embed.title}</h3>
|
||||||
</BlurFade>
|
|
||||||
{#if embed.description?.trim()}
|
{#if embed.description?.trim()}
|
||||||
<BlurFade delay={0.12} once={true}>
|
<p class="ui:max-w-3xl ui:text-center ui:text-base ui:leading-7 ui:text-[var(--landing-fg-muted)]">
|
||||||
<p class="ui:max-w-3xl ui:text-center ui:text-base ui:leading-7 ui:text-muted-foreground">
|
|
||||||
{embed.description}
|
{embed.description}
|
||||||
</p>
|
</p>
|
||||||
</BlurFade>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if embed.secondaryAction?.label && embed.secondaryAction?.href}
|
{#if embed.secondaryAction?.label && embed.secondaryAction?.href}
|
||||||
<BlurFade delay={0.2} once={true}>
|
|
||||||
<SecondaryActionButton href={embed.secondaryAction.href} label={embed.secondaryAction.label} {variant} />
|
<SecondaryActionButton href={embed.secondaryAction.href} label={embed.secondaryAction.label} {variant} />
|
||||||
</BlurFade>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if embed.code}
|
{#if embed.code}
|
||||||
<BlurFade delay={0.28} once={true}>
|
|
||||||
<div class="ui:flex ui:w-full ui:justify-center">
|
<div class="ui:flex ui:w-full ui:justify-center">
|
||||||
{@html embed.code}
|
{@html embed.code}
|
||||||
</div>
|
</div>
|
||||||
</BlurFade>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+90
-11
@@ -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',
|
orgName: 'Certifi Academy',
|
||||||
logoUrl: 'https://ui-avatars.com/api/?name=CA&background=0f172a&color=fff',
|
logoUrl: 'https://ui-avatars.com/api/?name=CA&background=0f172a&color=fff',
|
||||||
navItems: [
|
navItems: [
|
||||||
@@ -154,25 +154,100 @@ export const mockProps: OrgLandingPageProps = {
|
|||||||
brand: {
|
brand: {
|
||||||
socials: [
|
socials: [
|
||||||
{ platform: 'linkedin', href: 'https://linkedin.com/company/example' },
|
{ 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: [
|
columns: [
|
||||||
{
|
{
|
||||||
id: 'col-company',
|
id: 'col-product',
|
||||||
heading: 'Company',
|
heading: 'Product',
|
||||||
links: [
|
links: [
|
||||||
{ id: 'col-company-about', label: 'About', href: '#about' },
|
{ id: 'col-product-overview', label: 'Overview', href: '#overview' },
|
||||||
{ id: 'col-company-careers', label: 'Careers', href: '#careers' }
|
{ id: 'col-product-features', label: 'Features', href: '#features' },
|
||||||
],
|
{ id: 'col-product-integrations', label: 'Integrations', href: '#integrations' },
|
||||||
cta: { label: 'Explore more', href: '#more' }
|
{ 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',
|
id: 'col-resources',
|
||||||
heading: 'Resources',
|
heading: 'Resources',
|
||||||
links: [
|
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: [
|
links: [
|
||||||
{ id: 'bottom-terms', label: 'Terms of Service', href: '#terms' },
|
{ id: 'bottom-terms', label: 'Terms of Service', href: '#terms' },
|
||||||
{ id: 'bottom-privacy', label: 'Privacy Policy', href: '#privacy' },
|
{ 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' }
|
{ id: 'bottom-support', label: 'Support', href: '#support' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,8 @@ export { default as TechLandingPage } from './tech.svelte';
|
|||||||
export { default as StudioLandingPage } from './studio.svelte';
|
export { default as StudioLandingPage } from './studio.svelte';
|
||||||
export { default as CorporateLandingPage } from './corporate.svelte';
|
export { default as CorporateLandingPage } from './corporate.svelte';
|
||||||
export { default as TerminalLandingPage } from './terminal.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 BoldLandingNav } from './bold/nav.svelte';
|
||||||
export { default as BoldLandingHero } from './bold/hero.svelte';
|
export { default as BoldLandingHero } from './bold/hero.svelte';
|
||||||
export { default as ClassicLandingNav } from './classic/nav.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 CorporateLandingHero } from './corporate/hero.svelte';
|
||||||
export { default as TerminalLandingNav } from './terminal/nav.svelte';
|
export { default as TerminalLandingNav } from './terminal/nav.svelte';
|
||||||
export { default as TerminalLandingHero } from './terminal/hero.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 OrgLandingPageEmbed } from './embed.svelte';
|
||||||
export { default as OrgLandingPageCallout } from './callout.svelte';
|
export { default as OrgLandingPageCallout } from './callout.svelte';
|
||||||
export { default as OrgLandingPageLinks } from './links.svelte';
|
export { default as OrgLandingPageLinks } from './links.svelte';
|
||||||
@@ -35,4 +52,6 @@ export {
|
|||||||
type LandingPageEditContext
|
type LandingPageEditContext
|
||||||
} from './edit-context';
|
} from './edit-context';
|
||||||
export * from './landing-page-link-icons';
|
export * from './landing-page-link-icons';
|
||||||
|
export * from './theme-style';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
|
export { mockOrgLandingPageProps } from './fixtures';
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
type Variant = 'primary' | 'secondary' | 'tertiary';
|
||||||
|
type Size = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
variant?: Variant;
|
||||||
|
size?: Size;
|
||||||
|
href?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
type?: 'button' | 'submit' | 'reset';
|
||||||
|
onclick?: (event: MouseEvent) => void;
|
||||||
|
class?: string;
|
||||||
|
children: Snippet;
|
||||||
|
'aria-label'?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
href,
|
||||||
|
disabled = false,
|
||||||
|
type = 'button',
|
||||||
|
onclick,
|
||||||
|
class: extraClass = '',
|
||||||
|
children,
|
||||||
|
'aria-label': ariaLabel
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const sizeClass = $derived(
|
||||||
|
size === 'sm'
|
||||||
|
? 'ui:px-3 ui:py-1.5 ui:text-sm'
|
||||||
|
: size === 'lg'
|
||||||
|
? 'ui:px-6 ui:py-3 ui:text-base'
|
||||||
|
: 'ui:px-4 ui:py-2 ui:text-sm'
|
||||||
|
);
|
||||||
|
|
||||||
|
const variantClass = $derived(
|
||||||
|
variant === 'primary'
|
||||||
|
? 'ui:bg-[var(--landing-button-primary-bg)] ui:text-[var(--landing-button-primary-fg)] ui:hover:bg-[var(--landing-button-primary-bg-hover)] ui:border-transparent'
|
||||||
|
: variant === 'secondary'
|
||||||
|
? 'ui:bg-[var(--landing-button-secondary-bg)] ui:text-[var(--landing-button-secondary-fg)] ui:hover:bg-[var(--landing-button-secondary-bg-hover)] ui:border-[var(--landing-button-secondary-border)]'
|
||||||
|
: 'ui:bg-transparent ui:text-[var(--landing-button-tertiary-fg)] ui:hover:bg-[var(--landing-button-tertiary-bg-hover)] ui:border-transparent'
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseClass =
|
||||||
|
'ui:inline-flex ui:items-center ui:justify-center ui:gap-1.5 ui:rounded-md ui:font-medium ui:border ui:transition-colors ui:focus-visible:outline-none ui:focus-visible:ring-2 ui:focus-visible:ring-[var(--landing-accent)]/50 ui:disabled:opacity-50 ui:disabled:cursor-not-allowed ui:cursor-pointer ui:no-underline ui:whitespace-nowrap';
|
||||||
|
|
||||||
|
const finalClass = $derived(`${baseClass} ${sizeClass} ${variantClass} ${extraClass}`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if href && !disabled}
|
||||||
|
<a {href} class={finalClass} aria-label={ariaLabel} {onclick}>
|
||||||
|
{@render children()}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<button {type} class={finalClass} {disabled} {onclick} aria-label={ariaLabel}>
|
||||||
|
{@render children()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
import { getFooterTokens } from './landing-page-footer.tokens';
|
import { getFooterTokens } from './landing-page-footer.tokens';
|
||||||
import FooterSocialIcon from './footer-social-icon.svelte';
|
import FooterSocialIcon from './footer-social-icon.svelte';
|
||||||
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
||||||
import { BlurFade } from '../animation/blurfade';
|
|
||||||
import EditableLandingSection from './editable-section.svelte';
|
import EditableLandingSection from './editable-section.svelte';
|
||||||
|
|
||||||
interface Props {
|
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))]"
|
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))]"
|
||||||
>
|
>
|
||||||
<div class="ui:sm:col-span-2 ui:md:col-span-1 ui:min-w-0">
|
<div class="ui:sm:col-span-2 ui:md:col-span-1 ui:min-w-0">
|
||||||
<a href="/" class={tokens.brandName}>
|
<a href="/" class="{tokens.brandName} ui:cursor-pointer">
|
||||||
{#if logoUrl}
|
{#if logoUrl}
|
||||||
<img src={logoUrl} alt="" class={tokens.logoImg} />
|
<img src={logoUrl} alt="" class={tokens.logoImg} />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -79,7 +78,7 @@
|
|||||||
<li class="ui:list-none">
|
<li class="ui:list-none">
|
||||||
<a
|
<a
|
||||||
href={social.href}
|
href={social.href}
|
||||||
class={tokens.socialIcon}
|
class="{tokens.socialIcon} ui:cursor-pointer"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
aria-label={social.platform}
|
aria-label={social.platform}
|
||||||
@@ -103,12 +102,12 @@
|
|||||||
<ul class="ui:list-none ui:m-0 ui:p-0 ui:space-y-1">
|
<ul class="ui:list-none ui:m-0 ui:p-0 ui:space-y-1">
|
||||||
{#each column.links as link (link.id)}
|
{#each column.links as link (link.id)}
|
||||||
<li>
|
<li>
|
||||||
<a href={link.href} class={tokens.columnLink}>{link.label}</a>
|
<a href={link.href} class="{tokens.columnLink} ui:cursor-pointer">{link.label}</a>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{#if hasCta(column)}
|
{#if hasCta(column)}
|
||||||
<a href={column.cta!.href} class={tokens.columnCta}>
|
<a href={column.cta!.href} class="{tokens.columnCta} ui:cursor-pointer">
|
||||||
{column.cta!.label}
|
{column.cta!.label}
|
||||||
<ArrowRightIcon class="ui:size-4 ui:shrink-0" aria-hidden="true" />
|
<ArrowRightIcon class="ui:size-4 ui:shrink-0" aria-hidden="true" />
|
||||||
</a>
|
</a>
|
||||||
@@ -119,11 +118,9 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#if variant === 'minimal' || variant === 'bold' || variant === 'saas' || variant === 'tech' || variant === 'studio' || variant === 'corporate' || variant === 'terminal'}
|
{#if variant === 'minimal' || variant === 'bold' || variant === 'saas' || variant === 'tech' || variant === 'studio' || variant === 'corporate' || variant === 'terminal'}
|
||||||
<BlurFade delay={0} once={true}>
|
|
||||||
{#snippet children()}
|
{#snippet children()}
|
||||||
{@render gridInner()}
|
{@render gridInner()}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</BlurFade>
|
|
||||||
{:else}
|
{:else}
|
||||||
{@render gridInner()}
|
{@render gridInner()}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -139,7 +136,7 @@
|
|||||||
{#if bottomLinks.length > 0}
|
{#if bottomLinks.length > 0}
|
||||||
<div class={tokens.bottomLinksWrap}>
|
<div class={tokens.bottomLinksWrap}>
|
||||||
{#each bottomLinks as link (link.id)}
|
{#each bottomLinks as link (link.id)}
|
||||||
<a href={link.href} class={tokens.bottomLink}>{link.label}</a>
|
<a href={link.href} class="{tokens.bottomLink} ui:cursor-pointer">{link.label}</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -147,7 +144,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class={tokens.compactRow}>
|
<div class={tokens.compactRow}>
|
||||||
<a href="/" class={tokens.brandName}>
|
<a href="/" class="{tokens.brandName} ui:cursor-pointer">
|
||||||
{#if logoUrl}
|
{#if logoUrl}
|
||||||
<img src={logoUrl} alt="" class={tokens.logoImg} />
|
<img src={logoUrl} alt="" class={tokens.logoImg} />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -158,7 +155,7 @@
|
|||||||
class="ui:flex ui:flex-wrap ui:justify-start ui:gap-6 ui:md:justify-center ui:md:gap-8 ui:w-full ui:md:w-auto"
|
class="ui:flex ui:flex-wrap ui:justify-start ui:gap-6 ui:md:justify-center ui:md:gap-8 ui:w-full ui:md:w-auto"
|
||||||
>
|
>
|
||||||
{#each bottomLinks as link (link.id)}
|
{#each bottomLinks as link (link.id)}
|
||||||
<a href={link.href} class={tokens.bottomLink}>{link.label}</a>
|
<a href={link.href} class="{tokens.bottomLink} ui:cursor-pointer">{link.label}</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -23,82 +23,83 @@ export type FooterRegionTokens = {
|
|||||||
export function getFooterTokens(variant: OrgLandingPageTheme): FooterRegionTokens {
|
export function getFooterTokens(variant: OrgLandingPageTheme): FooterRegionTokens {
|
||||||
if (variant === 'minimal') {
|
if (variant === 'minimal') {
|
||||||
return {
|
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',
|
container: 'ui:max-w-[1200px] ui:mx-auto',
|
||||||
compactRow:
|
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:
|
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:
|
brandName:
|
||||||
'ui:font-semibold ui:text-xl ui:text-foreground ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words',
|
'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-muted-foreground ui:break-words ui:mt-2',
|
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-foreground ui:mb-4',
|
columnHeading: 'ui:text-sm ui:font-semibold ui:text-[var(--landing-fg)] ui:mb-4',
|
||||||
columnLink:
|
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:
|
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',
|
socialWrap: 'ui:flex ui:flex-wrap ui:gap-2 ui:mt-3 ui:list-none ui:m-0 ui:p-0',
|
||||||
socialIcon:
|
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',
|
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',
|
bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-6 ui:gap-y-2 ui:justify-start ui:sm:justify-end',
|
||||||
bottomLink:
|
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'
|
logoImg: 'ui:h-7 ui:md:h-8 ui:w-auto'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (variant === 'tech') {
|
if (variant === 'tech') {
|
||||||
return {
|
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',
|
container: 'ui:max-w-7xl ui:mx-auto',
|
||||||
compactRow:
|
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: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:
|
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:
|
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',
|
'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-background/60 ui:break-words ui:mt-3 ui:max-w-[260px]',
|
brandTagline: 'ui:text-sm ui:text-[var(--landing-bg)]/60 ui:break-words ui:mt-3 ui:max-w-[260px]',
|
||||||
columnHeading:
|
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:
|
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:
|
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',
|
socialWrap: 'ui:flex ui:flex-wrap ui:gap-2 ui:mt-4 ui:list-none ui:m-0 ui:p-0',
|
||||||
socialIcon:
|
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',
|
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',
|
bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-5 ui:gap-y-2 ui:justify-start ui:sm:justify-end',
|
||||||
bottomLink:
|
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'
|
logoImg: 'ui:h-6 ui:md:h-7 ui:w-auto'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (variant === 'studio') {
|
if (variant === 'studio') {
|
||||||
return {
|
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',
|
container: 'ui:max-w-[1080px] ui:mx-auto',
|
||||||
compactRow:
|
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:
|
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:
|
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',
|
'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-muted-foreground ui:break-words ui:mt-3 ui:max-w-[240px] ui:leading-relaxed',
|
brandTagline:
|
||||||
columnHeading: 'ui:text-xs ui:font-medium ui:text-muted-foreground ui:mb-3',
|
'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:
|
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:
|
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',
|
socialWrap: 'ui:flex ui:flex-wrap ui:gap-2 ui:mt-3 ui:list-none ui:m-0 ui:p-0',
|
||||||
socialIcon:
|
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',
|
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',
|
bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-5 ui:gap-y-2 ui:justify-start ui:sm:justify-end',
|
||||||
bottomLink:
|
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'
|
logoImg: 'ui:h-6 ui:md:h-7 ui:w-auto'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -132,108 +133,165 @@ export function getFooterTokens(variant: OrgLandingPageTheme): FooterRegionToken
|
|||||||
|
|
||||||
if (variant === 'corporate') {
|
if (variant === 'corporate') {
|
||||||
return {
|
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',
|
container: 'ui:max-w-[1120px] ui:mx-auto',
|
||||||
compactRow:
|
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:
|
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:
|
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',
|
'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-muted-foreground ui:break-words ui:mt-3 ui:max-w-[260px] ui:leading-relaxed',
|
brandTagline:
|
||||||
columnHeading: 'ui:text-xs ui:font-semibold ui:tracking-wider ui:text-foreground ui:mb-3 ui:uppercase',
|
'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:
|
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:
|
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',
|
socialWrap: 'ui:flex ui:flex-wrap ui:gap-3 ui:mt-4 ui:list-none ui:m-0 ui:p-0',
|
||||||
socialIcon:
|
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',
|
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',
|
bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-6 ui:gap-y-2 ui:justify-start ui:sm:justify-end',
|
||||||
bottomLink:
|
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'
|
logoImg: 'ui:h-6 ui:md:h-7 ui:w-auto'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (variant === 'saas') {
|
if (variant === 'saas') {
|
||||||
return {
|
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',
|
container: 'ui:max-w-[1180px] ui:mx-auto',
|
||||||
compactRow:
|
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:
|
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:
|
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',
|
'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-muted-foreground ui:break-words ui:mt-3 ui:max-w-[260px]',
|
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-foreground ui:mb-3',
|
columnHeading: 'ui:text-sm ui:font-bold ui:text-[var(--landing-fg)] ui:mb-3',
|
||||||
columnLink:
|
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:
|
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',
|
socialWrap: 'ui:flex ui:flex-wrap ui:gap-2 ui:mt-4 ui:list-none ui:m-0 ui:p-0',
|
||||||
socialIcon:
|
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',
|
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',
|
bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-5 ui:gap-y-2 ui:justify-start ui:sm:justify-end',
|
||||||
bottomLink:
|
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'
|
logoImg: 'ui:h-6 ui:md:h-7 ui:w-auto'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (variant === 'bold') {
|
if (variant === 'bold') {
|
||||||
return {
|
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',
|
container: 'ui:max-w-7xl ui:mx-auto',
|
||||||
compactRow:
|
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: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:
|
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:
|
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',
|
'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-background/60 ui:break-words ui:mt-2',
|
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-background ui:mb-4',
|
columnHeading: 'ui:text-xs ui:font-bold ui:uppercase ui:tracking-widest ui:text-[var(--landing-bg)] ui:mb-4',
|
||||||
columnLink:
|
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:
|
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',
|
socialWrap: 'ui:flex ui:flex-wrap ui:gap-2 ui:mt-3 ui:list-none ui:m-0 ui:p-0',
|
||||||
socialIcon:
|
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',
|
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',
|
bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-6 ui:gap-y-2 ui:justify-start ui:sm:justify-end',
|
||||||
bottomLink:
|
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'
|
logoImg: 'ui:h-7 ui:md:h-8 ui:w-auto ui:grayscale ui:brightness-200'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
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',
|
container: 'ui:max-w-7xl ui:mx-auto',
|
||||||
compactRow:
|
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: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:
|
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',
|
'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-background/30 ui:mx-auto ui:mb-6',
|
bottomHairline: 'ui:h-px ui:w-16 ui:md:w-24 ui:bg-[var(--landing-bg)]/30 ui:mx-auto ui:mb-6',
|
||||||
brandName:
|
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',
|
'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-background/70 ui:break-words ui:mt-2',
|
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-background ui:mb-4',
|
columnHeading: 'ui:text-sm ui:font-semibold ui:text-[var(--landing-bg)] ui:mb-4',
|
||||||
columnLink:
|
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:
|
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',
|
socialWrap: 'ui:flex ui:flex-wrap ui:gap-2 ui:mt-3 ui:list-none ui:m-0 ui:p-0',
|
||||||
socialIcon:
|
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',
|
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',
|
bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-6 ui:gap-y-2 ui:justify-start ui:sm:justify-end',
|
||||||
bottomLink:
|
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'
|
logoImg: 'ui:h-7 ui:md:h-8 ui:w-auto'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,3 +72,15 @@ export function calcCourseDiscount(percent = 0, cost: number, showDiscount: bool
|
|||||||
const discountedPrice = cost - discountAmount;
|
const discountedPrice = cost - discountAmount;
|
||||||
return Math.round(discountedPrice);
|
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`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { LandingPageLinks, OrgLandingPageTheme } from './types';
|
import type { LandingPageLinks, OrgLandingPageLabels, OrgLandingPageTheme } from './types';
|
||||||
import { landingPageLinkIconMap } from './landing-page-link-icons';
|
import { landingPageLinkIconMap } from './landing-page-link-icons';
|
||||||
import { BlurFade } from '../animation/blurfade';
|
|
||||||
import { BorderBeam } from '../animation/border-beam';
|
import { BorderBeam } from '../animation/border-beam';
|
||||||
import { DotPattern } from '../animation/dot-pattern';
|
import { DotPattern } from '../animation/dot-pattern';
|
||||||
import * as Card from '../../base/card';
|
import * as Card from '../../base/card';
|
||||||
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
||||||
import EditableLandingSection from './editable-section.svelte';
|
import EditableLandingSection from './editable-section.svelte';
|
||||||
|
|
||||||
let { links, variant = 'minimal' }: { links?: LandingPageLinks; variant?: OrgLandingPageTheme } = $props();
|
let {
|
||||||
|
links,
|
||||||
|
variant = 'minimal',
|
||||||
|
labels
|
||||||
|
}: {
|
||||||
|
links?: LandingPageLinks;
|
||||||
|
variant?: OrgLandingPageTheme;
|
||||||
|
labels?: OrgLandingPageLabels;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const resolvedResourcesEyebrow = $derived(labels?.resourcesEyebrow ?? 'Resources');
|
||||||
|
|
||||||
function linkDisplayHint(href: string) {
|
function linkDisplayHint(href: string) {
|
||||||
const trimmed = href.trim();
|
const trimmed = href.trim();
|
||||||
@@ -29,42 +38,37 @@
|
|||||||
{#if variant === 'minimal'}
|
{#if variant === 'minimal'}
|
||||||
<section class="ui:py-20 ui:px-4">
|
<section class="ui:py-20 ui:px-4">
|
||||||
<div class="ui:max-w-[1200px] ui:mx-auto">
|
<div class="ui:max-w-[1200px] ui:mx-auto">
|
||||||
<BlurFade delay={0} once={true}>
|
|
||||||
<h2 class="ui:text-2xl ui:font-semibold {links.description?.trim() ? 'ui:mb-3' : 'ui:mb-10'}">
|
<h2 class="ui:text-2xl ui:font-semibold {links.description?.trim() ? 'ui:mb-3' : 'ui:mb-10'}">
|
||||||
{links.heading}
|
{links.heading}
|
||||||
</h2>
|
</h2>
|
||||||
</BlurFade>
|
|
||||||
{#if links.description?.trim()}
|
{#if links.description?.trim()}
|
||||||
<BlurFade delay={0.12} once={true}>
|
<p class="ui:text-base ui:text-[var(--landing-fg-muted)] ui:max-w-2xl ui:mb-10">
|
||||||
<p class="ui:text-base ui:text-muted-foreground ui:max-w-2xl ui:mb-10">
|
|
||||||
{links.description}
|
{links.description}
|
||||||
</p>
|
</p>
|
||||||
</BlurFade>
|
|
||||||
{/if}
|
{/if}
|
||||||
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-6">
|
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-6">
|
||||||
{#each links.cards as card, index (index)}
|
{#each links.cards as card, index (index)}
|
||||||
{@const IconComponent = landingPageLinkIconMap[card.icon]}
|
{@const IconComponent = landingPageLinkIconMap[card.icon]}
|
||||||
<BlurFade delay={0.1 * index} once={true}>
|
|
||||||
<a
|
<a
|
||||||
href={card.href}
|
href={card.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="ui:group ui:block ui:h-full ui:no-underline"
|
class="ui:group ui:block ui:h-full ui:no-underline ui:cursor-pointer"
|
||||||
aria-label={`${card.title} — ${linkDisplayHint(card.href)} (opens in new tab)`}
|
aria-label={`${card.title} — ${linkDisplayHint(card.href)} (opens in new tab)`}
|
||||||
>
|
>
|
||||||
<Card.Root
|
<Card.Root
|
||||||
class="ui:rounded-none ui:border-border/60 ui:shadow-none ui:h-full ui:p-0 ui:gap-0 ui:transition-colors ui:hover:border-border"
|
class="ui:rounded-none ui:border-[var(--landing-border)]/60 ui:shadow-none ui:h-full ui:p-0 ui:gap-0 ui:transition-colors ui:hover:border-[var(--landing-border)]"
|
||||||
>
|
>
|
||||||
<Card.Content class="ui:p-8 ui:flex ui:flex-col ui:flex-1 ui:h-full">
|
<Card.Content class="ui:p-8 ui:flex ui:flex-col ui:flex-1 ui:h-full">
|
||||||
<IconComponent class="ui:size-6 ui:text-foreground ui:mb-6" aria-hidden="true" />
|
<IconComponent class="ui:size-6 ui:text-[var(--landing-fg)] ui:mb-6" aria-hidden="true" />
|
||||||
<Card.Title class="ui:text-xl ui:font-normal ui:mb-3">{card.title}</Card.Title>
|
<Card.Title class="ui:text-xl ui:font-normal ui:mb-3">{card.title}</Card.Title>
|
||||||
<Card.Description
|
<Card.Description
|
||||||
class="ui:text-base ui:leading-relaxed ui:text-muted-foreground ui:line-clamp-3 ui:mb-6"
|
class="ui:text-base ui:leading-relaxed ui:text-[var(--landing-fg-muted)] ui:line-clamp-3 ui:mb-6"
|
||||||
>
|
>
|
||||||
{card.description}
|
{card.description}
|
||||||
</Card.Description>
|
</Card.Description>
|
||||||
<div
|
<div
|
||||||
class="ui:flex ui:items-center ui:gap-1.5 ui:text-sm ui:text-muted-foreground ui:mt-auto"
|
class="ui:flex ui:items-center ui:gap-1.5 ui:text-sm ui:text-[var(--landing-fg-muted)] ui:mt-auto"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<span class="ui:truncate">{linkDisplayHint(card.href)}</span>
|
<span class="ui:truncate">{linkDisplayHint(card.href)}</span>
|
||||||
@@ -75,98 +79,89 @@
|
|||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
</a>
|
</a>
|
||||||
</BlurFade>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{:else if variant === 'tech'}
|
{:else if variant === 'tech'}
|
||||||
<section class="ui:relative ui:py-24 ui:px-6 ui:bg-primary ui:text-primary-foreground ui:overflow-hidden">
|
<section
|
||||||
|
class="ui:relative ui:py-24 ui:px-6 ui:bg-[var(--landing-accent)] ui:text-[var(--landing-accent-fg)] ui:overflow-hidden"
|
||||||
|
>
|
||||||
<DotPattern class="ui:opacity-[0.08]" />
|
<DotPattern class="ui:opacity-[0.08]" />
|
||||||
<div class="ui:relative ui:max-w-7xl ui:mx-auto">
|
<div class="ui:relative ui:max-w-7xl ui:mx-auto">
|
||||||
<BlurFade delay={0} once={true}>
|
|
||||||
<h2 class="ui:text-4xl ui:lg:text-5xl ui:font-extrabold ui:tracking-tight ui:mb-3">{links.heading}</h2>
|
<h2 class="ui:text-4xl ui:lg:text-5xl ui:font-extrabold ui:tracking-tight ui:mb-3">{links.heading}</h2>
|
||||||
</BlurFade>
|
|
||||||
{#if links.description?.trim()}
|
{#if links.description?.trim()}
|
||||||
<BlurFade delay={0.12} once={true}>
|
<p class="ui:text-lg ui:text-[var(--landing-accent-fg)]/80 ui:max-w-2xl ui:mb-14 ui:font-mono">
|
||||||
<p class="ui:text-lg ui:text-primary-foreground/80 ui:max-w-2xl ui:mb-14 ui:font-mono">
|
|
||||||
{links.description}
|
{links.description}
|
||||||
</p>
|
</p>
|
||||||
</BlurFade>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="ui:mb-14" aria-hidden="true"></div>
|
<div class="ui:mb-14" aria-hidden="true"></div>
|
||||||
{/if}
|
{/if}
|
||||||
<div
|
<div
|
||||||
class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:border ui:border-primary-foreground/20"
|
class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:border ui:border-[var(--landing-accent-fg)]/20"
|
||||||
>
|
>
|
||||||
{#each links.cards as card, index (index)}
|
{#each links.cards as card, index (index)}
|
||||||
{@const IconComponent = landingPageLinkIconMap[card.icon]}
|
{@const IconComponent = landingPageLinkIconMap[card.icon]}
|
||||||
<BlurFade delay={0.08 * index} once={true}>
|
|
||||||
<a
|
<a
|
||||||
href={card.href}
|
href={card.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="ui:group ui:block ui:h-full ui:p-8 ui:border-r ui:border-b ui:border-primary-foreground/15 ui:transition-colors ui:hover:bg-primary-foreground/5 ui:no-underline ui:text-primary-foreground"
|
class="ui:group ui:block ui:h-full ui:p-8 ui:border-r ui:border-b ui:border-[var(--landing-accent-fg)]/15 ui:transition-colors ui:hover:bg-[var(--landing-accent-fg)]/5 ui:no-underline ui:text-[var(--landing-accent-fg)] ui:cursor-pointer"
|
||||||
aria-label={`${card.title} — ${linkDisplayHint(card.href)} (opens in new tab)`}
|
aria-label={`${card.title} — ${linkDisplayHint(card.href)} (opens in new tab)`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="ui:inline-flex ui:items-center ui:justify-center ui:size-9 ui:bg-primary-foreground/15 ui:text-primary-foreground ui:font-mono ui:font-bold ui:mb-5"
|
class="ui:inline-flex ui:items-center ui:justify-center ui:size-9 ui:bg-[var(--landing-accent-fg)]/15 ui:text-[var(--landing-accent-fg)] ui:font-mono ui:font-bold ui:mb-5"
|
||||||
>
|
>
|
||||||
<IconComponent class="ui:size-4 custom" aria-hidden="true" />
|
<IconComponent class="ui:size-4 custom" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<h3 class="ui:text-lg ui:font-bold ui:tracking-tight ui:mb-2">{card.title}</h3>
|
<h3 class="ui:text-lg ui:font-bold ui:tracking-tight ui:mb-2">{card.title}</h3>
|
||||||
<p class="ui:text-sm ui:text-primary-foreground/70 ui:leading-relaxed ui:line-clamp-3 ui:m-0">
|
<p class="ui:text-sm ui:text-[var(--landing-accent-fg)]/70 ui:leading-relaxed ui:line-clamp-3 ui:m-0">
|
||||||
{card.description}
|
{card.description}
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
</BlurFade>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{:else if variant === 'studio'}
|
{:else if variant === 'studio'}
|
||||||
<section class="ui:py-24 ui:px-6 ui:border-t ui:border-border ui:bg-background">
|
<section class="ui:py-24 ui:px-6 ui:border-t ui:border-[var(--landing-border)] ui:bg-[var(--landing-bg)]">
|
||||||
<div class="ui:max-w-[1080px] ui:mx-auto">
|
<div class="ui:max-w-[1080px] ui:mx-auto">
|
||||||
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-12 ui:mb-12 ui:items-end">
|
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-12 ui:mb-12 ui:items-end">
|
||||||
<BlurFade delay={0} once={true}>
|
|
||||||
<div>
|
<div>
|
||||||
<p class="ui:text-sm ui:text-muted-foreground ui:mb-1.5 ui:inline-flex ui:items-center ui:gap-2">
|
<p class="ui:text-sm ui:text-[var(--landing-fg-muted)] ui:mb-1.5 ui:inline-flex ui:items-center ui:gap-2">
|
||||||
<span class="ui:size-1.5 ui:rounded-full ui:bg-primary"></span>
|
<span class="ui:size-1.5 ui:rounded-full ui:bg-[var(--landing-accent)]"></span>
|
||||||
Resources
|
{resolvedResourcesEyebrow}
|
||||||
</p>
|
</p>
|
||||||
<h2 class="ui:text-3xl ui:lg:text-4xl ui:font-semibold ui:tracking-tight ui:m-0">{links.heading}</h2>
|
<h2 class="ui:text-3xl ui:lg:text-4xl ui:font-semibold ui:tracking-tight ui:m-0">{links.heading}</h2>
|
||||||
</div>
|
</div>
|
||||||
</BlurFade>
|
|
||||||
{#if links.description?.trim()}
|
{#if links.description?.trim()}
|
||||||
<BlurFade delay={0.1} once={true}>
|
<p class="ui:text-base ui:text-[var(--landing-fg-muted)] ui:leading-relaxed ui:m-0">
|
||||||
<p class="ui:text-base ui:text-muted-foreground ui:leading-relaxed ui:m-0">
|
|
||||||
{links.description}
|
{links.description}
|
||||||
</p>
|
</p>
|
||||||
</BlurFade>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-4 ui:gap-3">
|
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-4 ui:gap-3">
|
||||||
{#each links.cards as card, index (index)}
|
{#each links.cards as card, index (index)}
|
||||||
{@const IconComponent = landingPageLinkIconMap[card.icon]}
|
{@const IconComponent = landingPageLinkIconMap[card.icon]}
|
||||||
<BlurFade delay={0.06 * index} once={true}>
|
|
||||||
<a
|
<a
|
||||||
href={card.href}
|
href={card.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="ui:group ui:block ui:h-full ui:p-6 ui:bg-card ui:border ui:border-border ui:rounded-xl ui:transition-colors ui:hover:border-foreground/30 ui:hover:bg-muted/30 ui:no-underline"
|
class="ui:group ui:block ui:h-full ui:p-6 ui:bg-[var(--landing-card)] ui:border ui:border-[var(--landing-border)] ui:rounded-xl ui:transition-colors ui:hover:border-[var(--landing-fg)]/30 ui:hover:bg-[var(--landing-card-soft)]/30 ui:no-underline ui:cursor-pointer"
|
||||||
aria-label={`${card.title} — ${linkDisplayHint(card.href)} (opens in new tab)`}
|
aria-label={`${card.title} — ${linkDisplayHint(card.href)} (opens in new tab)`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="ui:inline-flex ui:items-center ui:justify-center ui:size-8 ui:rounded-lg ui:bg-muted ui:border ui:border-border ui:text-primary ui:mb-3"
|
class="ui:inline-flex ui:items-center ui:justify-center ui:size-8 ui:rounded-lg ui:bg-[var(--landing-card-soft)] ui:border ui:border-[var(--landing-border)] ui:text-[var(--landing-accent)] ui:mb-3"
|
||||||
>
|
>
|
||||||
<IconComponent class="ui:size-4" aria-hidden="true" />
|
<IconComponent class="ui:size-4" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<h3 class="ui:text-sm ui:font-medium ui:tracking-tight ui:mb-1.5 ui:text-foreground">{card.title}</h3>
|
<h3 class="ui:text-sm ui:font-medium ui:tracking-tight ui:mb-1.5 ui:text-[var(--landing-fg)]">
|
||||||
<p class="ui:text-[13px] ui:text-muted-foreground ui:leading-relaxed ui:line-clamp-3 ui:m-0">
|
{card.title}
|
||||||
|
</h3>
|
||||||
|
<p class="ui:text-[13px] ui:text-[var(--landing-fg-muted)] ui:leading-relaxed ui:line-clamp-3 ui:m-0">
|
||||||
{card.description}
|
{card.description}
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
</BlurFade>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -185,42 +180,37 @@
|
|||||||
<section
|
<section
|
||||||
class="ui:py-24 ui:px-6"
|
class="ui:py-24 ui:px-6"
|
||||||
style="border-top: 1px solid #1c1f28; background:
|
style="border-top: 1px solid #1c1f28; background:
|
||||||
radial-gradient(ellipse 60% 100% at 50% 0%, color-mix(in oklab, var(--primary) 6%, transparent) 0%, transparent 60%),
|
radial-gradient(ellipse 60% 100% at 50% 0%, color-mix(in oklab, var(--landing-accent) 6%, transparent) 0%, transparent 60%),
|
||||||
#06070a;"
|
#06070a;"
|
||||||
>
|
>
|
||||||
<div class="ui:max-w-[1120px] ui:mx-auto">
|
<div class="ui:max-w-[1120px] ui:mx-auto">
|
||||||
<BlurFade delay={0} once={true}>
|
|
||||||
<p
|
<p
|
||||||
class="ui:font-mono ui:text-[11px] ui:tracking-[0.12em] ui:uppercase ui:mb-3 ui:inline-flex ui:items-center ui:gap-2"
|
class="ui:font-mono ui:text-[11px] ui:tracking-[0.12em] ui:uppercase ui:mb-3 ui:inline-flex ui:items-center ui:gap-2"
|
||||||
style="color: var(--primary);"
|
style="color: var(--landing-accent);"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="ui:size-1.5 ui:rounded-full"
|
class="ui:size-1.5 ui:rounded-full"
|
||||||
style="background: var(--primary); box-shadow: 0 0 12px var(--primary);"
|
style="background: var(--landing-accent); box-shadow: 0 0 12px var(--landing-accent);"
|
||||||
></span>
|
></span>
|
||||||
Resources
|
{resolvedResourcesEyebrow}
|
||||||
</p>
|
</p>
|
||||||
</BlurFade>
|
|
||||||
<BlurFade delay={0.08} once={true}>
|
|
||||||
<h2
|
<h2
|
||||||
class="ui:text-[40px] ui:font-semibold ui:tracking-tight ui:m-0 ui:mb-10"
|
class="ui:text-[40px] ui:font-semibold ui:tracking-tight ui:m-0 ui:mb-10"
|
||||||
style="color: #e9eaed; letter-spacing: -0.025em; line-height: 1.08;"
|
style="color: #e9eaed; letter-spacing: -0.025em; line-height: 1.08;"
|
||||||
>
|
>
|
||||||
{links.heading}
|
{links.heading}
|
||||||
</h2>
|
</h2>
|
||||||
</BlurFade>
|
|
||||||
<div
|
<div
|
||||||
class="ui:grid ui:grid-cols-1 {mdColsClass} {lgColsClass} ui:overflow-hidden ui:rounded-[14px] ui:gap-[1px]"
|
class="ui:grid ui:grid-cols-1 {mdColsClass} {lgColsClass} ui:overflow-hidden ui:rounded-[14px] ui:gap-[1px]"
|
||||||
style="border: 1px solid #1c1f28; background: #1c1f28;"
|
style="border: 1px solid #1c1f28; background: #1c1f28;"
|
||||||
>
|
>
|
||||||
{#each links.cards as card, index (index)}
|
{#each links.cards as card, index (index)}
|
||||||
{@const IconComponent = landingPageLinkIconMap[card.icon]}
|
{@const IconComponent = landingPageLinkIconMap[card.icon]}
|
||||||
<BlurFade delay={0.06 * index} once={true}>
|
|
||||||
<a
|
<a
|
||||||
href={card.href}
|
href={card.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="ui:flex ui:flex-col ui:gap-3 ui:p-7 ui:no-underline ui:h-full ui:transition-colors"
|
class="ui:flex ui:flex-col ui:gap-3 ui:p-7 ui:no-underline ui:h-full ui:transition-colors ui:cursor-pointer"
|
||||||
style="background: #0f1218; color: #e9eaed;"
|
style="background: #0f1218; color: #e9eaed;"
|
||||||
onmouseenter={(e) => (e.currentTarget.style.background = '#14171f')}
|
onmouseenter={(e) => (e.currentTarget.style.background = '#14171f')}
|
||||||
onmouseleave={(e) => (e.currentTarget.style.background = '#0f1218')}
|
onmouseleave={(e) => (e.currentTarget.style.background = '#0f1218')}
|
||||||
@@ -228,7 +218,7 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="ui:inline-flex ui:items-center ui:justify-center ui:size-9 ui:rounded-lg"
|
class="ui:inline-flex ui:items-center ui:justify-center ui:size-9 ui:rounded-lg"
|
||||||
style="border: 1px solid #262a35; background: #14171f; color: var(--primary);"
|
style="border: 1px solid #262a35; background: #14171f; color: var(--landing-accent);"
|
||||||
>
|
>
|
||||||
<IconComponent class="ui:size-4" aria-hidden="true" />
|
<IconComponent class="ui:size-4" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
@@ -239,112 +229,210 @@
|
|||||||
{card.description}
|
{card.description}
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
</BlurFade>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{:else if variant === 'corporate'}
|
{:else if variant === 'corporate'}
|
||||||
<section class="ui:py-20 ui:px-6 ui:bg-muted/30 ui:border-t ui:border-b ui:border-border">
|
<section
|
||||||
|
class="ui:py-20 ui:px-6 ui:bg-[var(--landing-card-soft)]/30 ui:border-t ui:border-b ui:border-[var(--landing-border)]"
|
||||||
|
>
|
||||||
<div class="ui:max-w-[1120px] ui:mx-auto">
|
<div class="ui:max-w-[1120px] ui:mx-auto">
|
||||||
<BlurFade delay={0} once={true}>
|
<p class="ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-[var(--landing-fg)] ui:mb-3">
|
||||||
<p class="ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-foreground ui:mb-3">
|
{resolvedResourcesEyebrow}
|
||||||
Resources
|
|
||||||
</p>
|
</p>
|
||||||
</BlurFade>
|
|
||||||
<BlurFade delay={0.1} once={true}>
|
|
||||||
<h2 class="ui:text-3xl ui:font-semibold ui:tracking-tight ui:m-0 ui:mb-10">{links.heading}</h2>
|
<h2 class="ui:text-3xl ui:font-semibold ui:tracking-tight ui:m-0 ui:mb-10">{links.heading}</h2>
|
||||||
</BlurFade>
|
<div
|
||||||
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:border-t ui:border-l ui:border-border">
|
class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:border-t ui:border-l ui:border-[var(--landing-border)]"
|
||||||
|
>
|
||||||
{#each links.cards as card, index (index)}
|
{#each links.cards as card, index (index)}
|
||||||
{@const IconComponent = landingPageLinkIconMap[card.icon]}
|
{@const IconComponent = landingPageLinkIconMap[card.icon]}
|
||||||
<BlurFade delay={0.06 * index} once={true}>
|
|
||||||
<a
|
<a
|
||||||
href={card.href}
|
href={card.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="ui:flex ui:gap-5 ui:items-start ui:p-7 ui:border-r ui:border-b ui:border-border ui:bg-background ui:transition-colors ui:hover:bg-muted/40 ui:no-underline ui:h-full"
|
class="ui:flex ui:gap-5 ui:items-start ui:p-7 ui:border-r ui:border-b ui:border-[var(--landing-border)] ui:bg-[var(--landing-bg)] ui:transition-colors ui:hover:bg-[var(--landing-card-soft)]/40 ui:no-underline ui:h-full ui:cursor-pointer"
|
||||||
aria-label={`${card.title} — ${linkDisplayHint(card.href)} (opens in new tab)`}
|
aria-label={`${card.title} — ${linkDisplayHint(card.href)} (opens in new tab)`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="ui:inline-flex ui:items-center ui:justify-center ui:size-9 ui:bg-foreground ui:text-background ui:flex-shrink-0"
|
class="ui:inline-flex ui:items-center ui:justify-center ui:size-9 ui:bg-[var(--landing-fg)] ui:text-[var(--landing-bg)] ui:flex-shrink-0"
|
||||||
>
|
>
|
||||||
<IconComponent class="ui:size-4" aria-hidden="true" />
|
<IconComponent class="ui:size-4" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<div class="ui:min-w-0">
|
<div class="ui:min-w-0">
|
||||||
<h3 class="ui:text-base ui:font-semibold ui:tracking-tight ui:mb-1.5 ui:m-0">{card.title}</h3>
|
<h3 class="ui:text-base ui:font-semibold ui:tracking-tight ui:mb-1.5 ui:m-0">{card.title}</h3>
|
||||||
<p class="ui:text-sm ui:text-muted-foreground ui:leading-relaxed ui:line-clamp-3 ui:m-0">
|
<p class="ui:text-sm ui:text-[var(--landing-fg-muted)] ui:leading-relaxed ui:line-clamp-3 ui:m-0">
|
||||||
{card.description}
|
{card.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</BlurFade>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{:else if variant === 'saas'}
|
{:else if variant === 'saas'}
|
||||||
<section class="ui:py-20 ui:px-6 ui:bg-muted/40">
|
<section class="ui:py-20 ui:px-6 ui:bg-[var(--landing-card-soft)]/40">
|
||||||
<div class="ui:max-w-[1180px] ui:mx-auto">
|
<div class="ui:max-w-[1180px] ui:mx-auto">
|
||||||
<div class="ui:text-center ui:max-w-xl ui:mx-auto ui:mb-12">
|
<div class="ui:text-center ui:max-w-xl ui:mx-auto ui:mb-12">
|
||||||
<BlurFade delay={0} once={true}>
|
<p
|
||||||
<p class="ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-primary ui:mb-3">
|
class="ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-[var(--landing-accent)] ui:mb-3"
|
||||||
Resources
|
>
|
||||||
|
{resolvedResourcesEyebrow}
|
||||||
</p>
|
</p>
|
||||||
</BlurFade>
|
|
||||||
<BlurFade delay={0.1} once={true}>
|
|
||||||
<h2 class="ui:text-3xl ui:md:text-4xl ui:font-bold ui:tracking-tight ui:m-0">
|
<h2 class="ui:text-3xl ui:md:text-4xl ui:font-bold ui:tracking-tight ui:m-0">
|
||||||
{links.heading}
|
{links.heading}
|
||||||
</h2>
|
</h2>
|
||||||
</BlurFade>
|
|
||||||
{#if links.description?.trim()}
|
{#if links.description?.trim()}
|
||||||
<BlurFade delay={0.18} once={true}>
|
<p class="ui:text-base ui:text-[var(--landing-fg-muted)] ui:mt-3 ui:leading-relaxed">
|
||||||
<p class="ui:text-base ui:text-muted-foreground ui:mt-3 ui:leading-relaxed">
|
|
||||||
{links.description}
|
{links.description}
|
||||||
</p>
|
</p>
|
||||||
</BlurFade>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-[1px] ui:bg-border ui:border ui:border-border"
|
class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-[1px] ui:bg-[var(--landing-border)] ui:border ui:border-[var(--landing-border)]"
|
||||||
>
|
>
|
||||||
{#each links.cards as card, index (index)}
|
{#each links.cards as card, index (index)}
|
||||||
{@const IconComponent = landingPageLinkIconMap[card.icon]}
|
{@const IconComponent = landingPageLinkIconMap[card.icon]}
|
||||||
<BlurFade delay={0.08 * index} once={true}>
|
|
||||||
<a
|
<a
|
||||||
href={card.href}
|
href={card.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="ui:bg-background ui:p-7 ui:flex ui:flex-col ui:items-center ui:text-center ui:gap-3 ui:transition-colors ui:hover:bg-muted/40 ui:no-underline ui:h-full"
|
class="ui:bg-[var(--landing-bg)] ui:p-7 ui:flex ui:flex-col ui:items-center ui:text-center ui:gap-3 ui:transition-colors ui:hover:bg-[var(--landing-card-soft)]/40 ui:no-underline ui:h-full ui:cursor-pointer"
|
||||||
aria-label={`${card.title} — ${linkDisplayHint(card.href)} (opens in new tab)`}
|
aria-label={`${card.title} — ${linkDisplayHint(card.href)} (opens in new tab)`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="ui:inline-flex ui:items-center ui:justify-center ui:size-10 ui:rounded-xl ui:bg-primary/10 ui:text-primary ui:mb-1"
|
class="ui:inline-flex ui:items-center ui:justify-center ui:size-10 ui:rounded-xl ui:bg-[var(--landing-accent)]/10 ui:text-[var(--landing-accent)] ui:mb-1"
|
||||||
>
|
>
|
||||||
<IconComponent class="ui:size-5" aria-hidden="true" />
|
<IconComponent class="ui:size-5" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<h3 class="ui:text-base ui:font-bold ui:tracking-tight ui:m-0">{card.title}</h3>
|
<h3 class="ui:text-base ui:font-bold ui:tracking-tight ui:m-0">{card.title}</h3>
|
||||||
<p class="ui:text-sm ui:text-muted-foreground ui:leading-relaxed ui:line-clamp-3 ui:m-0">
|
<p class="ui:text-sm ui:text-[var(--landing-fg-muted)] ui:leading-relaxed ui:line-clamp-3 ui:m-0">
|
||||||
{card.description}
|
{card.description}
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
</BlurFade>
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{:else if variant === 'vibrant'}
|
||||||
|
<section class="ui:py-24 ui:md:py-28 ui:px-6 ui:bg-[var(--landing-bg)]">
|
||||||
|
<div class="ui:max-w-[1320px] ui:mx-auto">
|
||||||
|
<h2
|
||||||
|
class="ui:text-4xl ui:md:text-5xl ui:font-medium ui:tracking-tight ui:text-center ui:m-0 ui:mb-3 ui:text-[var(--landing-fg)]"
|
||||||
|
style="letter-spacing: -0.025em; line-height: 1.1;"
|
||||||
|
>
|
||||||
|
{links.heading}
|
||||||
|
</h2>
|
||||||
|
{#if links.description?.trim()}
|
||||||
|
<p class="ui:text-lg ui:text-[var(--landing-fg-muted)] ui:text-center ui:max-w-xl ui:mx-auto ui:mb-14">
|
||||||
|
{links.description}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div class="ui:mb-14" aria-hidden="true"></div>
|
||||||
|
{/if}
|
||||||
|
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-5">
|
||||||
|
{#each links.cards as card, index (index)}
|
||||||
|
{@const IconComponent = landingPageLinkIconMap[card.icon]}
|
||||||
|
<a
|
||||||
|
href={card.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
class="ui:group ui:flex ui:flex-col ui:h-full ui:p-8 ui:rounded-[20px] ui:bg-[var(--landing-card-soft)]/60 ui:no-underline ui:transition-colors ui:hover:bg-[var(--landing-card-soft)] ui:cursor-pointer"
|
||||||
|
aria-label={`${card.title} — ${linkDisplayHint(card.href)} (opens in new tab)`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="ui:inline-flex ui:items-center ui:justify-center ui:size-11 ui:rounded-xl ui:bg-[var(--landing-accent)]/10 ui:text-[var(--landing-accent)] ui:mb-5"
|
||||||
|
>
|
||||||
|
<IconComponent class="ui:size-5" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<h3 class="ui:text-lg ui:font-semibold ui:tracking-tight ui:m-0 ui:mb-2 ui:text-[var(--landing-fg)]">
|
||||||
|
{card.title}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
class="ui:text-[15px] ui:text-[var(--landing-fg-muted)] ui:leading-relaxed ui:line-clamp-3 ui:m-0 ui:mb-6"
|
||||||
|
>
|
||||||
|
{card.description}
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
class="ui:mt-auto ui:inline-flex ui:items-center ui:gap-1 ui:text-sm ui:font-medium ui:text-[var(--landing-accent)]"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{labels?.learnMoreLabel ?? 'Learn more'}
|
||||||
|
<ArrowRightIcon class="ui:size-3.5 ui:transition-transform ui:group-hover:translate-x-0.5" />
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{:else if variant === 'editorial'}
|
||||||
|
{@const cardCount = links.cards.length}
|
||||||
|
{@const lgColsClass =
|
||||||
|
cardCount >= 4
|
||||||
|
? 'ui:lg:grid-cols-4'
|
||||||
|
: cardCount === 3
|
||||||
|
? 'ui:lg:grid-cols-3'
|
||||||
|
: cardCount === 2
|
||||||
|
? 'ui:lg:grid-cols-2'
|
||||||
|
: 'ui:lg:grid-cols-1'}
|
||||||
|
<section class="ui:py-24 ui:md:py-28 ui:px-6 ui:bg-[#f4f3ed]">
|
||||||
|
<div class="ui:max-w-[1240px] ui:mx-auto">
|
||||||
|
<div class="ui:flex ui:flex-col ui:md:flex-row ui:md:items-end ui:justify-between ui:gap-6 ui:mb-10">
|
||||||
|
<div>
|
||||||
|
<p class="ui:text-[13px] ui:text-[#76746c] ui:mb-2">{resolvedResourcesEyebrow}</p>
|
||||||
|
<h2
|
||||||
|
class="ui:text-3xl ui:md:text-[38px] ui:font-medium ui:tracking-tight ui:m-0 ui:text-[#1a1a1a] ui:leading-[1.1]"
|
||||||
|
>
|
||||||
|
{links.heading}
|
||||||
|
</h2>
|
||||||
|
{#if links.description?.trim()}
|
||||||
|
<p class="ui:text-base ui:text-[#76746c] ui:mt-3 ui:max-w-xl ui:leading-relaxed">
|
||||||
|
{links.description}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 {lgColsClass} ui:gap-4">
|
||||||
|
{#each links.cards as card, index (index)}
|
||||||
|
{@const IconComponent = landingPageLinkIconMap[card.icon]}
|
||||||
|
<a
|
||||||
|
href={card.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
class="ui:group ui:flex ui:flex-col ui:h-full ui:p-7 ui:rounded-lg ui:bg-[#ecebe5] ui:no-underline ui:transition-colors ui:hover:bg-[#e2e1d9] ui:cursor-pointer"
|
||||||
|
aria-label={`${card.title} — ${linkDisplayHint(card.href)} (opens in new tab)`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="ui:inline-flex ui:items-center ui:justify-center ui:size-9 ui:rounded-lg ui:bg-[#fafaf5] ui:border ui:border-[#d9d8d0] ui:text-[#c25237] ui:mb-5"
|
||||||
|
>
|
||||||
|
<IconComponent class="ui:size-4" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<h3 class="ui:text-base ui:font-semibold ui:tracking-tight ui:m-0 ui:mb-1.5 ui:text-[#1a1a1a]">
|
||||||
|
{card.title}
|
||||||
|
</h3>
|
||||||
|
<p class="ui:text-[13.5px] ui:text-[#76746c] ui:leading-relaxed ui:line-clamp-3 ui:m-0 ui:mb-5">
|
||||||
|
{card.description}
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
class="ui:mt-auto ui:inline-flex ui:items-center ui:gap-1 ui:text-[13.5px] ui:font-medium ui:text-[#c25237]"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{labels?.learnMoreLabel ?? 'Visit'}
|
||||||
|
<ArrowRightIcon class="ui:size-3.5 ui:transition-transform ui:group-hover:translate-x-0.5" />
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{:else if variant === 'bold'}
|
{:else if variant === 'bold'}
|
||||||
<section class="ui:relative ui:py-20 ui:px-6 ui:bg-background ui:overflow-hidden">
|
<section class="ui:relative ui:py-20 ui:px-6 ui:bg-[var(--landing-bg)] ui:overflow-hidden">
|
||||||
<DotPattern class="ui:opacity-[0.10]" />
|
<DotPattern class="ui:opacity-[0.10]" />
|
||||||
<div class="ui:relative ui:max-w-7xl ui:mx-auto">
|
<div class="ui:relative ui:max-w-7xl ui:mx-auto">
|
||||||
<BlurFade delay={0} once={true}>
|
|
||||||
<h2 class="ui:text-4xl ui:font-black ui:tracking-tight ui:mb-3">{links.heading}</h2>
|
<h2 class="ui:text-4xl ui:font-black ui:tracking-tight ui:mb-3">{links.heading}</h2>
|
||||||
</BlurFade>
|
|
||||||
{#if links.description?.trim()}
|
{#if links.description?.trim()}
|
||||||
<BlurFade delay={0.12} once={true}>
|
<p class="ui:text-lg ui:text-[var(--landing-fg-muted)] ui:max-w-2xl ui:mb-12">{links.description}</p>
|
||||||
<p class="ui:text-lg ui:text-muted-foreground ui:max-w-2xl ui:mb-12">{links.description}</p>
|
|
||||||
</BlurFade>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="ui:mb-12" aria-hidden="true"></div>
|
<div class="ui:mb-12" aria-hidden="true"></div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -352,35 +440,34 @@
|
|||||||
{#each links.cards as card, index (index)}
|
{#each links.cards as card, index (index)}
|
||||||
{@const IconComponent = landingPageLinkIconMap[card.icon]}
|
{@const IconComponent = landingPageLinkIconMap[card.icon]}
|
||||||
{@const visitLabel = links.boldVisitLabel?.trim()}
|
{@const visitLabel = links.boldVisitLabel?.trim()}
|
||||||
<BlurFade delay={0.1 * index} once={true}>
|
|
||||||
<a
|
<a
|
||||||
href={card.href}
|
href={card.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="ui:group ui:block ui:h-full ui:no-underline"
|
class="ui:group ui:block ui:h-full ui:no-underline ui:cursor-pointer"
|
||||||
aria-label={`${card.title}${visitLabel ? ` — ${visitLabel}` : ''} (opens in new tab)`}
|
aria-label={`${card.title}${visitLabel ? ` — ${visitLabel}` : ''} (opens in new tab)`}
|
||||||
>
|
>
|
||||||
<Card.Root
|
<Card.Root
|
||||||
class="ui:relative ui:overflow-hidden ui:rounded-3xl ui:bg-background ui:border-border/50 ui:p-8 ui:h-full ui:flex ui:flex-col ui:transition-all ui:duration-300 ui:hover:shadow-xl ui:hover:shadow-primary/5"
|
class="ui:relative ui:overflow-hidden ui:rounded-3xl ui:bg-[var(--landing-bg)] ui:border-[var(--landing-border)]/50 ui:p-8 ui:h-full ui:flex ui:flex-col ui:transition-all ui:duration-300 ui:hover:shadow-xl ui:hover:shadow-primary/5"
|
||||||
>
|
>
|
||||||
<BorderBeam
|
<BorderBeam
|
||||||
size={120}
|
size={120}
|
||||||
duration={8}
|
duration={8}
|
||||||
colorFrom="var(--primary)"
|
colorFrom="var(--landing-accent)"
|
||||||
colorTo="color-mix(in oklab, var(--primary) 65%, white)"
|
colorTo="color-mix(in oklab, var(--landing-accent) 65%, white)"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="ui:inline-flex ui:items-center ui:justify-center ui:size-12 ui:rounded-2xl ui:bg-primary/10 ui:text-primary ui:mb-6"
|
class="ui:inline-flex ui:items-center ui:justify-center ui:size-12 ui:rounded-2xl ui:bg-[var(--landing-accent)]/10 ui:text-[var(--landing-accent)] ui:mb-6"
|
||||||
>
|
>
|
||||||
<IconComponent class="ui:size-6" aria-hidden="true" />
|
<IconComponent class="ui:size-6" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<Card.Title class="ui:text-2xl ui:font-bold ui:tracking-tight ui:mb-3">{card.title}</Card.Title>
|
<Card.Title class="ui:text-2xl ui:font-bold ui:tracking-tight ui:mb-3">{card.title}</Card.Title>
|
||||||
<Card.Description class="ui:text-base ui:text-muted-foreground ui:line-clamp-3 ui:mb-6">
|
<Card.Description class="ui:text-base ui:text-[var(--landing-fg-muted)] ui:line-clamp-3 ui:mb-6">
|
||||||
{card.description}
|
{card.description}
|
||||||
</Card.Description>
|
</Card.Description>
|
||||||
<div class="ui:mt-auto">
|
<div class="ui:mt-auto">
|
||||||
<span
|
<span
|
||||||
class="ui:inline-flex ui:items-center ui:gap-1.5 ui:text-sm ui:font-bold ui:text-foreground ui:bg-muted ui:px-3 ui:py-1.5 ui:rounded-lg ui:transition-colors ui:group-hover:bg-primary ui:group-hover:text-primary-foreground"
|
class="ui:inline-flex ui:items-center ui:gap-1.5 ui:text-sm ui:font-bold ui:text-[var(--landing-fg)] ui:bg-[var(--landing-card-soft)] ui:px-3 ui:py-1.5 ui:rounded-lg ui:transition-colors ui:group-hover:bg-[var(--landing-accent)] ui:group-hover:text-[var(--landing-accent-fg)]"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{#if visitLabel}
|
{#if visitLabel}
|
||||||
@@ -391,7 +478,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
</a>
|
</a>
|
||||||
</BlurFade>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -399,18 +485,12 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<section class="ui:py-10 ui:px-6 ui:lg:px-8 ui:max-w-7xl ui:mx-auto">
|
<section class="ui:py-10 ui:px-6 ui:lg:px-8 ui:max-w-7xl ui:mx-auto">
|
||||||
<div class="ui:text-center">
|
<div class="ui:text-center">
|
||||||
<BlurFade delay={0} once={true}>
|
<h2 class="ui:text-3xl ui:font-bold ui:text-[var(--landing-fg)]">{links.heading}</h2>
|
||||||
<h2 class="ui:text-3xl ui:font-bold ui:text-foreground">{links.heading}</h2>
|
<div class="ui:mt-6 ui:h-px ui:w-24 ui:bg-[var(--landing-border)] ui:mx-auto"></div>
|
||||||
</BlurFade>
|
|
||||||
<BlurFade delay={0.15} once={true}>
|
|
||||||
<div class="ui:mt-6 ui:h-px ui:w-24 ui:bg-border ui:mx-auto"></div>
|
|
||||||
</BlurFade>
|
|
||||||
{#if links.description?.trim()}
|
{#if links.description?.trim()}
|
||||||
<BlurFade delay={0.22} once={true}>
|
<p class="ui:mt-6 ui:text-center ui:text-[var(--landing-fg-muted)] ui:max-w-2xl ui:mx-auto">
|
||||||
<p class="ui:mt-6 ui:text-center ui:text-muted-foreground ui:max-w-2xl ui:mx-auto">
|
|
||||||
{links.description}
|
{links.description}
|
||||||
</p>
|
</p>
|
||||||
</BlurFade>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -419,26 +499,27 @@
|
|||||||
{#each links.cards as card, index (index)}
|
{#each links.cards as card, index (index)}
|
||||||
{@const IconComponent = landingPageLinkIconMap[card.icon]}
|
{@const IconComponent = landingPageLinkIconMap[card.icon]}
|
||||||
{@const learnMoreLabel = links.classicLearnMoreLabel?.trim()}
|
{@const learnMoreLabel = links.classicLearnMoreLabel?.trim()}
|
||||||
<BlurFade delay={0.1 * index} once={true} class="ui:w-full ui:max-w-md">
|
|
||||||
<a
|
<a
|
||||||
href={card.href}
|
href={card.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="ui:group ui:block ui:h-full ui:no-underline"
|
class="ui:group ui:block ui:h-full ui:no-underline ui:cursor-pointer"
|
||||||
aria-label={`${card.title}${learnMoreLabel ? ` — ${learnMoreLabel}` : ''} (opens in new tab)`}
|
aria-label={`${card.title}${learnMoreLabel ? ` — ${learnMoreLabel}` : ''} (opens in new tab)`}
|
||||||
>
|
>
|
||||||
<Card.Root
|
<Card.Root
|
||||||
class="ui:w-full ui:rounded-xl ui:border-border ui:bg-background ui:p-6 ui:h-full ui:flex ui:flex-col ui:hover:shadow-md ui:transition-shadow"
|
class="ui:w-full ui:rounded-xl ui:border-[var(--landing-border)] ui:bg-[var(--landing-bg)] ui:p-6 ui:h-full ui:flex ui:flex-col ui:hover:shadow-md ui:transition-shadow"
|
||||||
>
|
>
|
||||||
<div class="ui:flex ui:items-center ui:justify-center ui:size-12 ui:rounded-full ui:bg-muted ui:mb-4">
|
<div
|
||||||
<IconComponent class="ui:size-5 ui:text-foreground" aria-hidden="true" />
|
class="ui:flex ui:items-center ui:justify-center ui:size-12 ui:rounded-full ui:bg-[var(--landing-card-soft)] ui:mb-4"
|
||||||
|
>
|
||||||
|
<IconComponent class="ui:size-5 ui:text-[var(--landing-fg)]" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<Card.Title class="ui:text-xl ui:font-semibold ui:mb-2">{card.title}</Card.Title>
|
<Card.Title class="ui:text-xl ui:font-semibold ui:mb-2">{card.title}</Card.Title>
|
||||||
<Card.Description class="ui:text-sm ui:text-muted-foreground ui:line-clamp-3 ui:mb-4">
|
<Card.Description class="ui:text-sm ui:text-[var(--landing-fg-muted)] ui:line-clamp-3 ui:mb-4">
|
||||||
{card.description}
|
{card.description}
|
||||||
</Card.Description>
|
</Card.Description>
|
||||||
<div
|
<div
|
||||||
class="ui:text-sm ui:font-medium ui:text-foreground ui:border-t ui:border-border/60 ui:pt-3 ui:mt-auto ui:flex ui:items-center ui:gap-1.5 ui:group-hover:text-primary ui:transition-colors"
|
class="ui:text-sm ui:font-medium ui:text-[var(--landing-fg)] ui:border-t ui:border-[var(--landing-border)]/60 ui:pt-3 ui:mt-auto ui:flex ui:items-center ui:gap-1.5 ui:group-hover:text-[var(--landing-accent)] ui:transition-colors"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{#if learnMoreLabel}
|
{#if learnMoreLabel}
|
||||||
@@ -448,7 +529,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
</a>
|
</a>
|
||||||
</BlurFade>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -6,12 +6,9 @@
|
|||||||
import OrgLandingPageFooter from './landing-page-footer.svelte';
|
import OrgLandingPageFooter from './landing-page-footer.svelte';
|
||||||
import MinimalNav from './minimal/nav.svelte';
|
import MinimalNav from './minimal/nav.svelte';
|
||||||
import MinimalHero from './minimal/hero.svelte';
|
import MinimalHero from './minimal/hero.svelte';
|
||||||
|
import MinimalCourseCard from './minimal/course-card.svelte';
|
||||||
import { Button } from '../../base/button';
|
import { Button } from '../../base/button';
|
||||||
import { BlurFade } from '../animation/blurfade';
|
import { themeStyle } from './theme-style';
|
||||||
import * as Card from '../../base/card';
|
|
||||||
import ClockIcon from '@lucide/svelte/icons/clock';
|
|
||||||
import TagIcon from '@lucide/svelte/icons/tag';
|
|
||||||
import { getCourseTypeLandingMeta, getPrimaryCourseTag } from './landing-page-utils';
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
orgName,
|
orgName,
|
||||||
@@ -25,16 +22,15 @@
|
|||||||
embed,
|
embed,
|
||||||
callout,
|
callout,
|
||||||
links,
|
links,
|
||||||
footer
|
footer,
|
||||||
|
labels
|
||||||
}: OrgLandingPageProps = $props();
|
}: OrgLandingPageProps = $props();
|
||||||
|
|
||||||
function formatCurrency(cost?: number, currency = 'USD') {
|
|
||||||
if (!cost) return 'Free';
|
|
||||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cost);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="ui:min-h-screen ui:bg-background ui:text-foreground ui:font-sans">
|
<div
|
||||||
|
class="ui:min-h-screen ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:font-sans"
|
||||||
|
style={themeStyle('minimal')}
|
||||||
|
>
|
||||||
<main>
|
<main>
|
||||||
<MinimalHero {hero}>
|
<MinimalHero {hero}>
|
||||||
{#snippet navigation()}
|
{#snippet navigation()}
|
||||||
@@ -44,68 +40,10 @@
|
|||||||
|
|
||||||
<section class="ui:py-20 ui:px-4">
|
<section class="ui:py-20 ui:px-4">
|
||||||
<div class="ui:max-w-[1200px] ui:mx-auto">
|
<div class="ui:max-w-[1200px] ui:mx-auto">
|
||||||
<h2 class="ui:text-2xl ui:font-semibold ui:mb-8">Our Courses</h2>
|
<h2 class="ui:text-2xl ui:font-semibold ui:mb-8">{labels?.catalogHeading ?? 'Our Courses'}</h2>
|
||||||
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-6">
|
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-6">
|
||||||
{#each courses as course, index (course.id)}
|
{#each courses as course, index (course.id)}
|
||||||
{@const courseTypeMeta = getCourseTypeLandingMeta(course)}
|
<MinimalCourseCard {course} {disableCourseLinks} {labels} />
|
||||||
{@const primaryTag = getPrimaryCourseTag(course)}
|
|
||||||
<BlurFade delay={0.1 * index} once={true}>
|
|
||||||
<a
|
|
||||||
href={disableCourseLinks
|
|
||||||
? undefined
|
|
||||||
: course.link || (course.slug ? `/course/${course.slug}` : undefined)}
|
|
||||||
class="ui:block ui:h-full ui:no-underline {disableCourseLinks
|
|
||||||
? 'ui:cursor-default'
|
|
||||||
: 'ui:transition-colors'}"
|
|
||||||
aria-disabled={disableCourseLinks}
|
|
||||||
tabindex={disableCourseLinks ? -1 : undefined}
|
|
||||||
>
|
|
||||||
<Card.Root
|
|
||||||
class="ui:rounded-none ui:border-border/60 ui:shadow-none ui:h-full ui:p-0 ui:gap-0 {disableCourseLinks
|
|
||||||
? ''
|
|
||||||
: 'ui:hover:border-border ui:transition-colors'}"
|
|
||||||
>
|
|
||||||
<Card.Content class="ui:p-8 ui:flex ui:flex-col ui:flex-1">
|
|
||||||
<Card.Title class="ui:text-xl ui:font-normal ui:mb-4">{course.title}</Card.Title>
|
|
||||||
<Card.Description class="ui:mb-8 ui:line-clamp-3 ui:text-base ui:leading-relaxed">
|
|
||||||
{course.description}
|
|
||||||
</Card.Description>
|
|
||||||
|
|
||||||
<div class="ui:flex ui:items-center ui:gap-6 ui:text-sm ui:mt-auto">
|
|
||||||
{#if course.duration}
|
|
||||||
<div class="ui:flex ui:items-center ui:gap-1.5 ui:text-muted-foreground">
|
|
||||||
<ClockIcon class="ui:size-4" />
|
|
||||||
<span>{course.duration}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if courseTypeMeta}
|
|
||||||
{@const TypeIcon = courseTypeMeta.icon}
|
|
||||||
<div class="ui:flex ui:items-center ui:gap-1.5 ui:text-muted-foreground">
|
|
||||||
<TypeIcon class={courseTypeMeta.iconClass} />
|
|
||||||
<span>{courseTypeMeta.label}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if primaryTag}
|
|
||||||
<div class="ui:flex ui:items-center ui:gap-1.5 ui:text-muted-foreground">
|
|
||||||
<TagIcon class="ui:size-4" />
|
|
||||||
{#if primaryTag.color}
|
|
||||||
<span
|
|
||||||
class="ui:inline-block ui:h-2 ui:w-2 ui:rounded-full"
|
|
||||||
style={`background-color: ${primaryTag.color}`}
|
|
||||||
aria-hidden="true"
|
|
||||||
></span>
|
|
||||||
{/if}
|
|
||||||
<span>{primaryTag.name}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="ui:ml-auto ui:font-semibold">
|
|
||||||
{course.price || formatCurrency(course.cost, course.currency)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card.Content>
|
|
||||||
</Card.Root>
|
|
||||||
</a>
|
|
||||||
</BlurFade>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -117,7 +55,7 @@
|
|||||||
size="lg"
|
size="lg"
|
||||||
disabled={disableCourseLinks}
|
disabled={disableCourseLinks}
|
||||||
>
|
>
|
||||||
View more courses
|
{labels?.browseCoursesLabel ?? 'View more courses'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -125,11 +63,11 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<OrgLandingPageLinks {links} variant="minimal" />
|
<OrgLandingPageLinks {links} {labels} variant="minimal" />
|
||||||
|
|
||||||
<OrgLandingPageEmbed {embed} variant="minimal" />
|
<OrgLandingPageEmbed {embed} {labels} variant="minimal" />
|
||||||
|
|
||||||
<OrgLandingPageCallout {callout} variant="minimal" />
|
<OrgLandingPageCallout {callout} {labels} variant="minimal" />
|
||||||
|
|
||||||
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="minimal" />
|
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="minimal" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { CourseItem, OrgLandingPageLabels } from '../types';
|
||||||
|
import * as Card from '../../../base/card';
|
||||||
|
import ClockIcon from '@lucide/svelte/icons/clock';
|
||||||
|
import TagIcon from '@lucide/svelte/icons/tag';
|
||||||
|
import { getCourseTypeLandingMeta, getPrimaryCourseTag } from '../landing-page-utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
course: CourseItem;
|
||||||
|
disableCourseLinks?: boolean;
|
||||||
|
labels?: OrgLandingPageLabels;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { course, disableCourseLinks = false, labels }: Props = $props();
|
||||||
|
|
||||||
|
const courseTypeMeta = $derived(getCourseTypeLandingMeta(course));
|
||||||
|
const primaryTag = $derived(getPrimaryCourseTag(course));
|
||||||
|
|
||||||
|
const href = $derived.by(() => {
|
||||||
|
if (disableCourseLinks) return undefined;
|
||||||
|
return course.link || (course.slug ? `/course/${course.slug}` : undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatCurrency(cost?: number, currency = 'USD') {
|
||||||
|
if (!cost) return labels?.freeLabel ?? 'Free';
|
||||||
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cost);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a
|
||||||
|
{href}
|
||||||
|
class="ui:block ui:h-full ui:no-underline {disableCourseLinks
|
||||||
|
? 'ui:cursor-default'
|
||||||
|
: 'ui:cursor-pointer ui:transition-colors'}"
|
||||||
|
aria-disabled={disableCourseLinks}
|
||||||
|
tabindex={disableCourseLinks ? -1 : undefined}
|
||||||
|
>
|
||||||
|
<Card.Root
|
||||||
|
class="ui:rounded-none ui:border-[var(--landing-border)]/60 ui:shadow-none ui:h-full ui:p-0 ui:gap-0 {disableCourseLinks
|
||||||
|
? ''
|
||||||
|
: 'ui:hover:border-[var(--landing-border)] ui:transition-colors'}"
|
||||||
|
>
|
||||||
|
<Card.Content class="ui:p-8 ui:flex ui:flex-col ui:flex-1">
|
||||||
|
<Card.Title class="ui:text-xl ui:font-normal ui:mb-4">{course.title}</Card.Title>
|
||||||
|
<Card.Description class="ui:mb-8 ui:line-clamp-3 ui:text-base ui:leading-relaxed">
|
||||||
|
{course.description}
|
||||||
|
</Card.Description>
|
||||||
|
|
||||||
|
<div class="ui:flex ui:items-center ui:gap-6 ui:text-sm ui:mt-auto">
|
||||||
|
{#if course.duration}
|
||||||
|
<div class="ui:flex ui:items-center ui:gap-1.5 ui:text-[var(--landing-fg-muted)]">
|
||||||
|
<ClockIcon class="ui:size-4" />
|
||||||
|
<span>{course.duration}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if courseTypeMeta}
|
||||||
|
{@const TypeIcon = courseTypeMeta.icon}
|
||||||
|
<div class="ui:flex ui:items-center ui:gap-1.5 ui:text-[var(--landing-fg-muted)]">
|
||||||
|
<TypeIcon class={courseTypeMeta.iconClass} />
|
||||||
|
<span>{courseTypeMeta.label}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if primaryTag}
|
||||||
|
<div class="ui:flex ui:items-center ui:gap-1.5 ui:text-[var(--landing-fg-muted)]">
|
||||||
|
<TagIcon class="ui:size-4" />
|
||||||
|
{#if primaryTag.color}
|
||||||
|
<span
|
||||||
|
class="ui:inline-block ui:h-2 ui:w-2 ui:rounded-full"
|
||||||
|
style={`background-color: ${primaryTag.color}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
|
<span>{primaryTag.name}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="ui:ml-auto ui:font-semibold">
|
||||||
|
{course.price || formatCurrency(course.cost, course.currency)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</a>
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import SecondaryActionButton from '../secondary-action-button.svelte';
|
import SecondaryActionButton from '../secondary-action-button.svelte';
|
||||||
import { Button } from '../../../base/button';
|
import { Button } from '../../../base/button';
|
||||||
import { BlurFade } from '../../animation/blurfade';
|
|
||||||
import { DotPattern } from '../../animation/dot-pattern';
|
import { DotPattern } from '../../animation/dot-pattern';
|
||||||
import { ShinnyText } from '../../animation/shinny-text';
|
import { ShinnyText } from '../../animation/shinny-text';
|
||||||
import EditableLandingSection from '../editable-section.svelte';
|
import EditableLandingSection from '../editable-section.svelte';
|
||||||
@@ -18,14 +17,14 @@
|
|||||||
const heroGradientBackground = `
|
const heroGradientBackground = `
|
||||||
radial-gradient(
|
radial-gradient(
|
||||||
circle at top center,
|
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%
|
transparent 58%
|
||||||
),
|
),
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
135deg,
|
135deg,
|
||||||
color-mix(in oklab, var(--primary) 12%, var(--background)) 0%,
|
color-mix(in oklab, var(--landing-accent) 12%, var(--landing-bg)) 0%,
|
||||||
color-mix(in oklab, var(--primary) 6%, var(--background)) 48%,
|
color-mix(in oklab, var(--landing-accent) 6%, var(--landing-bg)) 48%,
|
||||||
var(--background) 100%
|
var(--landing-bg) 100%
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
</script>
|
</script>
|
||||||
@@ -41,18 +40,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ui:relative ui:py-20 ui:text-center ui:max-w-4xl ui:mx-auto">
|
<div class="ui:relative ui:py-20 ui:text-center ui:max-w-4xl ui:mx-auto">
|
||||||
<BlurFade delay={0} once={true}>
|
|
||||||
<h1 class="ui:text-5xl ui:md:text-6xl ui:font-semibold ui:tracking-tight ui:mb-6">{hero.heading}</h1>
|
<h1 class="ui:text-5xl ui:md:text-6xl ui:font-semibold ui:tracking-tight ui:mb-6">{hero.heading}</h1>
|
||||||
</BlurFade>
|
|
||||||
<BlurFade delay={0.15} once={true}>
|
|
||||||
<ShinnyText
|
<ShinnyText
|
||||||
shimmerWidth={120}
|
shimmerWidth={120}
|
||||||
class="ui:text-xl ui:mb-10 ui:max-w-2xl ui:mx-auto ui:font-light ui:text-foreground/72"
|
class="ui:text-xl ui:mb-10 ui:max-w-2xl ui:mx-auto ui:font-light ui:text-[var(--landing-fg)]/72"
|
||||||
>
|
>
|
||||||
{hero.subheading}
|
{hero.subheading}
|
||||||
</ShinnyText>
|
</ShinnyText>
|
||||||
</BlurFade>
|
|
||||||
<BlurFade delay={0.3} once={true}>
|
|
||||||
<div class="ui:flex ui:items-center ui:justify-center ui:gap-4">
|
<div class="ui:flex ui:items-center ui:justify-center ui:gap-4">
|
||||||
<Button
|
<Button
|
||||||
href={hero.primaryAction.href}
|
href={hero.primaryAction.href}
|
||||||
@@ -70,7 +64,6 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</BlurFade>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</EditableLandingSection>
|
</EditableLandingSection>
|
||||||
|
|||||||
@@ -18,18 +18,18 @@
|
|||||||
const navigationGradientBackground = `
|
const navigationGradientBackground = `
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
135deg,
|
135deg,
|
||||||
color-mix(in oklab, var(--primary) 16%, var(--background)) 0%,
|
color-mix(in oklab, var(--landing-accent) 16%, var(--landing-bg)) 0%,
|
||||||
color-mix(in oklab, var(--primary) 8%, var(--background)) 100%
|
color-mix(in oklab, var(--landing-accent) 8%, var(--landing-bg)) 100%
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<EditableLandingSection sectionKey="navigation">
|
<EditableLandingSection sectionKey="navigation">
|
||||||
<header
|
<header
|
||||||
class="ui:flex ui:items-center ui:justify-between ui:gap-6 ui:px-6 ui:py-4 ui:rounded-full ui:border ui:border-border/60 ui:backdrop-blur-sm"
|
class="ui:flex ui:items-center ui:justify-between ui:gap-6 ui:px-6 ui:py-4 ui:rounded-full ui:border ui:border-[var(--landing-border)]/60 ui:backdrop-blur-sm"
|
||||||
style={`background: ${navigationGradientBackground};`}
|
style={`background: ${navigationGradientBackground};`}
|
||||||
>
|
>
|
||||||
<a href="/" class="ui:font-semibold ui:text-xl ui:flex ui:items-center ui:gap-2 ui:no-underline">
|
<a href="/" class="ui:font-semibold ui:text-xl ui:flex ui:items-center ui:gap-2 ui:no-underline ui:cursor-pointer">
|
||||||
{#if logoUrl}
|
{#if logoUrl}
|
||||||
<img src={logoUrl} alt={orgName} class="ui:h-8 ui:w-auto" />
|
<img src={logoUrl} alt={orgName} class="ui:h-8 ui:w-auto" />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -38,7 +38,9 @@
|
|||||||
<div class="ui:flex ui:items-center ui:gap-4">
|
<div class="ui:flex ui:items-center ui:gap-4">
|
||||||
<nav class="ui:hidden ui:md:flex ui:gap-8">
|
<nav class="ui:hidden ui:md:flex ui:gap-8">
|
||||||
{#each navItems as item}
|
{#each navItems as item}
|
||||||
<a href={item.href} class="ui:text-sm ui:text-foreground/70 ui:hover:text-foreground ui:transition-colors"
|
<a
|
||||||
|
href={item.href}
|
||||||
|
class="ui:text-sm ui:text-[var(--landing-fg)]/70 ui:hover:text-[var(--landing-fg)] ui:transition-colors ui:cursor-pointer"
|
||||||
>{item.label}</a
|
>{item.label}</a
|
||||||
>
|
>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -6,12 +6,9 @@
|
|||||||
import OrgLandingPageFooter from './landing-page-footer.svelte';
|
import OrgLandingPageFooter from './landing-page-footer.svelte';
|
||||||
import SaasNav from './saas/nav.svelte';
|
import SaasNav from './saas/nav.svelte';
|
||||||
import SaasHero from './saas/hero.svelte';
|
import SaasHero from './saas/hero.svelte';
|
||||||
|
import SaasCourseCard from './saas/course-card.svelte';
|
||||||
import { Button } from '../../base/button';
|
import { Button } from '../../base/button';
|
||||||
import { BlurFade } from '../animation/blurfade';
|
import { themeStyle } from './theme-style';
|
||||||
import ClockIcon from '@lucide/svelte/icons/clock';
|
|
||||||
import TagIcon from '@lucide/svelte/icons/tag';
|
|
||||||
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
|
||||||
import { getCourseTypeLandingMeta, getPrimaryCourseTag } from './landing-page-utils';
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
orgName,
|
orgName,
|
||||||
@@ -25,16 +22,15 @@
|
|||||||
embed,
|
embed,
|
||||||
callout,
|
callout,
|
||||||
links,
|
links,
|
||||||
footer
|
footer,
|
||||||
|
labels
|
||||||
}: OrgLandingPageProps = $props();
|
}: OrgLandingPageProps = $props();
|
||||||
|
|
||||||
function formatCurrency(cost?: number, currency = 'USD') {
|
|
||||||
if (!cost) return 'Free';
|
|
||||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cost);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="saas-root ui:min-h-screen ui:bg-background ui:text-foreground ui:font-sans">
|
<div
|
||||||
|
class="saas-root ui:min-h-screen ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:font-sans"
|
||||||
|
style={themeStyle('saas')}
|
||||||
|
>
|
||||||
<div class="frame">
|
<div class="frame">
|
||||||
<SaasHero {hero}>
|
<SaasHero {hero}>
|
||||||
{#snippet navigation()}
|
{#snippet navigation()}
|
||||||
@@ -49,8 +45,14 @@
|
|||||||
<div class="ui:max-w-[1180px] ui:mx-auto">
|
<div class="ui:max-w-[1180px] ui:mx-auto">
|
||||||
<div class="ui:flex ui:flex-col ui:md:flex-row ui:md:items-end ui:md:justify-between ui:gap-4 ui:mb-12">
|
<div class="ui:flex ui:flex-col ui:md:flex-row ui:md:items-end ui:md:justify-between ui:gap-4 ui:mb-12">
|
||||||
<div>
|
<div>
|
||||||
<p class="ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-primary ui:mb-3">Catalog</p>
|
<p
|
||||||
<h2 class="ui:text-3xl ui:md:text-4xl ui:font-bold ui:tracking-tight">Featured programs.</h2>
|
class="ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-[var(--landing-accent)] ui:mb-3"
|
||||||
|
>
|
||||||
|
{labels?.catalogEyebrow ?? 'Catalog'}
|
||||||
|
</p>
|
||||||
|
<h2 class="ui:text-3xl ui:md:text-4xl ui:font-bold ui:tracking-tight">
|
||||||
|
{labels?.catalogHeading ?? 'Featured programs.'}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
{#if hasMoreCourses}
|
{#if hasMoreCourses}
|
||||||
<Button
|
<Button
|
||||||
@@ -59,90 +61,14 @@
|
|||||||
class="ui:rounded-full ui:self-start ui:md:self-auto"
|
class="ui:rounded-full ui:self-start ui:md:self-auto"
|
||||||
disabled={disableCourseLinks}
|
disabled={disableCourseLinks}
|
||||||
>
|
>
|
||||||
Browse catalog
|
{labels?.browseCoursesLabel ?? 'Browse catalog'}
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="course-grid ui:relative">
|
<div class="course-grid ui:relative">
|
||||||
{#each courses as course, index (course.id)}
|
{#each courses as course, index (course.id)}
|
||||||
{@const courseTypeMeta = getCourseTypeLandingMeta(course)}
|
<SaasCourseCard {course} {disableCourseLinks} {labels} />
|
||||||
{@const primaryTag = getPrimaryCourseTag(course)}
|
|
||||||
<BlurFade delay={0.06 * index} once={true} class="course-card-wrap">
|
|
||||||
<a
|
|
||||||
href={disableCourseLinks
|
|
||||||
? undefined
|
|
||||||
: course.link || (course.slug ? `/course/${course.slug}` : undefined)}
|
|
||||||
class="course-card ui:flex ui:flex-col ui:h-full ui:no-underline ui:bg-background ui:transition-colors {disableCourseLinks
|
|
||||||
? 'ui:cursor-default'
|
|
||||||
: 'ui:hover:bg-muted/40'}"
|
|
||||||
aria-disabled={disableCourseLinks}
|
|
||||||
tabindex={disableCourseLinks ? -1 : undefined}
|
|
||||||
>
|
|
||||||
{#if course.logo}
|
|
||||||
<div
|
|
||||||
class="ui:h-40 ui:w-full ui:bg-cover ui:bg-center ui:relative"
|
|
||||||
style={`background-image: url(${course.logo});`}
|
|
||||||
>
|
|
||||||
{#if courseTypeMeta}
|
|
||||||
<span
|
|
||||||
class="ui:absolute ui:top-3 ui:left-3 ui:bg-background/95 ui:text-foreground ui:text-[11px] ui:font-semibold ui:px-2.5 ui:py-1 ui:rounded-full"
|
|
||||||
>
|
|
||||||
{courseTypeMeta.label}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="ui:p-5 ui:flex ui:flex-col ui:flex-1 ui:gap-2">
|
|
||||||
<h3 class="ui:text-lg ui:font-bold ui:tracking-tight ui:leading-snug ui:m-0">
|
|
||||||
{course.title}
|
|
||||||
</h3>
|
|
||||||
<p class="ui:text-sm ui:text-muted-foreground ui:line-clamp-2 ui:leading-relaxed ui:m-0 ui:flex-1">
|
|
||||||
{course.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="ui:flex ui:items-center ui:gap-2 ui:flex-wrap ui:text-xs ui:text-muted-foreground ui:mt-2"
|
|
||||||
>
|
|
||||||
{#if course.duration}
|
|
||||||
<span class="ui:inline-flex ui:items-center ui:gap-1">
|
|
||||||
<ClockIcon class="ui:size-3.5" />
|
|
||||||
{course.duration}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if primaryTag}
|
|
||||||
<span class="ui:inline-flex ui:items-center ui:gap-1">
|
|
||||||
<TagIcon class="ui:size-3.5" />
|
|
||||||
{#if primaryTag.color}
|
|
||||||
<span
|
|
||||||
class="ui:inline-block ui:size-2 ui:rounded-full"
|
|
||||||
style={`background-color: ${primaryTag.color}`}
|
|
||||||
aria-hidden="true"
|
|
||||||
></span>
|
|
||||||
{/if}
|
|
||||||
{primaryTag.name}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="ui:flex ui:items-center ui:justify-between ui:pt-4 ui:mt-2 ui:border-t ui:border-border/60"
|
|
||||||
>
|
|
||||||
<span class="ui:text-sm ui:font-bold">
|
|
||||||
{course.price || formatCurrency(course.cost, course.currency)}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="ui:inline-flex ui:items-center ui:gap-1 ui:text-xs ui:font-semibold ui:text-primary"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
Enroll
|
|
||||||
<ArrowRightIcon class="ui:size-3.5" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</BlurFade>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,17 +77,17 @@
|
|||||||
|
|
||||||
{#if links && links.cards.length > 0}
|
{#if links && links.cards.length > 0}
|
||||||
<div class="plus-row"></div>
|
<div class="plus-row"></div>
|
||||||
<OrgLandingPageLinks {links} variant="saas" />
|
<OrgLandingPageLinks {links} {labels} variant="saas" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if embed}
|
{#if embed}
|
||||||
<div class="plus-row"></div>
|
<div class="plus-row"></div>
|
||||||
<OrgLandingPageEmbed {embed} variant="saas" />
|
<OrgLandingPageEmbed {embed} {labels} variant="saas" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if callout}
|
{#if callout}
|
||||||
<div class="plus-row"></div>
|
<div class="plus-row"></div>
|
||||||
<OrgLandingPageCallout {callout} variant="saas" />
|
<OrgLandingPageCallout {callout} {labels} variant="saas" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -177,7 +103,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
background-image: radial-gradient(
|
background-image: radial-gradient(
|
||||||
circle at center,
|
circle at center,
|
||||||
color-mix(in oklab, var(--foreground) 5%, transparent) 1px,
|
color-mix(in oklab, var(--landing-fg) 5%, transparent) 1px,
|
||||||
transparent 1.5px
|
transparent 1.5px
|
||||||
);
|
);
|
||||||
background-size: 24px 24px;
|
background-size: 24px 24px;
|
||||||
@@ -189,7 +115,7 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
background: var(--border);
|
background: var(--landing-border);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
@@ -208,7 +134,7 @@
|
|||||||
.saas-root :global(.plus-row) {
|
.saas-root :global(.plus-row) {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 0;
|
height: 0;
|
||||||
border-top: 1px dashed var(--border);
|
border-top: 1px dashed var(--landing-border);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.saas-root :global(.plus-row::before),
|
.saas-root :global(.plus-row::before),
|
||||||
@@ -219,9 +145,9 @@
|
|||||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--muted-foreground);
|
color: var(--landing-fg-muted);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
background: var(--background);
|
background: var(--landing-bg);
|
||||||
padding: 1px 3px;
|
padding: 1px 3px;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
@@ -237,8 +163,8 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, 1fr);
|
grid-template-columns: repeat(6, 1fr);
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
background: var(--border);
|
background: var(--landing-border);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--landing-border);
|
||||||
}
|
}
|
||||||
.saas-root :global(.course-grid > .course-card-wrap) {
|
.saas-root :global(.course-grid > .course-card-wrap) {
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
@@ -256,7 +182,7 @@
|
|||||||
max-width: 520px;
|
max-width: 520px;
|
||||||
}
|
}
|
||||||
.saas-root :global(.course-grid:has(> .course-card-wrap:only-child) .course-card) {
|
.saas-root :global(.course-grid:has(> .course-card-wrap:only-child) .course-card) {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--landing-border);
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { CourseItem, OrgLandingPageLabels } from '../types';
|
||||||
|
import ClockIcon from '@lucide/svelte/icons/clock';
|
||||||
|
import TagIcon from '@lucide/svelte/icons/tag';
|
||||||
|
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
||||||
|
import { getCourseTypeLandingMeta, getPrimaryCourseTag } from '../landing-page-utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
course: CourseItem;
|
||||||
|
disableCourseLinks?: boolean;
|
||||||
|
labels?: OrgLandingPageLabels;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { course, disableCourseLinks = false, labels }: Props = $props();
|
||||||
|
|
||||||
|
const courseTypeMeta = $derived(getCourseTypeLandingMeta(course));
|
||||||
|
const primaryTag = $derived(getPrimaryCourseTag(course));
|
||||||
|
|
||||||
|
const href = $derived.by(() => {
|
||||||
|
if (disableCourseLinks) return undefined;
|
||||||
|
return course.link || (course.slug ? `/course/${course.slug}` : undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatCurrency(cost?: number, currency = 'USD') {
|
||||||
|
if (!cost) return labels?.freeLabel ?? 'Free';
|
||||||
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cost);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a
|
||||||
|
{href}
|
||||||
|
class="course-card ui:flex ui:flex-col ui:h-full ui:no-underline ui:bg-[var(--landing-bg)] ui:transition-colors {disableCourseLinks
|
||||||
|
? 'ui:cursor-default'
|
||||||
|
: 'ui:cursor-pointer ui:hover:bg-[var(--landing-card-soft)]/40'}"
|
||||||
|
aria-disabled={disableCourseLinks}
|
||||||
|
tabindex={disableCourseLinks ? -1 : undefined}
|
||||||
|
>
|
||||||
|
{#if course.logo}
|
||||||
|
<div
|
||||||
|
class="ui:h-40 ui:w-full ui:bg-cover ui:bg-center ui:relative"
|
||||||
|
style={`background-image: url(${course.logo});`}
|
||||||
|
>
|
||||||
|
{#if courseTypeMeta}
|
||||||
|
<span
|
||||||
|
class="ui:absolute ui:top-3 ui:left-3 ui:bg-[var(--landing-bg)]/95 ui:text-[var(--landing-fg)] ui:text-[11px] ui:font-semibold ui:px-2.5 ui:py-1 ui:rounded-full"
|
||||||
|
>
|
||||||
|
{courseTypeMeta.label}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="ui:p-5 ui:flex ui:flex-col ui:flex-1 ui:gap-2">
|
||||||
|
<h3 class="ui:text-lg ui:font-bold ui:tracking-tight ui:leading-snug ui:m-0">
|
||||||
|
{course.title}
|
||||||
|
</h3>
|
||||||
|
<p class="ui:text-sm ui:text-[var(--landing-fg-muted)] ui:line-clamp-2 ui:leading-relaxed ui:m-0 ui:flex-1">
|
||||||
|
{course.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="ui:flex ui:items-center ui:gap-2 ui:flex-wrap ui:text-xs ui:text-[var(--landing-fg-muted)] ui:mt-2">
|
||||||
|
{#if course.duration}
|
||||||
|
<span class="ui:inline-flex ui:items-center ui:gap-1">
|
||||||
|
<ClockIcon class="ui:size-3.5" />
|
||||||
|
{course.duration}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if primaryTag}
|
||||||
|
<span class="ui:inline-flex ui:items-center ui:gap-1">
|
||||||
|
<TagIcon class="ui:size-3.5" />
|
||||||
|
{#if primaryTag.color}
|
||||||
|
<span
|
||||||
|
class="ui:inline-block ui:size-2 ui:rounded-full"
|
||||||
|
style={`background-color: ${primaryTag.color}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
|
{primaryTag.name}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="ui:flex ui:items-center ui:justify-between ui:pt-4 ui:mt-2 ui:border-t ui:border-[var(--landing-border)]/60"
|
||||||
|
>
|
||||||
|
<span class="ui:text-sm ui:font-bold">
|
||||||
|
{course.price || formatCurrency(course.cost, course.currency)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="ui:inline-flex ui:items-center ui:gap-1 ui:text-xs ui:font-semibold ui:text-[var(--landing-accent)]"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{labels?.enrollLabel ?? 'Enroll'}
|
||||||
|
<ArrowRightIcon class="ui:size-3.5" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import SecondaryActionButton from '../secondary-action-button.svelte';
|
import SecondaryActionButton from '../secondary-action-button.svelte';
|
||||||
import { Button } from '../../../base/button';
|
import { Button } from '../../../base/button';
|
||||||
import { BlurFade } from '../../animation/blurfade';
|
|
||||||
import EditableLandingSection from '../editable-section.svelte';
|
import EditableLandingSection from '../editable-section.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -21,23 +20,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ui:relative ui:max-w-[1180px] ui:mx-auto ui:pt-16 ui:md:pt-20 ui:text-center">
|
<div class="ui:relative ui:max-w-[1180px] ui:mx-auto ui:pt-16 ui:md:pt-20 ui:text-center">
|
||||||
<BlurFade delay={0} once={true}>
|
|
||||||
<h1
|
<h1
|
||||||
class="ui:text-4xl ui:md:text-5xl ui:lg:text-[56px] ui:font-bold ui:tracking-tight ui:leading-[1.05] ui:max-w-3xl ui:mx-auto ui:mb-5"
|
class="ui:text-4xl ui:md:text-5xl ui:lg:text-[56px] ui:font-bold ui:tracking-tight ui:leading-[1.05] ui:max-w-3xl ui:mx-auto ui:mb-5"
|
||||||
>
|
>
|
||||||
{hero.heading}
|
{hero.heading}
|
||||||
</h1>
|
</h1>
|
||||||
</BlurFade>
|
|
||||||
|
|
||||||
<BlurFade delay={0.15} once={true}>
|
|
||||||
<p
|
<p
|
||||||
class="ui:text-base ui:md:text-lg ui:text-muted-foreground ui:max-w-xl ui:mx-auto ui:mb-8 ui:leading-relaxed"
|
class="ui:text-base ui:md:text-lg ui:text-[var(--landing-fg-muted)] ui:max-w-xl ui:mx-auto ui:mb-8 ui:leading-relaxed"
|
||||||
>
|
>
|
||||||
{hero.subheading}
|
{hero.subheading}
|
||||||
</p>
|
</p>
|
||||||
</BlurFade>
|
|
||||||
|
|
||||||
<BlurFade delay={0.3} once={true}>
|
|
||||||
<div class="ui:flex ui:items-center ui:justify-center ui:gap-2.5">
|
<div class="ui:flex ui:items-center ui:justify-center ui:gap-2.5">
|
||||||
<Button
|
<Button
|
||||||
href={hero.primaryAction.href}
|
href={hero.primaryAction.href}
|
||||||
@@ -51,18 +45,15 @@
|
|||||||
<SecondaryActionButton href={hero.secondaryAction.href} label={hero.secondaryAction.label} variant="saas" />
|
<SecondaryActionButton href={hero.secondaryAction.href} label={hero.secondaryAction.label} variant="saas" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</BlurFade>
|
|
||||||
|
|
||||||
{#if hero.image}
|
{#if hero.image}
|
||||||
<BlurFade delay={0.45} once={true}>
|
|
||||||
<div class="ui:mt-16 ui:max-w-4xl ui:mx-auto">
|
<div class="ui:mt-16 ui:max-w-4xl ui:mx-auto">
|
||||||
<img
|
<img
|
||||||
src={hero.image}
|
src={hero.image}
|
||||||
alt=""
|
alt=""
|
||||||
class="ui:w-full ui:h-auto ui:rounded-2xl ui:border ui:border-border ui:shadow-[0_20px_60px_-30px_rgba(14,14,16,0.25)]"
|
class="ui:w-full ui:h-auto ui:rounded-2xl ui:border ui:border-[var(--landing-border)] ui:shadow-[0_20px_60px_-30px_rgba(14,14,16,0.25)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</BlurFade>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -18,11 +18,11 @@
|
|||||||
|
|
||||||
<EditableLandingSection sectionKey="navigation">
|
<EditableLandingSection sectionKey="navigation">
|
||||||
<header
|
<header
|
||||||
class="ui:flex ui:items-center ui:justify-between ui:gap-4 ui:py-2 ui:pl-5 ui:pr-2 ui:rounded-full ui:border ui:border-border ui:bg-background/85 ui:backdrop-blur-md ui:shadow-[0_4px_24px_-8px_rgba(14,14,16,0.08)]"
|
class="ui:flex ui:items-center ui:justify-between ui:gap-4 ui:py-2 ui:pl-5 ui:pr-2 ui:rounded-full ui:border ui:border-[var(--landing-border)] ui:bg-[var(--landing-bg)]/85 ui:backdrop-blur-md ui:shadow-[0_4px_24px_-8px_rgba(14,14,16,0.08)]"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
class="ui:flex ui:items-center ui:gap-2 ui:font-semibold ui:text-[15px] ui:tracking-tight ui:text-foreground ui:no-underline"
|
class="ui:flex ui:items-center ui:gap-2 ui:font-semibold ui:text-[15px] ui:tracking-tight ui:text-[var(--landing-fg)] ui:no-underline ui:cursor-pointer"
|
||||||
>
|
>
|
||||||
{#if logoUrl}
|
{#if logoUrl}
|
||||||
<img src={logoUrl} alt={orgName} class="ui:h-6 ui:w-auto" />
|
<img src={logoUrl} alt={orgName} class="ui:h-6 ui:w-auto" />
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
{#each navItems as item (item.href + item.label)}
|
{#each navItems as item (item.href + item.label)}
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class="ui:px-3.5 ui:py-1.5 ui:text-sm ui:font-medium ui:text-foreground/80 ui:hover:text-foreground ui:hover:bg-muted/60 ui:rounded-full ui:transition-colors ui:no-underline"
|
class="ui:px-3.5 ui:py-1.5 ui:text-sm ui:font-medium ui:text-[var(--landing-fg)]/80 ui:hover:text-[var(--landing-fg)] ui:hover:bg-[var(--landing-card-soft)]/60 ui:rounded-full ui:transition-colors ui:no-underline ui:cursor-pointer"
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -23,15 +23,17 @@
|
|||||||
},
|
},
|
||||||
bold: {
|
bold: {
|
||||||
className:
|
className:
|
||||||
'ui:rounded-xl ui:px-8 ui:py-6 ui:text-base ui:font-bold ui:border-2 ui:hover:border-primary ui:transition-all',
|
'ui:rounded-xl ui:px-8 ui:py-6 ui:text-base ui:font-bold ui:border-2 ui:hover:border-[var(--landing-accent)] ui:transition-all',
|
||||||
variant: 'outline'
|
variant: 'outline'
|
||||||
},
|
},
|
||||||
classic: {
|
classic: {
|
||||||
className: 'ui:px-8 ui:font-semibold ui:bg-background ui:text-foreground ui:border-border ui:transition-colors',
|
className:
|
||||||
|
'ui:px-8 ui:font-semibold ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:border-[var(--landing-border)] ui:transition-colors',
|
||||||
variant: 'outline'
|
variant: 'outline'
|
||||||
},
|
},
|
||||||
saas: {
|
saas: {
|
||||||
className: 'ui:rounded-full ui:px-6 ui:border-border ui:text-foreground ui:hover:bg-accent',
|
className:
|
||||||
|
'ui:rounded-full ui:px-6 ui:border-[var(--landing-border)] ui:text-[var(--landing-fg)] ui:hover:bg-accent',
|
||||||
variant: 'outline'
|
variant: 'outline'
|
||||||
},
|
},
|
||||||
tech: {
|
tech: {
|
||||||
@@ -41,18 +43,28 @@
|
|||||||
},
|
},
|
||||||
studio: {
|
studio: {
|
||||||
className:
|
className:
|
||||||
'ui:rounded-md ui:px-5 ui:border-border ui:bg-background ui:text-foreground ui:hover:bg-muted ui:transition-colors',
|
'ui:rounded-md ui:px-5 ui:border-[var(--landing-border)] ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:hover:bg-[var(--landing-card-soft)] ui:transition-colors',
|
||||||
variant: 'outline'
|
variant: 'outline'
|
||||||
},
|
},
|
||||||
corporate: {
|
corporate: {
|
||||||
className:
|
className:
|
||||||
'ui:rounded-none ui:px-6 ui:font-medium ui:border-foreground ui:text-foreground ui:hover:bg-foreground ui:hover:text-background ui:transition-colors',
|
'ui:rounded-none ui:px-6 ui:font-medium ui:border-[var(--landing-fg)] ui:text-[var(--landing-fg)] ui:hover:bg-[var(--landing-fg)] ui:hover:text-[var(--landing-bg)] ui:transition-colors',
|
||||||
variant: 'outline'
|
variant: 'outline'
|
||||||
},
|
},
|
||||||
terminal: {
|
terminal: {
|
||||||
className:
|
className:
|
||||||
'ui:rounded-full ui:px-5 ui:font-medium ui:bg-white/5 ui:border-[#262a35] ui:text-[#e9eaed] ui:hover:bg-white/10 ui:transition-colors',
|
'ui:rounded-full ui:px-5 ui:font-medium ui:bg-white/5 ui:border-[#262a35] ui:text-[#e9eaed] ui:hover:bg-white/10 ui:transition-colors',
|
||||||
variant: 'outline'
|
variant: 'outline'
|
||||||
|
},
|
||||||
|
editorial: {
|
||||||
|
className:
|
||||||
|
'ui:rounded-full ui:px-5 ui:font-medium ui:bg-transparent ui:border-[#d9d8d0] ui:text-[#1a1a1a] ui:hover:bg-[#ebeae3] ui:transition-colors',
|
||||||
|
variant: 'outline'
|
||||||
|
},
|
||||||
|
vibrant: {
|
||||||
|
className:
|
||||||
|
'ui:rounded-md ui:px-6 ui:py-3 ui:text-base ui:font-medium ui:bg-transparent ui:border-[var(--landing-border)] ui:text-[var(--landing-fg)] ui:hover:bg-[var(--landing-card-soft)] ui:transition-colors',
|
||||||
|
variant: 'outline'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -6,10 +6,9 @@
|
|||||||
import OrgLandingPageFooter from './landing-page-footer.svelte';
|
import OrgLandingPageFooter from './landing-page-footer.svelte';
|
||||||
import StudioNav from './studio/nav.svelte';
|
import StudioNav from './studio/nav.svelte';
|
||||||
import StudioHero from './studio/hero.svelte';
|
import StudioHero from './studio/hero.svelte';
|
||||||
|
import StudioCourseCard from './studio/course-card.svelte';
|
||||||
import { Button } from '../../base/button';
|
import { Button } from '../../base/button';
|
||||||
import { BlurFade } from '../animation/blurfade';
|
import { themeStyle } from './theme-style';
|
||||||
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
|
||||||
import { getCourseTypeLandingMeta } from './landing-page-utils';
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
orgName,
|
orgName,
|
||||||
@@ -23,16 +22,15 @@
|
|||||||
embed,
|
embed,
|
||||||
callout,
|
callout,
|
||||||
links,
|
links,
|
||||||
footer
|
footer,
|
||||||
|
labels
|
||||||
}: OrgLandingPageProps = $props();
|
}: OrgLandingPageProps = $props();
|
||||||
|
|
||||||
function formatCurrency(cost?: number, currency = 'USD') {
|
|
||||||
if (!cost) return 'Free';
|
|
||||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cost);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="ui:min-h-screen ui:bg-background ui:text-foreground ui:font-sans">
|
<div
|
||||||
|
class="ui:min-h-screen ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:font-sans"
|
||||||
|
style={themeStyle('studio')}
|
||||||
|
>
|
||||||
<main>
|
<main>
|
||||||
<StudioHero {hero} {courses}>
|
<StudioHero {hero} {courses}>
|
||||||
{#snippet navigation()}
|
{#snippet navigation()}
|
||||||
@@ -41,16 +39,16 @@
|
|||||||
</StudioHero>
|
</StudioHero>
|
||||||
|
|
||||||
{#if courses.length > 0}
|
{#if courses.length > 0}
|
||||||
<section class="ui:py-24 ui:px-6 ui:border-t ui:border-border">
|
<section class="ui:py-24 ui:px-6 ui:border-t ui:border-[var(--landing-border)]">
|
||||||
<div class="ui:max-w-[1080px] ui:mx-auto">
|
<div class="ui:max-w-[1080px] ui:mx-auto">
|
||||||
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-12 ui:items-end ui:mb-12">
|
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-12 ui:items-end ui:mb-12">
|
||||||
<div>
|
<div>
|
||||||
<p class="ui:text-sm ui:text-muted-foreground ui:mb-1.5 ui:inline-flex ui:items-center ui:gap-2">
|
<p class="ui:text-sm ui:text-[var(--landing-fg-muted)] ui:mb-1.5 ui:inline-flex ui:items-center ui:gap-2">
|
||||||
<span class="ui:size-1.5 ui:rounded-full ui:bg-primary"></span>
|
<span class="ui:size-1.5 ui:rounded-full ui:bg-[var(--landing-accent)]"></span>
|
||||||
Catalog
|
{labels?.catalogEyebrow ?? 'Catalog'}
|
||||||
</p>
|
</p>
|
||||||
<h2 class="ui:text-3xl ui:lg:text-4xl ui:font-semibold ui:tracking-tight ui:m-0">
|
<h2 class="ui:text-3xl ui:lg:text-4xl ui:font-semibold ui:tracking-tight ui:m-0">
|
||||||
Programs your team can actually finish.
|
{labels?.catalogHeading ?? 'Programs your team can actually finish.'}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
{#if hasMoreCourses}
|
{#if hasMoreCourses}
|
||||||
@@ -61,7 +59,7 @@
|
|||||||
class="ui:rounded-md"
|
class="ui:rounded-md"
|
||||||
disabled={disableCourseLinks}
|
disabled={disableCourseLinks}
|
||||||
>
|
>
|
||||||
Browse all →
|
{labels?.browseCoursesLabel ?? 'Browse all →'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -69,59 +67,7 @@
|
|||||||
|
|
||||||
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-3">
|
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-3">
|
||||||
{#each courses as course, index (course.id)}
|
{#each courses as course, index (course.id)}
|
||||||
{@const courseTypeMeta = getCourseTypeLandingMeta(course)}
|
<StudioCourseCard {course} {disableCourseLinks} {labels} />
|
||||||
<BlurFade delay={0.06 * index} once={true}>
|
|
||||||
<a
|
|
||||||
href={disableCourseLinks
|
|
||||||
? undefined
|
|
||||||
: course.link || (course.slug ? `/course/${course.slug}` : undefined)}
|
|
||||||
class="ui:group ui:flex ui:flex-col ui:h-full ui:bg-card ui:border ui:border-border ui:rounded-xl ui:overflow-hidden ui:transition-colors ui:no-underline {disableCourseLinks
|
|
||||||
? 'ui:cursor-default'
|
|
||||||
: 'ui:hover:border-foreground/30'}"
|
|
||||||
aria-disabled={disableCourseLinks}
|
|
||||||
tabindex={disableCourseLinks ? -1 : undefined}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="ui:relative ui:h-[200px] ui:bg-muted/50 ui:border-b ui:border-border ui:p-5 ui:overflow-hidden"
|
|
||||||
>
|
|
||||||
{#if course.logo}
|
|
||||||
<img
|
|
||||||
src={course.logo}
|
|
||||||
alt=""
|
|
||||||
class="ui:absolute ui:inset-0 ui:w-full ui:h-full ui:object-cover ui:opacity-60"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
<div
|
|
||||||
class="ui:relative ui:flex ui:items-start ui:justify-between"
|
|
||||||
style="background: linear-gradient(180deg, transparent 60%, var(--card) 100%);"
|
|
||||||
>
|
|
||||||
{#if courseTypeMeta}
|
|
||||||
<span
|
|
||||||
class="ui:inline-flex ui:items-center ui:gap-1.5 ui:px-2 ui:py-0.5 ui:bg-background/95 ui:border ui:border-border ui:rounded-md ui:text-[11px] ui:font-medium ui:text-foreground"
|
|
||||||
>
|
|
||||||
{courseTypeMeta.label}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="ui:p-5 ui:flex ui:items-end ui:justify-between ui:gap-3 ui:flex-1">
|
|
||||||
<div class="ui:flex-1 ui:min-w-0">
|
|
||||||
<p class="ui:text-[13px] ui:text-muted-foreground ui:mb-1.5 ui:m-0">
|
|
||||||
{course.price || formatCurrency(course.cost, course.currency)}
|
|
||||||
</p>
|
|
||||||
<h3 class="ui:text-base ui:font-medium ui:tracking-tight ui:leading-snug ui:m-0">
|
|
||||||
{course.title}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="ui:flex-shrink-0 ui:inline-flex ui:items-center ui:justify-center ui:size-8 ui:rounded-full ui:border ui:border-border ui:text-muted-foreground ui:group-hover:bg-foreground ui:group-hover:text-background ui:group-hover:border-foreground ui:transition-colors"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<ArrowRightIcon class="ui:size-3.5" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</BlurFade>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,11 +75,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<OrgLandingPageLinks {links} variant="studio" />
|
<OrgLandingPageLinks {links} {labels} variant="studio" />
|
||||||
|
|
||||||
<OrgLandingPageEmbed {embed} variant="studio" />
|
<OrgLandingPageEmbed {embed} {labels} variant="studio" />
|
||||||
|
|
||||||
<OrgLandingPageCallout {callout} variant="studio" />
|
<OrgLandingPageCallout {callout} {labels} variant="studio" />
|
||||||
|
|
||||||
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="studio" />
|
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="studio" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { CourseItem, OrgLandingPageLabels } from '../types';
|
||||||
|
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
||||||
|
import { getCourseTypeLandingMeta } from '../landing-page-utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
course: CourseItem;
|
||||||
|
disableCourseLinks?: boolean;
|
||||||
|
labels?: OrgLandingPageLabels;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { course, disableCourseLinks = false, labels }: Props = $props();
|
||||||
|
|
||||||
|
const courseTypeMeta = $derived(getCourseTypeLandingMeta(course));
|
||||||
|
|
||||||
|
const href = $derived.by(() => {
|
||||||
|
if (disableCourseLinks) return undefined;
|
||||||
|
return course.link || (course.slug ? `/course/${course.slug}` : undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatCurrency(cost?: number, currency = 'USD') {
|
||||||
|
if (!cost) return labels?.freeLabel ?? 'Free';
|
||||||
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cost);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a
|
||||||
|
{href}
|
||||||
|
class="ui:group ui:flex ui:flex-col ui:h-full ui:bg-[var(--landing-card)] ui:border ui:border-[var(--landing-border)] ui:rounded-xl ui:overflow-hidden ui:transition-colors ui:no-underline {disableCourseLinks
|
||||||
|
? 'ui:cursor-default'
|
||||||
|
: 'ui:cursor-pointer ui:hover:border-[var(--landing-fg)]/30'}"
|
||||||
|
aria-disabled={disableCourseLinks}
|
||||||
|
tabindex={disableCourseLinks ? -1 : undefined}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="ui:relative ui:h-[200px] ui:bg-[var(--landing-card-soft)]/50 ui:border-b ui:border-[var(--landing-border)] ui:p-5 ui:overflow-hidden"
|
||||||
|
>
|
||||||
|
{#if course.logo}
|
||||||
|
<img src={course.logo} alt="" class="ui:absolute ui:inset-0 ui:w-full ui:h-full ui:object-cover ui:opacity-60" />
|
||||||
|
{/if}
|
||||||
|
<div
|
||||||
|
class="ui:relative ui:flex ui:items-start ui:justify-between"
|
||||||
|
style="background: linear-gradient(180deg, transparent 60%, var(--landing-card) 100%);"
|
||||||
|
>
|
||||||
|
{#if courseTypeMeta}
|
||||||
|
<span
|
||||||
|
class="ui:inline-flex ui:items-center ui:gap-1.5 ui:px-2 ui:py-0.5 ui:bg-[var(--landing-bg)]/95 ui:border ui:border-[var(--landing-border)] ui:rounded-md ui:text-[11px] ui:font-medium ui:text-[var(--landing-fg)]"
|
||||||
|
>
|
||||||
|
{courseTypeMeta.label}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ui:p-5 ui:flex ui:items-end ui:justify-between ui:gap-3 ui:flex-1">
|
||||||
|
<div class="ui:flex-1 ui:min-w-0">
|
||||||
|
<p class="ui:text-[13px] ui:text-[var(--landing-fg-muted)] ui:mb-1.5 ui:m-0">
|
||||||
|
{course.price || formatCurrency(course.cost, course.currency)}
|
||||||
|
</p>
|
||||||
|
<h3 class="ui:text-base ui:font-medium ui:tracking-tight ui:leading-snug ui:m-0">
|
||||||
|
{course.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="ui:flex-shrink-0 ui:inline-flex ui:items-center ui:justify-center ui:size-8 ui:rounded-full ui:border ui:border-[var(--landing-border)] ui:text-[var(--landing-fg-muted)] ui:group-hover:bg-[var(--landing-fg)] ui:group-hover:text-[var(--landing-bg)] ui:group-hover:border-[var(--landing-fg)] ui:transition-colors"
|
||||||
|
aria-hidden="true"
|
||||||
|
aria-label={labels?.enrollLabel ?? 'Enroll'}
|
||||||
|
>
|
||||||
|
<ArrowRightIcon class="ui:size-3.5" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import SecondaryActionButton from '../secondary-action-button.svelte';
|
import SecondaryActionButton from '../secondary-action-button.svelte';
|
||||||
import { Button } from '../../../base/button';
|
import { Button } from '../../../base/button';
|
||||||
import { BlurFade } from '../../animation/blurfade';
|
|
||||||
import EditableLandingSection from '../editable-section.svelte';
|
import EditableLandingSection from '../editable-section.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -12,7 +11,7 @@
|
|||||||
navigation: Snippet;
|
navigation: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { hero, courses, navigation }: Props = $props();
|
let { hero, courses = [], navigation }: Props = $props();
|
||||||
|
|
||||||
const programsCount = $derived(courses.length);
|
const programsCount = $derived(courses.length);
|
||||||
|
|
||||||
@@ -32,40 +31,33 @@
|
|||||||
<section class="ui:relative ui:overflow-hidden ui:px-6 ui:pt-20 ui:pb-20 ui:md:pt-24 ui:md:pb-24">
|
<section class="ui:relative ui:overflow-hidden ui:px-6 ui:pt-20 ui:pb-20 ui:md:pt-24 ui:md:pb-24">
|
||||||
<div
|
<div
|
||||||
class="ui:pointer-events-none ui:absolute ui:-top-32 ui:-left-24 ui:h-[520px] ui:w-[520px] ui:rounded-full ui:opacity-60 ui:blur-3xl"
|
class="ui:pointer-events-none ui:absolute ui:-top-32 ui:-left-24 ui:h-[520px] ui:w-[520px] ui:rounded-full ui:opacity-60 ui:blur-3xl"
|
||||||
style="background: radial-gradient(circle, color-mix(in oklab, var(--primary) 35%, transparent) 0%, transparent 70%);"
|
style="background: radial-gradient(circle, color-mix(in oklab, var(--landing-accent) 35%, transparent) 0%, transparent 70%);"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
class="ui:pointer-events-none ui:absolute ui:-top-20 ui:right-[-10%] ui:h-[420px] ui:w-[420px] ui:rounded-full ui:opacity-40 ui:blur-3xl"
|
class="ui:pointer-events-none ui:absolute ui:-top-20 ui:right-[-10%] ui:h-[420px] ui:w-[420px] ui:rounded-full ui:opacity-40 ui:blur-3xl"
|
||||||
style="background: radial-gradient(circle, color-mix(in oklab, var(--primary) 18%, transparent) 0%, transparent 70%);"
|
style="background: radial-gradient(circle, color-mix(in oklab, var(--landing-accent) 18%, transparent) 0%, transparent 70%);"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div class="ui:relative ui:max-w-[1080px] ui:mx-auto">
|
<div class="ui:relative ui:max-w-[1080px] ui:mx-auto">
|
||||||
<BlurFade delay={0} once={true}>
|
|
||||||
<span
|
<span
|
||||||
class="ui:inline-flex ui:items-center ui:gap-2 ui:mb-7 ui:px-3 ui:py-1.5 ui:rounded-full ui:border ui:border-border ui:bg-card/70 ui:backdrop-blur-sm ui:text-[12px] ui:font-medium ui:text-foreground"
|
class="ui:inline-flex ui:items-center ui:gap-2 ui:mb-7 ui:px-3 ui:py-1.5 ui:rounded-full ui:border ui:border-[var(--landing-border)] ui:bg-[var(--landing-card)]/70 ui:backdrop-blur-sm ui:text-[12px] ui:font-medium ui:text-[var(--landing-fg)]"
|
||||||
>
|
>
|
||||||
<span class="studio-status-dot ui:size-1.5 ui:rounded-full" aria-hidden="true"></span>
|
<span class="studio-status-dot ui:size-1.5 ui:rounded-full" aria-hidden="true"></span>
|
||||||
{statusLabel}
|
{statusLabel}
|
||||||
</span>
|
</span>
|
||||||
</BlurFade>
|
|
||||||
|
|
||||||
<BlurFade delay={0.08} once={true}>
|
|
||||||
<h1
|
<h1
|
||||||
class="ui:text-4xl ui:md:text-5xl ui:lg:text-[64px] ui:font-semibold ui:tracking-tight ui:leading-[1.02] ui:max-w-[18ch] ui:mb-5 ui:text-foreground ui:text-balance"
|
class="ui:text-4xl ui:md:text-5xl ui:lg:text-[64px] ui:font-semibold ui:tracking-tight ui:leading-[1.02] ui:max-w-[18ch] ui:mb-5 ui:text-[var(--landing-fg)] ui:text-balance"
|
||||||
>
|
>
|
||||||
{hero.heading}
|
{hero.heading}
|
||||||
</h1>
|
</h1>
|
||||||
</BlurFade>
|
|
||||||
|
|
||||||
<BlurFade delay={0.18} once={true}>
|
<p class="ui:text-base ui:md:text-lg ui:text-[var(--landing-fg-muted)] ui:max-w-xl ui:mb-8 ui:leading-relaxed">
|
||||||
<p class="ui:text-base ui:md:text-lg ui:text-muted-foreground ui:max-w-xl ui:mb-8 ui:leading-relaxed">
|
|
||||||
{hero.subheading}
|
{hero.subheading}
|
||||||
</p>
|
</p>
|
||||||
</BlurFade>
|
|
||||||
|
|
||||||
<BlurFade delay={0.28} once={true}>
|
|
||||||
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-2">
|
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-2">
|
||||||
<Button
|
<Button
|
||||||
href={hero.primaryAction.href}
|
href={hero.primaryAction.href}
|
||||||
@@ -75,40 +67,38 @@
|
|||||||
{hero.primaryAction.label}
|
{hero.primaryAction.label}
|
||||||
</Button>
|
</Button>
|
||||||
{#if hero.secondaryAction}
|
{#if hero.secondaryAction}
|
||||||
<SecondaryActionButton
|
<SecondaryActionButton href={hero.secondaryAction.href} label={hero.secondaryAction.label} variant="studio" />
|
||||||
href={hero.secondaryAction.href}
|
|
||||||
label={hero.secondaryAction.label}
|
|
||||||
variant="studio"
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</BlurFade>
|
|
||||||
|
|
||||||
{#if hero.image}
|
{#if hero.image}
|
||||||
<BlurFade delay={0.4} once={true}>
|
|
||||||
<div
|
<div
|
||||||
class="studio-polaroid ui:mt-16 ui:relative ui:rounded-xl ui:border ui:border-border ui:bg-card ui:p-2 ui:shadow-[0_40px_90px_-30px_rgba(0,0,0,0.35)]"
|
class="studio-polaroid ui:mt-16 ui:relative ui:rounded-xl ui:border ui:border-[var(--landing-border)] ui:bg-[var(--landing-card)] ui:p-2 ui:shadow-[0_40px_90px_-30px_rgba(0,0,0,0.35)]"
|
||||||
>
|
>
|
||||||
<img src={hero.image} alt="" class="ui:w-full ui:h-auto ui:rounded-lg ui:border ui:border-border" />
|
<img
|
||||||
|
src={hero.image}
|
||||||
|
alt=""
|
||||||
|
class="ui:w-full ui:h-auto ui:rounded-lg ui:border ui:border-[var(--landing-border)]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</BlurFade>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showMarquee}
|
{#if showMarquee}
|
||||||
<BlurFade delay={hero.image ? 0.5 : 0.4} once={true}>
|
<div
|
||||||
<div class="studio-marquee-mask ui:mt-16 ui:py-4 ui:border-y ui:border-border" aria-hidden="true">
|
class="studio-marquee-mask ui:mt-16 ui:py-4 ui:border-y ui:border-[var(--landing-border)]"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<div class="studio-marquee-track ui:flex ui:gap-12 ui:whitespace-nowrap ui:w-max">
|
<div class="studio-marquee-track ui:flex ui:gap-12 ui:whitespace-nowrap ui:w-max">
|
||||||
{#each [...marqueeItems, ...marqueeItems] as course, index (`${course.id}-${index}`)}
|
{#each [...marqueeItems, ...marqueeItems] as course, index (`${course.id}-${index}`)}
|
||||||
<span class="ui:inline-flex ui:items-center ui:gap-3">
|
<span class="ui:inline-flex ui:items-center ui:gap-3">
|
||||||
<span class="ui:size-1 ui:rounded-full ui:bg-primary/70"></span>
|
<span class="ui:size-1 ui:rounded-full ui:bg-[var(--landing-accent)]/70"></span>
|
||||||
<span class="ui:text-sm ui:font-medium ui:text-muted-foreground">
|
<span class="ui:text-sm ui:font-medium ui:text-[var(--landing-fg-muted)]">
|
||||||
{course.title}
|
{course.title}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</BlurFade>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -116,20 +106,20 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.studio-status-dot {
|
.studio-status-dot {
|
||||||
background-color: var(--primary);
|
background-color: var(--landing-accent);
|
||||||
box-shadow: 0 0 0 0 color-mix(in oklab, var(--primary) 60%, transparent);
|
box-shadow: 0 0 0 0 color-mix(in oklab, var(--landing-accent) 60%, transparent);
|
||||||
animation: studio-pulse 2s ease-out infinite;
|
animation: studio-pulse 2s ease-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes studio-pulse {
|
@keyframes studio-pulse {
|
||||||
0% {
|
0% {
|
||||||
box-shadow: 0 0 0 0 color-mix(in oklab, var(--primary) 50%, transparent);
|
box-shadow: 0 0 0 0 color-mix(in oklab, var(--landing-accent) 50%, transparent);
|
||||||
}
|
}
|
||||||
70% {
|
70% {
|
||||||
box-shadow: 0 0 0 8px color-mix(in oklab, var(--primary) 0%, transparent);
|
box-shadow: 0 0 0 8px color-mix(in oklab, var(--landing-accent) 0%, transparent);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
box-shadow: 0 0 0 0 color-mix(in oklab, var(--primary) 0%, transparent);
|
box-shadow: 0 0 0 0 color-mix(in oklab, var(--landing-accent) 0%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,14 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<EditableLandingSection sectionKey="navigation">
|
<EditableLandingSection sectionKey="navigation">
|
||||||
<header class="ui:sticky ui:top-0 ui:z-50 ui:bg-background/75 ui:backdrop-blur-md ui:border-b ui:border-border">
|
<header
|
||||||
|
class="ui:sticky ui:top-0 ui:z-50 ui:bg-[var(--landing-bg)]/75 ui:backdrop-blur-md ui:border-b ui:border-[var(--landing-border)]"
|
||||||
|
>
|
||||||
<div class="ui:max-w-[1080px] ui:mx-auto ui:px-6">
|
<div class="ui:max-w-[1080px] ui:mx-auto ui:px-6">
|
||||||
<div class="ui:grid ui:grid-cols-[1fr_auto_1fr] ui:items-center ui:h-14">
|
<div class="ui:grid ui:grid-cols-[1fr_auto_1fr] ui:items-center ui:h-14">
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
class="ui:flex ui:items-center ui:gap-2 ui:font-semibold ui:text-[14px] ui:tracking-tight ui:text-foreground ui:no-underline"
|
class="ui:flex ui:items-center ui:gap-2 ui:font-semibold ui:text-[14px] ui:tracking-tight ui:text-[var(--landing-fg)] ui:no-underline ui:cursor-pointer"
|
||||||
>
|
>
|
||||||
{#if logoUrl}
|
{#if logoUrl}
|
||||||
<img src={logoUrl} alt={orgName} class="ui:h-5 ui:w-auto" />
|
<img src={logoUrl} alt={orgName} class="ui:h-5 ui:w-auto" />
|
||||||
@@ -35,7 +37,7 @@
|
|||||||
{#each navItems as item (item.href + item.label)}
|
{#each navItems as item (item.href + item.label)}
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class="ui:px-3 ui:py-1.5 ui:text-[13px] ui:text-muted-foreground ui:hover:text-foreground ui:hover:bg-muted ui:rounded-md ui:transition-colors ui:no-underline"
|
class="ui:px-3 ui:py-1.5 ui:text-[13px] ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] ui:hover:bg-[var(--landing-card-soft)] ui:rounded-md ui:transition-colors ui:no-underline ui:cursor-pointer"
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -6,11 +6,9 @@
|
|||||||
import OrgLandingPageFooter from './landing-page-footer.svelte';
|
import OrgLandingPageFooter from './landing-page-footer.svelte';
|
||||||
import TechNav from './tech/nav.svelte';
|
import TechNav from './tech/nav.svelte';
|
||||||
import TechHero from './tech/hero.svelte';
|
import TechHero from './tech/hero.svelte';
|
||||||
|
import TechCourseCard from './tech/course-card.svelte';
|
||||||
import { Button } from '../../base/button';
|
import { Button } from '../../base/button';
|
||||||
import { BlurFade } from '../animation/blurfade';
|
import { themeStyle } from './theme-style';
|
||||||
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
|
||||||
import ClockIcon from '@lucide/svelte/icons/clock';
|
|
||||||
import { getCourseTypeLandingMeta } from './landing-page-utils';
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
orgName,
|
orgName,
|
||||||
@@ -24,16 +22,15 @@
|
|||||||
embed,
|
embed,
|
||||||
callout,
|
callout,
|
||||||
links,
|
links,
|
||||||
footer
|
footer,
|
||||||
|
labels
|
||||||
}: OrgLandingPageProps = $props();
|
}: OrgLandingPageProps = $props();
|
||||||
|
|
||||||
function formatCurrency(cost?: number, currency = 'USD') {
|
|
||||||
if (!cost) return 'Free';
|
|
||||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cost);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="ui:min-h-screen ui:bg-background ui:text-foreground ui:font-sans">
|
<div
|
||||||
|
class="ui:min-h-screen ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:font-sans"
|
||||||
|
style={themeStyle('tech')}
|
||||||
|
>
|
||||||
<main>
|
<main>
|
||||||
<TechHero {hero}>
|
<TechHero {hero}>
|
||||||
{#snippet navigation()}
|
{#snippet navigation()}
|
||||||
@@ -46,8 +43,14 @@
|
|||||||
<div class="ui:max-w-[1280px] ui:mx-auto">
|
<div class="ui:max-w-[1280px] ui:mx-auto">
|
||||||
<div class="ui:flex ui:items-end ui:justify-between ui:flex-wrap ui:gap-4 ui:mb-14">
|
<div class="ui:flex ui:items-end ui:justify-between ui:flex-wrap ui:gap-4 ui:mb-14">
|
||||||
<div>
|
<div>
|
||||||
<p class="ui:font-mono ui:text-xs ui:tracking-widest ui:uppercase ui:text-primary ui:mb-3">// catalog</p>
|
<p
|
||||||
<h2 class="ui:text-4xl ui:lg:text-5xl ui:font-extrabold ui:tracking-tight ui:m-0">Latest courses</h2>
|
class="ui:font-mono ui:text-xs ui:tracking-widest ui:uppercase ui:text-[var(--landing-accent)] ui:mb-3"
|
||||||
|
>
|
||||||
|
{labels?.catalogEyebrow ?? '// catalog'}
|
||||||
|
</p>
|
||||||
|
<h2 class="ui:text-4xl ui:lg:text-5xl ui:font-extrabold ui:tracking-tight ui:m-0">
|
||||||
|
{labels?.catalogHeading ?? 'Latest courses'}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
{#if hasMoreCourses}
|
{#if hasMoreCourses}
|
||||||
<Button
|
<Button
|
||||||
@@ -56,66 +59,16 @@
|
|||||||
class="ui:rounded-none ui:font-mono ui:text-sm"
|
class="ui:rounded-none ui:font-mono ui:text-sm"
|
||||||
disabled={disableCourseLinks}
|
disabled={disableCourseLinks}
|
||||||
>
|
>
|
||||||
view all →
|
{labels?.browseCoursesLabel ?? 'view all →'}
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:border-t ui:border-l ui:border-border"
|
class="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)]"
|
||||||
>
|
>
|
||||||
{#each courses as course, index (course.id)}
|
{#each courses as course, index (course.id)}
|
||||||
{@const courseTypeMeta = getCourseTypeLandingMeta(course)}
|
<TechCourseCard {course} {disableCourseLinks} {labels} />
|
||||||
<BlurFade delay={0.06 * index} once={true}>
|
|
||||||
<a
|
|
||||||
href={disableCourseLinks
|
|
||||||
? undefined
|
|
||||||
: course.link || (course.slug ? `/course/${course.slug}` : undefined)}
|
|
||||||
class="ui:flex ui:flex-col ui:h-full ui:p-7 ui:border-r ui:border-b ui:border-border ui:bg-background ui:transition-colors ui:no-underline {disableCourseLinks
|
|
||||||
? 'ui:cursor-default'
|
|
||||||
: 'ui:hover:bg-muted/40'}"
|
|
||||||
aria-disabled={disableCourseLinks}
|
|
||||||
tabindex={disableCourseLinks ? -1 : undefined}
|
|
||||||
>
|
|
||||||
{#if courseTypeMeta}
|
|
||||||
<span
|
|
||||||
class="ui:inline-block ui:self-start ui:px-2.5 ui:py-1 ui:bg-primary ui:text-primary-foreground ui:font-mono ui:text-[10px] ui:font-semibold ui:tracking-wider ui:uppercase ui:mb-4"
|
|
||||||
>
|
|
||||||
{courseTypeMeta.label}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
<h3 class="ui:text-xl ui:font-bold ui:tracking-tight ui:leading-tight ui:mb-3 ui:m-0">
|
|
||||||
{course.title}
|
|
||||||
</h3>
|
|
||||||
<p
|
|
||||||
class="ui:text-sm ui:text-muted-foreground ui:leading-relaxed ui:line-clamp-3 ui:flex-1 ui:m-0 ui:mb-5"
|
|
||||||
>
|
|
||||||
{course.description}
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
class="ui:flex ui:items-center ui:justify-between ui:pt-4 ui:border-t ui:border-dashed ui:border-border"
|
|
||||||
>
|
|
||||||
<span class="ui:font-mono ui:text-sm ui:font-bold">
|
|
||||||
{course.price || formatCurrency(course.cost, course.currency)}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="ui:inline-flex ui:items-center ui:gap-1 ui:font-mono ui:text-xs ui:text-primary ui:font-bold"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
enroll
|
|
||||||
<ArrowRightIcon class="ui:size-3.5" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{#if course.duration}
|
|
||||||
<p
|
|
||||||
class="ui:font-mono ui:text-[11px] ui:text-muted-foreground ui:mt-3 ui:inline-flex ui:items-center ui:gap-1.5"
|
|
||||||
>
|
|
||||||
<ClockIcon class="ui:size-3" />
|
|
||||||
{course.duration}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</a>
|
|
||||||
</BlurFade>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,11 +76,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<OrgLandingPageLinks {links} variant="tech" />
|
<OrgLandingPageLinks {links} {labels} variant="tech" />
|
||||||
|
|
||||||
<OrgLandingPageEmbed {embed} variant="tech" />
|
<OrgLandingPageEmbed {embed} {labels} variant="tech" />
|
||||||
|
|
||||||
<OrgLandingPageCallout {callout} variant="tech" />
|
<OrgLandingPageCallout {callout} {labels} variant="tech" />
|
||||||
|
|
||||||
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="tech" />
|
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="tech" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { CourseItem, OrgLandingPageLabels } from '../types';
|
||||||
|
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
||||||
|
import ClockIcon from '@lucide/svelte/icons/clock';
|
||||||
|
import { getCourseTypeLandingMeta } from '../landing-page-utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
course: CourseItem;
|
||||||
|
disableCourseLinks?: boolean;
|
||||||
|
labels?: OrgLandingPageLabels;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { course, disableCourseLinks = false, labels }: Props = $props();
|
||||||
|
|
||||||
|
const courseTypeMeta = $derived(getCourseTypeLandingMeta(course));
|
||||||
|
|
||||||
|
const href = $derived.by(() => {
|
||||||
|
if (disableCourseLinks) return undefined;
|
||||||
|
return course.link || (course.slug ? `/course/${course.slug}` : undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatCurrency(cost?: number, currency = 'USD') {
|
||||||
|
if (!cost) return labels?.freeLabel ?? 'Free';
|
||||||
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cost);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a
|
||||||
|
{href}
|
||||||
|
class="ui:flex ui:flex-col ui:h-full ui:p-7 ui:border-r ui:border-b ui:border-[var(--landing-border)] ui:bg-[var(--landing-bg)] ui:transition-colors ui:no-underline {disableCourseLinks
|
||||||
|
? 'ui:cursor-default'
|
||||||
|
: 'ui:cursor-pointer ui:hover:bg-[var(--landing-card-soft)]/40'}"
|
||||||
|
aria-disabled={disableCourseLinks}
|
||||||
|
tabindex={disableCourseLinks ? -1 : undefined}
|
||||||
|
>
|
||||||
|
{#if courseTypeMeta}
|
||||||
|
<span
|
||||||
|
class="ui:inline-block ui:self-start ui:px-2.5 ui:py-1 ui:bg-[var(--landing-accent)] ui:text-[var(--landing-accent-fg)] ui:font-mono ui:text-[10px] ui:font-semibold ui:tracking-wider ui:uppercase ui:mb-4"
|
||||||
|
>
|
||||||
|
{courseTypeMeta.label}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<h3 class="ui:text-xl ui:font-bold ui:tracking-tight ui:leading-tight ui:mb-3 ui:m-0">
|
||||||
|
{course.title}
|
||||||
|
</h3>
|
||||||
|
<p class="ui:text-sm ui:text-[var(--landing-fg-muted)] ui:leading-relaxed ui:line-clamp-3 ui:flex-1 ui:m-0 ui:mb-5">
|
||||||
|
{course.description}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="ui:flex ui:items-center ui:justify-between ui:pt-4 ui:border-t ui:border-dashed ui:border-[var(--landing-border)]"
|
||||||
|
>
|
||||||
|
<span class="ui:font-mono ui:text-sm ui:font-bold">
|
||||||
|
{course.price || formatCurrency(course.cost, course.currency)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="ui:inline-flex ui:items-center ui:gap-1 ui:font-mono ui:text-xs ui:text-[var(--landing-accent)] ui:font-bold"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{labels?.enrollLabel ?? 'enroll'}
|
||||||
|
<ArrowRightIcon class="ui:size-3.5" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if course.duration}
|
||||||
|
<p
|
||||||
|
class="ui:font-mono ui:text-[11px] ui:text-[var(--landing-fg-muted)] ui:mt-3 ui:inline-flex ui:items-center ui:gap-1.5"
|
||||||
|
>
|
||||||
|
<ClockIcon class="ui:size-3" />
|
||||||
|
{course.duration}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import SecondaryActionButton from '../secondary-action-button.svelte';
|
import SecondaryActionButton from '../secondary-action-button.svelte';
|
||||||
import { Button } from '../../../base/button';
|
import { Button } from '../../../base/button';
|
||||||
import { BlurFade } from '../../animation/blurfade';
|
|
||||||
import EditableLandingSection from '../editable-section.svelte';
|
import EditableLandingSection from '../editable-section.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -15,7 +14,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<EditableLandingSection sectionKey="hero">
|
<EditableLandingSection sectionKey="hero">
|
||||||
<section class="ui:relative ui:bg-primary ui:text-primary-foreground ui:overflow-hidden ui:px-6 ui:pt-2 ui:pb-20">
|
<section
|
||||||
|
class="ui:relative ui:bg-[var(--landing-accent)] ui:text-[var(--landing-accent-fg)] ui:overflow-hidden ui:px-6 ui:pt-2 ui:pb-20"
|
||||||
|
>
|
||||||
<!-- subtle grid overlay -->
|
<!-- subtle grid overlay -->
|
||||||
<div
|
<div
|
||||||
class="ui:pointer-events-none ui:absolute ui:inset-0 ui:opacity-[0.08]"
|
class="ui:pointer-events-none ui:absolute ui:inset-0 ui:opacity-[0.08]"
|
||||||
@@ -31,56 +32,46 @@
|
|||||||
class="ui:relative ui:max-w-[1280px] ui:mx-auto ui:py-16 ui:lg:py-24 ui:grid ui:lg:grid-cols-2 ui:gap-12 ui:items-center"
|
class="ui:relative ui:max-w-[1280px] ui:mx-auto ui:py-16 ui:lg:py-24 ui:grid ui:lg:grid-cols-2 ui:gap-12 ui:items-center"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<BlurFade delay={0} once={true}>
|
|
||||||
<p
|
<p
|
||||||
class="ui:inline-block ui:font-mono ui:text-xs ui:tracking-widest ui:uppercase ui:text-primary-foreground/80 ui:bg-primary-foreground/10 ui:px-3 ui:py-1.5 ui:mb-6"
|
class="ui:inline-block ui:font-mono ui:text-xs ui:tracking-widest ui:uppercase ui:text-[var(--landing-accent-fg)]/80 ui:bg-[var(--landing-accent-fg)]/10 ui:px-3 ui:py-1.5 ui:mb-6"
|
||||||
>
|
>
|
||||||
// engineering academy
|
// engineering academy
|
||||||
</p>
|
</p>
|
||||||
</BlurFade>
|
|
||||||
<BlurFade delay={0.12} once={true}>
|
|
||||||
<h1 class="ui:text-5xl ui:lg:text-7xl ui:font-extrabold ui:tracking-tight ui:leading-[0.95] ui:mb-6">
|
<h1 class="ui:text-5xl ui:lg:text-7xl ui:font-extrabold ui:tracking-tight ui:leading-[0.95] ui:mb-6">
|
||||||
{hero.heading}
|
{hero.heading}
|
||||||
</h1>
|
</h1>
|
||||||
</BlurFade>
|
<p
|
||||||
<BlurFade delay={0.24} once={true}>
|
class="ui:text-lg ui:lg:text-xl ui:text-[var(--landing-accent-fg)]/80 ui:max-w-xl ui:mb-8 ui:leading-relaxed"
|
||||||
<p class="ui:text-lg ui:lg:text-xl ui:text-primary-foreground/80 ui:max-w-xl ui:mb-8 ui:leading-relaxed">
|
>
|
||||||
{hero.subheading}
|
{hero.subheading}
|
||||||
</p>
|
</p>
|
||||||
</BlurFade>
|
|
||||||
<BlurFade delay={0.36} once={true}>
|
|
||||||
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-3">
|
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-3">
|
||||||
<Button
|
<Button
|
||||||
href={hero.primaryAction.href}
|
href={hero.primaryAction.href}
|
||||||
disabled={hero.primaryAction.disabled ?? false}
|
disabled={hero.primaryAction.disabled ?? false}
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
class="ui:rounded-none ui:px-7 ui:font-semibold ui:bg-primary-foreground ui:text-primary ui:hover:bg-primary-foreground/90"
|
class="ui:rounded-none ui:px-7 ui:font-semibold ui:bg-[var(--landing-accent-fg)] ui:text-[var(--landing-accent)] ui:hover:bg-[var(--landing-accent-fg)]/90"
|
||||||
>
|
>
|
||||||
{hero.primaryAction.label}
|
{hero.primaryAction.label}
|
||||||
</Button>
|
</Button>
|
||||||
{#if hero.secondaryAction}
|
{#if hero.secondaryAction}
|
||||||
<SecondaryActionButton
|
<SecondaryActionButton href={hero.secondaryAction.href} label={hero.secondaryAction.label} variant="tech" />
|
||||||
href={hero.secondaryAction.href}
|
|
||||||
label={hero.secondaryAction.label}
|
|
||||||
variant="tech"
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</BlurFade>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if hero.image}
|
{#if hero.image}
|
||||||
<BlurFade delay={0.3} once={true} class="ui:hidden ui:lg:block">
|
|
||||||
<div class="ui:relative">
|
<div class="ui:relative">
|
||||||
<div class="ui:absolute ui:inset-0 ui:bg-primary-foreground/10 ui:translate-x-3 ui:translate-y-3"></div>
|
<div
|
||||||
|
class="ui:absolute ui:inset-0 ui:bg-[var(--landing-accent-fg)]/10 ui:translate-x-3 ui:translate-y-3"
|
||||||
|
></div>
|
||||||
<img
|
<img
|
||||||
src={hero.image}
|
src={hero.image}
|
||||||
alt=""
|
alt=""
|
||||||
class="ui:relative ui:w-full ui:h-[460px] ui:object-cover ui:border ui:border-primary-foreground/20"
|
class="ui:relative ui:w-full ui:h-[460px] ui:object-cover ui:border ui:border-[var(--landing-accent-fg)]/20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</BlurFade>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -17,10 +17,10 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<EditableLandingSection sectionKey="navigation">
|
<EditableLandingSection sectionKey="navigation">
|
||||||
<header class="ui:flex ui:items-center ui:justify-between ui:gap-6 ui:py-4 ui:text-primary-foreground">
|
<header class="ui:flex ui:items-center ui:justify-between ui:gap-6 ui:py-4 ui:text-[var(--landing-accent-fg)]">
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
class="ui:flex ui:items-center ui:gap-2 ui:font-bold ui:text-base ui:tracking-tight ui:text-primary-foreground ui:no-underline"
|
class="ui:flex ui:items-center ui:gap-2 ui:font-bold ui:text-base ui:tracking-tight ui:text-[var(--landing-accent-fg)] ui:no-underline ui:cursor-pointer"
|
||||||
>
|
>
|
||||||
{#if logoUrl}
|
{#if logoUrl}
|
||||||
<img src={logoUrl} alt={orgName} class="ui:h-7 ui:w-auto" />
|
<img src={logoUrl} alt={orgName} class="ui:h-7 ui:w-auto" />
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
{#each navItems as item (item.href + item.label)}
|
{#each navItems as item (item.href + item.label)}
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class="ui:px-3 ui:py-1.5 ui:rounded-full ui:border ui:border-transparent ui:text-primary-foreground/85 ui:hover:text-primary-foreground ui:hover:border-primary-foreground/30 ui:hover:bg-primary-foreground/10 ui:transition-colors ui:no-underline"
|
class="ui:px-3 ui:py-1.5 ui:rounded-full ui:border ui:border-transparent ui:text-[var(--landing-accent-fg)]/85 ui:hover:text-[var(--landing-accent-fg)] ui:hover:border-[var(--landing-accent-fg)]/30 ui:hover:bg-[var(--landing-accent-fg)]/10 ui:transition-colors ui:no-underline ui:cursor-pointer"
|
||||||
>
|
>
|
||||||
( {item.label.toLowerCase()} )
|
( {item.label.toLowerCase()} )
|
||||||
</a>
|
</a>
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
href={authAction.href}
|
href={authAction.href}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="ui:rounded-none ui:font-semibold ui:border-primary-foreground ui:text-primary-foreground ui:bg-transparent ui:hover:bg-primary-foreground/10 ui:hover:text-primary-foreground"
|
class="ui:rounded-none ui:font-semibold ui:border-[var(--landing-accent-fg)] ui:text-[var(--landing-accent-fg)] ui:bg-transparent ui:hover:bg-[var(--landing-accent-fg)]/10 ui:hover:text-[var(--landing-accent-fg)]"
|
||||||
>
|
>
|
||||||
{authAction.label} →
|
{authAction.label} →
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
import OrgLandingPageFooter from './landing-page-footer.svelte';
|
import OrgLandingPageFooter from './landing-page-footer.svelte';
|
||||||
import TerminalNav from './terminal/nav.svelte';
|
import TerminalNav from './terminal/nav.svelte';
|
||||||
import TerminalHero from './terminal/hero.svelte';
|
import TerminalHero from './terminal/hero.svelte';
|
||||||
|
import TerminalCourseCard from './terminal/course-card.svelte';
|
||||||
import { Button } from '../../base/button';
|
import { Button } from '../../base/button';
|
||||||
import { BlurFade } from '../animation/blurfade';
|
import { themeStyle } from './theme-style';
|
||||||
import { getCourseTypeLandingMeta } from './landing-page-utils';
|
import { getCourseTypeLandingMeta } from './landing-page-utils';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -22,12 +23,13 @@
|
|||||||
embed,
|
embed,
|
||||||
callout,
|
callout,
|
||||||
links,
|
links,
|
||||||
footer
|
footer,
|
||||||
|
labels
|
||||||
}: OrgLandingPageProps = $props();
|
}: OrgLandingPageProps = $props();
|
||||||
|
|
||||||
function priceLabel(course: CourseItem): string {
|
function priceLabel(course: CourseItem): string {
|
||||||
if (course.price) return course.price;
|
if (course.price) return course.price;
|
||||||
if (!course.cost) return 'Free';
|
if (!course.cost) return labels?.freeLabel ?? 'Free';
|
||||||
|
|
||||||
return new Intl.NumberFormat('en-US', {
|
return new Intl.NumberFormat('en-US', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
@@ -43,7 +45,7 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
const tabs = $derived<{ key: TabKey; label: string }[]>([
|
const tabs = $derived<{ key: TabKey; label: string }[]>([
|
||||||
{ key: 'all', label: 'All' },
|
{ key: 'all', label: labels?.filterAllLabel ?? 'All' },
|
||||||
...courseTypeKeys.map((type) => {
|
...courseTypeKeys.map((type) => {
|
||||||
const meta = getCourseTypeLandingMeta({ id: '', title: '', description: '', type } as CourseItem);
|
const meta = getCourseTypeLandingMeta({ id: '', title: '', description: '', type } as CourseItem);
|
||||||
|
|
||||||
@@ -69,22 +71,8 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="ui:min-h-screen ui:font-sans ui:w-full"
|
class="ui:min-h-screen ui:font-sans ui:w-full ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)]"
|
||||||
style="
|
style={themeStyle('terminal')}
|
||||||
background: #06070a;
|
|
||||||
color: #e9eaed;
|
|
||||||
--terminal-bg: #06070a;
|
|
||||||
--terminal-bg-soft: #0c0e13;
|
|
||||||
--terminal-bg-card: #0f1218;
|
|
||||||
--terminal-bg-elevated: #14171f;
|
|
||||||
--terminal-line: #1c1f28;
|
|
||||||
--terminal-line-soft: #161922;
|
|
||||||
--terminal-line-strong: #262a35;
|
|
||||||
--terminal-ink: #e9eaed;
|
|
||||||
--terminal-ink-dim: #9da1ab;
|
|
||||||
--terminal-ink-muted: #61656f;
|
|
||||||
--terminal-ink-faint: #3d4049;
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<main>
|
<main>
|
||||||
<TerminalHero {orgName} {hero} {courses}>
|
<TerminalHero {orgName} {hero} {courses}>
|
||||||
@@ -95,43 +83,36 @@
|
|||||||
|
|
||||||
{#if courses.length > 0}
|
{#if courses.length > 0}
|
||||||
<section
|
<section
|
||||||
class="ui:py-24 ui:px-6"
|
class="ui:py-24 ui:px-6 ui:bg-[var(--landing-bg)]"
|
||||||
style="border-top: 1px solid var(--terminal-line); background: var(--terminal-bg);"
|
style="border-top: 1px solid var(--landing-border-soft);"
|
||||||
>
|
>
|
||||||
<div class="ui:max-w-[1120px] ui:mx-auto">
|
<div class="ui:max-w-[1120px] ui:mx-auto">
|
||||||
<BlurFade delay={0} once={true}>
|
|
||||||
<p
|
<p
|
||||||
class="ui:font-mono ui:text-[11px] ui:tracking-[0.12em] ui:uppercase ui:mb-3 ui:inline-flex ui:items-center ui:gap-2"
|
class="ui:font-mono ui:text-[11px] ui:tracking-[0.12em] ui:uppercase ui:mb-3 ui:inline-flex ui:items-center ui:gap-2"
|
||||||
style="color: var(--primary);"
|
style="color: var(--landing-accent);"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="ui:size-1.5 ui:rounded-full"
|
class="ui:size-1.5 ui:rounded-full"
|
||||||
style="background: var(--primary); box-shadow: 0 0 12px var(--primary);"
|
style="background: var(--landing-accent); box-shadow: 0 0 12px var(--landing-accent);"
|
||||||
></span>
|
></span>
|
||||||
Catalog
|
{labels?.catalogEyebrow ?? 'Catalog'}
|
||||||
</p>
|
</p>
|
||||||
</BlurFade>
|
|
||||||
|
|
||||||
<BlurFade delay={0.08} once={true}>
|
|
||||||
<h2
|
<h2
|
||||||
class="ui:text-4xl ui:lg:text-[40px] ui:font-semibold ui:tracking-tight ui:leading-[1.08] ui:m-0 ui:mb-3 ui:max-w-[700px]"
|
class="ui:text-4xl ui:lg:text-[40px] ui:font-semibold ui:tracking-tight ui:leading-[1.08] ui:m-0 ui:mb-3 ui:max-w-[700px] ui:text-[var(--landing-fg)]"
|
||||||
style="color: var(--terminal-ink);"
|
|
||||||
>
|
>
|
||||||
Programs your team can actually finish.
|
{labels?.catalogHeading ?? 'Programs your team can actually finish.'}
|
||||||
</h2>
|
</h2>
|
||||||
</BlurFade>
|
|
||||||
|
|
||||||
<BlurFade delay={0.14} once={true}>
|
<p class="ui:text-[15px] ui:max-w-xl ui:m-0 ui:mb-8 ui:text-[var(--landing-fg-muted)]">
|
||||||
<p class="ui:text-[15px] ui:max-w-xl ui:m-0 ui:mb-8" style="color: var(--terminal-ink-dim);">
|
{labels?.catalogDescription ??
|
||||||
Tracks built around real-world artifacts — pull requests, postmortems, and runbooks. No filler.
|
'Tracks built around real-world artifacts — pull requests, postmortems, and runbooks. No filler.'}
|
||||||
</p>
|
</p>
|
||||||
</BlurFade>
|
|
||||||
|
|
||||||
{#if tabs.length > 1}
|
{#if tabs.length > 1}
|
||||||
<BlurFade delay={0.2} once={true}>
|
|
||||||
<div
|
<div
|
||||||
class="ui:inline-flex ui:items-center ui:gap-0.5 ui:p-1 ui:rounded-full ui:mb-8"
|
class="ui:inline-flex ui:items-center ui:gap-0.5 ui:p-1 ui:rounded-full ui:mb-8 ui:bg-[var(--landing-card)]"
|
||||||
style="border: 1px solid var(--terminal-line-strong); background: var(--terminal-bg-card);"
|
style="border: 1px solid var(--landing-border);"
|
||||||
role="tablist"
|
role="tablist"
|
||||||
>
|
>
|
||||||
{#each tabs as tab (tab.key)}
|
{#each tabs as tab (tab.key)}
|
||||||
@@ -141,63 +122,20 @@
|
|||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={isActive}
|
aria-selected={isActive}
|
||||||
onclick={() => (activeTab = tab.key)}
|
onclick={() => (activeTab = tab.key)}
|
||||||
class="ui:px-3.5 ui:py-1.5 ui:text-[13px] ui:font-medium ui:rounded-full ui:transition-colors"
|
class="ui:px-3.5 ui:py-1.5 ui:text-[13px] ui:font-medium ui:rounded-full ui:transition-colors ui:cursor-pointer"
|
||||||
style={isActive
|
style={isActive
|
||||||
? 'background: var(--terminal-ink); color: var(--terminal-bg);'
|
? 'background: var(--landing-fg); color: var(--landing-bg);'
|
||||||
: 'background: transparent; color: var(--terminal-ink-dim);'}
|
: 'background: transparent; color: var(--landing-fg-muted);'}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</BlurFade>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-[14px]">
|
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-[14px]">
|
||||||
{#each visibleCourses as course, index (course.id)}
|
{#each visibleCourses as course, index (course.id)}
|
||||||
<BlurFade delay={0.04 * index} once={true}>
|
<TerminalCourseCard {course} {disableCourseLinks} {labels} />
|
||||||
<a
|
|
||||||
href={disableCourseLinks
|
|
||||||
? undefined
|
|
||||||
: course.link || (course.slug ? `/course/${course.slug}` : undefined)}
|
|
||||||
aria-disabled={disableCourseLinks}
|
|
||||||
tabindex={disableCourseLinks ? -1 : undefined}
|
|
||||||
class="ui:relative ui:flex ui:flex-col ui:gap-2.5 ui:p-6 ui:rounded-xl ui:no-underline ui:transition-colors ui:h-full"
|
|
||||||
style="
|
|
||||||
background: linear-gradient(180deg, var(--terminal-bg-card) 0%, var(--terminal-bg-soft) 100%);
|
|
||||||
border: 1px solid var(--terminal-line);
|
|
||||||
color: var(--terminal-ink);
|
|
||||||
min-height: 180px;
|
|
||||||
cursor: {disableCourseLinks ? 'default' : 'pointer'};
|
|
||||||
"
|
|
||||||
onmouseenter={(e) =>
|
|
||||||
!disableCourseLinks && (e.currentTarget.style.borderColor = 'var(--terminal-line-strong)')}
|
|
||||||
onmouseleave={(e) => (e.currentTarget.style.borderColor = 'var(--terminal-line)')}
|
|
||||||
>
|
|
||||||
<span class="ui:font-mono ui:text-[11px] ui:tracking-[0.04em]" style="color: var(--primary);">
|
|
||||||
$ {slugify(course)}
|
|
||||||
</span>
|
|
||||||
<h3
|
|
||||||
class="ui:text-[17px] ui:font-semibold ui:leading-snug ui:m-0"
|
|
||||||
style="color: var(--terminal-ink); letter-spacing: -0.01em;"
|
|
||||||
>
|
|
||||||
{course.title}
|
|
||||||
</h3>
|
|
||||||
<p
|
|
||||||
class="ui:text-[13.5px] ui:leading-[1.5] ui:m-0 ui:flex-1 ui:line-clamp-3"
|
|
||||||
style="color: var(--terminal-ink-dim);"
|
|
||||||
>
|
|
||||||
{course.description}
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
class="ui:flex ui:items-center ui:justify-between ui:pt-3.5 ui:font-mono ui:text-[12px]"
|
|
||||||
style="border-top: 1px dashed var(--terminal-line);"
|
|
||||||
>
|
|
||||||
<span style="color: var(--terminal-ink);">{priceLabel(course)}</span>
|
|
||||||
<span aria-hidden="true" style="color: var(--terminal-ink-dim);">→</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</BlurFade>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -207,9 +145,9 @@
|
|||||||
href={disableCourseLinks ? undefined : '/courses'}
|
href={disableCourseLinks ? undefined : '/courses'}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={disableCourseLinks}
|
disabled={disableCourseLinks}
|
||||||
class="ui:rounded-full ui:px-5 ui:bg-transparent ui:border-[var(--terminal-line-strong)] ui:text-[var(--terminal-ink)] ui:hover:bg-white/5"
|
class="ui:rounded-full ui:px-5 ui:bg-transparent ui:border-[var(--landing-border)] ui:text-[var(--landing-fg)] ui:hover:bg-white/5"
|
||||||
>
|
>
|
||||||
View all programs →
|
{labels?.browseCoursesLabel ?? 'View all programs →'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -218,11 +156,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<OrgLandingPageLinks {links} variant="terminal" />
|
<OrgLandingPageLinks {links} {labels} variant="terminal" />
|
||||||
|
|
||||||
<OrgLandingPageEmbed {embed} variant="terminal" />
|
<OrgLandingPageEmbed {embed} {labels} variant="terminal" />
|
||||||
|
|
||||||
<OrgLandingPageCallout {callout} variant="terminal" />
|
<OrgLandingPageCallout {callout} {labels} variant="terminal" />
|
||||||
|
|
||||||
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="terminal" />
|
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="terminal" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { CourseItem, OrgLandingPageLabels } from '../types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
course: CourseItem;
|
||||||
|
disableCourseLinks?: boolean;
|
||||||
|
labels?: OrgLandingPageLabels;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { course, disableCourseLinks = false, labels }: Props = $props();
|
||||||
|
|
||||||
|
const href = $derived.by(() => {
|
||||||
|
if (disableCourseLinks) return undefined;
|
||||||
|
return course.link || (course.slug ? `/course/${course.slug}` : undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
function slugify(c: CourseItem): string {
|
||||||
|
if (c.slug) return c.slug;
|
||||||
|
return c.title
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function priceLabel(c: CourseItem): string {
|
||||||
|
if (c.price) return c.price;
|
||||||
|
if (!c.cost) return labels?.freeLabel ?? 'Free';
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: c.currency ?? 'USD',
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
}).format(c.cost);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a
|
||||||
|
{href}
|
||||||
|
aria-disabled={disableCourseLinks}
|
||||||
|
tabindex={disableCourseLinks ? -1 : undefined}
|
||||||
|
class="ui:relative ui:flex ui:flex-col ui:gap-2.5 ui:p-6 ui:rounded-xl ui:no-underline ui:transition-colors ui:h-full ui:text-[var(--landing-fg)] {disableCourseLinks
|
||||||
|
? ''
|
||||||
|
: 'ui:cursor-pointer'}"
|
||||||
|
style="
|
||||||
|
background: linear-gradient(180deg, var(--landing-card) 0%, var(--landing-bg-section) 100%);
|
||||||
|
border: 1px solid var(--landing-border-soft);
|
||||||
|
min-height: 180px;
|
||||||
|
cursor: {disableCourseLinks ? 'default' : 'pointer'};
|
||||||
|
"
|
||||||
|
onmouseenter={(e) => !disableCourseLinks && (e.currentTarget.style.borderColor = 'var(--landing-border)')}
|
||||||
|
onmouseleave={(e) => (e.currentTarget.style.borderColor = 'var(--landing-border-soft)')}
|
||||||
|
>
|
||||||
|
<span class="ui:font-mono ui:text-[11px] ui:tracking-[0.04em]" style="color: var(--landing-accent);">
|
||||||
|
$ {slugify(course)}
|
||||||
|
</span>
|
||||||
|
<h3
|
||||||
|
class="ui:text-[17px] ui:font-semibold ui:leading-snug ui:m-0 ui:text-[var(--landing-fg)]"
|
||||||
|
style="letter-spacing: -0.01em;"
|
||||||
|
>
|
||||||
|
{course.title}
|
||||||
|
</h3>
|
||||||
|
<p class="ui:text-[13.5px] ui:leading-[1.5] ui:m-0 ui:flex-1 ui:line-clamp-3 ui:text-[var(--landing-fg-muted)]">
|
||||||
|
{course.description}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="ui:flex ui:items-center ui:justify-between ui:pt-3.5 ui:font-mono ui:text-[12px]"
|
||||||
|
style="border-top: 1px dashed var(--landing-border-soft);"
|
||||||
|
>
|
||||||
|
<span class="ui:text-[var(--landing-fg)]">{priceLabel(course)}</span>
|
||||||
|
<span aria-hidden="true" class="ui:text-[var(--landing-fg-muted)]">→</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
@@ -2,17 +2,16 @@
|
|||||||
import type { OrgLandingPageProps, CourseItem } from '../types';
|
import type { OrgLandingPageProps, CourseItem } from '../types';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { Button } from '../../../base/button';
|
import { Button } from '../../../base/button';
|
||||||
import { BlurFade } from '../../animation/blurfade';
|
|
||||||
import EditableLandingSection from '../editable-section.svelte';
|
import EditableLandingSection from '../editable-section.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
orgName: OrgLandingPageProps['orgName'];
|
orgName?: OrgLandingPageProps['orgName'];
|
||||||
hero: OrgLandingPageProps['hero'];
|
hero: OrgLandingPageProps['hero'];
|
||||||
courses: OrgLandingPageProps['courses'];
|
courses: OrgLandingPageProps['courses'];
|
||||||
navigation: Snippet;
|
navigation: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { orgName, hero, courses, navigation }: Props = $props();
|
let { orgName = '', hero, courses = [], navigation }: Props = $props();
|
||||||
|
|
||||||
const programsCount = $derived(courses.length);
|
const programsCount = $derived(courses.length);
|
||||||
const learnersCount = $derived(courses.reduce((sum, course) => sum + (course.totalStudents ?? 0), 0));
|
const learnersCount = $derived(courses.reduce((sum, course) => sum + (course.totalStudents ?? 0), 0));
|
||||||
@@ -63,58 +62,41 @@
|
|||||||
|
|
||||||
<EditableLandingSection sectionKey="hero">
|
<EditableLandingSection sectionKey="hero">
|
||||||
<section
|
<section
|
||||||
class="ui:relative ui:overflow-hidden ui:px-6 ui:pt-16 ui:pb-16 ui:md:pt-20 ui:md:pb-20"
|
class="ui:relative ui:overflow-hidden ui:px-6 ui:pt-16 ui:pb-16 ui:md:pt-20 ui:md:pb-20 ui:bg-[var(--landing-bg)]"
|
||||||
style="
|
|
||||||
background: #06070a;
|
|
||||||
--terminal-bg: #06070a;
|
|
||||||
--terminal-bg-soft: #0c0e13;
|
|
||||||
--terminal-bg-card: #0f1218;
|
|
||||||
--terminal-bg-elevated: #14171f;
|
|
||||||
--terminal-line: #1c1f28;
|
|
||||||
--terminal-line-soft: #161922;
|
|
||||||
--terminal-line-strong: #262a35;
|
|
||||||
--terminal-ink: #e9eaed;
|
|
||||||
--terminal-ink-dim: #9da1ab;
|
|
||||||
--terminal-ink-muted: #61656f;
|
|
||||||
--terminal-ink-faint: #3d4049;
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="ui:pointer-events-none ui:absolute ui:inset-x-0 ui:-top-32 ui:h-[680px] ui:opacity-90"
|
class="ui:pointer-events-none ui:absolute ui:inset-x-0 ui:-top-32 ui:h-[680px] ui:opacity-90"
|
||||||
style="background: radial-gradient(ellipse 60% 70% at 50% 30%, color-mix(in oklab, var(--primary) 22%, transparent) 0%, transparent 65%);"
|
style="background: radial-gradient(ellipse 60% 70% at 50% 30%, color-mix(in oklab, var(--landing-accent) 22%, transparent) 0%, transparent 65%);"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div class="ui:relative ui:max-w-[1120px] ui:mx-auto ui:text-center">
|
<div class="ui:relative ui:max-w-[1120px] ui:mx-auto ui:text-center">
|
||||||
<BlurFade delay={0} once={true}>
|
|
||||||
<h1
|
<h1
|
||||||
class="ui:font-semibold ui:leading-[0.95] ui:mb-10 ui:max-w-[20ch] ui:mx-auto ui:text-balance"
|
class="ui:font-semibold ui:leading-[0.95] ui:mb-10 ui:max-w-[20ch] ui:mx-auto ui:text-balance"
|
||||||
style="
|
style="
|
||||||
font-size: clamp(48px, 9vw, 128px);
|
font-size: clamp(48px, 9vw, 128px);
|
||||||
letter-spacing: -0.04em;
|
letter-spacing: -0.04em;
|
||||||
background: linear-gradient(180deg,
|
background: linear-gradient(180deg,
|
||||||
color-mix(in oklab, var(--primary) 60%, #ffffff) 0%,
|
color-mix(in oklab, var(--landing-accent) 60%, #ffffff) 0%,
|
||||||
color-mix(in oklab, var(--primary) 35%, transparent) 45%,
|
color-mix(in oklab, var(--landing-accent) 35%, transparent) 45%,
|
||||||
rgba(8, 10, 14, 0.3) 100%);
|
color-mix(in oklab, var(--landing-bg) 70%, transparent) 100%);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
color: transparent;
|
color: transparent;
|
||||||
text-shadow: 0 8px 60px color-mix(in oklab, var(--primary) 18%, transparent);
|
text-shadow: 0 8px 60px color-mix(in oklab, var(--landing-accent) 18%, transparent);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{hero.heading}
|
{hero.heading}
|
||||||
</h1>
|
</h1>
|
||||||
</BlurFade>
|
|
||||||
|
|
||||||
<BlurFade delay={0.12} once={true}>
|
|
||||||
<div class="ui:inline-flex ui:items-center ui:gap-2 ui:mb-8">
|
<div class="ui:inline-flex ui:items-center ui:gap-2 ui:mb-8">
|
||||||
{#if hero.secondaryAction}
|
{#if hero.secondaryAction}
|
||||||
<Button
|
<Button
|
||||||
href={hero.secondaryAction.href}
|
href={hero.secondaryAction.href}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="ui:rounded-full ui:px-4 ui:font-medium ui:bg-white/5 ui:border-[var(--terminal-line-strong)] ui:text-[var(--terminal-ink)] ui:hover:bg-white/10"
|
class="ui:rounded-full ui:px-4 ui:font-medium ui:bg-white/5 ui:border-[var(--landing-border)] ui:text-[var(--landing-fg)] ui:hover:bg-white/10"
|
||||||
>
|
>
|
||||||
{hero.secondaryAction.label}
|
{hero.secondaryAction.label}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -128,59 +110,49 @@
|
|||||||
{hero.primaryAction.label}
|
{hero.primaryAction.label}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</BlurFade>
|
|
||||||
|
|
||||||
<BlurFade delay={0.22} once={true}>
|
<p class="ui:text-[15px] ui:mb-7 ui:max-w-xl ui:mx-auto ui:text-[var(--landing-fg-muted)]">
|
||||||
<p class="ui:text-[15px] ui:mb-7 ui:max-w-xl ui:mx-auto" style="color: var(--terminal-ink-dim);">
|
|
||||||
{hero.subheading}
|
{hero.subheading}
|
||||||
</p>
|
</p>
|
||||||
</BlurFade>
|
|
||||||
|
|
||||||
{#if programsCount > 0}
|
{#if programsCount > 0}
|
||||||
<BlurFade delay={0.3} once={true}>
|
|
||||||
<span
|
<span
|
||||||
class="ui:inline-flex ui:items-center ui:gap-3 ui:px-4 ui:py-2 ui:rounded-full ui:font-mono ui:text-[13px]"
|
class="ui:inline-flex ui:items-center ui:gap-3 ui:px-4 ui:py-2 ui:rounded-full ui:font-mono ui:text-[13px] ui:text-[var(--landing-fg-muted)]"
|
||||||
style="
|
style="
|
||||||
border: 1px solid var(--terminal-line-strong);
|
border: 1px solid var(--landing-border);
|
||||||
background: rgba(255,255,255,0.02);
|
background: rgba(255,255,255,0.02);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
color: var(--terminal-ink-dim);
|
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<span style="color: var(--primary);">></span>
|
<span style="color: var(--landing-accent);">></span>
|
||||||
<span
|
<span
|
||||||
><span style="color: var(--terminal-ink); font-weight: 500;">{programsCount}</span>
|
><span class="ui:font-medium ui:text-[var(--landing-fg)]">{programsCount}</span>
|
||||||
{programsCount === 1 ? 'program' : 'programs'} running</span
|
{programsCount === 1 ? 'program' : 'programs'} running</span
|
||||||
>
|
>
|
||||||
{#if learnersCount > 0}
|
{#if learnersCount > 0}
|
||||||
<span class="ui:inline-block ui:w-px ui:h-3" style="background: var(--terminal-line-strong);"></span>
|
<span class="ui:inline-block ui:w-px ui:h-3 ui:bg-[var(--landing-border)]"></span>
|
||||||
<span
|
<span><span class="ui:font-medium ui:text-[var(--landing-fg)]">{learnersFormatted}</span> learners</span>
|
||||||
><span style="color: var(--terminal-ink); font-weight: 500;">{learnersFormatted}</span> learners</span
|
|
||||||
>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if tracksCount > 1}
|
{#if tracksCount > 1}
|
||||||
<span class="ui:inline-block ui:w-px ui:h-3" style="background: var(--terminal-line-strong);"></span>
|
<span class="ui:inline-block ui:w-px ui:h-3 ui:bg-[var(--landing-border)]"></span>
|
||||||
<span><span style="color: var(--terminal-ink); font-weight: 500;">{tracksCount}</span> tracks</span>
|
<span><span class="ui:font-medium ui:text-[var(--landing-fg)]">{tracksCount}</span> tracks</span>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</BlurFade>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if hero.image}
|
{#if hero.image}
|
||||||
<BlurFade delay={0.4} once={true}>
|
|
||||||
<div
|
<div
|
||||||
class="ui:mt-14 ui:max-w-[880px] ui:mx-auto ui:rounded-xl ui:overflow-hidden ui:text-left"
|
class="ui:mt-14 ui:max-w-[880px] ui:mx-auto ui:rounded-xl ui:overflow-hidden ui:text-left ui:bg-[var(--landing-card)]"
|
||||||
style="
|
style="
|
||||||
border: 1px solid var(--terminal-line-strong);
|
border: 1px solid var(--landing-border);
|
||||||
background: var(--terminal-bg-card);
|
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 30px 90px -30px rgba(0,0,0,0.7),
|
0 30px 90px -30px rgba(0,0,0,0.7),
|
||||||
0 0 0 1px color-mix(in oklab, var(--primary) 6%, transparent);
|
0 0 0 1px color-mix(in oklab, var(--landing-accent) 6%, transparent);
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="ui:flex ui:items-center ui:gap-2 ui:px-4 ui:py-3"
|
class="ui:flex ui:items-center ui:gap-2 ui:px-4 ui:py-3 ui:bg-[var(--landing-card-soft)]"
|
||||||
style="border-bottom: 1px solid var(--terminal-line); background: var(--terminal-bg-elevated);"
|
style="border-bottom: 1px solid var(--landing-border-soft);"
|
||||||
>
|
>
|
||||||
<span class="ui:flex ui:gap-1.5 ui:mr-2">
|
<span class="ui:flex ui:gap-1.5 ui:mr-2">
|
||||||
<span class="ui:size-[11px] ui:rounded-full" style="background: #ff5f57;"></span>
|
<span class="ui:size-[11px] ui:rounded-full" style="background: #ff5f57;"></span>
|
||||||
@@ -188,30 +160,26 @@
|
|||||||
<span class="ui:size-[11px] ui:rounded-full" style="background: #28c840;"></span>
|
<span class="ui:size-[11px] ui:rounded-full" style="background: #28c840;"></span>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
class="ui:flex-1 ui:text-center ui:font-mono ui:text-xs ui:truncate"
|
class="ui:flex-1 ui:text-center ui:font-mono ui:text-xs ui:truncate ui:text-[var(--landing-fg-muted)]"
|
||||||
style="color: var(--terminal-ink-dim);"
|
|
||||||
>
|
>
|
||||||
{orgSlug}/preview
|
{orgSlug}/preview
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<img src={hero.image} alt={orgName} class="ui:block ui:w-full ui:h-auto" />
|
<img src={hero.image} alt={orgName} class="ui:block ui:w-full ui:h-auto" />
|
||||||
</div>
|
</div>
|
||||||
</BlurFade>
|
|
||||||
{:else if terminalRows.length > 0}
|
{:else if terminalRows.length > 0}
|
||||||
<BlurFade delay={0.4} once={true}>
|
|
||||||
<div
|
<div
|
||||||
class="ui:mt-14 ui:max-w-[880px] ui:mx-auto ui:rounded-xl ui:overflow-hidden ui:text-left"
|
class="ui:mt-14 ui:max-w-[880px] ui:mx-auto ui:rounded-xl ui:overflow-hidden ui:text-left ui:bg-[var(--landing-card)]"
|
||||||
style="
|
style="
|
||||||
border: 1px solid var(--terminal-line-strong);
|
border: 1px solid var(--landing-border);
|
||||||
background: var(--terminal-bg-card);
|
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 30px 90px -30px rgba(0,0,0,0.7),
|
0 30px 90px -30px rgba(0,0,0,0.7),
|
||||||
0 0 0 1px color-mix(in oklab, var(--primary) 6%, transparent);
|
0 0 0 1px color-mix(in oklab, var(--landing-accent) 6%, transparent);
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="ui:flex ui:items-center ui:gap-2 ui:px-4 ui:py-3"
|
class="ui:flex ui:items-center ui:gap-2 ui:px-4 ui:py-3 ui:bg-[var(--landing-card-soft)]"
|
||||||
style="border-bottom: 1px solid var(--terminal-line); background: var(--terminal-bg-elevated);"
|
style="border-bottom: 1px solid var(--landing-border-soft);"
|
||||||
>
|
>
|
||||||
<span class="ui:flex ui:gap-1.5 ui:mr-2">
|
<span class="ui:flex ui:gap-1.5 ui:mr-2">
|
||||||
<span class="ui:size-[11px] ui:rounded-full" style="background: #ff5f57;"></span>
|
<span class="ui:size-[11px] ui:rounded-full" style="background: #ff5f57;"></span>
|
||||||
@@ -219,65 +187,63 @@
|
|||||||
<span class="ui:size-[11px] ui:rounded-full" style="background: #28c840;"></span>
|
<span class="ui:size-[11px] ui:rounded-full" style="background: #28c840;"></span>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
class="ui:flex-1 ui:text-center ui:font-mono ui:text-xs ui:truncate"
|
class="ui:flex-1 ui:text-center ui:font-mono ui:text-xs ui:truncate ui:text-[var(--landing-fg-muted)]"
|
||||||
style="color: var(--terminal-ink-dim);"
|
|
||||||
>
|
>
|
||||||
{orgSlug}/main · catalog
|
{orgSlug}/main · catalog
|
||||||
</span>
|
</span>
|
||||||
<span class="ui:font-mono ui:text-[11px]" style="color: var(--terminal-ink-muted);">
|
<span class="ui:font-mono ui:text-[11px] ui:text-[var(--landing-fg-faint)]">
|
||||||
{programsCount}
|
{programsCount}
|
||||||
{programsCount === 1 ? 'program' : 'programs'}
|
{programsCount === 1 ? 'program' : 'programs'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ui:p-5 ui:font-mono ui:text-[13px]" style="color: var(--terminal-ink); line-height: 1.7;">
|
<div class="ui:p-5 ui:font-mono ui:text-[13px] ui:text-[var(--landing-fg)]" style="line-height: 1.7;">
|
||||||
<div
|
<div
|
||||||
class="ui:flex ui:items-baseline ui:gap-2 ui:px-3 ui:py-2 ui:rounded-md ui:mb-3"
|
class="ui:flex ui:items-baseline ui:gap-2 ui:px-3 ui:py-2 ui:rounded-md ui:mb-3"
|
||||||
style="background: rgba(255,255,255,0.02); border: 1px solid var(--terminal-line);"
|
style="background: rgba(255,255,255,0.02); border: 1px solid var(--landing-border-soft);"
|
||||||
>
|
>
|
||||||
<span style="color: var(--primary);">></span>
|
<span style="color: var(--landing-accent);">></span>
|
||||||
<span>list programs --org {orgSlug}</span>
|
<span>list programs --org {orgSlug}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ui:py-1 ui:text-[12px]" style="color: var(--terminal-ink-muted);">
|
<div class="ui:py-1 ui:text-[12px] ui:text-[var(--landing-fg-faint)]">
|
||||||
● Loaded {programsCount}
|
● Loaded {programsCount}
|
||||||
{programsCount === 1 ? 'program' : 'programs'}
|
{programsCount === 1 ? 'program' : 'programs'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ui:mt-2 ui:flex ui:flex-col">
|
<div class="ui:mt-2 ui:flex ui:flex-col">
|
||||||
{#each terminalRows as row (row.slug)}
|
{#each terminalRows as row (row.slug)}
|
||||||
<div class="ui:flex ui:items-baseline ui:gap-4 ui:py-1.5" style="color: var(--terminal-ink);">
|
<div class="ui:flex ui:items-baseline ui:gap-4 ui:py-1.5 ui:text-[var(--landing-fg)]">
|
||||||
<span style="color: var(--terminal-ink-faint);">{row.n}</span>
|
<span class="ui:text-[var(--landing-fg-faint)]">{row.n}</span>
|
||||||
<span class="ui:truncate ui:flex-1" style="color: var(--primary);">{row.slug}</span>
|
<span class="ui:truncate ui:flex-1" style="color: var(--landing-accent);">{row.slug}</span>
|
||||||
<span class="ui:text-[12px]" style="color: var(--terminal-ink-dim);">{row.duration}</span>
|
<span class="ui:text-[12px] ui:text-[var(--landing-fg-muted)]">{row.duration}</span>
|
||||||
<span class="ui:text-[12px] ui:w-20 ui:text-right">{row.price}</span>
|
<span class="ui:text-[12px] ui:w-20 ui:text-right">{row.price}</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ui:flex ui:items-baseline ui:gap-2 ui:mt-4">
|
<div class="ui:flex ui:items-baseline ui:gap-2 ui:mt-4">
|
||||||
<span style="color: var(--primary);">></span>
|
<span style="color: var(--landing-accent);">></span>
|
||||||
<span
|
<span
|
||||||
class="ui:inline-block ui:w-2 ui:h-[14px] ui:align-middle"
|
class="ui:inline-block ui:w-2 ui:h-[14px] ui:align-middle"
|
||||||
style="background: var(--primary); animation: terminal-caret 1s steps(1) infinite;"
|
style="background: var(--landing-accent); animation: terminal-caret 1s steps(1) infinite;"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></span>
|
></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="ui:flex ui:items-center ui:justify-between ui:px-4 ui:py-2.5 ui:font-mono ui:text-[11px]"
|
class="ui:flex ui:items-center ui:justify-between ui:px-4 ui:py-2.5 ui:font-mono ui:text-[11px] ui:bg-[var(--landing-bg-section)] ui:text-[var(--landing-fg-faint)]"
|
||||||
style="border-top: 1px solid var(--terminal-line); background: var(--terminal-bg-soft); color: var(--terminal-ink-muted);"
|
style="border-top: 1px solid var(--landing-border-soft);"
|
||||||
>
|
>
|
||||||
<div class="ui:flex ui:gap-4">
|
<div class="ui:flex ui:gap-4">
|
||||||
<span>Enter enroll</span>
|
<span>Enter enroll</span>
|
||||||
<span>↑/↓ navigate</span>
|
<span>↑/↓ navigate</span>
|
||||||
<span>^q quit</span>
|
<span>^q quit</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="color: var(--terminal-ink-dim);">{orgSlug}-cli</div>
|
<div class="ui:text-[var(--landing-fg-muted)]">{orgSlug}-cli</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</BlurFade>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -17,15 +17,14 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<EditableLandingSection sectionKey="navigation">
|
<EditableLandingSection sectionKey="navigation">
|
||||||
<header class="ui:relative ui:z-50 ui:px-6" style="background: transparent;">
|
<header class="ui:relative ui:z-50 ui:px-6 ui:bg-transparent">
|
||||||
<div class="ui:max-w-[1120px] ui:mx-auto">
|
<div class="ui:max-w-[1120px] ui:mx-auto">
|
||||||
<div class="ui:flex ui:items-center ui:justify-between ui:h-16">
|
<div class="ui:flex ui:items-center ui:justify-between ui:h-16">
|
||||||
<div class="ui:flex ui:items-center ui:gap-8">
|
<div class="ui:flex ui:items-center ui:gap-8">
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
aria-label={orgName}
|
aria-label={orgName}
|
||||||
class="ui:flex ui:items-center ui:gap-2 ui:no-underline ui:text-foreground"
|
class="ui:flex ui:items-center ui:gap-2 ui:no-underline ui:text-[var(--landing-fg)] ui:cursor-pointer"
|
||||||
style="color: var(--terminal-ink, #e9eaed);"
|
|
||||||
>
|
>
|
||||||
{#if logoUrl}
|
{#if logoUrl}
|
||||||
<img src={logoUrl} alt={orgName} class="ui:h-6 ui:w-auto" />
|
<img src={logoUrl} alt={orgName} class="ui:h-6 ui:w-auto" />
|
||||||
@@ -47,16 +46,12 @@
|
|||||||
{#each navItems as item (item.href + item.label)}
|
{#each navItems as item (item.href + item.label)}
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class="ui:text-[11px] ui:font-medium ui:tracking-[0.12em] ui:uppercase ui:no-underline ui:transition-colors"
|
class="ui:text-[11px] ui:font-medium ui:tracking-[0.12em] ui:uppercase ui:no-underline ui:transition-colors ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] ui:cursor-pointer"
|
||||||
style="color: var(--terminal-ink-dim, #9da1ab);"
|
|
||||||
onmouseenter={(e) => (e.currentTarget.style.color = 'var(--terminal-ink, #e9eaed)')}
|
|
||||||
onmouseleave={(e) => (e.currentTarget.style.color = 'var(--terminal-ink-dim, #9da1ab)')}
|
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
<span class="ui:select-none" style="color: var(--terminal-ink-faint, #3d4049);" aria-hidden="true">/</span
|
<span class="ui:select-none ui:text-[var(--landing-fg-faint)]" aria-hidden="true">/</span>
|
||||||
>
|
|
||||||
</nav>
|
</nav>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -67,7 +62,7 @@
|
|||||||
href={authAction.href}
|
href={authAction.href}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="ui:rounded-full ui:px-4 ui:font-medium ui:bg-transparent ui:border-[var(--terminal-line-strong,#262a35)] ui:text-[var(--terminal-ink,#e9eaed)] ui:hover:bg-white/5"
|
class="ui:rounded-full ui:px-4 ui:font-medium ui:bg-transparent ui:border-[var(--landing-border)] ui:text-[var(--landing-fg)] ui:hover:bg-white/5"
|
||||||
>
|
>
|
||||||
{authAction.label}
|
{authAction.label}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import type { OrgLandingPageTheme } from './types';
|
||||||
|
|
||||||
|
export type LandingThemeVarName =
|
||||||
|
| '--landing-bg'
|
||||||
|
| '--landing-bg-section'
|
||||||
|
| '--landing-card'
|
||||||
|
| '--landing-card-soft'
|
||||||
|
| '--landing-fg'
|
||||||
|
| '--landing-fg-muted'
|
||||||
|
| '--landing-fg-faint'
|
||||||
|
| '--landing-border'
|
||||||
|
| '--landing-border-soft'
|
||||||
|
| '--landing-accent'
|
||||||
|
| '--landing-accent-fg'
|
||||||
|
| '--landing-button-primary-bg'
|
||||||
|
| '--landing-button-primary-fg'
|
||||||
|
| '--landing-button-primary-bg-hover'
|
||||||
|
| '--landing-button-secondary-bg'
|
||||||
|
| '--landing-button-secondary-fg'
|
||||||
|
| '--landing-button-secondary-border'
|
||||||
|
| '--landing-button-secondary-bg-hover'
|
||||||
|
| '--landing-button-tertiary-fg'
|
||||||
|
| '--landing-button-tertiary-bg-hover';
|
||||||
|
|
||||||
|
export type LandingThemeVars = Record<LandingThemeVarName, string>;
|
||||||
|
|
||||||
|
const baseTokenVars: LandingThemeVars = {
|
||||||
|
'--landing-bg': 'var(--background)',
|
||||||
|
'--landing-bg-section': 'var(--muted)',
|
||||||
|
'--landing-card': 'var(--card)',
|
||||||
|
'--landing-card-soft': 'var(--muted)',
|
||||||
|
'--landing-fg': 'var(--foreground)',
|
||||||
|
'--landing-fg-muted': 'var(--muted-foreground)',
|
||||||
|
'--landing-fg-faint': 'var(--muted-foreground)',
|
||||||
|
'--landing-border': 'var(--border)',
|
||||||
|
'--landing-border-soft': 'var(--border)',
|
||||||
|
'--landing-accent': 'var(--primary)',
|
||||||
|
'--landing-accent-fg': 'var(--primary-foreground)',
|
||||||
|
'--landing-button-primary-bg': 'var(--primary)',
|
||||||
|
'--landing-button-primary-fg': 'var(--primary-foreground)',
|
||||||
|
'--landing-button-primary-bg-hover': 'color-mix(in oklab, var(--primary) 88%, black)',
|
||||||
|
'--landing-button-secondary-bg': 'transparent',
|
||||||
|
'--landing-button-secondary-fg': 'var(--foreground)',
|
||||||
|
'--landing-button-secondary-border': 'var(--border)',
|
||||||
|
'--landing-button-secondary-bg-hover': 'var(--muted)',
|
||||||
|
'--landing-button-tertiary-fg': 'var(--primary)',
|
||||||
|
'--landing-button-tertiary-bg-hover': 'var(--muted)'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LANDING_THEME_VARS: Record<OrgLandingPageTheme, LandingThemeVars> = {
|
||||||
|
minimal: baseTokenVars,
|
||||||
|
bold: baseTokenVars,
|
||||||
|
classic: baseTokenVars,
|
||||||
|
saas: baseTokenVars,
|
||||||
|
tech: baseTokenVars,
|
||||||
|
studio: baseTokenVars,
|
||||||
|
corporate: baseTokenVars,
|
||||||
|
vibrant: baseTokenVars,
|
||||||
|
terminal: {
|
||||||
|
'--landing-bg': '#06070a',
|
||||||
|
'--landing-bg-section': '#0c0e13',
|
||||||
|
'--landing-card': '#0f1218',
|
||||||
|
'--landing-card-soft': '#14171f',
|
||||||
|
'--landing-fg': '#e9eaed',
|
||||||
|
'--landing-fg-muted': '#9da1ab',
|
||||||
|
'--landing-fg-faint': '#61656f',
|
||||||
|
'--landing-border': '#1c1f28',
|
||||||
|
'--landing-border-soft': '#161922',
|
||||||
|
'--landing-accent': 'var(--primary)',
|
||||||
|
'--landing-accent-fg': 'var(--primary-foreground)',
|
||||||
|
'--landing-button-primary-bg': '#f4f5f7',
|
||||||
|
'--landing-button-primary-fg': '#0a0b0e',
|
||||||
|
'--landing-button-primary-bg-hover': '#ffffff',
|
||||||
|
'--landing-button-secondary-bg': 'rgba(255,255,255,0.04)',
|
||||||
|
'--landing-button-secondary-fg': '#e9eaed',
|
||||||
|
'--landing-button-secondary-border': '#262a35',
|
||||||
|
'--landing-button-secondary-bg-hover': 'rgba(255,255,255,0.08)',
|
||||||
|
'--landing-button-tertiary-fg': 'var(--primary)',
|
||||||
|
'--landing-button-tertiary-bg-hover': 'rgba(255,255,255,0.06)'
|
||||||
|
},
|
||||||
|
editorial: {
|
||||||
|
'--landing-bg': '#f4f3ed',
|
||||||
|
'--landing-bg-section': '#ebeae3',
|
||||||
|
'--landing-card': '#fafaf5',
|
||||||
|
'--landing-card-soft': '#ecebe5',
|
||||||
|
'--landing-fg': '#1a1a1a',
|
||||||
|
'--landing-fg-muted': '#76746c',
|
||||||
|
'--landing-fg-faint': '#98968e',
|
||||||
|
'--landing-border': '#d9d8d0',
|
||||||
|
'--landing-border-soft': '#e3e2da',
|
||||||
|
'--landing-accent': '#c25237',
|
||||||
|
'--landing-accent-fg': '#fafaf5',
|
||||||
|
'--landing-button-primary-bg': '#1a1a1a',
|
||||||
|
'--landing-button-primary-fg': '#fafaf5',
|
||||||
|
'--landing-button-primary-bg-hover': '#2c2b29',
|
||||||
|
'--landing-button-secondary-bg': 'transparent',
|
||||||
|
'--landing-button-secondary-fg': '#1a1a1a',
|
||||||
|
'--landing-button-secondary-border': '#d9d8d0',
|
||||||
|
'--landing-button-secondary-bg-hover': '#ebeae3',
|
||||||
|
'--landing-button-tertiary-fg': '#c25237',
|
||||||
|
'--landing-button-tertiary-bg-hover': '#ebeae3'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function themeStyle(theme: OrgLandingPageTheme): string {
|
||||||
|
const vars = LANDING_THEME_VARS[theme] ?? baseTokenVars;
|
||||||
|
return Object.entries(vars)
|
||||||
|
.map(([name, value]) => `${name}: ${value}`)
|
||||||
|
.join('; ');
|
||||||
|
}
|
||||||
@@ -8,7 +8,9 @@ export type OrgLandingPageTheme =
|
|||||||
| 'tech'
|
| 'tech'
|
||||||
| 'studio'
|
| 'studio'
|
||||||
| 'corporate'
|
| 'corporate'
|
||||||
| 'terminal';
|
| 'terminal'
|
||||||
|
| 'editorial'
|
||||||
|
| 'vibrant';
|
||||||
|
|
||||||
export type CourseItem = {
|
export type CourseItem = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -122,6 +124,48 @@ export type LandingPageLinks = {
|
|||||||
cards: LandingPageLinkCard[];
|
cards: LandingPageLinkCard[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Localizable UI chrome strings used across landing templates.
|
||||||
|
* Every field is optional; when absent, each template falls back to its own
|
||||||
|
* English default that fits the template's voice. Consumers (e.g. dashboard
|
||||||
|
* with translations) pass an object of resolved strings to override defaults
|
||||||
|
* in one place rather than per template.
|
||||||
|
*/
|
||||||
|
export interface OrgLandingPageLabels {
|
||||||
|
/** Eyebrow above the catalog section. Default per template (often "Catalog"). */
|
||||||
|
catalogEyebrow?: string;
|
||||||
|
/** Section heading for the catalog. Default per template (e.g. "Our Courses", "Programs starting this season."). */
|
||||||
|
catalogHeading?: string;
|
||||||
|
/** Optional description under the catalog heading. Default per template, only used where the template renders one. */
|
||||||
|
catalogDescription?: string;
|
||||||
|
/** Label for the catalog "browse all"/"view more" CTA. Default per template (e.g. "Browse all →", "View more courses"). */
|
||||||
|
browseCoursesLabel?: string;
|
||||||
|
/** Per-card CTA on a course card. Default: "Enroll" (Bold defaults to "Details"). */
|
||||||
|
enrollLabel?: string;
|
||||||
|
/** Displayed in place of the price when cost is 0. Default: "Free". */
|
||||||
|
freeLabel?: string;
|
||||||
|
/** Tag/badge prefix for a featured course (editorial hero, vibrant featured panel). Default: "Featured". */
|
||||||
|
featuredLabel?: string;
|
||||||
|
/** Filter chip label for "show all" in templates that render a filter bar. Default: "All". */
|
||||||
|
filterAllLabel?: string;
|
||||||
|
/** CTA for the featured-course block (vibrant). Default: "Start this course". */
|
||||||
|
startCourseLabel?: string;
|
||||||
|
/** Plural-aware lesson-count label. Default: `${count} lessons` / `1 lesson`. */
|
||||||
|
lessonsLabel?: (count: number) => string;
|
||||||
|
/** Plural-aware exercise-count label. Default: `${count} exercises` / `1 exercise`. */
|
||||||
|
exercisesLabel?: (count: number) => string;
|
||||||
|
/** Plural-aware enrolled-students label. Default: `${count} enrolled`. */
|
||||||
|
enrolledLabel?: (count: number) => string;
|
||||||
|
/** Eyebrow for the Resources/Links section. Default per template (typically "Resources"). */
|
||||||
|
resourcesEyebrow?: string;
|
||||||
|
/** Eyebrow for the Embed section in templates that render one (terminal). Default: "Embed". */
|
||||||
|
embedEyebrow?: string;
|
||||||
|
/** Eyebrow for the Callout section. Default per template (e.g. "Ready when you are", "Get started"). */
|
||||||
|
calloutEyebrow?: string;
|
||||||
|
/** Card footer CTA on the resources/links section. Default per template ("Learn more", "Visit"). Separate from the existing per-template `boldVisitLabel` / `classicLearnMoreLabel` on `LandingPageLinks`, which still apply to those specific templates. */
|
||||||
|
learnMoreLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OrgLandingPageProps {
|
export interface OrgLandingPageProps {
|
||||||
orgName: string;
|
orgName: string;
|
||||||
logoUrl?: string;
|
logoUrl?: string;
|
||||||
@@ -145,4 +189,6 @@ export interface OrgLandingPageProps {
|
|||||||
callout?: LandingPageCallout;
|
callout?: LandingPageCallout;
|
||||||
links?: LandingPageLinks;
|
links?: LandingPageLinks;
|
||||||
footer: OrgLandingPageFooterConfig;
|
footer: OrgLandingPageFooterConfig;
|
||||||
|
/** Optional UI-chrome string overrides (eyebrows, CTA labels, plural-aware meta). */
|
||||||
|
labels?: OrgLandingPageLabels;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { OrgLandingPageProps, CourseItem } from './types';
|
||||||
|
import OrgLandingPageEmbed from './embed.svelte';
|
||||||
|
import OrgLandingPageLinks from './links.svelte';
|
||||||
|
import OrgLandingPageCallout from './callout.svelte';
|
||||||
|
import OrgLandingPageFooter from './landing-page-footer.svelte';
|
||||||
|
import VibrantNav from './vibrant/nav.svelte';
|
||||||
|
import VibrantHero from './vibrant/hero.svelte';
|
||||||
|
import VibrantCourseCard from './vibrant/course-card.svelte';
|
||||||
|
import { themeStyle } from './theme-style';
|
||||||
|
import { Button } from '../../base/button';
|
||||||
|
import { getCourseTypeLandingMeta, defaultLessonsLabel, defaultExercisesLabel } from './landing-page-utils';
|
||||||
|
|
||||||
|
let {
|
||||||
|
orgName,
|
||||||
|
logoUrl,
|
||||||
|
navItems,
|
||||||
|
authAction,
|
||||||
|
hero,
|
||||||
|
courses,
|
||||||
|
hasMoreCourses = false,
|
||||||
|
disableCourseLinks = false,
|
||||||
|
embed,
|
||||||
|
callout,
|
||||||
|
links,
|
||||||
|
footer,
|
||||||
|
labels
|
||||||
|
}: OrgLandingPageProps = $props();
|
||||||
|
|
||||||
|
const featured = $derived(courses[0]);
|
||||||
|
const featuredTypeMeta = $derived(featured ? getCourseTypeLandingMeta(featured) : undefined);
|
||||||
|
const remainingCourses = $derived(courses.slice(1));
|
||||||
|
|
||||||
|
const resolvedLessonsLabel = $derived(labels?.lessonsLabel ?? defaultLessonsLabel);
|
||||||
|
const resolvedExercisesLabel = $derived(labels?.exercisesLabel ?? defaultExercisesLabel);
|
||||||
|
|
||||||
|
function formatCost(cost?: number, currency = 'USD'): string {
|
||||||
|
if (!cost) return labels?.freeLabel ?? 'Free';
|
||||||
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency, maximumFractionDigits: 0 }).format(cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
function courseHref(course: CourseItem): string | undefined {
|
||||||
|
if (disableCourseLinks) return undefined;
|
||||||
|
return course.link || (course.slug ? `/course/${course.slug}` : undefined);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="ui:min-h-screen ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:font-sans"
|
||||||
|
style={themeStyle('vibrant')}
|
||||||
|
>
|
||||||
|
<main>
|
||||||
|
<VibrantHero {hero} {labels}>
|
||||||
|
{#snippet navigation()}
|
||||||
|
<VibrantNav {orgName} {logoUrl} {navItems} {authAction} />
|
||||||
|
{/snippet}
|
||||||
|
</VibrantHero>
|
||||||
|
|
||||||
|
{#if featured}
|
||||||
|
<section
|
||||||
|
class="ui:bg-[var(--landing-accent)] ui:text-[var(--landing-accent-fg)] ui:px-6 ui:md:px-8 ui:py-24 ui:md:py-32"
|
||||||
|
>
|
||||||
|
<div class="ui:max-w-[1320px] ui:mx-auto">
|
||||||
|
<div class="ui:grid ui:grid-cols-1 ui:lg:grid-cols-[1.15fr_1fr] ui:gap-12 ui:lg:gap-20 ui:items-center">
|
||||||
|
<div>
|
||||||
|
{#if featuredTypeMeta}
|
||||||
|
<span
|
||||||
|
class="ui:inline-flex ui:items-center ui:gap-1.5 ui:rounded-full ui:px-3 ui:py-1 ui:text-[13px] ui:font-medium ui:mb-6 ui:text-[var(--landing-accent-fg)]"
|
||||||
|
style="background: rgba(255,255,255,0.18);"
|
||||||
|
>
|
||||||
|
{labels?.featuredLabel ?? 'Featured'} · {featuredTypeMeta.label}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<h2
|
||||||
|
class="ui:text-4xl ui:md:text-5xl ui:lg:text-[56px] ui:font-medium ui:tracking-tight ui:m-0 ui:mb-6 ui:max-w-[520px]"
|
||||||
|
style="line-height: 1.05; letter-spacing: -0.025em;"
|
||||||
|
>
|
||||||
|
{featured.title}
|
||||||
|
</h2>
|
||||||
|
<p class="ui:text-lg ui:m-0 ui:mb-4 ui:max-w-[480px] ui:opacity-90" style="line-height: 1.5;">
|
||||||
|
{featured.description}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="ui:flex ui:flex-wrap ui:items-center ui:gap-x-5 ui:gap-y-2 ui:text-[14.5px] ui:mb-8 ui:opacity-85"
|
||||||
|
>
|
||||||
|
{#if featured.lessonCount}
|
||||||
|
<span>{resolvedLessonsLabel(featured.lessonCount)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if featured.exerciseCount}
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<span>{resolvedExercisesLabel(featured.exerciseCount)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if featured.duration}
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<span>{featured.duration}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-3">
|
||||||
|
<Button
|
||||||
|
href={courseHref(featured)}
|
||||||
|
disabled={disableCourseLinks}
|
||||||
|
class="ui:rounded-md ui:px-6 ui:py-3 ui:text-base ui:font-medium ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:hover:opacity-90"
|
||||||
|
>
|
||||||
|
{labels?.startCourseLabel ?? 'Start this course'}
|
||||||
|
</Button>
|
||||||
|
<span class="ui:text-base ui:font-medium ui:opacity-95">
|
||||||
|
{featured.price || formatCost(featured.cost, featured.currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="ui:relative ui:overflow-hidden ui:rounded-3xl ui:h-[440px] ui:md:h-[480px]"
|
||||||
|
style="
|
||||||
|
background:
|
||||||
|
radial-gradient(50% 60% at 40% 40%, rgba(255,255,255,0.55) 0%, transparent 70%),
|
||||||
|
radial-gradient(60% 70% at 70% 65%, rgba(255,255,255,0.32) 0%, transparent 70%),
|
||||||
|
radial-gradient(40% 50% at 25% 75%, rgba(255,235,210,0.5) 0%, transparent 70%),
|
||||||
|
linear-gradient(135deg, #b0a8a0 0%, #d4cabd 50%, #c8c0b5 100%);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="ui:absolute ui:left-1/2 ui:top-1/2 ui:w-[80%] ui:overflow-hidden ui:rounded-2xl ui:p-6"
|
||||||
|
style="
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: #0c1320;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 30px 60px -20px rgba(0,0,0,0.35);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<p class="ui:m-0 ui:mb-1 ui:text-[16px] ui:font-semibold">Course outline</p>
|
||||||
|
<p class="ui:m-0 ui:mb-5 ui:text-[13px]" style="color: #8a93a0;">
|
||||||
|
Pick up where you left off — anytime.
|
||||||
|
</p>
|
||||||
|
<div class="ui:flex ui:flex-col ui:gap-2">
|
||||||
|
{#each Array.from({ length: Math.min(featured.lessonCount ?? 4, 4) }) as _, idx (idx)}
|
||||||
|
<div
|
||||||
|
class="ui:flex ui:items-center ui:gap-3 ui:rounded-lg ui:px-3.5 ui:py-3"
|
||||||
|
style="background: #131c2c;"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="ui:inline-flex ui:items-center ui:justify-center ui:size-6 ui:rounded ui:text-[11px] ui:font-semibold ui:tabular-nums"
|
||||||
|
style="background: #2a3548; color: #fff;"
|
||||||
|
>
|
||||||
|
{String(idx + 1).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
<span class="ui:flex-1 ui:text-[13.5px] ui:font-medium" style="color: #fff;">
|
||||||
|
{idx === 0
|
||||||
|
? 'Foundations & setup'
|
||||||
|
: idx === 1
|
||||||
|
? 'Core concepts'
|
||||||
|
: idx === 2
|
||||||
|
? 'Hands-on practice'
|
||||||
|
: 'Capstone project'}
|
||||||
|
</span>
|
||||||
|
<span class="ui:text-[11.5px] ui:tabular-nums" style="color: #6c7585;">
|
||||||
|
{idx === 0 ? '42 min' : idx === 1 ? '1h 12m' : idx === 2 ? '58 min' : '2h 30m'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if remainingCourses.length > 0 || (!featured && courses.length > 0)}
|
||||||
|
{@const catalogCourses = featured ? remainingCourses : courses}
|
||||||
|
<section id="courses" class="ui:px-6 ui:md:px-8 ui:py-24 ui:md:py-28 ui:bg-[var(--landing-bg)]">
|
||||||
|
<div class="ui:max-w-[1320px] ui:mx-auto">
|
||||||
|
<div class="ui:flex ui:flex-col ui:md:flex-row ui:md:items-end ui:justify-between ui:gap-6 ui:mb-12">
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
class="ui:text-[13px] ui:font-medium ui:tracking-[0.08em] ui:uppercase ui:text-[var(--landing-fg-muted)] ui:m-0 ui:mb-3"
|
||||||
|
>
|
||||||
|
{labels?.catalogEyebrow ?? 'Catalog'}
|
||||||
|
</p>
|
||||||
|
<h2
|
||||||
|
class="ui:text-4xl ui:md:text-5xl ui:font-medium ui:tracking-tight ui:m-0 ui:text-[var(--landing-fg)] ui:max-w-2xl"
|
||||||
|
style="line-height: 1.05; letter-spacing: -0.025em;"
|
||||||
|
>
|
||||||
|
{labels?.catalogHeading ?? 'More courses to take next.'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{#if hasMoreCourses}
|
||||||
|
<Button
|
||||||
|
href={disableCourseLinks ? undefined : '/courses'}
|
||||||
|
variant="outline"
|
||||||
|
class="ui:rounded-md ui:px-5 ui:py-2.5 ui:font-medium ui:border-[var(--landing-border)] ui:text-[var(--landing-fg)] ui:hover:bg-[var(--landing-card-soft)]"
|
||||||
|
disabled={disableCourseLinks}
|
||||||
|
>
|
||||||
|
{labels?.browseCoursesLabel ?? 'Browse all →'}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-5">
|
||||||
|
{#each catalogCourses as course, index (course.id)}
|
||||||
|
<VibrantCourseCard {course} {disableCourseLinks} {labels} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<OrgLandingPageLinks {links} {labels} variant="vibrant" />
|
||||||
|
|
||||||
|
<OrgLandingPageEmbed {embed} {labels} variant="vibrant" />
|
||||||
|
|
||||||
|
<OrgLandingPageCallout {callout} {labels} variant="vibrant" />
|
||||||
|
|
||||||
|
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="vibrant" />
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { CourseItem, OrgLandingPageLabels } from '../types';
|
||||||
|
import { getCourseTypeLandingMeta } from '../landing-page-utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
course: CourseItem;
|
||||||
|
disableCourseLinks?: boolean;
|
||||||
|
labels?: OrgLandingPageLabels;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { course, disableCourseLinks = false, labels }: Props = $props();
|
||||||
|
|
||||||
|
const courseTypeMeta = $derived(getCourseTypeLandingMeta(course));
|
||||||
|
|
||||||
|
const href = $derived.by(() => {
|
||||||
|
if (disableCourseLinks) return undefined;
|
||||||
|
return course.link || (course.slug ? `/course/${course.slug}` : undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatCost(cost?: number, currency = 'USD'): string {
|
||||||
|
if (!cost) return labels?.freeLabel ?? 'Free';
|
||||||
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency, maximumFractionDigits: 0 }).format(cost);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a
|
||||||
|
{href}
|
||||||
|
class="ui:group ui:flex ui:flex-col ui:h-full ui:overflow-hidden ui:rounded-[20px] ui:bg-[var(--landing-card-soft)] ui:no-underline ui:transition-colors ui:hover:bg-[var(--landing-card)] {disableCourseLinks
|
||||||
|
? 'ui:cursor-default'
|
||||||
|
: 'ui:cursor-pointer'}"
|
||||||
|
aria-disabled={disableCourseLinks}
|
||||||
|
tabindex={disableCourseLinks ? -1 : undefined}
|
||||||
|
>
|
||||||
|
{#if course.logo}
|
||||||
|
<div
|
||||||
|
class="ui:h-[180px] ui:bg-cover ui:bg-center"
|
||||||
|
style="background-image: url('{course.logo}');"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="ui:h-[180px] ui:relative ui:overflow-hidden"
|
||||||
|
style="
|
||||||
|
background:
|
||||||
|
radial-gradient(60% 80% at 30% 30%, color-mix(in oklab, var(--landing-accent) 40%, white) 0%, transparent 65%),
|
||||||
|
linear-gradient(135deg, color-mix(in oklab, var(--landing-accent) 70%, white) 0%, color-mix(in oklab, var(--landing-accent) 30%, white) 100%);
|
||||||
|
"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="ui:flex ui:flex-col ui:flex-1 ui:p-7">
|
||||||
|
{#if courseTypeMeta}
|
||||||
|
<span
|
||||||
|
class="ui:text-[12px] ui:font-medium ui:tracking-[0.04em] ui:uppercase ui:text-[var(--landing-fg-muted)] ui:mb-3"
|
||||||
|
>
|
||||||
|
{courseTypeMeta.label}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<h3
|
||||||
|
class="ui:text-xl ui:font-semibold ui:tracking-tight ui:m-0 ui:mb-2.5 ui:text-[var(--landing-fg)]"
|
||||||
|
style="letter-spacing: -0.015em;"
|
||||||
|
>
|
||||||
|
{course.title}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
class="ui:text-[15px] ui:text-[var(--landing-fg-muted)] ui:leading-relaxed ui:line-clamp-3 ui:m-0 ui:mb-5 ui:flex-1"
|
||||||
|
>
|
||||||
|
{course.description}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="ui:flex ui:items-center ui:justify-between ui:pt-5 ui:mt-auto"
|
||||||
|
style="border-top: 1px solid var(--landing-border-soft);"
|
||||||
|
>
|
||||||
|
<span class="ui:text-lg ui:font-semibold ui:text-[var(--landing-fg)]" style="letter-spacing: -0.01em;">
|
||||||
|
{course.price || formatCost(course.cost, course.currency)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="ui:inline-flex ui:items-center ui:gap-1 ui:text-sm ui:font-medium ui:text-[var(--landing-accent)] ui:transition-transform ui:group-hover:translate-x-0.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{labels?.enrollLabel ?? 'Enroll'} <span>→</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { OrgLandingPageProps } from '../types';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import SecondaryActionButton from '../secondary-action-button.svelte';
|
||||||
|
import { Button } from '../../../base/button';
|
||||||
|
import EditableLandingSection from '../editable-section.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
hero: OrgLandingPageProps['hero'];
|
||||||
|
labels?: OrgLandingPageProps['labels'];
|
||||||
|
navigation: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { hero, labels, navigation }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{@render navigation()}
|
||||||
|
|
||||||
|
<EditableLandingSection sectionKey="hero">
|
||||||
|
<section
|
||||||
|
class="ui:relative ui:px-6 ui:md:px-8 ui:pt-20 ui:md:pt-24 ui:pb-20 ui:md:pb-24 ui:bg-[var(--landing-bg)] ui:text-center"
|
||||||
|
>
|
||||||
|
<div class="ui:max-w-[1320px] ui:mx-auto">
|
||||||
|
<h1
|
||||||
|
class="ui:text-[64px] ui:md:text-[88px] ui:lg:text-[110px] ui:font-medium ui:tracking-tight ui:text-[var(--landing-fg)] ui:m-0 ui:mb-7 ui:max-w-[1100px] ui:mx-auto ui:text-balance"
|
||||||
|
style="line-height: 0.98; letter-spacing: -0.04em;"
|
||||||
|
>
|
||||||
|
{hero.heading}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p
|
||||||
|
class="ui:text-lg ui:md:text-xl ui:lg:text-[22px] ui:text-[var(--landing-fg-muted)] ui:max-w-[640px] ui:mx-auto ui:m-0 ui:mb-10"
|
||||||
|
style="line-height: 1.4; letter-spacing: -0.012em;"
|
||||||
|
>
|
||||||
|
{hero.subheading}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="ui:inline-flex ui:flex-wrap ui:justify-center ui:items-center ui:gap-3">
|
||||||
|
<Button
|
||||||
|
href={hero.primaryAction.href}
|
||||||
|
disabled={hero.primaryAction.disabled ?? false}
|
||||||
|
class="ui:rounded-md ui:px-7 ui:py-3.5 ui:text-base ui:font-medium ui:bg-[var(--landing-accent)] ui:text-[var(--landing-accent-fg)] ui:hover:opacity-90"
|
||||||
|
>
|
||||||
|
{hero.primaryAction.label}
|
||||||
|
</Button>
|
||||||
|
{#if hero.secondaryAction}
|
||||||
|
<SecondaryActionButton
|
||||||
|
href={hero.secondaryAction.href}
|
||||||
|
label={hero.secondaryAction.label}
|
||||||
|
variant="vibrant"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
class="ui:h-[180px] ui:md:h-[220px]"
|
||||||
|
style="
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
var(--landing-bg) 0%,
|
||||||
|
var(--landing-bg) 18%,
|
||||||
|
color-mix(in oklab, var(--landing-accent) 12%, var(--landing-bg)) 38%,
|
||||||
|
color-mix(in oklab, var(--landing-accent) 40%, var(--landing-bg)) 58%,
|
||||||
|
color-mix(in oklab, var(--landing-accent) 78%, var(--landing-bg)) 78%,
|
||||||
|
var(--landing-accent) 100%
|
||||||
|
);
|
||||||
|
"
|
||||||
|
></div>
|
||||||
|
</EditableLandingSection>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { NavItem } from '../types';
|
||||||
|
import { Button } from '../../../base/button';
|
||||||
|
import EditableLandingSection from '../editable-section.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
orgName: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
navItems: NavItem[];
|
||||||
|
authAction?: {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let { orgName, logoUrl, navItems, authAction }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<EditableLandingSection sectionKey="navigation">
|
||||||
|
<header class="ui:sticky ui:top-0 ui:z-50 ui:bg-[var(--landing-bg)]">
|
||||||
|
<div class="ui:max-w-[1320px] ui:mx-auto ui:px-6 ui:md:px-8">
|
||||||
|
<div class="ui:flex ui:items-center ui:justify-between ui:h-[72px] ui:gap-6">
|
||||||
|
<div class="ui:flex ui:items-center ui:gap-8">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="ui:inline-flex ui:items-center ui:gap-2 ui:font-semibold ui:text-[22px] ui:tracking-tight ui:text-[var(--landing-fg)] ui:no-underline ui:cursor-pointer"
|
||||||
|
>
|
||||||
|
{#if logoUrl}
|
||||||
|
<img src={logoUrl} alt={orgName} class="ui:h-7 ui:w-auto" />
|
||||||
|
{:else}
|
||||||
|
<span class="ui:relative ui:inline-block ui:size-[22px]" aria-hidden="true">
|
||||||
|
<span
|
||||||
|
class="ui:absolute ui:left-0 ui:top-0 ui:w-[10px] ui:h-[22px] ui:rounded-[3px] ui:bg-[var(--landing-accent)]"
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
class="ui:absolute ui:right-0 ui:top-[6px] ui:w-[10px] ui:h-[10px] ui:rounded-[3px] ui:bg-[var(--landing-accent)]"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span>{orgName}</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{#if navItems.length > 0}
|
||||||
|
<nav class="ui:hidden ui:md:flex ui:gap-1 ui:items-center">
|
||||||
|
{#each navItems as item (item.href + item.label)}
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
class="ui:inline-flex ui:items-center ui:gap-1 ui:px-3.5 ui:py-2 ui:text-[15px] ui:font-medium ui:text-[var(--landing-fg)] ui:rounded-md ui:transition-colors ui:hover:bg-[var(--landing-card-soft)] ui:no-underline ui:cursor-pointer"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if authAction}
|
||||||
|
<div class="ui:flex ui:items-center ui:gap-4">
|
||||||
|
<Button
|
||||||
|
href={authAction.href}
|
||||||
|
size="sm"
|
||||||
|
class="ui:rounded-md ui:px-4 ui:font-medium ui:bg-[var(--landing-accent)] ui:text-[var(--landing-accent-fg)] ui:hover:opacity-90"
|
||||||
|
>
|
||||||
|
{authAction.label}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</EditableLandingSection>
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
const cssVars = $derived(payload ? buildCssVarsFromDesign(payload.design) : '');
|
const cssVars = $derived(payload ? buildCssVarsFromDesign(payload.design) : '');
|
||||||
const poweredByHref = $derived(payload ? buildPoweredByMarketingUrl(payload) : '');
|
const poweredByHref = $derived(payload ? buildPoweredByMarketingUrl(payload) : '');
|
||||||
|
const customCss = $derived(payload?.design.advanced.customCss?.trim() ?? '');
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!payload || !rootEl) return;
|
if (!payload || !rootEl) return;
|
||||||
@@ -27,6 +28,9 @@
|
|||||||
<div class="cio-widget cio-widget--empty">No widget payload provided.</div>
|
<div class="cio-widget cio-widget--empty">No widget payload provided.</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div bind:this={rootEl} class="cio-widget" style={cssVars} data-cio-layout={payload.layoutType}>
|
<div bind:this={rootEl} class="cio-widget" style={cssVars} data-cio-layout={payload.layoutType}>
|
||||||
|
{#if customCss}
|
||||||
|
<svelte:element this={'style'} data-cio-widget-custom-css="">{customCss}</svelte:element>
|
||||||
|
{/if}
|
||||||
{#if payload.layoutType === 'card_grid'}
|
{#if payload.layoutType === 'card_grid'}
|
||||||
<CardGrid {payload} />
|
<CardGrid {payload} />
|
||||||
{:else if payload.layoutType === 'tag_filter'}
|
{:else if payload.layoutType === 'tag_filter'}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user