✨ feat: Add new input, select, and tooltip components with validation and accessibility features
- Introduced Input component with support for various input types, validation, and error handling. - Added MedialibImage component for displaying images with lazy loading and caption support. - Implemented Pagination component for navigating through pages with ellipsis for large page sets. - Created SearchableSelect component allowing users to search and select options from a dropdown. - Developed Select component with integrated styling and validation. - Added Tooltip component for displaying additional information on hover/focus.
This commit is contained in:
@@ -12,6 +12,11 @@ applyTo: "frontend/src/**"
|
|||||||
- SSR safety: guard browser-only code with `typeof window !== "undefined"`.
|
- SSR safety: guard browser-only code with `typeof window !== "undefined"`.
|
||||||
- API behavior: PUT responses return only changed fields; filter by id uses `_id`; API requests reject non-2xx with `{ response, data }` and error payload in `error.data.error`.
|
- API behavior: PUT responses return only changed fields; filter by id uses `_id`; API requests reject non-2xx with `{ response, data }` and error payload in `error.data.error`.
|
||||||
|
|
||||||
|
## Tailwind CSS
|
||||||
|
|
||||||
|
- Always use canonical Tailwind utility classes instead of arbitrary values when a standard equivalent exists (e.g. `h-16.5` not `h-[66px]`, `min-h-3` not `min-h-[12px]`).
|
||||||
|
- Only use arbitrary values (`[...]`) when no standard utility covers the needed value.
|
||||||
|
|
||||||
## i18n
|
## i18n
|
||||||
|
|
||||||
- `svelte-i18n` is configured in `frontend/src/lib/i18n/index.ts` with lazy loading for locale files.
|
- `svelte-i18n` is configured in `frontend/src/lib/i18n/index.ts` with lazy loading for locale files.
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
import { _, locale } from "./lib/i18n/index"
|
import { _, locale } from "./lib/i18n/index"
|
||||||
import LoadingBar from "./widgets/LoadingBar.svelte"
|
import LoadingBar from "./widgets/LoadingBar.svelte"
|
||||||
import ToastContainer from "./widgets/ToastContainer.svelte"
|
import ToastContainer from "./widgets/ToastContainer.svelte"
|
||||||
|
import DebugFooterInfo from "./widgets/DebugFooterInfo.svelte"
|
||||||
|
import { initScrollRestoration } from "./lib/navigation"
|
||||||
import {
|
import {
|
||||||
SUPPORTED_LANGUAGES,
|
SUPPORTED_LANGUAGES,
|
||||||
LANGUAGE_LABELS,
|
LANGUAGE_LABELS,
|
||||||
@@ -17,6 +19,8 @@
|
|||||||
} from "./lib/i18n"
|
} from "./lib/i18n"
|
||||||
export let url = ""
|
export let url = ""
|
||||||
|
|
||||||
|
initScrollRestoration()
|
||||||
|
|
||||||
if (url) {
|
if (url) {
|
||||||
// ssr
|
// ssr
|
||||||
let l = url.split("?")
|
let l = url.split("?")
|
||||||
@@ -110,3 +114,7 @@
|
|||||||
<p>{$_("page.home.text")}</p>
|
<p>{$_("page.home.text")}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<footer class="text-center p-2">
|
||||||
|
<DebugFooterInfo />
|
||||||
|
</footer>
|
||||||
|
|||||||
195
frontend/src/lib/navigation.ts
Normal file
195
frontend/src/lib/navigation.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* SPA Navigation utilities
|
||||||
|
* Provides navigation functions that work with the history API
|
||||||
|
* and automatically update the location store.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { previousPath } from "./store"
|
||||||
|
|
||||||
|
export type SpaNavigateOptions = {
|
||||||
|
/** Use replaceState instead of pushState (default: false) */
|
||||||
|
replace?: boolean
|
||||||
|
/** State object to pass to pushState/replaceState */
|
||||||
|
state?: Record<string, unknown> | null
|
||||||
|
/** Skip scrolling to top (default: false) */
|
||||||
|
noScroll?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize scroll restoration for SPA navigation.
|
||||||
|
* Call this once at app startup to enable automatic scroll position restoration
|
||||||
|
* when using browser back/forward buttons.
|
||||||
|
*/
|
||||||
|
export const initScrollRestoration = (): void => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return // SSR guard
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable browser's automatic scroll restoration - we handle it manually
|
||||||
|
if ("scrollRestoration" in history) {
|
||||||
|
history.scrollRestoration = "manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore scroll position on popstate (back/forward navigation)
|
||||||
|
window.addEventListener("popstate", (event) => {
|
||||||
|
const state = event.state
|
||||||
|
if (state?.scrollY !== undefined) {
|
||||||
|
// Use requestAnimationFrame to ensure DOM has updated
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
window.scrollTo(0, state.scrollY)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to a new URL within the SPA.
|
||||||
|
* Uses pushState by default (creates history entry).
|
||||||
|
* Set replace: true to use replaceState (no history entry).
|
||||||
|
* Automatically scrolls to top and saves scroll position for back navigation.
|
||||||
|
*
|
||||||
|
* @param url - The URL to navigate to (can be relative or absolute path)
|
||||||
|
* @param options - Navigation options
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Navigate to /about (creates history entry)
|
||||||
|
* spaNavigate('/about')
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Navigate with hash, replacing current entry
|
||||||
|
* spaNavigate('/search#q=test', { replace: true })
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Navigate with state data
|
||||||
|
* spaNavigate('/product/123', { state: { from: 'search' } })
|
||||||
|
*/
|
||||||
|
export const spaNavigate = (url: string, options: SpaNavigateOptions = {}): void => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return // SSR guard
|
||||||
|
}
|
||||||
|
|
||||||
|
const { replace = false, state = null, noScroll = false } = options
|
||||||
|
|
||||||
|
// Save current path to previousPath store before navigating
|
||||||
|
previousPath.set(window.location.pathname)
|
||||||
|
|
||||||
|
// Save current scroll position in current history entry before navigating
|
||||||
|
const currentScrollY = window.scrollY
|
||||||
|
const currentState = history.state || {}
|
||||||
|
history.replaceState({ ...currentState, scrollY: currentScrollY }, "")
|
||||||
|
|
||||||
|
// Merge user state with scroll position (for new page, start at top)
|
||||||
|
const newState = { ...state, scrollY: 0 }
|
||||||
|
|
||||||
|
// Normalize relative URLs to absolute paths
|
||||||
|
let finalUrl = url
|
||||||
|
if (
|
||||||
|
!url.startsWith("/") &&
|
||||||
|
!url.startsWith("http") &&
|
||||||
|
!url.startsWith("mailto:") &&
|
||||||
|
!url.startsWith("tel:") &&
|
||||||
|
!url.startsWith("#")
|
||||||
|
) {
|
||||||
|
finalUrl = "/" + url
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replace) {
|
||||||
|
window.history.replaceState(newState, "", finalUrl)
|
||||||
|
} else {
|
||||||
|
window.history.pushState(newState, "", finalUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll to top for new navigation (unless noScroll is set)
|
||||||
|
if (!noScroll) {
|
||||||
|
window.scrollTo(0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse hash parameters from a URL hash string.
|
||||||
|
* @param hash - The hash string (with or without leading #)
|
||||||
|
* @returns URLSearchParams-like object for easy access
|
||||||
|
*/
|
||||||
|
export const parseHashParams = (hash: string): URLSearchParams => {
|
||||||
|
const cleanHash = hash.startsWith("#") ? hash.slice(1) : hash
|
||||||
|
return new URLSearchParams(cleanHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a hash string from key-value pairs.
|
||||||
|
* @param params - Object with parameter key-value pairs
|
||||||
|
* @returns Hash string with leading #, or empty string if no params
|
||||||
|
*/
|
||||||
|
export const buildHashString = (params: Record<string, string | string[] | null | undefined>): string => {
|
||||||
|
const parts: string[] = []
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value === null || value === undefined || value === "") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length > 0) {
|
||||||
|
parts.push(`${encodeURIComponent(key)}=${value.map(encodeURIComponent).join(",")}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length > 0 ? `#${parts.join("&")}` : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Svelte action for SPA link navigation.
|
||||||
|
* Intercepts clicks on anchor elements and uses spaNavigate instead of full page reload.
|
||||||
|
*
|
||||||
|
* @param node - The anchor element to enhance
|
||||||
|
* @param options - Navigation options passed to spaNavigate
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <a href="/products" use:spaLink>Products</a>
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // With options
|
||||||
|
* <a href="/search" use:spaLink={{ replace: true }}>Search</a>
|
||||||
|
*/
|
||||||
|
export const spaLink = (node: HTMLAnchorElement, options: SpaNavigateOptions = {}) => {
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
// Allow normal behavior for modifier keys (open in new tab, etc.)
|
||||||
|
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only handle left clicks
|
||||||
|
if (event.button !== 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const href = node.getAttribute("href")
|
||||||
|
|
||||||
|
// Skip external links or special protocols
|
||||||
|
if (!href || href.startsWith("http") || href.startsWith("mailto:") || href.startsWith("tel:")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if target is set (e.g., _blank)
|
||||||
|
if (node.target && node.target !== "_self") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
spaNavigate(href, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
node.addEventListener("click", handleClick)
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(newOptions: SpaNavigateOptions) {
|
||||||
|
options = newOptions
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
node.removeEventListener("click", handleClick)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
59
frontend/src/widgets/Button.svelte
Normal file
59
frontend/src/widgets/Button.svelte
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { MouseEventHandler } from "svelte/elements"
|
||||||
|
import type { Snippet } from "svelte"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
text?: string
|
||||||
|
variant?: "primary" | "secondary" | "outline" | "text" | "danger" | "ghost"
|
||||||
|
size?: "sm" | "md" | "lg"
|
||||||
|
type?: "button" | "submit"
|
||||||
|
disabled?: boolean
|
||||||
|
onclick?: MouseEventHandler<HTMLButtonElement>
|
||||||
|
class?: string
|
||||||
|
children?: Snippet
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
text = "Button",
|
||||||
|
variant = "primary",
|
||||||
|
size = "md",
|
||||||
|
type = "button",
|
||||||
|
disabled = false,
|
||||||
|
onclick = undefined,
|
||||||
|
class: className = "",
|
||||||
|
children,
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
// Base classes
|
||||||
|
const baseClasses =
|
||||||
|
"font-sans font-bold rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 flex items-center justify-center"
|
||||||
|
|
||||||
|
// Variant classes
|
||||||
|
const variantClasses = {
|
||||||
|
primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-600 disabled:bg-gray-300",
|
||||||
|
secondary:
|
||||||
|
"bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500 disabled:bg-gray-100 disabled:text-gray-400",
|
||||||
|
outline:
|
||||||
|
"bg-transparent border-2 border-blue-600 text-blue-600 hover:bg-blue-50 focus:ring-blue-600 disabled:border-gray-300 disabled:text-gray-400",
|
||||||
|
text: "bg-transparent text-blue-600 hover:bg-blue-50 hover:text-blue-700 focus:ring-blue-600 disabled:text-gray-400",
|
||||||
|
danger: "bg-transparent border-2 border-red-200 text-red-600 hover:bg-red-50 hover:border-red-300 focus:ring-red-500 disabled:border-gray-300 disabled:text-gray-400",
|
||||||
|
ghost: "bg-transparent text-gray-900 hover:text-blue-600 focus:ring-blue-600 disabled:text-gray-400 p-0",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size classes
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: "px-4 py-2 text-base",
|
||||||
|
md: "px-6 py-2.5 text-lg leading-relaxed",
|
||||||
|
lg: "px-8 py-4 text-xl",
|
||||||
|
}
|
||||||
|
|
||||||
|
let classes = $derived(`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button {type} class={classes} {onclick} {disabled}>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{:else}
|
||||||
|
{text}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
114
frontend/src/widgets/Carousel.svelte
Normal file
114
frontend/src/widgets/Carousel.svelte
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, type Snippet } from "svelte"
|
||||||
|
|
||||||
|
interface CarouselProps {
|
||||||
|
gap?: string
|
||||||
|
scrollAmount?: number
|
||||||
|
containerClass?: string
|
||||||
|
wrapperClass?: string
|
||||||
|
fullWidth?: boolean
|
||||||
|
/** Max width for the navigation arrows container (default: none) */
|
||||||
|
maxWidth?: string
|
||||||
|
children: Snippet
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
gap = "gap-3 md:gap-4 lg:gap-8",
|
||||||
|
scrollAmount = 450,
|
||||||
|
containerClass = "",
|
||||||
|
wrapperClass = "",
|
||||||
|
fullWidth = false,
|
||||||
|
maxWidth = "",
|
||||||
|
children,
|
||||||
|
}: CarouselProps = $props()
|
||||||
|
|
||||||
|
let scrollContainer: HTMLElement
|
||||||
|
let canScrollLeft = $state(false)
|
||||||
|
let canScrollRight = $state(false)
|
||||||
|
|
||||||
|
function checkScroll() {
|
||||||
|
if (scrollContainer) {
|
||||||
|
canScrollLeft = scrollContainer.scrollLeft > 50
|
||||||
|
canScrollRight = scrollContainer.scrollLeft < scrollContainer.scrollWidth - scrollContainer.clientWidth - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollLeft() {
|
||||||
|
if (scrollContainer) {
|
||||||
|
scrollContainer.scrollBy({
|
||||||
|
left: -scrollAmount,
|
||||||
|
behavior: "smooth",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollRight() {
|
||||||
|
if (scrollContainer) {
|
||||||
|
scrollContainer.scrollBy({
|
||||||
|
left: scrollAmount,
|
||||||
|
behavior: "smooth",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
checkScroll()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative {wrapperClass}">
|
||||||
|
<!-- Scroll Container -->
|
||||||
|
<div
|
||||||
|
class="flex {gap} overflow-x-auto snap-x snap-mandatory scrollbar-hide pb-4 scroll-smooth {fullWidth
|
||||||
|
? 'px-4 md:px-8 lg:px-12'
|
||||||
|
: ''} {containerClass}"
|
||||||
|
bind:this={scrollContainer}
|
||||||
|
onscroll={checkScroll}
|
||||||
|
onload={checkScroll}
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation Arrows -->
|
||||||
|
{#if canScrollLeft || canScrollRight}
|
||||||
|
<div class="{maxWidth ? `max-w-[${maxWidth}]` : ''} mx-auto px-4 md:px-8 lg:px-12">
|
||||||
|
<div class="flex gap-0 mt-6">
|
||||||
|
<button
|
||||||
|
onclick={scrollLeft}
|
||||||
|
disabled={!canScrollLeft}
|
||||||
|
class="w-12 h-10 flex items-center justify-center transition-opacity {canScrollLeft
|
||||||
|
? 'cursor-pointer hover:opacity-70'
|
||||||
|
: 'cursor-default opacity-30'}"
|
||||||
|
aria-label="Scroll left"
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={scrollRight}
|
||||||
|
disabled={!canScrollRight}
|
||||||
|
class="w-12 h-10 flex items-center justify-center transition-opacity {canScrollRight
|
||||||
|
? 'cursor-pointer hover:opacity-70'
|
||||||
|
: 'cursor-default opacity-30'}"
|
||||||
|
aria-label="Scroll right"
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
47
frontend/src/widgets/DebugFooterInfo.svelte
Normal file
47
frontend/src/widgets/DebugFooterInfo.svelte
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from "svelte"
|
||||||
|
import { gitHash, buildTime } from "../lib/buildInfo"
|
||||||
|
import { serverBuildTime } from "../lib/serverBuildInfo"
|
||||||
|
|
||||||
|
let debugText = ""
|
||||||
|
let interval: ReturnType<typeof setInterval> | undefined
|
||||||
|
|
||||||
|
function formatBuildTime(value: string | null | undefined): string {
|
||||||
|
if (!value) return "-"
|
||||||
|
try {
|
||||||
|
return new Date(value).toLocaleString("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function update() {
|
||||||
|
try {
|
||||||
|
const frontendBuilt = formatBuildTime(buildTime)
|
||||||
|
const serverBuilt = formatBuildTime($serverBuildTime)
|
||||||
|
debugText = `fe:${frontendBuilt} | srv:${serverBuilt} | v:${gitHash}`
|
||||||
|
} catch (e) {
|
||||||
|
debugText = `debug-error: ${e}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
update()
|
||||||
|
interval = setInterval(update, 5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (interval) clearInterval(interval)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="text-[10px] leading-none select-all text-gray-400" aria-hidden="true" title="Debug Info">
|
||||||
|
{debugText}
|
||||||
|
</span>
|
||||||
63
frontend/src/widgets/Form.svelte
Normal file
63
frontend/src/widgets/Form.svelte
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { setContext, type Snippet } from "svelte"
|
||||||
|
import { FORM_CONTEXT, type FormContext, type ValidatableField } from "../lib/formContext"
|
||||||
|
import type { HTMLFormAttributes } from "svelte/elements"
|
||||||
|
|
||||||
|
let {
|
||||||
|
children,
|
||||||
|
onsubmit,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
children: Snippet
|
||||||
|
onsubmit?: (e: SubmitEvent) => void
|
||||||
|
} & HTMLFormAttributes = $props()
|
||||||
|
|
||||||
|
const fields: ValidatableField[] = []
|
||||||
|
|
||||||
|
const register = (field: ValidatableField) => {
|
||||||
|
fields.push(field)
|
||||||
|
}
|
||||||
|
|
||||||
|
const unregister = (field: ValidatableField) => {
|
||||||
|
const index = fields.indexOf(field)
|
||||||
|
if (index > -1) {
|
||||||
|
fields.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setContext<FormContext>(FORM_CONTEXT, {
|
||||||
|
register,
|
||||||
|
unregister,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const validate = async () => {
|
||||||
|
let isValid = true
|
||||||
|
let firstInvalidField: ValidatableField | null = null
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
fields.map(async (field) => {
|
||||||
|
const valid = await field.validate()
|
||||||
|
return { field, valid }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const { field, valid } of results) {
|
||||||
|
if (!valid) {
|
||||||
|
isValid = false
|
||||||
|
if (!firstInvalidField) {
|
||||||
|
firstInvalidField = field
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstInvalidField && firstInvalidField.focus) {
|
||||||
|
firstInvalidField.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form novalidate {onsubmit} {...rest}>
|
||||||
|
{@render children()}
|
||||||
|
</form>
|
||||||
394
frontend/src/widgets/Input.svelte
Normal file
394
frontend/src/widgets/Input.svelte
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy, getContext } from "svelte"
|
||||||
|
import type { FormEventHandler, HTMLInputAttributes } from "svelte/elements"
|
||||||
|
import type { Snippet } from "svelte"
|
||||||
|
import { FORM_CONTEXT, type FormContext, type ValidatableField } from "../lib/formContext"
|
||||||
|
|
||||||
|
type InputValue = string | number | null | undefined
|
||||||
|
|
||||||
|
let {
|
||||||
|
placeholder = "",
|
||||||
|
value = $bindable<InputValue>(),
|
||||||
|
type = "text",
|
||||||
|
name = "",
|
||||||
|
required = false,
|
||||||
|
id = "",
|
||||||
|
label = "",
|
||||||
|
hideLabel = true,
|
||||||
|
variant = "default",
|
||||||
|
error = $bindable(""),
|
||||||
|
hint = "",
|
||||||
|
readonly = false,
|
||||||
|
disabled = false,
|
||||||
|
rightIcon,
|
||||||
|
messages = {},
|
||||||
|
validator,
|
||||||
|
oninput = undefined as FormEventHandler<HTMLInputElement> | undefined,
|
||||||
|
onchange = undefined as FormEventHandler<HTMLInputElement> | undefined,
|
||||||
|
onblur = undefined as FormEventHandler<HTMLInputElement> | undefined,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
placeholder?: string
|
||||||
|
value?: InputValue
|
||||||
|
type?: string
|
||||||
|
name?: string
|
||||||
|
required?: boolean
|
||||||
|
id?: string
|
||||||
|
label?: string
|
||||||
|
hideLabel?: boolean
|
||||||
|
variant?: "default" | "integrated"
|
||||||
|
error?: string
|
||||||
|
hint?: string
|
||||||
|
readonly?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
rightIcon?: Snippet
|
||||||
|
messages?: Partial<Record<keyof ValidityState, string>>
|
||||||
|
oninput?: FormEventHandler<HTMLInputElement>
|
||||||
|
onchange?: FormEventHandler<HTMLInputElement>
|
||||||
|
onblur?: FormEventHandler<HTMLInputElement>
|
||||||
|
validator?: (value: string) => Promise<string | null | undefined> | string | null | undefined
|
||||||
|
} & HTMLInputAttributes = $props()
|
||||||
|
|
||||||
|
// Generate unique ID if not provided
|
||||||
|
const fallbackId = `input-${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
const inputId = $derived(id || fallbackId)
|
||||||
|
const errorId = $derived(`${inputId}-error`)
|
||||||
|
|
||||||
|
let inputElement = $state<HTMLInputElement>()
|
||||||
|
const formContext = getContext<FormContext>(FORM_CONTEXT)
|
||||||
|
let isValidating = $state(false)
|
||||||
|
let validationRunId = 0
|
||||||
|
|
||||||
|
let showPassword = $state(false)
|
||||||
|
const isPasswordType = $derived(type === "password")
|
||||||
|
const effectiveType = $derived(isPasswordType && showPassword ? "text" : type)
|
||||||
|
|
||||||
|
const togglePassword = () => {
|
||||||
|
showPassword = !showPassword
|
||||||
|
inputElement?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDefaultValue = (inputType: string) => {
|
||||||
|
const normalizedType = inputType?.toLowerCase?.() || "text"
|
||||||
|
if (normalizedType === "number" || normalizedType === "range") {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
value = getDefaultValue(type)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const validate = async () => {
|
||||||
|
if (!inputElement) return true
|
||||||
|
|
||||||
|
// Reset custom validity to check native validity correctly
|
||||||
|
inputElement.setCustomValidity("")
|
||||||
|
|
||||||
|
const runId = ++validationRunId
|
||||||
|
|
||||||
|
if (inputElement.checkValidity()) {
|
||||||
|
if (validator) {
|
||||||
|
const valueForValidation = value === null || value === undefined ? "" : String(value)
|
||||||
|
const result = validator(valueForValidation)
|
||||||
|
const isAsync = !!result && typeof (result as Promise<string | null | undefined>).then === "function"
|
||||||
|
|
||||||
|
if (isAsync) {
|
||||||
|
isValidating = true
|
||||||
|
let customError: string | null | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
customError = await result
|
||||||
|
} finally {
|
||||||
|
if (runId === validationRunId) {
|
||||||
|
isValidating = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runId !== validationRunId) return false
|
||||||
|
if (customError) {
|
||||||
|
error = customError
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const customError = result as string | null | undefined
|
||||||
|
if (customError) {
|
||||||
|
error = customError
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
error = ""
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const validity = inputElement.validity
|
||||||
|
let errorMessage = inputElement.validationMessage
|
||||||
|
|
||||||
|
if (validity.valueMissing && messages.valueMissing) errorMessage = messages.valueMissing
|
||||||
|
else if (validity.typeMismatch && messages.typeMismatch) errorMessage = messages.typeMismatch
|
||||||
|
else if (validity.patternMismatch && messages.patternMismatch) errorMessage = messages.patternMismatch
|
||||||
|
else if (validity.tooLong && messages.tooLong) errorMessage = messages.tooLong
|
||||||
|
else if (validity.tooShort && messages.tooShort) errorMessage = messages.tooShort
|
||||||
|
else if (validity.rangeUnderflow && messages.rangeUnderflow) errorMessage = messages.rangeUnderflow
|
||||||
|
else if (validity.rangeOverflow && messages.rangeOverflow) errorMessage = messages.rangeOverflow
|
||||||
|
else if (validity.stepMismatch && messages.stepMismatch) errorMessage = messages.stepMismatch
|
||||||
|
else if (validity.badInput && messages.badInput) errorMessage = messages.badInput
|
||||||
|
|
||||||
|
error = errorMessage
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
error = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const focus = () => {
|
||||||
|
inputElement?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBlur: FormEventHandler<HTMLInputElement> = (e) => {
|
||||||
|
validate()
|
||||||
|
onblur?.(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
const handleInput: FormEventHandler<HTMLInputElement> = (e) => {
|
||||||
|
oninput?.(e)
|
||||||
|
|
||||||
|
// Only validate on input if we already have an error to clear
|
||||||
|
if (error) {
|
||||||
|
// Immediate check for native validity
|
||||||
|
inputElement!.setCustomValidity("")
|
||||||
|
const isNativelyValid = inputElement!.checkValidity()
|
||||||
|
|
||||||
|
if (!isNativelyValid) {
|
||||||
|
// If natively invalid (e.g. required missing), validate immediately to show correct error
|
||||||
|
validate()
|
||||||
|
} else if (validator) {
|
||||||
|
// If natively valid but we have a custom async validator, debounce it
|
||||||
|
clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
validate()
|
||||||
|
}, 300)
|
||||||
|
} else {
|
||||||
|
// If natively valid and no custom validator, validate immediately (clears error)
|
||||||
|
validate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const field: ValidatableField = { validate, reset, focus }
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (formContext) {
|
||||||
|
formContext.register(field)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
clearTimeout(debounceTimer)
|
||||||
|
if (formContext) {
|
||||||
|
formContext.unregister(field)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="w-full">
|
||||||
|
{#if variant === "integrated"}
|
||||||
|
<div
|
||||||
|
class="relative w-full border rounded-lg px-3 py-2 h-16.5 transition-colors group focus-within:ring-1 focus-within:ring-blue-600 {readonly ||
|
||||||
|
disabled
|
||||||
|
? 'bg-gray-50'
|
||||||
|
: 'bg-white'} {error
|
||||||
|
? 'border-red-500'
|
||||||
|
: 'border-gray-300 hover:border-blue-600 focus-within:border-blue-600'}"
|
||||||
|
>
|
||||||
|
{#if label}
|
||||||
|
<label
|
||||||
|
for={inputId}
|
||||||
|
class="block text-xs font-bold uppercase mb-1 {error ? 'text-red-500' : 'text-gray-500'}"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{#if required}<span class="text-red-600">*</span>{/if}
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
<input
|
||||||
|
bind:this={inputElement}
|
||||||
|
id={inputId}
|
||||||
|
type={effectiveType}
|
||||||
|
{name}
|
||||||
|
{placeholder}
|
||||||
|
{required}
|
||||||
|
{readonly}
|
||||||
|
{disabled}
|
||||||
|
bind:value
|
||||||
|
aria-label={label || placeholder}
|
||||||
|
aria-required={required}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
aria-describedby={error ? errorId : undefined}
|
||||||
|
class="w-full bg-transparent text-base text-gray-900 focus:outline-none placeholder:text-gray-400 {isPasswordType
|
||||||
|
? 'pr-12'
|
||||||
|
: rightIcon || isValidating
|
||||||
|
? 'pr-24'
|
||||||
|
: ''}"
|
||||||
|
oninput={handleInput}
|
||||||
|
onblur={handleBlur}
|
||||||
|
{onchange}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
{#if isPasswordType}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 focus:outline-none transition-colors"
|
||||||
|
onclick={togglePassword}
|
||||||
|
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||||
|
tabindex={-1}
|
||||||
|
>
|
||||||
|
{#if showPassword}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="w-5 h-5"
|
||||||
|
><path
|
||||||
|
d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"
|
||||||
|
></path><line x1="1" y1="1" x2="23" y2="23"></line></svg
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="w-5 h-5"
|
||||||
|
><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"
|
||||||
|
></circle></svg
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{:else if isValidating || rightIcon}
|
||||||
|
<div class="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||||
|
{#if isValidating}
|
||||||
|
<span class="inline-flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
<span
|
||||||
|
class="inline-block h-3 w-3 rounded-full border border-gray-400 border-t-transparent animate-spin"
|
||||||
|
></span>
|
||||||
|
Checking…
|
||||||
|
</span>
|
||||||
|
{:else if rightIcon}
|
||||||
|
{@render rightIcon()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#if label}
|
||||||
|
<label
|
||||||
|
for={inputId}
|
||||||
|
class="block text-base mb-1 {hideLabel ? 'sr-only' : ''} {error ? 'text-red-600' : 'text-gray-900'}"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{#if required}<span class="text-red-600">*</span>{/if}
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="relative w-full">
|
||||||
|
<input
|
||||||
|
bind:this={inputElement}
|
||||||
|
id={inputId}
|
||||||
|
type={effectiveType}
|
||||||
|
{name}
|
||||||
|
{placeholder}
|
||||||
|
{required}
|
||||||
|
{readonly}
|
||||||
|
{disabled}
|
||||||
|
bind:value
|
||||||
|
aria-label={label || placeholder}
|
||||||
|
aria-required={required}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
aria-describedby={error ? errorId : undefined}
|
||||||
|
class="w-full border rounded-lg px-4 py-3 bg-white text-base transition-colors focus:outline-none focus:ring-1 focus:ring-blue-600 placeholder:text-gray-400 {error
|
||||||
|
? 'border-red-500 focus:border-red-500'
|
||||||
|
: 'border-gray-300 hover:border-blue-600 focus:border-blue-600'} {readonly || disabled
|
||||||
|
? 'bg-gray-50'
|
||||||
|
: 'bg-white'} {isPasswordType ? 'pr-12' : rightIcon || isValidating ? 'pr-24' : ''}"
|
||||||
|
oninput={handleInput}
|
||||||
|
onblur={handleBlur}
|
||||||
|
{onchange}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
{#if isPasswordType}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 focus:outline-none transition-colors"
|
||||||
|
onclick={togglePassword}
|
||||||
|
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||||
|
tabindex={-1}
|
||||||
|
>
|
||||||
|
{#if showPassword}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="w-5 h-5"
|
||||||
|
><path
|
||||||
|
d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"
|
||||||
|
></path><line x1="1" y1="1" x2="23" y2="23"></line></svg
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="w-5 h-5"
|
||||||
|
><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"
|
||||||
|
></circle></svg
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{:else if isValidating || rightIcon}
|
||||||
|
<div class="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||||
|
{#if isValidating}
|
||||||
|
<span class="inline-flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
<span
|
||||||
|
class="inline-block h-3 w-3 rounded-full border border-gray-400 border-t-transparent animate-spin"
|
||||||
|
></span>
|
||||||
|
Checking…
|
||||||
|
</span>
|
||||||
|
{:else if rightIcon}
|
||||||
|
{@render rightIcon()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if hint}
|
||||||
|
<p class="mt-0.5 text-xs text-gray-500">{hint}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="min-h-3 mt-0">
|
||||||
|
{#if error}
|
||||||
|
<p id={errorId} class="text-sm text-red-600">{error}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
191
frontend/src/widgets/MedialibImage.svelte
Normal file
191
frontend/src/widgets/MedialibImage.svelte
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getDBEntries, getDBEntry } from "../lib/api"
|
||||||
|
import { apiBaseURL } from "../config"
|
||||||
|
import { apiBaseOverride } from "../lib/store"
|
||||||
|
import { get } from "svelte/store"
|
||||||
|
|
||||||
|
// Medialib cache (module-level)
|
||||||
|
const medialibCache: { [id: string]: MedialibEntry } = {}
|
||||||
|
let loadQueue: string[] = []
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
async function processQueue() {
|
||||||
|
if (loadQueue.length) {
|
||||||
|
const _ids = [...loadQueue]
|
||||||
|
loadQueue = []
|
||||||
|
const entries = await getDBEntries(
|
||||||
|
"medialib",
|
||||||
|
{ _id: { $in: _ids } },
|
||||||
|
"_id",
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
"public"
|
||||||
|
)
|
||||||
|
entries.forEach((entry: MedialibEntry) => {
|
||||||
|
if (entry.id) medialibCache[entry.id] = entry
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMedialibEntry(id: string): Promise<MedialibEntry> {
|
||||||
|
if (medialibCache[id]) return medialibCache[id]
|
||||||
|
loadQueue.push(id)
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = setTimeout(async () => {
|
||||||
|
await processQueue()
|
||||||
|
resolve()
|
||||||
|
}, 50)
|
||||||
|
})
|
||||||
|
return medialibCache[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string
|
||||||
|
filter?: string | null
|
||||||
|
noPlaceholder?: boolean
|
||||||
|
caption?: string
|
||||||
|
showCaption?: boolean
|
||||||
|
minWidth?: number
|
||||||
|
widthMultiplier?: number
|
||||||
|
lazy?: boolean
|
||||||
|
style?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
id,
|
||||||
|
filter = null,
|
||||||
|
noPlaceholder = false,
|
||||||
|
caption = "",
|
||||||
|
showCaption = false,
|
||||||
|
minWidth = 0,
|
||||||
|
widthMultiplier = 1,
|
||||||
|
lazy = false,
|
||||||
|
style = "",
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
let loading = $state(true)
|
||||||
|
let entry = $state<MedialibEntry | null>(null)
|
||||||
|
let fileSrc = $state<string | null>(null)
|
||||||
|
let imgEl = $state<HTMLImageElement | null>(null)
|
||||||
|
let currentFilter = $state<string>("l-webp")
|
||||||
|
|
||||||
|
// Sync explicit filter prop reactively
|
||||||
|
$effect(() => {
|
||||||
|
if (filter) currentFilter = filter
|
||||||
|
})
|
||||||
|
|
||||||
|
function getAutoFilter(imgWidth: number): string {
|
||||||
|
const width = minWidth ? Math.max(imgWidth, minWidth) : imgWidth
|
||||||
|
const effectiveWidth = width * (widthMultiplier || 1)
|
||||||
|
|
||||||
|
if (effectiveWidth <= 90) return "xs-webp"
|
||||||
|
if (effectiveWidth <= 300) return "s-webp"
|
||||||
|
if (effectiveWidth <= 600) return "m-webp"
|
||||||
|
if (effectiveWidth <= 1200) return "l-webp"
|
||||||
|
if (effectiveWidth <= 2000) return "xl-webp"
|
||||||
|
return "xxl-webp"
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRasterImage(entry: MedialibEntry | null): boolean {
|
||||||
|
if (entry?.file?.type?.match(/^image\/(png|jpe?g|webp)/)) return true
|
||||||
|
// Fallback: check file extension when MIME type is missing (e.g. public projection)
|
||||||
|
if (entry?.file?.src?.match(/\.(jpe?g|png|webp)$/i)) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFile() {
|
||||||
|
if (!id) return
|
||||||
|
loading = true
|
||||||
|
entry = null
|
||||||
|
fileSrc = null
|
||||||
|
try {
|
||||||
|
const _apiBase = get(apiBaseOverride) || apiBaseURL
|
||||||
|
entry =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? await loadMedialibEntry(id)
|
||||||
|
: await getDBEntry("medialib", { _id: id }, "public")
|
||||||
|
if (entry?.file?.src) {
|
||||||
|
fileSrc = _apiBase + "medialib/" + id + "/" + entry.file.src
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSR: fire-and-forget — $effect does NOT run during SSR.
|
||||||
|
// loadFile() internally checks if id is set.
|
||||||
|
if (typeof window === "undefined") loadFile()
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (id) loadFile()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ResizeObserver: only when no explicit filter and raster image
|
||||||
|
$effect(() => {
|
||||||
|
const el = imgEl
|
||||||
|
if (!el || filter || !entry || !isRasterImage(entry)) return
|
||||||
|
if (typeof ResizeObserver === "undefined") return
|
||||||
|
|
||||||
|
let maxObservedWidth = 0
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
const newWidth = el.clientWidth
|
||||||
|
if (newWidth <= maxObservedWidth) return // only scale up
|
||||||
|
maxObservedWidth = newWidth
|
||||||
|
currentFilter = getAutoFilter(newWidth)
|
||||||
|
})
|
||||||
|
observer.observe(el)
|
||||||
|
|
||||||
|
return () => observer.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
function getSrc(src: string | null, entry: MedialibEntry | null): string {
|
||||||
|
if (!src) return "/assets/img/placeholder-image.svg"
|
||||||
|
if (!isRasterImage(entry)) return src
|
||||||
|
if (filter) return src + `?filter=${filter}`
|
||||||
|
return src + `?filter=${currentFilter}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if id}
|
||||||
|
{#if loading}
|
||||||
|
{#if !noPlaceholder}
|
||||||
|
<img src="/assets/img/placeholder-image.svg" alt="loading" />
|
||||||
|
{/if}
|
||||||
|
{:else if entry && fileSrc}
|
||||||
|
{#if showCaption && caption}
|
||||||
|
<figure>
|
||||||
|
<picture>
|
||||||
|
<img
|
||||||
|
bind:this={imgEl}
|
||||||
|
src={getSrc(fileSrc, entry)}
|
||||||
|
alt={entry.alt || ""}
|
||||||
|
data-entry-id={id}
|
||||||
|
loading={lazy ? "lazy" : undefined}
|
||||||
|
{style}
|
||||||
|
/>
|
||||||
|
</picture>
|
||||||
|
<figcaption class="mt-2 text-sm text-gray-500 text-center italic">
|
||||||
|
{@html caption}
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
{:else}
|
||||||
|
<picture>
|
||||||
|
<img
|
||||||
|
bind:this={imgEl}
|
||||||
|
src={getSrc(fileSrc, entry)}
|
||||||
|
alt={entry.alt || ""}
|
||||||
|
data-entry-id={id}
|
||||||
|
loading={lazy ? "lazy" : undefined}
|
||||||
|
{style}
|
||||||
|
/>
|
||||||
|
</picture>
|
||||||
|
{/if}
|
||||||
|
{:else if !noPlaceholder}
|
||||||
|
<picture>
|
||||||
|
<img src="/assets/img/placeholder-image.svg" alt="not found" data-entry-id={id} />
|
||||||
|
</picture>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
126
frontend/src/widgets/Pagination.svelte
Normal file
126
frontend/src/widgets/Pagination.svelte
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
currentPage: number
|
||||||
|
totalPages: number
|
||||||
|
onPageChange: (page: number) => void
|
||||||
|
/** Maximum number of visible page buttons (excluding prev/next) */
|
||||||
|
maxVisiblePages?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let { currentPage, totalPages, onPageChange, maxVisiblePages = 7 }: Props = $props()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate visible page numbers with ellipsis.
|
||||||
|
* Always shows first and last page, with a range around the current page.
|
||||||
|
*/
|
||||||
|
const getVisiblePages = (): (number | "ellipsis")[] => {
|
||||||
|
if (totalPages <= maxVisiblePages) {
|
||||||
|
return Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages: (number | "ellipsis")[] = []
|
||||||
|
const halfVisible = Math.floor((maxVisiblePages - 3) / 2)
|
||||||
|
|
||||||
|
// Always first page
|
||||||
|
pages.push(1)
|
||||||
|
|
||||||
|
let rangeStart = Math.max(2, currentPage - halfVisible)
|
||||||
|
let rangeEnd = Math.min(totalPages - 1, currentPage + halfVisible)
|
||||||
|
|
||||||
|
if (currentPage <= halfVisible + 2) {
|
||||||
|
rangeEnd = Math.min(totalPages - 1, maxVisiblePages - 2)
|
||||||
|
} else if (currentPage >= totalPages - halfVisible - 1) {
|
||||||
|
rangeStart = Math.max(2, totalPages - maxVisiblePages + 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rangeStart > 2) {
|
||||||
|
pages.push("ellipsis")
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = rangeStart; i <= rangeEnd; i++) {
|
||||||
|
pages.push(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rangeEnd < totalPages - 1) {
|
||||||
|
pages.push("ellipsis")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalPages > 1) {
|
||||||
|
pages.push(totalPages)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages
|
||||||
|
}
|
||||||
|
|
||||||
|
let visiblePages = $derived(getVisiblePages())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if totalPages > 1}
|
||||||
|
<nav class="flex items-center justify-center gap-1 sm:gap-2 mt-8" aria-label="Pagination">
|
||||||
|
<!-- Previous button -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="
|
||||||
|
flex items-center justify-center
|
||||||
|
w-10 h-10 sm:w-12 sm:h-12
|
||||||
|
rounded-md
|
||||||
|
text-gray-900
|
||||||
|
transition-colors
|
||||||
|
{currentPage === 1 ? 'opacity-40 cursor-not-allowed' : 'hover:bg-blue-600/10 cursor-pointer'}
|
||||||
|
"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
onclick={() => onPageChange(currentPage - 1)}
|
||||||
|
aria-label="Previous page"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Page numbers -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
{#each visiblePages as page, index (index)}
|
||||||
|
{#if page === "ellipsis"}
|
||||||
|
<span class="w-10 h-10 sm:w-12 sm:h-12 flex items-center justify-center text-gray-900/50"> … </span>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="
|
||||||
|
flex items-center justify-center
|
||||||
|
w-10 h-10 sm:w-12 sm:h-12
|
||||||
|
rounded-md
|
||||||
|
font-semibold text-sm sm:text-base
|
||||||
|
transition-colors
|
||||||
|
{page === currentPage ? 'bg-blue-600 text-white' : 'text-gray-900 hover:bg-blue-600/10'}
|
||||||
|
"
|
||||||
|
aria-current={page === currentPage ? "page" : undefined}
|
||||||
|
aria-label={page === currentPage ? `Page ${page}, current page` : `Go to page ${page}`}
|
||||||
|
onclick={() => onPageChange(page)}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Next button -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="
|
||||||
|
flex items-center justify-center
|
||||||
|
w-10 h-10 sm:w-12 sm:h-12
|
||||||
|
rounded-md
|
||||||
|
text-gray-900
|
||||||
|
transition-colors
|
||||||
|
{currentPage === totalPages ? 'opacity-40 cursor-not-allowed' : 'hover:bg-blue-600/10 cursor-pointer'}
|
||||||
|
"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
onclick={() => onPageChange(currentPage + 1)}
|
||||||
|
aria-label="Next page"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
313
frontend/src/widgets/SearchableSelect.svelte
Normal file
313
frontend/src/widgets/SearchableSelect.svelte
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy, getContext } from "svelte"
|
||||||
|
import type { HTMLInputAttributes } from "svelte/elements"
|
||||||
|
import { FORM_CONTEXT, type FormContext, type ValidatableField } from "../lib/formContext"
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(""),
|
||||||
|
options = [] as { value: string; label: string; searchText?: string }[],
|
||||||
|
name = "",
|
||||||
|
required = false,
|
||||||
|
id = "",
|
||||||
|
label = "",
|
||||||
|
hideLabel = true,
|
||||||
|
error = $bindable(""),
|
||||||
|
disabled = false,
|
||||||
|
placeholder = "",
|
||||||
|
messages = {},
|
||||||
|
validator,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
value?: string
|
||||||
|
options?: { value: string; label: string; searchText?: string }[]
|
||||||
|
name?: string
|
||||||
|
required?: boolean
|
||||||
|
id?: string
|
||||||
|
label?: string
|
||||||
|
hideLabel?: boolean
|
||||||
|
error?: string
|
||||||
|
disabled?: boolean
|
||||||
|
placeholder?: string
|
||||||
|
messages?: Partial<Record<"valueMissing", string>>
|
||||||
|
validator?: (value: string) => Promise<string | null | undefined> | string | null | undefined
|
||||||
|
} & HTMLInputAttributes = $props()
|
||||||
|
|
||||||
|
const fallbackId = `searchable-select-${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
const inputId = $derived(id || fallbackId)
|
||||||
|
const listboxId = $derived(`${inputId}-listbox`)
|
||||||
|
const errorId = $derived(`${inputId}-error`)
|
||||||
|
|
||||||
|
let inputElement = $state<HTMLInputElement>()
|
||||||
|
let inputValue = $state("")
|
||||||
|
let isOpen = $state(false)
|
||||||
|
let highlightedIndex = $state(-1)
|
||||||
|
let hasTypedSinceOpen = $state(false)
|
||||||
|
|
||||||
|
const formContext = getContext<FormContext>(FORM_CONTEXT)
|
||||||
|
|
||||||
|
let selectedOption = $derived.by(() => options.find((option) => option.value === value) || null)
|
||||||
|
|
||||||
|
let filteredOptions = $derived.by(() => {
|
||||||
|
const query = isOpen && !hasTypedSinceOpen ? "" : inputValue.trim().toLowerCase()
|
||||||
|
if (!query) {
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
return options.filter((option) => {
|
||||||
|
const searchable = `${option.label} ${option.searchText || ""}`.toLowerCase()
|
||||||
|
return searchable.includes(query)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
inputValue = selectedOption?.label || ""
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const validate = async () => {
|
||||||
|
if (required && !value) {
|
||||||
|
error = messages.valueMissing || "Please select an option"
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && !options.some((option) => option.value === value)) {
|
||||||
|
error = messages.valueMissing || "Please select a valid option"
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validator) {
|
||||||
|
const customError = await validator(value)
|
||||||
|
if (customError) {
|
||||||
|
error = customError
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
error = ""
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
error = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const focus = () => {
|
||||||
|
inputElement?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const field: ValidatableField = { validate, reset, focus }
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (formContext) {
|
||||||
|
formContext.register(field)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (formContext) {
|
||||||
|
formContext.unregister(field)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function openDropdown() {
|
||||||
|
if (disabled) return
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
hasTypedSinceOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
isOpen = true
|
||||||
|
inputElement?.focus()
|
||||||
|
|
||||||
|
const cursorPosition = inputValue.length
|
||||||
|
setTimeout(() => {
|
||||||
|
inputElement?.setSelectionRange(cursorPosition, cursorPosition)
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
highlightedIndex = filteredOptions.length > 0 ? 0 : -1
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDropdown() {
|
||||||
|
isOpen = false
|
||||||
|
highlightedIndex = -1
|
||||||
|
hasTypedSinceOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectOption(option: { value: string; label: string; searchText?: string }) {
|
||||||
|
value = option.value
|
||||||
|
inputValue = option.label
|
||||||
|
closeDropdown()
|
||||||
|
validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
inputValue = target.value
|
||||||
|
openDropdown()
|
||||||
|
hasTypedSinceOpen = true
|
||||||
|
|
||||||
|
const normalizedInput = inputValue.trim().toLowerCase()
|
||||||
|
const exactMatch = options.find((option) => {
|
||||||
|
const labelMatch = option.label.toLowerCase() === normalizedInput
|
||||||
|
const searchTextMatch = option.searchText
|
||||||
|
?.toLowerCase()
|
||||||
|
.split("|")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.includes(normalizedInput)
|
||||||
|
return labelMatch || !!searchTextMatch
|
||||||
|
})
|
||||||
|
value = exactMatch?.value || ""
|
||||||
|
|
||||||
|
validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBlur() {
|
||||||
|
setTimeout(() => {
|
||||||
|
const normalizedInput = inputValue.trim().toLowerCase()
|
||||||
|
const exactMatch = options.find((option) => {
|
||||||
|
const labelMatch = option.label.toLowerCase() === normalizedInput
|
||||||
|
const searchTextMatch = option.searchText
|
||||||
|
?.toLowerCase()
|
||||||
|
.split("|")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.includes(normalizedInput)
|
||||||
|
return labelMatch || !!searchTextMatch
|
||||||
|
})
|
||||||
|
if (exactMatch) {
|
||||||
|
value = exactMatch.value
|
||||||
|
inputValue = exactMatch.label
|
||||||
|
} else if (selectedOption) {
|
||||||
|
inputValue = selectedOption.label
|
||||||
|
}
|
||||||
|
closeDropdown()
|
||||||
|
validate()
|
||||||
|
}, 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (disabled) return
|
||||||
|
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!isOpen) {
|
||||||
|
openDropdown()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (filteredOptions.length > 0) {
|
||||||
|
highlightedIndex = Math.min(highlightedIndex + 1, filteredOptions.length - 1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!isOpen) {
|
||||||
|
openDropdown()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (filteredOptions.length > 0) {
|
||||||
|
highlightedIndex = Math.max(highlightedIndex - 1, 0)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "Enter" && isOpen && highlightedIndex >= 0 && filteredOptions[highlightedIndex]) {
|
||||||
|
event.preventDefault()
|
||||||
|
selectOption(filteredOptions[highlightedIndex])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
closeDropdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="w-full">
|
||||||
|
{#if label}
|
||||||
|
<label
|
||||||
|
for={inputId}
|
||||||
|
class="block text-base mb-1 {hideLabel ? 'sr-only' : ''} {error ? 'text-red-600' : 'text-gray-900'}"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{#if required}<span class="text-red-600">*</span>{/if}
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="relative w-full">
|
||||||
|
<input
|
||||||
|
bind:this={inputElement}
|
||||||
|
id={inputId}
|
||||||
|
type="text"
|
||||||
|
{name}
|
||||||
|
value={inputValue}
|
||||||
|
{disabled}
|
||||||
|
{placeholder}
|
||||||
|
autocomplete="off"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-controls={isOpen ? listboxId : undefined}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-invalid={!!error}
|
||||||
|
aria-describedby={error ? errorId : undefined}
|
||||||
|
class="w-full border rounded-lg px-4 py-3 bg-white text-base transition-colors focus:outline-none focus:ring-1 focus:ring-blue-600 {error
|
||||||
|
? 'border-red-500 focus:border-red-500'
|
||||||
|
: 'border-gray-300 hover:border-blue-600 focus:border-blue-600'} {disabled ? 'bg-gray-50' : 'bg-white'}"
|
||||||
|
onfocus={openDropdown}
|
||||||
|
oninput={handleInput}
|
||||||
|
onblur={handleBlur}
|
||||||
|
onkeydown={handleKeyDown}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute right-4 top-1/2 -translate-y-1/2 cursor-pointer"
|
||||||
|
aria-label="Open dropdown"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={() => {
|
||||||
|
openDropdown()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5 {error ? 'text-red-500' : 'text-gray-400'}"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if isOpen && filteredOptions.length > 0}
|
||||||
|
<ul
|
||||||
|
id={listboxId}
|
||||||
|
role="listbox"
|
||||||
|
class="absolute z-30 mt-1 w-full max-h-64 overflow-auto rounded-lg border border-gray-300 bg-white shadow-lg"
|
||||||
|
>
|
||||||
|
{#each filteredOptions as option, index}
|
||||||
|
<li
|
||||||
|
role="option"
|
||||||
|
aria-selected={option.value === value}
|
||||||
|
class="px-4 py-2 cursor-pointer text-sm {index === highlightedIndex
|
||||||
|
? 'bg-blue-600/10 text-blue-600'
|
||||||
|
: 'text-gray-900 hover:bg-gray-50'}"
|
||||||
|
onmousedown={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
selectOption(option)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-h-5.5 mt-1">
|
||||||
|
{#if error}
|
||||||
|
<p id={errorId} class="text-sm text-red-600">{error}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
209
frontend/src/widgets/Select.svelte
Normal file
209
frontend/src/widgets/Select.svelte
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy, getContext } from "svelte"
|
||||||
|
import type { FormEventHandler, HTMLSelectAttributes } from "svelte/elements"
|
||||||
|
import type { Snippet } from "svelte"
|
||||||
|
import { FORM_CONTEXT, type FormContext, type ValidatableField } from "../lib/formContext"
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(""),
|
||||||
|
options = [] as { value: string; label: string }[],
|
||||||
|
name = "",
|
||||||
|
required = false,
|
||||||
|
id = "",
|
||||||
|
label = "",
|
||||||
|
hideLabel = true,
|
||||||
|
variant = "default",
|
||||||
|
error = $bindable(""),
|
||||||
|
disabled = false,
|
||||||
|
messages = {},
|
||||||
|
validator,
|
||||||
|
onchange = undefined as FormEventHandler<HTMLSelectElement> | undefined,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
value?: string
|
||||||
|
options?: { value: string; label: string }[]
|
||||||
|
name?: string
|
||||||
|
required?: boolean
|
||||||
|
id?: string
|
||||||
|
label?: string
|
||||||
|
hideLabel?: boolean
|
||||||
|
variant?: "default" | "integrated"
|
||||||
|
error?: string
|
||||||
|
disabled?: boolean
|
||||||
|
messages?: Partial<Record<keyof ValidityState, string>>
|
||||||
|
onchange?: FormEventHandler<HTMLSelectElement>
|
||||||
|
children?: Snippet
|
||||||
|
validator?: (value: string) => Promise<string | null | undefined> | string | null | undefined
|
||||||
|
} & HTMLSelectAttributes = $props()
|
||||||
|
|
||||||
|
const fallbackId = `select-${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
const selectId = $derived(id || fallbackId)
|
||||||
|
const errorId = $derived(`${selectId}-error`)
|
||||||
|
|
||||||
|
let selectElement = $state<HTMLSelectElement>()
|
||||||
|
const formContext = getContext<FormContext>(FORM_CONTEXT)
|
||||||
|
|
||||||
|
export const validate = async () => {
|
||||||
|
if (!selectElement) return true
|
||||||
|
|
||||||
|
selectElement.setCustomValidity("")
|
||||||
|
|
||||||
|
if (selectElement.checkValidity()) {
|
||||||
|
if (validator) {
|
||||||
|
const customError = await validator(value)
|
||||||
|
if (customError) {
|
||||||
|
error = customError
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
error = ""
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const validity = selectElement.validity
|
||||||
|
let errorMessage = selectElement.validationMessage
|
||||||
|
|
||||||
|
if (validity.valueMissing && messages.valueMissing) errorMessage = messages.valueMissing
|
||||||
|
else if (validity.customError && messages.customError) errorMessage = messages.customError
|
||||||
|
|
||||||
|
error = errorMessage
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
error = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const focus = () => {
|
||||||
|
selectElement?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange: FormEventHandler<HTMLSelectElement> = (e) => {
|
||||||
|
onchange?.(e)
|
||||||
|
validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const field: ValidatableField = { validate, reset, focus }
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (formContext) {
|
||||||
|
formContext.register(field)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (formContext) {
|
||||||
|
formContext.unregister(field)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="w-full">
|
||||||
|
{#if variant === "integrated"}
|
||||||
|
<div
|
||||||
|
class="relative w-full border rounded-lg px-3 py-2 h-16.5 transition-colors group focus-within:ring-1 focus-within:ring-blue-600 {disabled
|
||||||
|
? 'bg-gray-50'
|
||||||
|
: 'bg-white'} {error
|
||||||
|
? 'border-red-500'
|
||||||
|
: 'border-gray-300 hover:border-blue-600 focus-within:border-blue-600'}"
|
||||||
|
>
|
||||||
|
{#if label}
|
||||||
|
<label
|
||||||
|
for={selectId}
|
||||||
|
class="block text-xs font-bold uppercase mb-1 {error ? 'text-red-500' : 'text-gray-500'}"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{#if required}<span class="text-red-600">*</span>{/if}
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
<select
|
||||||
|
bind:this={selectElement}
|
||||||
|
id={selectId}
|
||||||
|
{name}
|
||||||
|
bind:value
|
||||||
|
{required}
|
||||||
|
{disabled}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
aria-describedby={error ? errorId : undefined}
|
||||||
|
class="w-full bg-transparent text-base text-gray-900 focus:outline-none appearance-none cursor-pointer"
|
||||||
|
onchange={handleChange}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{#if options.length > 0}
|
||||||
|
{#each options as option}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
{:else if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</select>
|
||||||
|
<div class="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5 text-gray-400 group-hover:text-blue-600"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#if label}
|
||||||
|
<label
|
||||||
|
for={selectId}
|
||||||
|
class="block text-base mb-1 {hideLabel ? 'sr-only' : ''} {error ? 'text-red-600' : 'text-gray-900'}"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{#if required}<span class="text-red-600">*</span>{/if}
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="relative w-full">
|
||||||
|
<select
|
||||||
|
bind:this={selectElement}
|
||||||
|
id={selectId}
|
||||||
|
{name}
|
||||||
|
bind:value
|
||||||
|
{required}
|
||||||
|
{disabled}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
aria-describedby={error ? errorId : undefined}
|
||||||
|
class="w-full border rounded-lg px-4 py-3 bg-white text-base appearance-none cursor-pointer transition-colors focus:outline-none focus:ring-1 focus:ring-blue-600 {error
|
||||||
|
? 'border-red-500 focus:border-red-500'
|
||||||
|
: 'border-gray-300 hover:border-blue-600 focus:border-blue-600'} {disabled
|
||||||
|
? 'bg-gray-50'
|
||||||
|
: 'bg-white'}"
|
||||||
|
onchange={handleChange}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{#if options.length > 0}
|
||||||
|
{#each options as option}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
{:else if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</select>
|
||||||
|
<div class="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5 {error ? 'text-red-500' : 'text-gray-400'} group-hover:text-blue-600"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="min-h-5.5 mt-1">
|
||||||
|
{#if error}
|
||||||
|
<p id={errorId} class="text-sm text-red-600">{error}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
83
frontend/src/widgets/Tooltip.svelte
Normal file
83
frontend/src/widgets/Tooltip.svelte
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Tooltip component for displaying additional information on hover/focus.
|
||||||
|
* Uses position:fixed to escape overflow:hidden containers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Snippet } from "svelte"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** Tooltip text to display */
|
||||||
|
text: string
|
||||||
|
/** Optional CSS class for the trigger wrapper */
|
||||||
|
class?: string
|
||||||
|
/** Child content (trigger element) */
|
||||||
|
children: Snippet
|
||||||
|
}
|
||||||
|
|
||||||
|
let { text, class: className = "", children }: Props = $props()
|
||||||
|
|
||||||
|
let visible = $state(false)
|
||||||
|
let triggerEl: HTMLSpanElement | undefined = $state()
|
||||||
|
let tooltipStyle = $state("")
|
||||||
|
|
||||||
|
function updatePosition() {
|
||||||
|
if (!triggerEl) return
|
||||||
|
const rect = triggerEl.getBoundingClientRect()
|
||||||
|
const top = rect.top - 8 // 8px gap above trigger
|
||||||
|
const left = rect.left
|
||||||
|
tooltipStyle = `top:${top}px;left:${left}px;transform:translateY(-100%)`
|
||||||
|
}
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
updatePosition()
|
||||||
|
visible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
visible = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<span
|
||||||
|
bind:this={triggerEl}
|
||||||
|
class="inline-block {className}"
|
||||||
|
onmouseenter={show}
|
||||||
|
onmouseleave={hide}
|
||||||
|
onfocusin={show}
|
||||||
|
onfocusout={hide}
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
{#if visible && text}
|
||||||
|
<span
|
||||||
|
role="tooltip"
|
||||||
|
class="fixed px-3 py-2 text-sm leading-snug
|
||||||
|
bg-gray-800 text-white rounded-lg shadow-lg
|
||||||
|
whitespace-normal text-left z-50
|
||||||
|
pointer-events-none animate-fade-in"
|
||||||
|
style="max-width:320px;{tooltipStyle}"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
<!-- Arrow -->
|
||||||
|
<span class="absolute top-full left-3 border-4 border-transparent border-t-gray-800"></span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(calc(-100% + 4px));
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fade-in 0.15s ease-out forwards;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user