✨ feat: add Svelte actions and global stores for enhanced functionality
This commit is contained in:
26
frontend/src/lib/actions/clickOutside.ts
Normal file
26
frontend/src/lib/actions/clickOutside.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
12
frontend/src/lib/formContext.ts
Normal file
12
frontend/src/lib/formContext.ts
Normal 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
|
||||
}
|
||||
21
frontend/src/lib/requestsStore.ts
Normal file
21
frontend/src/lib/requestsStore.ts
Normal 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))
|
||||
}
|
||||
10
frontend/src/lib/serverBuildInfo.ts
Normal file
10
frontend/src/lib/serverBuildInfo.ts
Normal 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 }
|
||||
@@ -2,8 +2,19 @@ import { get, writable } from "svelte/store"
|
||||
|
||||
/*********** 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 = {
|
||||
path: (typeof window !== "undefined" && window.location?.pathname) || "/",
|
||||
path: stripTrailingSlash((typeof window !== "undefined" && window.location?.pathname) || "/"),
|
||||
search: (typeof window !== "undefined" && window.location?.search) || "",
|
||||
hash: (typeof window !== "undefined" && window.location?.hash) || "",
|
||||
push: false,
|
||||
@@ -26,8 +37,9 @@ const publishLocation = (_p?: string) => {
|
||||
if (_s) _s = "?" + _s
|
||||
}
|
||||
|
||||
const rawPath = _p || (typeof window !== "undefined" && window.location?.pathname)
|
||||
const newLocation: LocationStore = {
|
||||
path: _p || (typeof window !== "undefined" && window.location?.pathname),
|
||||
path: stripTrailingSlash(rawPath),
|
||||
search: _p ? _s : typeof window !== "undefined" && window.location?.search,
|
||||
hash: _p ? _h : typeof window !== "undefined" && window.location?.hash,
|
||||
push: !!_p,
|
||||
@@ -79,6 +91,26 @@ typeof window !== "undefined" &&
|
||||
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 *****************/
|
||||
|
||||
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
59
frontend/src/lib/utils.ts
Normal 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
104
types/global.d.ts
vendored
@@ -19,6 +19,7 @@ interface ApiOptions {
|
||||
params?: {
|
||||
[key: string]: string
|
||||
}
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
interface LocationStore {
|
||||
@@ -33,6 +34,8 @@ interface LocationStore {
|
||||
interface ApiResult<T> {
|
||||
data: T
|
||||
count: number
|
||||
/** Build timestamp from server (X-Build-Time header), only present on client requests */
|
||||
buildTime?: string | null
|
||||
}
|
||||
|
||||
interface FileField {
|
||||
@@ -43,13 +46,106 @@ interface FileField {
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user