Files
my-notes-viewer/frontend/src/App.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} &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>