feat: preview templates, thumbnail features, 2 new org templates

This commit is contained in:
Rotimi Best
2026-05-23 12:24:57 +01:00
parent 4fdd4115b9
commit 78ab13f54b
106 changed files with 8320 additions and 2040 deletions
@@ -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 { goto } from '$app/navigation';
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import EyeIcon from '@lucide/svelte/icons/eye';
import LayoutTemplateIcon from '@lucide/svelte/icons/layout-template';
import PaintbrushIcon from '@lucide/svelte/icons/paintbrush';
import ZapIcon from '@lucide/svelte/icons/zap';
import { currentOrg, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
@@ -10,12 +13,19 @@
import { t } from '$lib/utils/functions/translations';
import { user } from '$lib/utils/store/user';
import { basePath } from '$lib/utils/store/app';
import type { AccountOrg } from '$features/app/types';
import { orgApi } from '$features/org/api/org.svelte';
import { snackbar } from '$features/ui/snackbar/store';
import { Button } from '@cio/ui/base/button';
import { Badge } from '@cio/ui/base/badge';
import * as Card from '@cio/ui/base/card';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import { IconButton } from '@cio/ui/custom/icon-button';
import { PremiumIcon } from '@cio/ui/custom/moving-icons';
import ThemePreviewDialog from '$features/settings/components/theme-preview-dialog.svelte';
import {
createDefaultLandingPageSettings,
landingPageThemes,
@@ -86,6 +96,18 @@
preview: '/templates/terminal.png',
titleKey: 'settings.landing_page.theme.cards.terminal.title',
descriptionKey: 'settings.landing_page.theme.cards.terminal.description'
},
{
value: 'editorial',
preview: '/templates/editorial.png',
titleKey: 'settings.landing_page.theme.cards.editorial.title',
descriptionKey: 'settings.landing_page.theme.cards.editorial.description'
},
{
value: 'vibrant',
preview: '/templates/vibrant.png',
titleKey: 'settings.landing_page.theme.cards.vibrant.title',
descriptionKey: 'settings.landing_page.theme.cards.vibrant.description'
}
] as const satisfies ReadonlyArray<{
value: LandingPageTheme;
@@ -125,6 +147,37 @@
goto(resolve(`${$currentOrgPath}/settings/landingpage/edit?theme=${theme}`, {}));
}
async function handleApplyTheme(theme: LandingPageTheme) {
if (!$currentOrg.id) return;
if ($isFreePlan && isPaidTheme(theme)) {
openUpgradeModal();
return;
}
const updatedLandingPage = normalizeLandingPageSettings({
...normalized,
theme
});
await orgApi.update($currentOrg.id, {
landingpage: updatedLandingPage as AccountOrg['landingpage']
});
normalized = updatedLandingPage;
snackbar.success('snackbar.success_update');
}
function handlePreviewTheme(theme: LandingPageTheme) {
const nextUrl = new URL(window.location.href);
nextUrl.searchParams.set('preview', theme);
goto(`${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`, {
replaceState: false,
keepFocus: true,
noScroll: true
});
}
const navCount = $derived(normalized.navItems?.length ?? 0);
const heroHeading = $derived(normalized.hero?.heading?.trim() || t.get('settings.landing_page.current.hero_empty'));
const navCountLabel = $derived(
@@ -227,12 +280,31 @@
<div class="ui:border-border bg-background relative overflow-hidden rounded-xl border">
{#if isLocked}
<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" />
{$t('settings.landing_page.gallery.upgrade')}
</div>
{/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">
<img
src={themeCard.preview}
@@ -257,3 +329,5 @@
</div>
</section>
</div>
<ThemePreviewDialog />