diff --git a/.github/instructions/frontend.instructions.md b/.github/instructions/frontend.instructions.md index 3b7cf6e..fbf8212 100644 --- a/.github/instructions/frontend.instructions.md +++ b/.github/instructions/frontend.instructions.md @@ -12,6 +12,11 @@ applyTo: "frontend/src/**" - 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`. +## 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 - `svelte-i18n` is configured in `frontend/src/lib/i18n/index.ts` with lazy loading for locale files. diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 5eb43e1..63743fc 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -4,6 +4,8 @@ import { _, locale } from "./lib/i18n/index" import LoadingBar from "./widgets/LoadingBar.svelte" import ToastContainer from "./widgets/ToastContainer.svelte" + import DebugFooterInfo from "./widgets/DebugFooterInfo.svelte" + import { initScrollRestoration } from "./lib/navigation" import { SUPPORTED_LANGUAGES, LANGUAGE_LABELS, @@ -17,6 +19,8 @@ } from "./lib/i18n" export let url = "" + initScrollRestoration() + if (url) { // ssr let l = url.split("?") @@ -110,3 +114,7 @@

{$_("page.home.text")}

{/if} + + diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts new file mode 100644 index 0000000..0b645cb --- /dev/null +++ b/frontend/src/lib/navigation.ts @@ -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 | 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 => { + 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 + * Products + * + * @example + * // With options + * Search + */ +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) + }, + } +} diff --git a/frontend/src/widgets/Button.svelte b/frontend/src/widgets/Button.svelte new file mode 100644 index 0000000..27e3a7a --- /dev/null +++ b/frontend/src/widgets/Button.svelte @@ -0,0 +1,59 @@ + + + diff --git a/frontend/src/widgets/Carousel.svelte b/frontend/src/widgets/Carousel.svelte new file mode 100644 index 0000000..478e608 --- /dev/null +++ b/frontend/src/widgets/Carousel.svelte @@ -0,0 +1,114 @@ + + +
+ +
+ {@render children()} +
+
+ + +{#if canScrollLeft || canScrollRight} +
+
+ + +
+
+{/if} + + diff --git a/frontend/src/widgets/DebugFooterInfo.svelte b/frontend/src/widgets/DebugFooterInfo.svelte new file mode 100644 index 0000000..50db4af --- /dev/null +++ b/frontend/src/widgets/DebugFooterInfo.svelte @@ -0,0 +1,47 @@ + + + diff --git a/frontend/src/widgets/Form.svelte b/frontend/src/widgets/Form.svelte new file mode 100644 index 0000000..887386e --- /dev/null +++ b/frontend/src/widgets/Form.svelte @@ -0,0 +1,63 @@ + + +
+ {@render children()} +
diff --git a/frontend/src/widgets/Input.svelte b/frontend/src/widgets/Input.svelte new file mode 100644 index 0000000..a081270 --- /dev/null +++ b/frontend/src/widgets/Input.svelte @@ -0,0 +1,394 @@ + + +
+ {#if variant === "integrated"} +
+ {#if label} + + {/if} + + {#if isPasswordType} + + {:else if isValidating || rightIcon} +
+ {#if isValidating} + + + Checking… + + {:else if rightIcon} + {@render rightIcon()} + {/if} +
+ {/if} +
+ {:else} + {#if label} + + {/if} + +
+ + {#if isPasswordType} + + {:else if isValidating || rightIcon} +
+ {#if isValidating} + + + Checking… + + {:else if rightIcon} + {@render rightIcon()} + {/if} +
+ {/if} +
+ {/if} + + {#if hint} +

{hint}

+ {/if} +
+ {#if error} +

{error}

+ {/if} +
+
diff --git a/frontend/src/widgets/MedialibImage.svelte b/frontend/src/widgets/MedialibImage.svelte new file mode 100644 index 0000000..fe1d11f --- /dev/null +++ b/frontend/src/widgets/MedialibImage.svelte @@ -0,0 +1,191 @@ + + +{#if id} + {#if loading} + {#if !noPlaceholder} + loading + {/if} + {:else if entry && fileSrc} + {#if showCaption && caption} +
+ + {entry.alt + +
+ {@html caption} +
+
+ {:else} + + {entry.alt + + {/if} + {:else if !noPlaceholder} + + not found + + {/if} +{/if} diff --git a/frontend/src/widgets/Pagination.svelte b/frontend/src/widgets/Pagination.svelte new file mode 100644 index 0000000..79246d4 --- /dev/null +++ b/frontend/src/widgets/Pagination.svelte @@ -0,0 +1,126 @@ + + +{#if totalPages > 1} + +{/if} diff --git a/frontend/src/widgets/SearchableSelect.svelte b/frontend/src/widgets/SearchableSelect.svelte new file mode 100644 index 0000000..d276110 --- /dev/null +++ b/frontend/src/widgets/SearchableSelect.svelte @@ -0,0 +1,313 @@ + + +
+ {#if label} + + {/if} + +
+ + + + + {#if isOpen && filteredOptions.length > 0} +
    + {#each filteredOptions as option, index} +
  • { + event.preventDefault() + selectOption(option) + }} + > + {option.label} +
  • + {/each} +
+ {/if} +
+ +
+ {#if error} +

{error}

+ {/if} +
+
diff --git a/frontend/src/widgets/Select.svelte b/frontend/src/widgets/Select.svelte new file mode 100644 index 0000000..81f21d5 --- /dev/null +++ b/frontend/src/widgets/Select.svelte @@ -0,0 +1,209 @@ + + +
+ {#if variant === "integrated"} +
+ {#if label} + + {/if} + +
+ + + +
+
+ {:else} + {#if label} + + {/if} + +
+ +
+ + + +
+
+ {/if} + +
+ {#if error} +

{error}

+ {/if} +
+
diff --git a/frontend/src/widgets/Tooltip.svelte b/frontend/src/widgets/Tooltip.svelte new file mode 100644 index 0000000..dd6b464 --- /dev/null +++ b/frontend/src/widgets/Tooltip.svelte @@ -0,0 +1,83 @@ + + + + + {@render children()} + {#if visible && text} + + {text} + + + + {/if} + + +