forked from cms/tibi-svelte-starter
359 lines
14 KiB
Svelte
359 lines
14 KiB
Svelte
<script lang="ts">
|
|
import { untrack } from "svelte"
|
|
import { metricCall } from "./config"
|
|
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,
|
|
currentLanguage,
|
|
localizedPath,
|
|
getLanguageSwitchUrl,
|
|
getBrowserLanguage,
|
|
extractLanguageFromPath,
|
|
DEFAULT_LANGUAGE,
|
|
stripLanguageFromPath,
|
|
} from "./lib/i18n"
|
|
|
|
let { url = "" }: { url?: string } = $props()
|
|
|
|
initScrollRestoration()
|
|
|
|
// SSR: capture initial URL once (not reactive — url is only set server-side)
|
|
untrack(() => {
|
|
if (url) {
|
|
let l = url.split("?")
|
|
$location = {
|
|
path: l[0],
|
|
search: l.length > 1 ? l[1] : "",
|
|
hash: "",
|
|
push: false,
|
|
pop: false,
|
|
}
|
|
const lang = extractLanguageFromPath(l[0]) || DEFAULT_LANGUAGE
|
|
$locale = lang
|
|
}
|
|
})
|
|
|
|
// Redirect root "/" to default language
|
|
$effect(() => {
|
|
if (typeof window !== "undefined" && $location.path === "/") {
|
|
const lang = getBrowserLanguage()
|
|
history.replaceState(null, "", `/${lang}/`)
|
|
}
|
|
})
|
|
|
|
// metrics
|
|
let oldPath = $state("")
|
|
$effect(() => {
|
|
if (metricCall && typeof window !== "undefined" && oldPath !== $location.path) {
|
|
const ref = oldPath
|
|
? document.location.protocol + "//" + document.location.host + oldPath
|
|
: document.referrer
|
|
oldPath = $location.path
|
|
const fetchPath = oldPath + (oldPath.includes("?") ? "&" : "?") + "metrics"
|
|
fetch(fetchPath, {
|
|
headers: {
|
|
"x-ssr-skip": "204",
|
|
"x-ssr-ref": ref,
|
|
"x-ssr-res": `${window.innerWidth}x${window.innerHeight}`,
|
|
"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 || "/")
|
|
})
|
|
|
|
// Dynamic page title
|
|
const SITE_NAME = "Tibi Starter"
|
|
let pageTitle = $derived(
|
|
contentEntry?.meta?.title
|
|
? `${contentEntry.meta.title} — ${SITE_NAME}`
|
|
: contentEntry?.name
|
|
? `${contentEntry.name} — ${SITE_NAME}`
|
|
: SITE_NAME
|
|
)
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>{pageTitle}</title>
|
|
{#if contentEntry?.meta?.description}
|
|
<meta name="description" content={contentEntry.meta.description} />
|
|
{/if}
|
|
</svelte:head>
|
|
|
|
<LoadingBar />
|
|
<ToastContainer />
|
|
|
|
<!-- ── 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={localizedPath(item.page || "/")}
|
|
class="text-sm font-medium transition-colors duration-300 hover:text-brand-500 {scrolled
|
|
? 'text-gray-700'
|
|
: 'text-white/90'}"
|
|
>
|
|
{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 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 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 ────────────────────────────────────────────────── -->
|
|
<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>
|