✨ feat: implement new feature for enhanced user experience
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
168
frontend/src/lib/i18n.ts
Normal 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)
|
||||
}
|
||||
75
frontend/src/lib/i18n/index.ts
Normal file
75
frontend/src/lib/i18n/index.ts
Normal 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()
|
||||
}
|
||||
23
frontend/src/lib/i18n/locales/de.json
Normal file
23
frontend/src/lib/i18n/locales/de.json
Normal 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"
|
||||
}
|
||||
23
frontend/src/lib/i18n/locales/en.json
Normal file
23
frontend/src/lib/i18n/locales/en.json
Normal 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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user