forked from cms/tibi-svelte-starter
✨ 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:
@@ -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} ↗
|
||||
</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>© {new Date().getFullYear()} Tibi Svelte Starter. {$_("footer.rights")}</p>
|
||||
<DebugFooterInfo />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
Reference in New Issue
Block a user