feat: implement new feature for enhanced user experience

This commit is contained in:
2026-02-11 16:36:56 +00:00
parent 62f1906276
commit dc00d24899
75 changed files with 2456 additions and 35 deletions

View File

@@ -1,6 +1,18 @@
<script lang="ts">
import { metricCall } from "./config"
import { location } from "./lib/store"
import { _, locale } from "./lib/i18n/index"
import {
SUPPORTED_LANGUAGES,
LANGUAGE_LABELS,
currentLanguage,
localizedPath,
getLanguageSwitchUrl,
getBrowserLanguage,
extractLanguageFromPath,
DEFAULT_LANGUAGE,
getRoutePath,
} from "./lib/i18n"
export let url = ""
if (url) {
@@ -13,8 +25,19 @@
push: false,
pop: false,
}
// Set svelte-i18n locale from URL for SSR rendering
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(() => {
@@ -38,14 +61,47 @@
</script>
<header class="text-white p-4 bg-red-900">
<div class="container mx-auto flex justify-between items-center">
<a href="/" class="text-xl font-bold">Tibi Svelte Starter</a>
<nav>
<ul class="flex space-x-4">
<li><a href="/" class="hover:underline">Home</a></li>
<li><a href="/about" class="hover:underline">About</a></li>
<li><a href="/contact" class="hover:underline">Contact</a></li>
<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}
<a
href={getLanguageSwitchUrl(lang)}
class="hover:underline px-1"
class:font-bold={$currentLanguage === lang}
class:opacity-60={$currentLanguage !== lang}
>
{LANGUAGE_LABELS[lang]}
</a>
{/each}
</div>
</nav>
</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>
</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>
{/if}
</main>

View File

@@ -1,9 +1,11 @@
import "./css/style.css"
import App from "./App.svelte"
import { hydrate } from "svelte"
import { setupI18n } from "./lib/i18n/index"
let appContainer = document?.getElementById("appContainer")
// Initialize i18n before mounting the app
setupI18n().then(() => {
let appContainer = document?.getElementById("appContainer")
hydrate(App, { target: appContainer })
})
const app = hydrate(App, { target: appContainer })
export default app

168
frontend/src/lib/i18n.ts Normal file
View File

@@ -0,0 +1,168 @@
import { writable, derived, get } from "svelte/store"
import { location } from "./store"
/**
* Supported languages configuration.
* Add more languages as needed for your project.
*/
export const SUPPORTED_LANGUAGES = ["de", "en"] as const
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]
export const DEFAULT_LANGUAGE: SupportedLanguage = "de"
export const LANGUAGE_LABELS: Record<SupportedLanguage, string> = {
de: "Deutsch",
en: "English",
}
/**
* Route translations for localized URLs.
* Add entries for routes that need translated slugs.
* Example: { about: { de: "ueber-uns", en: "about" } }
*/
export const ROUTE_TRANSLATIONS: Record<string, Record<SupportedLanguage, string>> = {
// Add your route translations here:
// about: { de: "ueber-uns", en: "about" },
}
export const getLocalizedRoute = (canonicalRoute: string, lang: SupportedLanguage): string => {
const translations = ROUTE_TRANSLATIONS[canonicalRoute]
if (translations && translations[lang]) {
return translations[lang]
}
return canonicalRoute
}
export const getCanonicalRoute = (localizedSegment: string): string => {
for (const [canonical, translations] of Object.entries(ROUTE_TRANSLATIONS)) {
for (const translated of Object.values(translations)) {
if (translated === localizedSegment) {
return canonical
}
}
}
return localizedSegment
}
/**
* Extract the language code from a URL path.
* Returns null if no valid language prefix is found.
*/
export const extractLanguageFromPath = (path: string): SupportedLanguage | null => {
const match = path.match(/^\/([a-z]{2})(\/|$)/)
if (match && SUPPORTED_LANGUAGES.includes(match[1] as SupportedLanguage)) {
return match[1] as SupportedLanguage
}
return null
}
export const stripLanguageFromPath = (path: string): string => {
const lang = extractLanguageFromPath(path)
if (lang) {
const stripped = path.slice(3)
return stripped || "/"
}
return path
}
export const getRoutePath = (fullPath: string): string => {
const stripped = stripLanguageFromPath(fullPath)
if (stripped === "/" || stripped === "") {
return "/"
}
const segments = stripped.split("/").filter(Boolean)
if (segments.length > 0) {
const canonicalFirst = getCanonicalRoute(segments[0])
if (canonicalFirst !== segments[0]) {
segments[0] = canonicalFirst
return "/" + segments.join("/")
}
}
return stripped
}
/**
* Derived store: current language based on URL.
*/
export const currentLanguage = derived(location, ($location) => {
const path = $location.path || "/"
return extractLanguageFromPath(path) || DEFAULT_LANGUAGE
})
/**
* Writable store for the selected language.
*/
export const selectedLanguage = writable<SupportedLanguage>(DEFAULT_LANGUAGE)
if (typeof window !== "undefined") {
const initialLang = extractLanguageFromPath(window.location.pathname)
if (initialLang) {
selectedLanguage.set(initialLang)
}
}
if (typeof window !== "undefined") {
location.subscribe(($loc) => {
const lang = extractLanguageFromPath($loc.path)
if (lang) {
selectedLanguage.set(lang)
}
})
}
/**
* Get the localized path for a given route and language.
*/
export const localizedPath = (path: string, lang?: SupportedLanguage): string => {
const language = lang || get(currentLanguage)
if (path === "/" || path === "") {
return `/${language}`
}
let cleanPath = stripLanguageFromPath(path)
cleanPath = cleanPath.startsWith("/") ? cleanPath : `/${cleanPath}`
const segments = cleanPath.split("/").filter(Boolean)
if (segments.length > 0) {
const canonicalFirst = getCanonicalRoute(segments[0])
const localizedFirst = getLocalizedRoute(canonicalFirst, language)
segments[0] = localizedFirst
}
const translatedPath = "/" + segments.join("/")
return `/${language}${translatedPath}`
}
/**
* Derived store for localized href.
*/
export const localizedHref = (path: string) => {
return derived(currentLanguage, ($lang) => localizedPath(path, $lang))
}
export const isValidLanguage = (lang: string): lang is SupportedLanguage => {
return SUPPORTED_LANGUAGES.includes(lang as SupportedLanguage)
}
/**
* Get the browser's preferred language, falling back to default.
*/
export const getBrowserLanguage = (): SupportedLanguage => {
if (typeof navigator === "undefined") {
return DEFAULT_LANGUAGE
}
const browserLangs = navigator.languages || [navigator.language]
for (const lang of browserLangs) {
const code = lang.split("-")[0].toLowerCase()
if (isValidLanguage(code)) {
return code
}
}
return DEFAULT_LANGUAGE
}
/**
* Get URL for switching to a different language.
*/
export const getLanguageSwitchUrl = (newLang: SupportedLanguage): string => {
const currentPath = typeof window !== "undefined" ? window.location.pathname : "/"
const canonicalPath = getRoutePath(currentPath)
return localizedPath(canonicalPath, newLang)
}

View File

@@ -0,0 +1,75 @@
import { register, init, locale, waitLocale, getLocaleFromNavigator } from "svelte-i18n"
import { get } from "svelte/store"
import {
SUPPORTED_LANGUAGES,
DEFAULT_LANGUAGE,
extractLanguageFromPath,
selectedLanguage,
type SupportedLanguage,
} from "../i18n"
// Re-export svelte-i18n utilities for convenience
export { _, format, time, date, number, json } from "svelte-i18n"
export { locale, isLoading, addMessages } from "svelte-i18n"
// Register locale files for lazy loading
register("de", () => import("./locales/de.json"))
register("en", () => import("./locales/en.json"))
/**
* Determine the initial locale from URL, browser, or fallback.
*/
function getInitialLocale(url?: string): SupportedLanguage {
// 1. Try URL path
if (url) {
const langFromUrl = extractLanguageFromPath(url)
if (langFromUrl) return langFromUrl
}
if (typeof window !== "undefined") {
const langFromPath = extractLanguageFromPath(window.location.pathname)
if (langFromPath) return langFromPath
}
// 2. Try browser settings
if (typeof navigator !== "undefined") {
const browserLocale = getLocaleFromNavigator()
if (browserLocale) {
const langCode = browserLocale.split("-")[0] as SupportedLanguage
if (SUPPORTED_LANGUAGES.includes(langCode)) {
return langCode
}
}
}
// 3. Fallback
return DEFAULT_LANGUAGE
}
/**
* Initialize i18n for client-side rendering.
* Call this before mounting the Svelte app.
*/
export async function setupI18n(url?: string): Promise<void> {
const initialLocale = getInitialLocale(url)
init({
fallbackLocale: DEFAULT_LANGUAGE,
initialLocale,
})
selectedLanguage.set(initialLocale)
// Keep svelte-i18n locale and selectedLanguage store in sync
locale.subscribe((newLocale) => {
if (newLocale && SUPPORTED_LANGUAGES.includes(newLocale as SupportedLanguage)) {
selectedLanguage.set(newLocale as SupportedLanguage)
}
})
selectedLanguage.subscribe((newLang) => {
const currentLocale = get(locale)
if (newLang && newLang !== currentLocale) {
locale.set(newLang)
}
})
await waitLocale()
}

View File

@@ -0,0 +1,23 @@
{
"nav": {
"home": "Startseite",
"about": "Über uns",
"contact": "Kontakt"
},
"page": {
"home": {
"title": "Willkommen",
"text": "Dies ist der Tibi Svelte Starter. Passe diese Seite an dein Projekt an."
},
"about": {
"title": "Über uns",
"text": "Hier kannst du Informationen über dein Projekt darstellen."
},
"contact": {
"title": "Kontakt",
"text": "Hier kannst du ein Kontaktformular oder Kontaktdaten anzeigen."
}
},
"welcome": "Willkommen",
"language": "Sprache"
}

View File

@@ -0,0 +1,23 @@
{
"nav": {
"home": "Home",
"about": "About",
"contact": "Contact"
},
"page": {
"home": {
"title": "Welcome",
"text": "This is the Tibi Svelte Starter. Customise this page for your project."
},
"about": {
"title": "About",
"text": "Use this page to present information about your project."
},
"contact": {
"title": "Contact",
"text": "Use this page to display a contact form or contact details."
}
},
"welcome": "Welcome",
"language": "Language"
}

View File

@@ -1,3 +1,16 @@
import { addMessages, init } from "svelte-i18n"
import { DEFAULT_LANGUAGE } from "./lib/i18n"
import deLocale from "./lib/i18n/locales/de.json"
import enLocale from "./lib/i18n/locales/en.json"
import App from "./App.svelte"
// SSR: load messages synchronously (Babel transforms import → require)
addMessages("de", deLocale)
addMessages("en", enLocale)
init({
fallbackLocale: DEFAULT_LANGUAGE,
initialLocale: DEFAULT_LANGUAGE,
})
export default App