feat: add Svelte actions and global stores for enhanced functionality

This commit is contained in:
2026-02-25 13:10:52 +00:00
parent dc00d24899
commit f6f565bbcb
7 changed files with 262 additions and 6 deletions

View File

@@ -0,0 +1,26 @@
/**
* Svelte action: click outside detection
* Usage: <div use:clickOutside={onClickOutside}>
* Calls the callback when a click occurs outside the element.
*/
export function clickOutside(node: HTMLElement, callback: () => void) {
function handleClick(event: MouseEvent) {
if (!node.contains(event.target as Node)) {
callback()
}
}
// Delay adding the listener to avoid catching the triggering click
setTimeout(() => {
document.addEventListener("click", handleClick, true)
}, 0)
return {
update(newCallback: () => void) {
callback = newCallback
},
destroy() {
document.removeEventListener("click", handleClick, true)
},
}
}

View File

@@ -0,0 +1,12 @@
export const FORM_CONTEXT = Symbol("FORM_CONTEXT")
export interface ValidatableField {
validate: () => Promise<boolean> | boolean
reset: () => void
focus?: () => void
}
export interface FormContext {
register: (field: ValidatableField) => void
unregister: (field: ValidatableField) => void
}

View File

@@ -0,0 +1,21 @@
import { writable } from "svelte/store"
/**
* Global store to track active API requests
* Used to display global loading indicator
*/
export const activeRequests = writable<number>(0)
/**
* Increment active requests counter
*/
export function incrementRequests() {
activeRequests.update((count) => count + 1)
}
/**
* Decrement active requests counter
*/
export function decrementRequests() {
activeRequests.update((count) => Math.max(0, count - 1))
}

View File

@@ -0,0 +1,10 @@
import { writable } from "svelte/store"
const serverBuildTime = writable<string | null>(null)
const setServerBuildTime = (value: string | null | undefined): void => {
if (!value) return
serverBuildTime.set(value)
}
export { serverBuildTime, setServerBuildTime }

View File

@@ -2,8 +2,19 @@ import { get, writable } from "svelte/store"
/*********** location **************************/ /*********** location **************************/
/**
* Strip trailing slash from a path, preserving root "/".
* E.g. "/about/" → "/about"
*/
const stripTrailingSlash = (path: string): string => {
if (path && path.length > 1 && path.endsWith("/")) {
return path.replace(/\/+$/, "")
}
return path
}
const initLoc = { const initLoc = {
path: (typeof window !== "undefined" && window.location?.pathname) || "/", path: stripTrailingSlash((typeof window !== "undefined" && window.location?.pathname) || "/"),
search: (typeof window !== "undefined" && window.location?.search) || "", search: (typeof window !== "undefined" && window.location?.search) || "",
hash: (typeof window !== "undefined" && window.location?.hash) || "", hash: (typeof window !== "undefined" && window.location?.hash) || "",
push: false, push: false,
@@ -26,8 +37,9 @@ const publishLocation = (_p?: string) => {
if (_s) _s = "?" + _s if (_s) _s = "?" + _s
} }
const rawPath = _p || (typeof window !== "undefined" && window.location?.pathname)
const newLocation: LocationStore = { const newLocation: LocationStore = {
path: _p || (typeof window !== "undefined" && window.location?.pathname), path: stripTrailingSlash(rawPath),
search: _p ? _s : typeof window !== "undefined" && window.location?.search, search: _p ? _s : typeof window !== "undefined" && window.location?.search,
hash: _p ? _h : typeof window !== "undefined" && window.location?.hash, hash: _p ? _h : typeof window !== "undefined" && window.location?.hash,
push: !!_p, push: !!_p,
@@ -79,6 +91,26 @@ typeof window !== "undefined" &&
publishLocation() publishLocation()
}) })
/********************** UI State Stores *****************/
// Store for mobile menu open state (shared between Header and navigation components)
export const mobileMenuOpen = writable<boolean>(false)
/********************** Current Content Page *****************/
// Store for tracking the currently displayed content page (used for cross-language linking etc.)
export const currentContentEntry = writable<{ translationKey?: string; lang?: string; path?: string } | null>(null)
/********************** override for admin ui *****************/ /********************** override for admin ui *****************/
export const apiBaseOverride = writable<string | null>(null) export const apiBaseOverride = writable<string | null>(null)
/********************** Navigation History *****************/
// Store for tracking previous path (used for conditional back button)
export const previousPath = writable<string | null>(null)
/********************** Cookie Consent *****************/
// Whether the cookie consent banner is currently visible
export const cookieConsentVisible = writable(false)

59
frontend/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,59 @@
export function debounce<T extends (...args: any[]) => void>(func: T, wait: number): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null
return (...args: Parameters<T>) => {
if (timeout) {
clearTimeout(timeout)
}
timeout = setTimeout(() => func(...args), wait)
}
}
export function formatNumber(number: number, decimals = 2): string {
return number.toFixed(decimals)
}
export function formatCurrency(amount: number, currency = "EUR", locale = "de-DE"): string {
// goja (tibi-server SSR runtime) does not provide Intl
if (typeof Intl === "undefined") {
const fixed = amount.toFixed(2).replace(".", ",")
return `${fixed}\u00A0${currency === "EUR" ? "€" : currency}`
}
return new Intl.NumberFormat(locale, {
style: "currency",
currency: currency,
}).format(amount)
}
export function generateMockImage(seed: string): string {
// Generate a consistent color based on the seed
const colors = [
"bg-red-200",
"bg-blue-200",
"bg-green-200",
"bg-yellow-200",
"bg-purple-200",
"bg-pink-200",
"bg-indigo-200",
"bg-teal-200",
]
const colorIndex = seed.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0) % colors.length
return colors[colorIndex]
}
export function highlightSearchTerm(text: string, searchTerm: string): string {
if (!searchTerm.trim()) return text
const regex = new RegExp(`(${searchTerm})`, "gi")
return text.replace(regex, '<mark class="bg-yellow-200">$1</mark>')
}
export function slugify(value: string): string {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "")
}

104
types/global.d.ts vendored
View File

@@ -19,6 +19,7 @@ interface ApiOptions {
params?: { params?: {
[key: string]: string [key: string]: string
} }
signal?: AbortSignal
} }
interface LocationStore { interface LocationStore {
@@ -33,6 +34,8 @@ interface LocationStore {
interface ApiResult<T> { interface ApiResult<T> {
data: T data: T
count: number count: number
/** Build timestamp from server (X-Build-Time header), only present on client requests */
buildTime?: string | null
} }
interface FileField { interface FileField {
@@ -43,13 +46,106 @@ interface FileField {
} }
interface MedialibEntry { interface MedialibEntry {
id: string id?: string
// ... file?: {
src?: string
type?: string
}
alt?: string
[key: string]: unknown
} }
/** Pagebuilder: Content Block Entry */
interface ContentBlockEntry {
hide?: boolean
headline?: string
headlineH1?: boolean
subline?: string
tagline?: string
anchorId?: string
containerWidth?: "" | "wide" | "full"
background?: {
color?: string
image?: string
}
padding?: {
top?: string
bottom?: string
}
type?: string
callToAction?: {
buttonText?: string
buttonLink?: string
buttonTarget?: string
}
heroImage?: {
image?: string
}
// richtext fields
text?: string
imagePosition?: "none" | "left" | "right"
imageRounded?: string
image?: string
// accordion fields
accordionItems?: {
question?: string
answer?: string
open?: boolean
}[]
// imageGallery fields
imageGallery?: {
images?: {
image?: string
caption?: string
showCaption?: boolean
}[]
}
// richtext caption fields
showImageCaption?: boolean
imageCaption?: string
}
/** Content Entry from the CMS */
interface ContentEntry { interface ContentEntry {
id: string id?: string
// ... _id?: string
active?: boolean
publication?: {
from?: string | Date
to?: string | Date
}
type?: string
lang?: string
translationKey?: string
name?: string
path?: string
alternativePaths?: { path?: string }[]
thumbnail?: string
teaserText?: string
blocks?: ContentBlockEntry[]
meta?: {
title?: string
description?: string
keywords?: string
}
}
/** Navigation element */
interface NavigationElement {
name: string
external?: boolean
page?: string
hash?: string
externalUrl?: string
}
/** Navigation entry from the CMS */
interface NavigationEntry {
id?: string
_id?: string
language?: string
type?: "header" | "footer"
elements?: NavigationElement[]
} }
interface ProductEntry { interface ProductEntry {