From 4b08ba0a340d83e77cd1b9db2525bb1cf128bbf0 Mon Sep 17 00:00:00 2001 From: Jed Esposito Date: Mon, 30 Mar 2026 14:51:54 +1300 Subject: [PATCH 1/7] =?UTF-8?q?fix:=20full=20port=20of=20cloud=20SSR/navig?= =?UTF-8?q?ation=20files=20=E2=80=94=20exact=20match?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direct copy of all SSR and navigation files from facets-sh cloud. Only difference: no courses link, no hasFeature import. Build verified clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/components/public/SiteNav.svelte | 3 +- frontend/src/params/slug.ts | 9 ++- frontend/src/routes/+layout.server.ts | 23 +++++- frontend/src/routes/+layout.svelte | 71 +++++++++++----- frontend/src/routes/+page.server.ts | 81 +++++++++---------- .../src/routes/[slug=slug]/+page.server.ts | 5 +- 6 files changed, 122 insertions(+), 70 deletions(-) diff --git a/frontend/src/components/public/SiteNav.svelte b/frontend/src/components/public/SiteNav.svelte index 4ca230a9..ab0c552f 100644 --- a/frontend/src/components/public/SiteNav.svelte +++ b/frontend/src/components/public/SiteNav.svelte @@ -171,7 +171,8 @@ {item.label} {/each} - + + {/if} diff --git a/frontend/src/params/slug.ts b/frontend/src/params/slug.ts index 562d8550..1b313519 100644 --- a/frontend/src/params/slug.ts +++ b/frontend/src/params/slug.ts @@ -28,6 +28,7 @@ const RESERVED_SLUGS = new Set([ 'projects', 'posts', 'talks', + 'courses', // SvelteKit internal '_app', '_', @@ -48,11 +49,17 @@ const RESERVED_SLUGS = new Set([ 'auth', 'oauth', 'callback', + // Public pages + 'unsubscribe', + 'testimonial', // Prevent confusion 'home', 'index', 'default', - 'profile' + 'profile', + // Product terminology + 'facet', + 'facets' ]); export const match: ParamMatcher = (param) => { diff --git a/frontend/src/routes/+layout.server.ts b/frontend/src/routes/+layout.server.ts index 84a8b878..04545647 100644 --- a/frontend/src/routes/+layout.server.ts +++ b/frontend/src/routes/+layout.server.ts @@ -1,3 +1,12 @@ +/** + * Root layout server load: Preload plan config server-side to eliminate FOUC + * + * Fetches plan configuration from PocketBase before first render, ensuring + * the "Powered by Facet" badge visibility state is correct on initial paint. + * + * Pro/Creator users (managed, !badge_forced) will never see the badge flash. + */ + import type { LayoutServerLoad } from './$types'; import type { PlanConfig } from '$lib/stores/plan'; import { logger } from '$lib/logger'; @@ -23,7 +32,7 @@ export const load: LayoutServerLoad = async ({ fetch }) => { logger.debug('[LAYOUT SSR] Failed to load site settings:', error); } - // Fetch plan config server-side (FOUC fix) + // Fetch plan config server-side (new functionality - FOUC fix) let planConfig: PlanConfig | null = null; try { const planResponse = await fetch(`${pbUrl}/api/plan`, { @@ -31,11 +40,19 @@ export const load: LayoutServerLoad = async ({ fetch }) => { }); if (planResponse.ok) { planConfig = await planResponse.json(); + logger.debug('[LAYOUT SSR] Loaded plan config:', planConfig?.plan); + } else { + logger.debug('[LAYOUT SSR] Plan API returned non-OK status:', planResponse.status); } } catch (error) { logger.debug('[LAYOUT SSR] Failed to load plan config:', error); } + // Pass APP_URL to the client for canonical/OG URLs. + // SSR $page.url.origin reflects the internal HTTP origin (behind Caddy proxy), + // not the public HTTPS URL. APP_URL is authoritative. + const appUrl = process.env.APP_URL || ''; + // Fetch site-nav server-side to eliminate nav pop-in on page load let siteNav = { enabled: false, items: [] as Array<{ viewId: string; slug: string; label: string; name: string }> }; try { @@ -45,6 +62,9 @@ export const load: LayoutServerLoad = async ({ fetch }) => { if (navResponse.ok) { const navData = await navResponse.json(); siteNav = { enabled: navData.enabled === true, items: navData.items || [] }; + logger.debug('[LAYOUT SSR] Loaded site nav:', siteNav.enabled, siteNav.items.length, 'items'); + } else { + logger.debug('[LAYOUT SSR] Site nav API returned:', navResponse.status); } } catch (error) { logger.debug('[LAYOUT SSR] Failed to load site nav:', error); @@ -71,6 +91,7 @@ export const load: LayoutServerLoad = async ({ fetch }) => { return { faviconUrl, planConfig, + appUrl, siteNav, accentColor, customHexColor, diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 45d57949..5615b17b 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,6 +1,4 @@ %sveltekit.head% From eb31296e7223840746527ed6edc6b868b9fac9cb Mon Sep 17 00:00:00 2001 From: Jed Esposito Date: Mon, 30 Mar 2026 15:13:17 +1300 Subject: [PATCH 3/7] fix: prevent Cloudflare email obfuscation from breaking hydration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloudflare's Email Address Obfuscation rewrites email addresses in SSR HTML, causing a Svelte hydration mismatch (server HTML ≠ client DOM). This corrupts SvelteKit's client router, causing 404 flashes and WelcomePage on rapid SPA navigation. Fix: wrap ATS content emails with comments, which Cloudflare respects and skips. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/components/public/ATSContent.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/public/ATSContent.svelte b/frontend/src/components/public/ATSContent.svelte index 301de31d..08ee9e94 100644 --- a/frontend/src/components/public/ATSContent.svelte +++ b/frontend/src/components/public/ATSContent.svelte @@ -84,7 +84,7 @@

Location: {profile.location}

{/if} {#if emailContact} -

Email: {emailContact.value}

+

Email: {emailContact.value}

{/if} {#if phoneContact}

Phone: {phoneContact.value}

@@ -99,7 +99,7 @@

Website: {websiteContact.value}

{/if} {#if !hasAnyContact && profile?.contact_email} -

Email: {profile.contact_email}

+

Email: {profile.contact_email}

{/if} From 8d9a0622cccb45a28f1768a2f9d2fea0ec3b6a42 Mon Sep 17 00:00:00 2001 From: Jed Esposito Date: Mon, 30 Mar 2026 15:37:05 +1300 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20align=20Caddyfile=20with=20cloud=20?= =?UTF-8?q?=E2=80=94=20cache=20control,=20CSP,=20immutable=20assets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-hosted Caddyfile was missing critical headers that cloud has: - Cache-Control "no-store, no-cache" on /api/* (prevents stale cached responses) - Cache-Control "immutable" on /_app/immutable/* (proper asset caching) - Cache-Control on /api/files/* (uploaded content caching) - CSP header (XSS protection) - HSTS header - Request body size limit (25MB) - X-Internal header stripping (prevents spoofing) - Maintenance mode support Missing cache-control on API routes likely caused Cloudflare or browser to cache __data.json responses, returning stale data during rapid SPA navigation → 404 error page. Co-Authored-By: Claude Opus 4.6 (1M context) --- docker/Caddyfile | 102 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 99 insertions(+), 3 deletions(-) diff --git a/docker/Caddyfile b/docker/Caddyfile index b602be8f..0d59231c 100644 --- a/docker/Caddyfile +++ b/docker/Caddyfile @@ -21,18 +21,108 @@ # Disable browser features not used by Facet Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" + # HSTS - safe because all traffic goes through Cloudflare (TLS-terminated at edge) + Strict-Transport-Security "max-age=31536000; includeSubDomains" + # Remove server identification -Server + # Content Security Policy (enforcing) + # Restricts script/style/frame sources to mitigate XSS. + # 'unsafe-inline' kept for scripts/styles due to Svelte's inline injection pattern. + Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; frame-src https://www.youtube.com https://www.youtube-nocookie.com https://player.vimeo.com https://www.loom.com https://w.soundcloud.com https://open.spotify.com https://codepen.io https://www.figma.com; connect-src 'self'; media-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'" + # INTENTIONALLY OMITTED: # - X-XSS-Protection: Deprecated since 2023, can cause vulnerabilities - # - HSTS: TLS terminates at edge proxy (Cloudflare), not here # - COOP/COEP/CORP: Phase 5B - requires testing with frontend - # - CSP: Phase 5B - requires report-only rollout first + # - CSP nonce-based: Would allow removing 'unsafe-inline'; requires SSR nonce injection + } + + # ========================================================================= + # Maintenance mode: serve a branded 503 page for all requests except /api/health + # Activated by setting FACET_MAINTENANCE_MODE=true + # ========================================================================= + @maintenance expression `{env.FACET_MAINTENANCE_MODE} == "true"` + @not_health not path /api/health + + handle @maintenance { + handle @not_health { + header Content-Type "text/html; charset=utf-8" + respond 503 < + + + + + Maintenance — Facet + + + +
+ +

We'll be right back

+

We're performing scheduled maintenance. Your data is safe and we'll be back shortly.

+
+ + +HTML + } + } + + # Request body size limit + request_body { + max_size 25MB + } + + # Uploaded files (images, etc.) — cacheable, served by PocketBase + handle /api/files/* { + header Cache-Control "public, max-age=86400" + reverse_proxy localhost:8090 } - # API and PocketBase routes go to backend + # API and PocketBase routes go to backend (no caching) handle /api/* { + header Cache-Control "no-store, no-cache" + # Strip X-Internal header from external requests to prevent spoofing. + # SSR bypasses Caddy (goes direct to :8090), so legitimate internal requests are unaffected. + request_header -X-Internal reverse_proxy localhost:8090 } @@ -64,6 +154,12 @@ reverse_proxy localhost:8090 } + # SvelteKit immutable assets (content-hashed filenames, safe to cache forever) + handle /_app/immutable/* { + header Cache-Control "public, max-age=31536000, immutable" + reverse_proxy localhost:3000 + } + # Everything else goes to SvelteKit frontend handle { reverse_proxy localhost:3000 From ab38d74bc31930b39abd224bc2eaef41f548c91a Mon Sep 17 00:00:00 2001 From: Jed Esposito Date: Mon, 30 Mar 2026 15:50:10 +1300 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20copy=20EXACT=20+page.svelte=20files?= =?UTF-8?q?=20from=20cloud=20=E2=80=94=20was=20still=20using=20old=20Facet?= =?UTF-8?q?=20versions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The +page.svelte files had 179 lines of differences from cloud despite earlier claims of being "identical." Copied the actual cloud files including CoursesSection and NewsletterSection components. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/public/CoursesSection.svelte | 587 ++++++++++++++++++ .../public/NewsletterSection.svelte | 286 +++++++++ frontend/src/routes/+page.svelte | 179 ++++-- frontend/src/routes/[slug=slug]/+page.svelte | 170 +++-- 4 files changed, 1143 insertions(+), 79 deletions(-) create mode 100644 frontend/src/components/public/CoursesSection.svelte create mode 100644 frontend/src/components/public/NewsletterSection.svelte diff --git a/frontend/src/components/public/CoursesSection.svelte b/frontend/src/components/public/CoursesSection.svelte new file mode 100644 index 00000000..bb524164 --- /dev/null +++ b/frontend/src/components/public/CoursesSection.svelte @@ -0,0 +1,587 @@ + + +
+ {#if showHeader} +

{$t('public.sections.courses')}

+ {/if} + + {#if layout === 'featured' && items.length > 0} + +
+ +
+
+ + + + +
+

+ + {featuredCourse.title} + +

+ + {#if featuredCourse.description} +
+ {@html parseMarkdown(featuredCourse.description)} +
+ {/if} + + +
+ {#if featuredCourse.difficulty} + + {formatDifficulty(featuredCourse.difficulty)} + + {/if} + {#if featuredCourse.estimated_hours} + + + {formatDuration(featuredCourse.estimated_hours)} + + {/if} + {#if featuredCourse.total_lessons} + + + {$t('public.courses.lessons_count', { values: { count: featuredCourse.total_lessons } })} + + {/if} +
+ + + +
+
+
+ + + {#if remainingCourses.length > 0} +
+ {#each remainingCourses as course, index (course.id)} +
+ + {#if coverImageUrl(course)} + + {course.title} +
+ + {#if course.is_gated && !course.is_purchased && course.price} +
+ + ${(course.price / 100).toFixed(2)} +
+ {:else if course.access_tier === 'free'} +
+ {$t('public.courses.free')} +
+ {/if} + + {#if course.show_progress_bar !== false && course.enable_progress !== false && course.is_purchased && course.progress_percent > 0 && course.progress_percent < 100} +
+
+
+ {#if course.lessons_completed && course.total_lessons} +
+ {$t('public.courses.progress_count', { values: { completed: course.lessons_completed, total: course.total_lessons } })} +
+ {/if} + {/if} +
+ {:else} +
+ + {#if course.is_gated && !course.is_purchased && course.price} +
+ + ${(course.price / 100).toFixed(2)} +
+ {:else if course.access_tier === 'free'} +
+ {$t('public.courses.free')} +
+ {/if} +
+ {/if} + + +
+

+ + {course.title} + +

+ + {#if course.description} +
+ {@html parseMarkdown(course.description)} +
+ {/if} + + +
+ {#if course.difficulty} + + {formatDifficulty(course.difficulty)} + + {/if} + {#if course.estimated_hours} + + + {formatDuration(course.estimated_hours)} + + {/if} + {#if course.total_lessons} + + + {$t('public.courses.lessons_count', { values: { count: course.total_lessons } })} + + {/if} +
+ + + +
+
+ {/each} +
+ {/if} +
+ {:else if layout === 'list'} + +
+ {#each items as course, index (course.id)} +
+ + {#if coverImageUrl(course)} + + {course.title} +
+ + {#if course.is_gated && !course.is_purchased && course.price} +
+ + ${(course.price / 100).toFixed(2)} +
+ {:else if course.access_tier === 'free'} +
+ {$t('public.courses.free')} +
+ {/if} + + {#if course.show_progress_bar !== false && course.enable_progress !== false && course.is_purchased && course.progress_percent > 0 && course.progress_percent < 100} +
+
+
+ {#if course.lessons_completed && course.total_lessons} +
+ {$t('public.courses.progress_count', { values: { completed: course.lessons_completed, total: course.total_lessons } })} +
+ {/if} + {/if} +
+ {:else} +
+ + {#if course.is_gated && !course.is_purchased && course.price} +
+ + ${(course.price / 100).toFixed(2)} +
+ {:else if course.access_tier === 'free'} +
+ {$t('public.courses.free')} +
+ {/if} +
+ {/if} + + +
+

+ + {course.title} + +

+ + {#if course.description} +
+ {@html parseMarkdown(course.description)} +
+ {/if} + + +
+ {#if course.difficulty} + + {formatDifficulty(course.difficulty)} + + {/if} + {#if course.estimated_hours} + + + {formatDuration(course.estimated_hours)} + + {/if} + {#if course.total_lessons} + + + {$t('public.courses.lessons_count', { values: { count: course.total_lessons } })} + + {/if} +
+ + + +
+
+ {/each} +
+ {:else} + +
+ {#each items as course, index (course.id)} +
+ + {#if coverImageUrl(course)} + + {course.title} + +
+ + + {#if course.is_gated && !course.is_purchased && course.price} +
+ + ${(course.price / 100).toFixed(2)} +
+ {:else if course.access_tier === 'free'} +
+ {$t('public.courses.free')} +
+ {/if} + + + {#if course.show_progress_bar !== false && course.enable_progress !== false && course.is_purchased && course.progress_percent > 0 && course.progress_percent < 100} +
+
+
+ {#if course.lessons_completed && course.total_lessons} +
+ {$t('public.courses.progress_count', { values: { completed: course.lessons_completed, total: course.total_lessons } })} +
+ {/if} + {/if} +
+ {:else} + +
+ + {#if course.is_gated && !course.is_purchased && course.price} +
+ + ${(course.price / 100).toFixed(2)} +
+ {:else if course.access_tier === 'free'} +
+ {$t('public.courses.free')} +
+ {/if} +
+ {/if} + + +
+

+ + {course.title} + +

+ + {#if course.description} +
+ {@html parseMarkdown(course.description)} +
+ {/if} + + +
+ {#if course.difficulty} + + {formatDifficulty(course.difficulty)} + + {/if} + {#if course.estimated_hours} + + + {formatDuration(course.estimated_hours)} + + {/if} + {#if course.total_lessons} + + + {$t('public.courses.lessons_count', { values: { count: course.total_lessons } })} + + {/if} +
+ + + +
+
+ {/each} +
+ {/if} +
diff --git a/frontend/src/components/public/NewsletterSection.svelte b/frontend/src/components/public/NewsletterSection.svelte new file mode 100644 index 00000000..430f671d --- /dev/null +++ b/frontend/src/components/public/NewsletterSection.svelte @@ -0,0 +1,286 @@ + + +
+ +
+ + + +
+ +
+ + + +
+ +

{$t('public.sections.newsletter_heading')}

+

{$t('public.sections.newsletter_description')}

+ + {#if status === 'success'} +
+
+ +
+ {$t('public.sections.newsletter_success')} +
+ {:else} +
+ +
+
+ +
+
+ +
+
+ + + + {#if errorMessage} + + {/if} + + + {#if siteKey} + + {/if} +
+ {/if} +
+
+
+ + diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 9aff7fd8..25ef7762 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -4,8 +4,9 @@ import type { PageData } from './$types'; import { onMount } from 'svelte'; import { browser } from '$app/environment'; - import { navigating } from '$app/stores'; + import { navigating, page } from '$app/stores'; import { t } from 'svelte-i18n'; + import { brandName, isDemoMode } from '$lib/stores/plan'; import ProfileHero from '$components/public/ProfileHero.svelte'; import ProfileNav from '$components/public/ProfileNav.svelte'; import ExperienceSection from '$components/public/ExperienceSection.svelte'; @@ -16,6 +17,7 @@ import SkillsSection from '$components/public/SkillsSection.svelte'; import PostsSection from '$components/public/PostsSection.svelte'; import TalksSection from '$components/public/TalksSection.svelte'; + import CoursesSection from '$components/public/CoursesSection.svelte'; import TestimonialsSection from '$components/public/TestimonialsSection.svelte'; import ContactMethodsList from '$components/public/ContactMethodsList.svelte'; import CustomContentSection from '$components/public/CustomContentSection.svelte'; @@ -26,7 +28,7 @@ import WelcomePage from '$components/public/WelcomePage.svelte'; import { ACCENT_COLORS, type AccentColor } from '$lib/colors'; import { getFontPack, DEFAULT_FONT_PACK } from '$lib/fonts'; - import { pb, currentUser, performLogout } from '$lib/pocketbase'; + import { pb, currentUser, performLogout, getSectionLayout as getDefaultSectionLayout } from '$lib/pocketbase'; import { generatePersonJsonLd, generateWebSiteJsonLd, serializeJsonLd, getCanonicalUrl, generateOpenGraphTags, type OpenGraphData } from '$lib/seo'; import { goto } from '$app/navigation'; @@ -38,7 +40,7 @@ // causing a brief frame where !data.profile && !isNavigating is true. let hasHydratedData = $state(false); - const DEFAULT_SECTION_ORDER = ['experience', 'projects', 'education', 'certifications', 'awards', 'skills', 'posts', 'talks', 'testimonials', 'contacts']; + const DEFAULT_SECTION_ORDER = ['experience', 'projects', 'education', 'certifications', 'awards', 'skills', 'posts', 'talks', 'courses', 'testimonials', 'contacts']; interface Props { data: PageData; @@ -59,7 +61,8 @@ let location = $derived(data.view?.hero_location || data.profile?.location); // Generate JSON-LD and Open Graph for SEO - let baseUrl = $derived(browser ? window.location.origin : 'http://localhost:8080'); + // Use APP_URL from server for correct https:// canonical URLs behind reverse proxy + let baseUrl = $derived($page.data.appUrl || $page.url.origin); let personJsonLd = $derived(data.profile ? serializeJsonLd(generatePersonJsonLd(data.profile, baseUrl)) : null); let websiteJsonLd = $derived(data.profile ? serializeJsonLd(generateWebSiteJsonLd(data.profile, baseUrl)) : null); let canonicalUrl = $derived(getCanonicalUrl(baseUrl, '')); @@ -95,6 +98,7 @@ case 'skills': return data.skills?.length > 0; case 'posts': return data.posts?.length > 0; case 'talks': return data.talks?.length > 0; + case 'courses': return data.courses?.length > 0; case 'testimonials': return data.testimonials?.length > 0; case 'contacts': return data.contacts?.length > 0; default: return false; @@ -151,7 +155,8 @@ if (data.homepageSections?.[sectionKey]?.layout) { return data.homepageSections[sectionKey].layout; } - return 'default'; + // Use the section-specific default (e.g. 'wall' for testimonials, 'grouped' for certifications) + return getDefaultSectionLayout(sectionKey); } type ContactLayoutType = 'vertical' | 'horizontal' | 'grid'; @@ -209,7 +214,8 @@ } function getFeaturedId(sectionKey: string): string | undefined { - return data.sectionFeaturedIds?.[sectionKey]; + const val = data.sectionFeaturedIds?.[sectionKey]; + return typeof val === 'string' ? val : undefined; } function getWidthClass(width: string): string { @@ -224,6 +230,9 @@ let showPrintMenu = $state(false); let showGenerateModal = $state(false); let generating = $state(false); + let modalEl: HTMLDivElement | undefined = $state(); + let printMenuTriggerEl: HTMLButtonElement | undefined = $state(); + let previousActiveElement: HTMLElement | null = $state(null); let aiPrintStatus = $state({ available: false, ai_configured: false, @@ -240,7 +249,7 @@ // Floating buttons visibility - hide when nav is pinned (sticky) let navPinned = $state(false); - let sentinelEl: HTMLDivElement | null = null; + let sentinelEl: HTMLDivElement | null = $state(null); // Apply view-specific accent color if default view has one function applyAccentColor(colorName: AccentColor) { @@ -401,6 +410,72 @@ showPrintMenu = false; } + function closeGenerateModal() { + showGenerateModal = false; + generatedUrl = null; + // Restore focus to the element that triggered the modal + if (previousActiveElement && typeof previousActiveElement.focus === 'function') { + previousActiveElement.focus(); + } + } + + // Modal focus trap, Escape key, and body scroll lock + function handleModalKeydown(event: KeyboardEvent) { + if (!showGenerateModal) return; + + if (event.key === 'Escape') { + event.preventDefault(); + closeGenerateModal(); + return; + } + + if (event.key === 'Tab') { + const focusableElements = modalEl?.querySelectorAll( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + ); + if (!focusableElements?.length) return; + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + if (event.shiftKey && document.activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + } else if (!event.shiftKey && document.activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + } + } + + // Lock body scroll and manage focus when modal opens/closes + $effect(() => { + if (typeof document === 'undefined') return; + if (showGenerateModal) { + previousActiveElement = document.activeElement as HTMLElement; + document.body.style.overflow = 'hidden'; + // Focus the first focusable element in the modal after render + requestAnimationFrame(() => { + const firstFocusable = modalEl?.querySelector( + 'select, button:not([disabled]), [href], input:not([disabled])' + ); + firstFocusable?.focus(); + }); + } else { + document.body.style.overflow = ''; + } + }); + + // Print menu keyboard handler + function handlePrintMenuKeydown(event: KeyboardEvent) { + if (!showPrintMenu) return; + if (event.key === 'Escape') { + event.preventDefault(); + closePrintMenu(); + printMenuTriggerEl?.focus(); + } + } + async function handleLogout() { await performLogout(); window.location.href = '/admin/login'; @@ -408,7 +483,7 @@ - {data.profile?.name || 'Profile'} | Facet + {data.profile?.name || 'Profile'} | {$brandName} @@ -430,20 +505,20 @@ {#if data.homepageDisabled} -
+
-
-

{$t('public.homepage.profile_private')}

-

{landingMessage}

-

+

+

{$t('public.homepage.profile_private')}

+

{landingMessage}

+

{$t('public.homepage.views_accessible')}

{:else if !data.profile && !data.experience?.length && !data.projects?.length && !isNavigating && !hasHydratedData} - + {:else}
@@ -455,26 +530,28 @@
{#if showPrintMenu} - -
-
+ + {#if showGenerateModal} - -
showGenerateModal = false)}> -
-
-

{$t('public.ai_resume.title')}

-

+