✨ 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 **************************/
|
/*********** 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
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?: {
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user