feat: add new contact form, hero, features, and richtext blocks; implement scroll-reveal action and update styles

- Introduced ContactFormBlock, FeaturesBlock, HeroBlock, and RichtextBlock components.
- Implemented a scroll-reveal action for animations on element visibility.
- Enhanced CSS styles for better theming and prose formatting.
- Added localization support for new components and updated existing translations.
- Created e2e tests for demo pages including contact form validation and navigation.
- Added a video tour showcasing the demo pages and interactions.
This commit is contained in:
2026-02-26 03:54:07 +00:00
parent e8fd38e98a
commit 40ffa8207e
27 changed files with 2009 additions and 98 deletions

View File

@@ -1,11 +1,14 @@
<script lang="ts">
import { metricCall } from "./config"
import { location } from "./lib/store"
import { location, mobileMenuOpen, currentContentEntry } from "./lib/store"
import { _, locale } from "./lib/i18n/index"
import LoadingBar from "./widgets/LoadingBar.svelte"
import ToastContainer from "./widgets/ToastContainer.svelte"
import DebugFooterInfo from "./widgets/DebugFooterInfo.svelte"
import BlockRenderer from "./blocks/BlockRenderer.svelte"
import NotFound from "./blocks/NotFound.svelte"
import { initScrollRestoration } from "./lib/navigation"
import { getCachedEntries } from "./lib/api"
import {
SUPPORTED_LANGUAGES,
LANGUAGE_LABELS,
@@ -15,8 +18,9 @@
getBrowserLanguage,
extractLanguageFromPath,
DEFAULT_LANGUAGE,
getRoutePath,
stripLanguageFromPath,
} from "./lib/i18n"
export let url = ""
initScrollRestoration()
@@ -31,7 +35,6 @@
push: false,
pop: false,
}
// Set svelte-i18n locale from URL for SSR rendering
const lang = extractLanguageFromPath(l[0]) || DEFAULT_LANGUAGE
$locale = lang
}
@@ -58,63 +61,278 @@
"x-ssr-skip": "204",
"x-ssr-ref": ref,
"x-ssr-res": `${window.innerWidth}x${window.innerHeight}`,
// no cache
"cache-control": "no-cache, no-store, must-revalidate",
},
})
}
})
// ── Content loading ──────────────────────────────────────────
let contentEntry = $state<ContentEntry | null>(null)
let headerNav = $state<NavigationEntry | null>(null)
let footerNav = $state<NavigationEntry | null>(null)
let loading = $state(true)
let notFound = $state(false)
// Header scroll detection
let scrolled = $state(false)
if (typeof window !== "undefined") {
window.addEventListener(
"scroll",
() => {
scrolled = window.scrollY > 20
},
{ passive: true }
)
}
// Close mobile menu on navigation
$effect(() => {
$location.path // subscribe
$mobileMenuOpen = false
})
async function loadContent(lang: string, routePath: string) {
loading = true
notFound = false
contentEntry = null
try {
// Load navigation
const [headerEntries, footerEntries] = await Promise.all([
getCachedEntries<"navigation">("navigation", { type: "header", language: lang }),
getCachedEntries<"navigation">("navigation", { type: "footer", language: lang }),
])
headerNav = headerEntries[0] || null
footerNav = footerEntries[0] || null
// Load content for current path
const contentEntries = await getCachedEntries<"content">("content", {
lang,
path: routePath,
active: true,
})
if (contentEntries.length > 0) {
contentEntry = contentEntries[0]
$currentContentEntry = {
translationKey: contentEntry.translationKey,
lang: contentEntry.lang,
path: contentEntry.path,
}
} else {
notFound = true
}
} catch (err) {
console.error("[App] Failed to load content:", err)
notFound = true
}
loading = false
}
// Re-load content when path or language changes
$effect(() => {
const lang = $currentLanguage
const routePath = stripLanguageFromPath($location.path)
loadContent(lang, routePath || "/")
})
</script>
<LoadingBar />
<ToastContainer />
<header class="text-white p-4 bg-red-900">
<div class="container mx-auto flex flex-wrap items-center justify-between gap-2">
<a href={localizedPath("/")} class="text-xl font-bold shrink-0">Tibi Svelte Starter</a>
<nav class="flex items-center gap-4">
<ul class="hidden sm:flex space-x-4">
<li><a href={localizedPath("/")} class="hover:underline">{$_("nav.home")}</a></li>
<li><a href={localizedPath("/about")} class="hover:underline">{$_("nav.about")}</a></li>
<li><a href={localizedPath("/contact")} class="hover:underline">{$_("nav.contact")}</a></li>
</ul>
<div class="flex space-x-2 text-sm sm:border-l sm:border-white/30 sm:pl-4">
{#each SUPPORTED_LANGUAGES as lang}
<!-- ── Sticky Header with glassmorphism ──────────────────────── -->
<header
class="fixed top-0 left-0 right-0 z-50 transition-all duration-300 {scrolled
? 'bg-white/80 backdrop-blur-lg shadow-sm'
: 'bg-transparent'}"
>
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<!-- Logo -->
<a
href={localizedPath("/")}
class="text-xl font-display font-bold transition-colors duration-300 {scrolled
? 'text-gray-900'
: 'text-white'}"
>
{#if scrolled}<span class="text-gradient">Tibi</span>{:else}Tibi{/if} Starter
</a>
<!-- Desktop Nav -->
<nav class="hidden md:flex items-center gap-8">
{#if headerNav?.elements}
{#each headerNav.elements as item}
<a
href={getLanguageSwitchUrl(lang)}
class="hover:underline px-1"
class:font-bold={$currentLanguage === lang}
class:opacity-60={$currentLanguage !== lang}
href={localizedPath(item.page || "/")}
class="text-sm font-medium transition-colors duration-300 hover:text-brand-500 {scrolled
? 'text-gray-700'
: 'text-white/90'}"
>
{LANGUAGE_LABELS[lang]}
{item.name}
</a>
{/each}
{/if}
<!-- Language Switcher -->
<div
class="flex items-center gap-1 border-l pl-6 transition-colors duration-300 {scrolled
? 'border-gray-200'
: 'border-white/20'}"
>
{#each SUPPORTED_LANGUAGES as lang}
{#if $currentLanguage === lang}
<a
href={getLanguageSwitchUrl(lang)}
class="text-xs font-semibold uppercase px-2 py-1 rounded transition-all duration-300 bg-brand-600 text-white"
>
{lang}
</a>
{:else}
<a
href={getLanguageSwitchUrl(lang)}
class="text-xs font-semibold uppercase px-2 py-1 rounded transition-all duration-300 hover:text-brand-500 {scrolled
? 'text-gray-500'
: 'text-white/50'}"
>
{lang}
</a>
{/if}
{/each}
</div>
</nav>
<!-- Mobile Hamburger -->
<button
class="md:hidden p-2 rounded-lg transition-colors {scrolled ? 'text-gray-900' : 'text-white'}"
onclick={() => ($mobileMenuOpen = !$mobileMenuOpen)}
aria-label="Menu"
aria-expanded={$mobileMenuOpen}
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{#if $mobileMenuOpen}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"
></path>
{:else}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"
></path>
{/if}
</svg>
</button>
</div>
<!-- Mobile nav -->
<nav class="sm:hidden mt-2 border-t border-white/20 pt-2">
<ul class="flex space-x-4 text-sm">
<li><a href={localizedPath("/")} class="hover:underline">{$_("nav.home")}</a></li>
<li><a href={localizedPath("/about")} class="hover:underline">{$_("nav.about")}</a></li>
<li><a href={localizedPath("/contact")} class="hover:underline">{$_("nav.contact")}</a></li>
</ul>
</nav>
<!-- Mobile Menu Overlay -->
{#if $mobileMenuOpen}
<div class="md:hidden bg-white border-t border-gray-100 shadow-lg">
<nav class="max-w-6xl mx-auto px-6 py-4 space-y-3">
{#if headerNav?.elements}
{#each headerNav.elements as item}
<a
href={localizedPath(item.page || "/")}
class="block text-gray-700 text-lg font-medium hover:text-brand-600 py-2"
>
{item.name}
</a>
{/each}
{/if}
<div class="flex gap-2 pt-3 border-t border-gray-100">
{#each SUPPORTED_LANGUAGES as lang}
<a
href={getLanguageSwitchUrl(lang)}
class="text-xs font-semibold uppercase px-3 py-1.5 rounded {$currentLanguage === lang
? 'bg-brand-600 text-white'
: 'text-gray-500 bg-gray-100'}"
>
{LANGUAGE_LABELS[lang]}
</a>
{/each}
</div>
</nav>
</div>
{/if}
</header>
<main class="container mx-auto p-4">
{#if getRoutePath($location.path) === "/about"}
<h1 class="text-2xl font-bold mb-4">{$_("page.about.title")}</h1>
<p>{$_("page.about.text")}</p>
{:else if getRoutePath($location.path) === "/contact"}
<h1 class="text-2xl font-bold mb-4">{$_("page.contact.title")}</h1>
<p>{$_("page.contact.text")}</p>
{:else}
<h1 class="text-2xl font-bold mb-4">{$_("page.home.title")}</h1>
<p>{$_("page.home.text")}</p>
<!-- ── Main Content ──────────────────────────────────────────── -->
<main>
{#if loading}
<div class="min-h-screen flex items-center justify-center">
<div class="w-8 h-8 border-4 border-brand-200 border-t-brand-600 rounded-full animate-spin"></div>
</div>
{:else if notFound}
<div class="pt-16">
<NotFound />
</div>
{:else if contentEntry?.blocks}
<div class="page-enter">
<BlockRenderer blocks={contentEntry.blocks} />
</div>
{/if}
</main>
<footer class="text-center p-2">
<DebugFooterInfo />
<!-- ── Footer ────────────────────────────────────────────────── -->
<footer class="bg-gray-900 text-gray-400">
<div class="max-w-6xl mx-auto px-6 py-12">
<div class="grid md:grid-cols-3 gap-8 mb-8">
<!-- Brand -->
<div>
<h3 class="text-white font-display font-bold text-lg mb-3">Tibi Starter</h3>
<p class="text-sm leading-relaxed">
{$_("footer.madeWith")}
</p>
</div>
<!-- Footer Nav -->
{#if footerNav?.elements}
<div>
<h4 class="text-white font-semibold text-sm uppercase tracking-wider mb-3">Navigation</h4>
<ul class="space-y-2">
{#each footerNav.elements as item}
<li>
{#if item.external}
<a
href={item.externalUrl || "#"}
target="_blank"
rel="noopener noreferrer"
class="text-sm hover:text-white transition-colors"
>
{item.name} &nearr;
</a>
{:else}
<a
href={localizedPath(item.page || "/")}
class="text-sm hover:text-white transition-colors"
>
{item.name}
</a>
{/if}
</li>
{/each}
</ul>
</div>
{/if}
<!-- Language -->
<div>
<h4 class="text-white font-semibold text-sm uppercase tracking-wider mb-3">{$_("language")}</h4>
<div class="flex gap-2">
{#each SUPPORTED_LANGUAGES as lang}
<a
href={getLanguageSwitchUrl(lang)}
class="text-sm px-3 py-1.5 rounded transition-all {$currentLanguage === lang
? 'bg-brand-600 text-white'
: 'hover:text-white'}"
>
{LANGUAGE_LABELS[lang]}
</a>
{/each}
</div>
</div>
</div>
<!-- Bottom bar -->
<div class="border-t border-gray-800 pt-6 flex flex-col sm:flex-row justify-between items-center gap-4 text-sm">
<p>&copy; {new Date().getFullYear()} Tibi Svelte Starter. {$_("footer.rights")}</p>
<DebugFooterInfo />
</div>
</div>
</footer>