UNPKG

blade

Version:
460 lines (453 loc) • 15.9 kB
import { a as useLocation, c as usePopulatePathname, d as useUniversalContext, g as construct, h as assign, m as FORM_TARGET_PREFIX, s as useParams } from "./hooks-BCUm8YC_.js"; import { t as wrapClientComponent } from "./wrap-client-DyCFsAJ7.js"; import { h as TriggerError } from "./utils-BeDKhSpT.js"; import { n as useMutation, t as useLinkEvents } from "./hooks-AV4d-jqO.js"; import { createContext, forwardRef, useCallback, useContext, useEffect, useRef, useState } from "react"; import { jsx, jsxs } from "react/jsx-runtime"; //#region ../../node_modules/radash/dist/esm/string.mjs const capitalize = (str) => { if (!str || str.length === 0) return ""; const lower = str.toLowerCase(); return lower.substring(0, 1).toUpperCase() + lower.substring(1, lower.length); }; const dash = (str) => { const parts = str?.replace(/([A-Z])+/g, capitalize)?.split(/(?=[A-Z])|[\.\-\s_]/).map((x) => x.toLowerCase()) ?? []; if (parts.length === 0) return ""; if (parts.length === 1) return parts[0]; return parts.reduce((acc, part) => { return `${acc}-${part.toLowerCase()}`; }); }; //#endregion //#region private/client/components/link.tsx /** * Normalizes a `LinkURL` object to a `URL` instance. * * @param url - The `LinkURL` or `URL` object to normalize. * @param currentURL - The currently active URL. * * @returns A new `URL` instance. */ const normalizeURL = (url, currentURL) => { if (url instanceof URL) return url; const newURL = new URL(currentURL); for (const [key, value] of Object.entries(url)) switch (key) { case "search": if (typeof url.search === "string") newURL.search = url.search; else if (url.search) { const params = Object.entries(url.search).filter(([, value$1]) => { return typeof value$1 !== "undefined" && value$1 !== null; }); newURL.search = new URLSearchParams(params).toString(); } break; case "hash": case "host": case "hostname": case "href": case "password": case "pathname": case "port": case "protocol": case "username": newURL[key] = value; } return newURL; }; /** * Get the pathname (including query parameters) of a URL. * * @param url - The URL to compose the pathname for. * * @returns The pathname (including query parameters) of the provided URL. */ const getPathFromURL = (url, currentURL) => { const normalized = normalizeURL(url, currentURL); return normalized.pathname + normalized.search + normalized.hash; }; const Link = ({ href: hrefDefault, segments, children, prefetch = true,...extraProps }) => { const universalContext = useUniversalContext(); const href = typeof hrefDefault === "string" ? hrefDefault : getPathFromURL(hrefDefault, universalContext.url); const destination = usePopulatePathname()(href, segments); const linkEventHandlers = useLinkEvents(destination); const isExternal = href.startsWith("https://") || href.startsWith("http://"); let eventHandlers = prefetch ? linkEventHandlers : { onClick: linkEventHandlers.onClick, onMouseEnter: void 0, onTouchStart: void 0 }; if (href.startsWith("#") || isExternal) eventHandlers = void 0; return /* @__PURE__ */ jsx("a", { href: destination, ...isExternal ? { target: "_blank", rel: "noreferrer" } : {}, ...extraProps, onClick: extraProps.onClick || eventHandlers?.onClick, onMouseEnter: extraProps.onMouseEnter || eventHandlers?.onMouseEnter, onTouchStart: extraProps.onTouchStart || eventHandlers?.onTouchStart, children }); }; wrapClientComponent(Link, "Link"); //#endregion //#region private/client/components/image.tsx const supportedFitValues = [ "fill", "contain", "cover" ]; const Image = forwardRef(({ src: input, alt, size: defaultSize, width: defaultWidth, height: defaultHeight, fit = "cover", format = "webp", quality = 80, aspect, loading, style, className }, ref) => { const imageElement = useRef(null); const renderTime = useRef(Date.now()); const isMediaObject = typeof input === "object" && input !== null; const width = defaultSize || defaultWidth; const height = defaultSize || defaultHeight; const onLoad = useCallback(() => { if (Date.now() - renderTime.current > 150) imageElement.current?.animate([{ filter: "blur(4px)", opacity: 0 }, { filter: "blur(0px)", opacity: 1 }], { duration: 200 }); }, []); if (!(height || width)) throw new Error("Either `width`, `height`, or `size` must be defined for `Image`."); if (quality && (quality < 0 || quality > 100)) throw new Error("The given `quality` was not in the range between 0 and 100."); const optimizationParams = new URLSearchParams({ ...width ? { w: width.toString() } : {}, ...height ? { h: height.toString() } : {}, ...format !== "original" ? { fm: format } : {}, fit: supportedFitValues.includes(fit) ? fit : "cover", q: quality.toString() }); const responsiveOptimizationParams = new URLSearchParams({ ...width ? { h: (width * 2).toString() } : {}, ...height ? { h: (height * 2).toString() } : {}, ...format !== "original" ? { fm: format } : {}, fit: supportedFitValues.includes(fit) ? fit : "cover", q: quality.toString() }); const source = isMediaObject ? `${input.src}?${optimizationParams}` : input; const responsiveSource = isMediaObject ? `${input.src}?${optimizationParams} 1x, ${input.src}?${responsiveOptimizationParams} 2x` : input; const placeholder = input && typeof input !== "string" ? input.placeholder?.base64 : null; return /* @__PURE__ */ jsxs("div", { ref, className, style: { position: "relative", overflow: "hidden", flexShrink: 0, width: width || "100%", height: height || "100%", aspectRatio: aspect === "video" ? "16/9" : aspect === "square" ? "1/1" : "auto", ...style }, children: [placeholder && /* @__PURE__ */ jsx("img", { style: { position: "absolute", width: "100%", height: "100%", objectFit: fit }, src: placeholder, alt }), /* @__PURE__ */ jsx("img", { alt, style: { position: "absolute", width: "100%", height: "100%", objectFit: fit }, decoding: "async", onLoad, loading, ref: imageElement, src: source, srcSet: responsiveSource })] }); }); wrapClientComponent(Image, "Image"); //#endregion //#region private/client/components/form-element.tsx const FormElement = ({ children, allowGlobalSave,...rest }) => { const form = useContext(FormContext); if (!form) throw new Error("`FormElement` can only be used within `Form`."); const formId = useRef(""); const formRef = useRef(null); useEffect(() => { formId.current = form.registerForm(formRef); return () => form.unregisterForm(formId.current); }, []); useEffect(() => { if (!allowGlobalSave) return; const listener = async (event) => { if ((window.navigator.userAgent.match("Mac") ? event.metaKey : event.ctrlKey) && event.key === "s") { event.preventDefault(); if (form?.waiting) return; if (!form) return; await form.submit(); } }; document.addEventListener("keydown", listener, false); return () => document.removeEventListener("keydown", listener); }, [allowGlobalSave, form]); return /* @__PURE__ */ jsxs("form", { id: form.key, onKeyDown: (event) => { /** * When the user presses Ctrl+Enter (or Cmd+Enter on macOS) while focused * on an input field, we want to submit the form. */ const focusedTagName = document.activeElement?.tagName; if (!focusedTagName) return; if (!(event.currentTarget.id === form.key)) return; const isFocusedOnInput = ["INPUT", "TEXTAREA"].includes(focusedTagName); if ((window.navigator.userAgent.match("Mac") ? event.metaKey : event.ctrlKey) && event.key === "Enter" && isFocusedOnInput && !form.waiting) form.submit(); }, onSubmit: (event) => { event.preventDefault(); if (form?.submit && !form?.waiting) form.submit(); }, ref: formRef, ...rest, children: [children, /* @__PURE__ */ jsx("button", { hidden: true, type: "submit" })] }); }; wrapClientComponent(FormElement, "FormElement"); //#endregion //#region private/client/components/form.tsx const parseFormValue = (value, type) => { if (typeof value === "string" && value?.length === 0 || value === null || value === "null") return null; switch (type) { case "BOOL": return typeof value === "boolean" ? value : value === "true"; case "STRING": return typeof value === "string" ? value : String(value); case "INT64": case "FLOAT64": return typeof value === "number" ? value : Number.parseFloat(value); case "DATE": case "TIMESTAMP": return value instanceof Date ? value : new Date(value); case "ARRAY": case "JSON": return typeof value === "object" ? value : JSON.parse(value); case "SUBQUERY": case "READABLE": return value; default: throw new Error("Unsupported field type."); } }; const FormContext = createContext(null); const Form = ({ model, clearOnSuccess = false, children, onSuccess, onError, redirect, disabled: defaultDisabled, disabledWhileWaiting, recordSlug, database, including, excludeEmptyFields, newRecordSlug, noElement, set: shouldSet, remove: shouldRemove,...rest }) => { const forms = useRef({}); const { set, add, remove } = useMutation(); const { pathname } = useLocation(); const params = useParams(); const populatePathname = usePopulatePathname(); const [waiting, setWaiting] = useState(false); const [result, setResult] = useState(null); const registeredProperties = useRef({}); const registeredRedirect = useRef(redirect || null); const disabled = Boolean(defaultDisabled || disabledWhileWaiting && waiting); const registerForm = ({ current }) => { const id = crypto.randomUUID(); forms.current[id] = current; return id; }; const unregisterForm = (id) => { delete forms.current[id]; }; const submit = async () => { setWaiting(true); const values = {}; const elements = []; for (const form of Object.values(forms.current)) { new FormData(form).forEach((value, key$1) => { if (key$1.endsWith("[]")) { const strippedKey = key$1.replace("[]", ""); if (values[strippedKey] && value !== "null") values[strippedKey].push(value); if (!values[strippedKey]) values[strippedKey] = value === "null" ? [] : [value]; } else if (key$1.startsWith(FORM_TARGET_PREFIX)) { const expanded = construct({ [key$1.replace(FORM_TARGET_PREFIX, "")]: value }); if (shouldRemove) shouldRemove = assign(typeof shouldRemove === "object" ? shouldRemove : {}, expanded); else shouldSet = assign(typeof shouldSet === "object" ? shouldSet : {}, expanded); } else values[key$1] = value; }); elements.push(...Array.from(form.elements)); } const currentTime = /* @__PURE__ */ new Date(); for (const prop in registeredProperties.current) try { values[prop] = await registeredProperties.current[prop]?.getValue(); } catch (err) { const error$1 = err; setWaiting(false); setResult({ error: error$1, updatedAt: currentTime }); if (onError) onError(error$1); return; } let valuesNormalized = {}; for (const key$1 in values) { let type; switch (key$1) { case "id": type = "STRING"; break; case "ronin": type = "JSON"; break; default: type = registeredProperties.current[key$1]?.type || void 0; } if (!type) type = elements.find(({ name }) => name === key$1 || name === `${key$1}[]`)?.getAttribute("data-type"); if (!type) throw new Error(`No \`data-type\` provided for field "${key$1}".`); const isChildOfJSON = key$1.includes("."); const isArray = Array.isArray(values[key$1]); const value = values[key$1]; const normalizedValue = parseFormValue(value, type); if (excludeEmptyFields?.includes(key$1) && normalizedValue === null) continue; if (isChildOfJSON) { const expandable = {}; expandable[key$1] = normalizedValue; valuesNormalized = assign(valuesNormalized, construct(expandable)); } else if (isArray) valuesNormalized[key$1] = values[key$1].map((value$1) => parseFormValue(value$1, type)); else valuesNormalized[key$1] = normalizedValue; } let slugRedirect = null; if (recordSlug) { const currentParam = Array.isArray(params[recordSlug.param]) ? params[recordSlug.param]?.[0] : params[recordSlug.param]; const desiredParam = recordSlug.formatAs === "dash-case" ? dash(valuesNormalized[recordSlug.field]) : valuesNormalized[recordSlug.field]; if (currentParam !== desiredParam) { const newParam = desiredParam || (currentParam === newRecordSlug ? `{0.${recordSlug.field}}` : null); if (newParam) slugRedirect = populatePathname(pathname, { [recordSlug.param]: newParam }); } } let result$1; let error; try { const queryOptions = { redirect: slugRedirect || registeredRedirect.current || void 0, database }; if (shouldSet) result$1 = await set[model]({ with: typeof shouldSet === "object" ? shouldSet : {}, to: valuesNormalized, using: including }, queryOptions); else if (shouldRemove) result$1 = await remove[model]({ with: typeof shouldRemove === "object" ? shouldRemove : {}, using: including }, queryOptions); else result$1 = await add[model]({ with: valuesNormalized, using: including }, queryOptions); } catch (err) { error = err; if (!(error instanceof TriggerError)) console.error(error); } setWaiting(false); if (result$1) { setResult({ value: result$1, updatedAt: currentTime }); if (onSuccess) onSuccess(result$1); if (clearOnSuccess) clearForms(); } else if (error) { setResult({ error, updatedAt: currentTime }); if (onError) onError(error); } }; const clearForms = () => { Object.values(forms.current).map((form) => form.reset()); }; const reset = (clearFields) => { setWaiting(false); setResult(null); if (clearFields) clearForms(); }; const registerProperty = (name, type, getValue) => { registeredProperties.current[name] = { type, getValue }; }; const unregisterProperty = (name) => { delete registeredProperties.current[name]; }; const registerRedirect = (destination) => { registeredRedirect.current = destination; }; const unregisterRedirect = () => { registeredRedirect.current = null; }; useEffect(() => { registeredRedirect.current = redirect || null; }, [redirect]); const key = [ model, typeof shouldSet === "object" ? JSON.stringify(shouldSet) : "", typeof shouldRemove === "object" ? JSON.stringify(shouldRemove) : "" ].join(""); return /* @__PURE__ */ jsx(FormContext.Provider, { value: { key, waiting, disabled, clearOnSuccess, result: result?.value || null, error: result?.error || null, updatedAt: result?.updatedAt || null, submit, reset, registerProperty, unregisterProperty, registerRedirect, unregisterRedirect, registerForm, unregisterForm }, children: noElement ? children : /* @__PURE__ */ jsx(FormElement, { ...rest, children }) }); }; wrapClientComponent(Form, "Form"); //#endregion //#region private/client/components/input.tsx const stringifyFormValue = (value) => { let newValue; if (value === null) newValue = "null"; else if (typeof value === "boolean") newValue = value.toString(); else if (value instanceof Date) newValue = value.toISOString(); else if (typeof value === "object") newValue = JSON.stringify(value); else newValue = value || ""; return newValue; }; const Input = ({ value, fieldType, target,...rest }) => { const initialValue = typeof value === "undefined" ? value : stringifyFormValue(value); if (!fieldType) switch (rest.type) { case "number": fieldType = "INT64"; break; case "checkbox": fieldType = "BOOL"; break; default: fieldType = "STRING"; } return /* @__PURE__ */ jsx("input", { "data-type": fieldType, value: initialValue, ...rest, name: target && rest.name ? FORM_TARGET_PREFIX + rest.name : rest.name }); }; //#endregion export { Image as a, FormElement as i, Form as n, Link as o, FormContext as r, Input as t };