diff --git a/backend/src/api/controllers/configuration.ts b/backend/src/api/controllers/configuration.ts index 9a1dbd528017..e833bb879e36 100644 --- a/backend/src/api/controllers/configuration.ts +++ b/backend/src/api/controllers/configuration.ts @@ -12,7 +12,7 @@ import { MonkeyRequest } from "../types"; export async function getConfiguration( _req: MonkeyRequest, ): Promise { - const currentConfiguration = await Configuration.getLiveConfiguration(); + const currentConfiguration = await Configuration.getCachedConfiguration(true); return new MonkeyResponse("Configuration retrieved", currentConfiguration); } diff --git a/backend/src/api/controllers/psa.ts b/backend/src/api/controllers/psa.ts index c5f561807ff7..eabbae7c385b 100644 --- a/backend/src/api/controllers/psa.ts +++ b/backend/src/api/controllers/psa.ts @@ -3,8 +3,14 @@ import * as PsaDAL from "../../dal/psa"; import { MonkeyResponse } from "../../utils/monkey-response"; import { replaceObjectIds } from "../../utils/misc"; import { MonkeyRequest } from "../types"; +import { PSA } from "@monkeytype/schemas/psas"; +import { cacheWithTTL } from "../../utils/ttl-cache"; + +//cache for one minute +const cache = cacheWithTTL(1 * 60 * 1000, async () => { + return replaceObjectIds(await PsaDAL.get()); +}); export async function getPsas(_req: MonkeyRequest): Promise { - const data = await PsaDAL.get(); - return new MonkeyResponse("PSAs retrieved", replaceObjectIds(data)); + return new MonkeyResponse("PSAs retrieved", (await cache()) ?? []); } diff --git a/backend/src/utils/ttl-cache.ts b/backend/src/utils/ttl-cache.ts new file mode 100644 index 000000000000..f7538be577b8 --- /dev/null +++ b/backend/src/utils/ttl-cache.ts @@ -0,0 +1,27 @@ +/** + * Creates a caching function that loads data with a specified TTL (Time-to-Live). + * If the cache has expired (based on TTL), it will re-fetch the data by calling the provided function. + * Otherwise, it returns the cached value. + * + * @template T - The type of the value being cached. + * + * @param {number} ttlMs - The Time-to-Live (TTL) in milliseconds. The cache will refetch on call after this duration. + * @param {() => Promise} fn - A function that returns a promise resolving to the data to cache. + * + * @returns {() => Promise} + */ +export function cacheWithTTL( + ttlMs: number, + fn: () => Promise, +): () => Promise { + let lastFetchTime = 0; + let cache: T | undefined; + + return async () => { + if (lastFetchTime < Date.now() - ttlMs) { + lastFetchTime = Date.now(); + cache = await fn(); + } + return cache; + }; +} diff --git a/frontend/__tests__/components/ui/table/DataTable.spec.tsx b/frontend/__tests__/components/ui/table/DataTable.spec.tsx new file mode 100644 index 000000000000..5b4143708140 --- /dev/null +++ b/frontend/__tests__/components/ui/table/DataTable.spec.tsx @@ -0,0 +1,151 @@ +import { render, screen, fireEvent } from "@solidjs/testing-library"; +import { createSignal } from "solid-js"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { DataTable } from "../../../../src/ts/components/ui/table/DataTable"; + +const [localStorage, setLocalStorage] = createSignal([]); +vi.mock("../../../../src/ts/hooks/useLocalStorage", () => { + return { + useLocalStorage: () => { + return [localStorage, setLocalStorage] as const; + }, + }; +}); + +const bpSignal = createSignal({ + xxs: true, + sm: true, + md: true, +}); + +vi.mock("../../../../src/ts/signals/breakpoints", () => ({ + bp: () => bpSignal[0](), +})); + +type Person = { + name: string; + age: number; +}; + +const columns = [ + { + accessorKey: "name", + header: "Name", + cell: (info: any) => info.getValue(), + meta: { breakpoint: "xxs" }, + }, + { + accessorKey: "age", + header: "Age", + cell: (info: any) => info.getValue(), + meta: { breakpoint: "sm" }, + }, +]; + +const data: Person[] = [ + { name: "Alice", age: 30 }, + { name: "Bob", age: 20 }, +]; + +describe("DataTable", () => { + beforeEach(() => { + bpSignal[1]({ + xxs: true, + sm: true, + md: true, + }); + }); + + it("renders table headers and rows", () => { + render(() => ); + + expect(screen.getByText("Name")).toBeInTheDocument(); + expect(screen.getByText("Age")).toBeInTheDocument(); + + expect(screen.getByText("Alice")).toBeInTheDocument(); + expect(screen.getByText("Bob")).toBeInTheDocument(); + expect(screen.getByText("30")).toBeInTheDocument(); + expect(screen.getByText("20")).toBeInTheDocument(); + }); + + it("renders fallback when there is no data", () => { + render(() => ( + No data} + /> + )); + + expect(screen.getByText("No data")).toBeInTheDocument(); + }); + + it("sorts rows when clicking a sortable header", async () => { + render(() => ); + + const ageHeaderButton = screen.getByRole("button", { name: "Age" }); + const ageHeaderCell = ageHeaderButton.closest("th"); + + // Initial + expect(ageHeaderCell).toHaveAttribute("aria-sort", "none"); + expect(ageHeaderCell?.querySelector("i")).toHaveClass("fa-fw"); + + // Descending + await fireEvent.click(ageHeaderButton); + expect(ageHeaderCell).toHaveAttribute("aria-sort", "descending"); + expect(ageHeaderCell?.querySelector("i")).toHaveClass( + "fa-sort-down", + "fas", + "fa-fw", + ); + expect(localStorage()).toEqual([ + { + desc: true, + id: "age", + }, + ]); + + let rows = screen.getAllByRole("row"); + expect(rows[1]).toHaveTextContent("Alice"); // age 30 + expect(rows[2]).toHaveTextContent("Bob"); // age 20 + + // Ascending + await fireEvent.click(ageHeaderButton); + expect(ageHeaderCell).toHaveAttribute("aria-sort", "ascending"); + expect(ageHeaderCell?.querySelector("i")).toHaveClass( + "fa-sort-up", + "fas", + "fa-fw", + ); + expect(localStorage()).toEqual([ + { + desc: false, + id: "age", + }, + ]); + + rows = screen.getAllByRole("row"); + expect(rows[1]).toHaveTextContent("Bob"); + expect(rows[2]).toHaveTextContent("Alice"); + + //back to initial + await fireEvent.click(ageHeaderButton); + expect(ageHeaderCell).toHaveAttribute("aria-sort", "none"); + expect(localStorage()).toEqual([]); + }); + + it("hides columns based on breakpoint visibility", () => { + bpSignal[1]({ + xxs: true, + sm: false, + md: false, + }); + + render(() => ); + + expect(screen.getByText("Name")).toBeInTheDocument(); + expect(screen.queryByText("Age")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/package.json b/frontend/package.json index bac5f695da18..333ca44f4f2b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,6 +30,7 @@ "@sentry/browser": "9.14.0", "@sentry/vite-plugin": "3.3.1", "@solidjs/meta": "0.29.4", + "@tanstack/solid-table": "8.21.3", "@ts-rest/core": "3.52.1", "animejs": "4.2.2", "balloon-css": "1.2.0", @@ -49,7 +50,7 @@ "idb": "8.0.3", "konami": "1.7.0", "lz-ts": "1.1.2", - "modern-screenshot": "4.6.5", + "modern-screenshot": "4.6.8", "object-hash": "3.0.0", "slim-select": "2.9.2", "stemmer": "2.0.1", diff --git a/frontend/src/404.html b/frontend/src/404.html index ac3020f2ead8..00c2712dda4b 100644 --- a/frontend/src/404.html +++ b/frontend/src/404.html @@ -63,6 +63,5 @@

- diff --git a/frontend/src/email-handler.html b/frontend/src/email-handler.html index 324965b0087c..c1ae78a6e89a 100644 --- a/frontend/src/email-handler.html +++ b/frontend/src/email-handler.html @@ -14,7 +14,6 @@ crossorigin="anonymous" referrerpolicy="no-referrer" /> - - - - = + | ColumnDef + | AccessorFnColumnDef + | AccessorKeyColumnDef; + +type DataTableProps = { + id: string; + columns: AnyColumnDef[]; + data: TData[]; + fallback?: JSXElement; +}; + +export function DataTable( + props: DataTableProps, +): JSXElement { + const [sorting, setSorting] = useLocalStorage({ + //oxlint-disable-next-line solid/reactivity + key: `${props.id}Sort`, + schema: SortingStateSchema, + fallback: [], + //migrate old state from sorted-table + migrate: (value: Record | unknown[]) => + value !== null && + typeof value === "object" && + "property" in value && + "descending" in value + ? [ + { + id: value["property"] as string, + desc: value["descending"] as boolean, + }, + ] + : [], + }); + + const columnVisibility = createMemo(() => { + const current = bp(); + const result = Object.fromEntries( + props.columns.map((col, index) => { + const id = + col.id ?? + ("accessorKey" in col && col.accessorKey !== null + ? String(col.accessorKey) + : `__col_${index}`); + + return [id, current[col.meta?.breakpoint ?? "xxs"]]; + }), + ); + + return result; + }); + + const table = createSolidTable({ + get data() { + return props.data; + }, + get columns() { + return props.columns; + }, + getCoreRowModel: getCoreRowModel(), + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + state: { + get sorting() { + return sorting(); + }, + get columnVisibility() { + return columnVisibility(); + }, + }, + }); + + return ( + + + + + {(headerGroup) => ( + + + {(header) => ( + + + + } + else={ + + + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + + } + /> + )} + + + )} + + + + + {(row) => ( + + + {(cell) => { + const cellMeta = + typeof cell.column.columnDef.meta?.cellMeta === "function" + ? cell.column.columnDef.meta.cellMeta({ + value: cell.getValue(), + row: cell.row.original, + }) + : (cell.column.columnDef.meta?.cellMeta ?? {}); + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ); + }} + + + )} + + +
+
+ ); +} diff --git a/frontend/src/ts/components/ui/table/Table.tsx b/frontend/src/ts/components/ui/table/Table.tsx new file mode 100644 index 000000000000..1e1f5e762ae4 --- /dev/null +++ b/frontend/src/ts/components/ui/table/Table.tsx @@ -0,0 +1,87 @@ +import type { Component, ComponentProps } from "solid-js"; +import { splitProps } from "solid-js"; + +import { cn } from "../../../utils/cn"; + +const Table: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]); + return ( +
+ ); +}; + +const TableHeader: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]); + return ( + tr]:bg-none", local.class)} + {...others} + > + ); +}; + +const TableBody: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]); + return ( + tr]:odd:bg-sub-alt text-xs md:text-sm lg:text-base", + local.class, + )} + {...others} + > + ); +}; + +const TableFooter: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]); + return ; +}; + +const TableRow: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]); + return ( + td]:first:rounded-l [&>td]:last:rounded-r", local.class)} + {...others} + > + ); +}; + +const TableHead: Component> = (props) => { + const [local, others] = splitProps(props, ["class", "aria-label"]); + return ( + + ); +}; + +const TableCell: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]); + return ; +}; + +const TableCaption: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]); + return ; +}; + +export { + Table, + TableBody, + TableCaption, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +}; diff --git a/frontend/src/ts/elements/profile.ts b/frontend/src/ts/elements/profile.ts index d45378c2a0c0..65ea20773bb6 100644 --- a/frontend/src/ts/elements/profile.ts +++ b/frontend/src/ts/elements/profile.ts @@ -56,21 +56,19 @@ export async function update( const avatar = details?.qs(".avatarAndName .avatar"); avatar?.replaceWith(getAvatarElement(profile, { size: 256 })); + let badgeMainHtml = ""; + let badgeRestHtml = ""; if (profile.inventory?.badges && !banned) { - let mainHtml = ""; - let restHtml = ""; - for (const badge of profile.inventory.badges) { if (badge.selected === true) { - mainHtml = getHTMLById(badge.id); + badgeMainHtml = getHTMLById(badge.id); } else { - restHtml += getHTMLById(badge.id, true); + badgeRestHtml += getHTMLById(badge.id, true); } } - - details?.qs(".badges")?.empty().appendHtml(mainHtml); - details?.qs(".allBadges")?.empty().appendHtml(restHtml); } + details?.qs(".badges")?.empty().appendHtml(badgeMainHtml); + details?.qs(".allBadges")?.empty().appendHtml(badgeRestHtml); details?.qs(".name")?.setText(profile.name); details diff --git a/frontend/src/ts/signals/breakpoints.ts b/frontend/src/ts/signals/breakpoints.ts index e1664f25718c..d007aa935686 100644 --- a/frontend/src/ts/signals/breakpoints.ts +++ b/frontend/src/ts/signals/breakpoints.ts @@ -1,11 +1,11 @@ import { Accessor, createSignal, onCleanup } from "solid-js"; import { debounce } from "throttle-debounce"; -type BreakpointKeys = "xxl" | "xl" | "lg" | "md" | "sm" | "xs" | "xxs"; -type Breakpoints = Record; +export type BreakpointKey = "xxl" | "xl" | "lg" | "md" | "sm" | "xs" | "xxs"; +type Breakpoints = Record; const styles = getComputedStyle(document.documentElement); -const tw: Record = { +const tw: Record = { xxs: 0, xs: parseInt(styles.getPropertyValue("--breakpoint-xs")), sm: parseInt(styles.getPropertyValue("--breakpoint-sm")), @@ -18,7 +18,7 @@ const tw: Record = { export const bp = createBreakpoints(tw); function createBreakpoints( - breakpoints: Record, + breakpoints: Record, ): Accessor { const queries = Object.fromEntries( Object.entries(breakpoints).map(([key, px]) => [ @@ -49,5 +49,5 @@ function createBreakpoints( } }); - return matches as Accessor>; + return matches as Accessor>; } diff --git a/frontend/src/ts/test/test-screenshot.ts b/frontend/src/ts/test/test-screenshot.ts index 1939d3095b04..c8cf55ec4bc1 100644 --- a/frontend/src/ts/test/test-screenshot.ts +++ b/frontend/src/ts/test/test-screenshot.ts @@ -110,14 +110,6 @@ async function generateCanvas(): Promise { (document.querySelector("html") as HTMLElement).style.scrollBehavior = "auto"; window.scrollTo({ top: 0, behavior: "auto" }); - // --- Build embedded font CSS --- - let embeddedFontCss = ""; - try { - embeddedFontCss = await buildEmbeddedFontCss(); - } catch (e) { - console.warn("Failed to embed fonts:", e); - } - // --- Target Element Calculation --- const src = qs("#result .wrapper"); if (src === null) { @@ -149,10 +141,6 @@ async function generateCanvas(): Promise { backgroundColor: getTheme().bg, // Sharp output scale: window.devicePixelRatio ?? 1, - - // Pass embedded font CSS with data URLs - font: embeddedFontCss ? { cssText: embeddedFontCss } : undefined, - style: { width: `${targetWidth}px`, height: `${targetHeight}px`, @@ -393,151 +381,3 @@ document.addEventListener("keyup", (event) => { ?.removeClass(["fas", "fa-download"]) ?.addClass(["far", "fa-image"]); }); - -//below is all ai magic - -/** - * Recursively extracts all @font-face rules from stylesheets, including those inside @layer - */ -function extractAllFontFaceRules(): CSSFontFaceRule[] { - const fontRules: CSSFontFaceRule[] = []; - - function traverseRules(rules: CSSRuleList): void { - for (const rule of rules) { - if (rule instanceof CSSFontFaceRule) { - fontRules.push(rule); - } else if ( - "cssRules" in rule && - typeof rule.cssRules === "object" && - rule.cssRules !== null - ) { - traverseRules(rule.cssRules as CSSRuleList); - } - } - } - - for (const sheet of document.styleSheets) { - try { - if (sheet?.cssRules?.length && sheet.cssRules.length > 0) { - traverseRules(sheet.cssRules); - } - } catch (e) { - console.warn("Cannot access stylesheet:", e); - } - } - - return fontRules; -} - -/** - * Fetches a font file and converts it to a data URL - */ -async function fontUrlToDataUrl(url: string): Promise { - try { - const absoluteUrl = new URL(url, window.location.href).href; - const response = await fetch(absoluteUrl, { - mode: "cors", - credentials: "omit", - }); - if (!response.ok) return null; - const blob = await response.blob(); - return await new Promise((resolve) => { - const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result as string); - reader.onerror = () => resolve(null); - reader.readAsDataURL(blob); - }); - } catch { - return null; - } -} - -/** - * Converts a @font-face rule to CSS text with embedded data URLs - */ -async function fontFaceRuleToEmbeddedCss( - rule: CSSFontFaceRule, -): Promise { - let cssText = rule.cssText; - const srcProperty = rule.style.getPropertyValue("src"); - - if (!srcProperty) return null; - - // Extract all url() references - const urlRegex = /url\(['"]?([^'"]+?)['"]?\)/g; - const matches = [...srcProperty.matchAll(urlRegex)]; - - if (matches.length === 0) return cssText; - - for (const match of matches) { - const originalUrl = match[1]; - if ( - typeof originalUrl !== "string" || - originalUrl === "" || - originalUrl.startsWith("data:") - ) { - continue; - } - const dataUrl = await fontUrlToDataUrl(originalUrl); - if (typeof dataUrl === "string" && dataUrl !== "") { - const urlPattern = new RegExp( - `url\\(['"]?${originalUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}['"]?\\)`, - "g", - ); - cssText = cssText.replace(urlPattern, () => `url(${dataUrl})`); - } - } - - return cssText; -} - -/** - * Collects all used font families in the document - */ -function getUsedFontFamilies(): Set { - const families = new Set(); - - // Walk through all elements - const walker = document.createTreeWalker( - document.body, - NodeFilter.SHOW_ELEMENT, - null, - ); - - let node: Node | null; - while ((node = walker.nextNode())) { - if (node instanceof HTMLElement) { - const fontFamily = getComputedStyle(node).fontFamily; - if (fontFamily) { - fontFamily.split(",").forEach((family) => { - families.add(family.trim().replace(/['"]/g, "").toLowerCase()); - }); - } - } - } - - return families; -} - -/** - * Builds font CSS with data URLs embedded, including fonts from @layer - */ -async function buildEmbeddedFontCss(): Promise { - const allFontRules = extractAllFontFaceRules(); - const usedFamilies = getUsedFontFamilies(); - const embeddedRules: string[] = []; - - for (const rule of allFontRules) { - const fontFamily = rule.style.getPropertyValue("font-family"); - if (!fontFamily) continue; - const normalizedFamily = fontFamily - .trim() - .replace(/['"]/g, "") - .toLowerCase(); - if (!usedFamilies.has(normalizedFamily)) continue; - const embeddedCss = await fontFaceRuleToEmbeddedCss(rule); - if (embeddedCss !== null) embeddedRules.push(embeddedCss); - } - - return embeddedRules.join("\n"); -} diff --git a/frontend/src/ts/types/tanstack-table.d.ts b/frontend/src/ts/types/tanstack-table.d.ts new file mode 100644 index 000000000000..d69863e3b835 --- /dev/null +++ b/frontend/src/ts/types/tanstack-table.d.ts @@ -0,0 +1,32 @@ +import "@tanstack/solid-table"; +import type { JSX } from "solid-js"; +import { BreakpointKey } from "../signals/breakpoints"; + +declare module "@tanstack/solid-table" { + //This needs to be an interface + // oxlint-disable-next-line typescript/consistent-type-definitions + interface ColumnMeta { + /** + * define minimal breakpoint for the column to be visible. + * If not set, the column is always visible + */ + breakpoint?: BreakpointKey; + + /** + * additional attributes to be set on the table cell. + * Can be used to define mouse-overs with `aria-label` and `data-balloon-pos` + */ + cellMeta?: + | JSX.HTMLAttributes + | ((ctx: { + value: TValue; + row: TData; + }) => JSX.HTMLAttributes); + + /** + * additional attributes to be set on the header if it is sortable + * Can be used to define mouse-overs with `aria-label` and `data-balloon-pos` + */ + sortableHeaderMeta?: JSX.HTMLAttributes; + } +} diff --git a/frontend/src/ts/utils/json-data.ts b/frontend/src/ts/utils/json-data.ts index e8e5ab2b207b..da84b000122d 100644 --- a/frontend/src/ts/utils/json-data.ts +++ b/frontend/src/ts/utils/json-data.ts @@ -35,34 +35,31 @@ async function fetchJson(url: string): Promise { /** * Memoizes an asynchronous function. - * @template P The type of the function's parameters. - * @template T The type of the function. - * @param {T} fn The asynchronous function to memoize. - * @param {(...args: Parameters) => P} [getKey] Optional function to generate cache keys based on function arguments. - * @returns {T} The memoized function. + * @template P Cache key type + * @template Args Function argument tuple + * @template R Resolved value of the Promise + * @param fn The async function to memoize. + * @param getKey Optional function to compute a cache key from the function arguments. If omitted, the first argument is used as the key. + * @returns A memoized version of the async function with the same signature. */ -export function memoizeAsync(...args: P[]) => Promise>( - fn: T, - getKey?: (...args: Parameters) => P, -): T { - const cache = new Map>>(); - - return (async (...args: Parameters): Promise> => { - const key = getKey ? getKey.apply(args) : (args[0] as P); - - if (cache.has(key)) { - const ret = await cache.get(key); - if (ret !== undefined) { - return ret as ReturnType; - } +export function memoizeAsync( + fn: (...args: Args) => Promise, + getKey?: (...args: Args) => P, +): (...args: Args) => Promise { + const cache = new Map>(); + + return async (...args: Args): Promise => { + const key = getKey ? getKey(...args) : (args[0] as P); + + const cached = cache.get(key); + if (cached) { + return cached; } - // oxlint-disable-next-line prefer-spread - const result = fn.apply(null, args) as Promise>; + const result = fn(...args); cache.set(key, result); - return result; - }) as T; + }; } /** @@ -70,9 +67,7 @@ export function memoizeAsync(...args: P[]) => Promise>( * @param url - The URL used to fetch JSON data. * @returns A promise that resolves to the cached JSON data. */ -export const cachedFetchJson = memoizeAsync( - fetchJson, -); +export const cachedFetchJson = memoizeAsync(fetchJson); /** * Fetches a layout by name from the server. @@ -92,17 +87,9 @@ export type LanguageProperties = Pick< let currentLanguage: LanguageObject; -/** - * Fetches the language object for a given language from the server. - * @param lang The language code. - * @returns A promise that resolves to the language object. - */ -export async function getLanguage(lang: Language): Promise { - // try { - if (currentLanguage === undefined || currentLanguage.name !== lang) { - const loaded = await cachedFetchJson( - `/languages/${lang}.json`, - ); +const cachedFetchLanguage = memoizeAsync( + async (lang: Language): Promise => { + const loaded = await fetchJson(`/languages/${lang}.json`); if (!isDevEnvironment()) { //check the content to make it less easy to manipulate @@ -116,6 +103,19 @@ export async function getLanguage(lang: Language): Promise { ); } } + return loaded; + }, +); +/** + * Fetches the language object for a given language from the server. + * @param lang The language code. + * @returns A promise that resolves to the language object. + */ +export async function getLanguage(lang: Language): Promise { + // try { + if (currentLanguage === undefined || currentLanguage.name !== lang) { + const loaded = await cachedFetchLanguage(lang); + currentLanguage = loaded; } return currentLanguage; diff --git a/frontend/vite-plugins/inject-preload.ts b/frontend/vite-plugins/inject-preload.ts new file mode 100644 index 000000000000..1729c571956d --- /dev/null +++ b/frontend/vite-plugins/inject-preload.ts @@ -0,0 +1,48 @@ +import { IndexHtmlTransformContext, Plugin } from "vite"; +// eslint-disable-next-line import/no-unresolved +import UnpluginInjectPreload from "unplugin-inject-preload/vite"; +import { basename } from "node:path"; +export function injectPreload(): Plugin { + const base = UnpluginInjectPreload({ + files: [ + { + outputMatch: /css\/.*\.css$/, + attributes: { + as: "style", + type: "text/css", + rel: "preload", + crossorigin: true, + }, + }, + { + outputMatch: /.*\.woff2$/, + attributes: { + as: "font", + type: "font/woff2", + rel: "preload", + crossorigin: true, + }, + }, + ], + injectTo: "head-prepend", + }) as { + name: string; + vite: { + transformIndexHtml: { + handler: (html: string, ctx: IndexHtmlTransformContext) => string; + }; + }; + }; + + return { + name: base.name, + ...base.vite, + transformIndexHtml(html, ctx) { + //only add preload to the index.html file + if (basename(ctx.filename) !== "index.html") { + return html; + } + return base.vite.transformIndexHtml.handler(html, ctx); + }, + }; +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index d56546ce1808..c38466a7438f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -19,13 +19,12 @@ import { languageHashes } from "./vite-plugins/language-hashes"; import { minifyJson } from "./vite-plugins/minify-json"; import { versionFile } from "./vite-plugins/version-file"; import { oxlintChecker } from "./vite-plugins/oxlint-checker"; +import { injectPreload } from "./vite-plugins/inject-preload"; import Inspect from "vite-plugin-inspect"; import { ViteMinifyPlugin } from "vite-plugin-minify"; import { VitePWA } from "vite-plugin-pwa"; import { sentryVitePlugin } from "@sentry/vite-plugin"; import replace from "vite-plugin-filter-replace"; -// eslint-disable-next-line import/no-unresolved -import UnpluginInjectPreload from "unplugin-inject-preload/vite"; import { KnownFontName } from "@monkeytype/schemas/fonts"; import solidPlugin from "vite-plugin-solid"; import tailwindcss from "@tailwindcss/vite"; @@ -178,29 +177,7 @@ function getPlugins({ }, }, ]), - UnpluginInjectPreload({ - files: [ - { - outputMatch: /css\/.*\.css$/, - attributes: { - as: "style", - type: "text/css", - rel: "preload", - crossorigin: true, - }, - }, - { - outputMatch: /.*\.woff2$/, - attributes: { - as: "font", - type: "font/woff2", - rel: "preload", - crossorigin: true, - }, - }, - ], - injectTo: "head-prepend", - }), + injectPreload(), minifyJson(), ]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25366c8d830d..f6b3a29768f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -282,6 +282,9 @@ importers: '@solidjs/meta': specifier: 0.29.4 version: 0.29.4(solid-js@1.9.10) + '@tanstack/solid-table': + specifier: 8.21.3 + version: 8.21.3(solid-js@1.9.10) '@ts-rest/core': specifier: 3.52.1 version: 3.52.1(@types/node@24.9.1)(zod@3.23.8) @@ -340,8 +343,8 @@ importers: specifier: 1.1.2 version: 1.1.2 modern-screenshot: - specifier: 4.6.5 - version: 4.6.5 + specifier: 4.6.8 + version: 4.6.8 object-hash: specifier: 3.0.0 version: 3.0.0 @@ -417,7 +420,7 @@ importers: version: 5.0.2 '@vitest/coverage-v8': specifier: 4.0.15 - version: 4.0.15(vitest@4.0.15(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1)) + version: 4.0.15(vitest@4.0.15(@opentelemetry/api@1.8.0)(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1)) autoprefixer: specifier: 10.4.20 version: 10.4.20(postcss@8.4.31) @@ -510,7 +513,7 @@ importers: version: 2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.10)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1)) vitest: specifier: 4.0.15 - version: 4.0.15(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1) + version: 4.0.15(@opentelemetry/api@1.8.0)(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1) packages/contracts: dependencies: @@ -705,7 +708,7 @@ packages: resolution: {integrity: sha512-q0qHfnuNYVKu0Swrnnvfj9971AEyW7c8v9jCOZGCl5ZbyGMNG4RPyJkRcMi/JC8CRfdOe0IDfNm1nNsi2avprg==} peerDependencies: openapi3-ts: ^2.0.0 || ^3.0.0 - zod: 3.23.8 + zod: ^3.20.0 '@apideck/better-ajv-errors@0.3.6': resolution: {integrity: sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==} @@ -3376,6 +3379,16 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@tanstack/solid-table@8.21.3': + resolution: {integrity: sha512-PmhfSLBxVKiFs01LtYOYrCRhCyTUjxmb4KlxRQiqcALtip8+DOJeeezQM4RSX/GUS0SMVHyH/dNboCpcO++k2A==} + engines: {node: '>=12'} + peerDependencies: + solid-js: '>=1.3' + + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -7359,8 +7372,8 @@ packages: mobx@6.13.1: resolution: {integrity: sha512-ekLRxgjWJr8hVxj9ZKuClPwM/iHckx3euIJ3Np7zLVNtqJvfbbq7l370W/98C8EabdQ1pB5Jd3BbDWxJPNnaOg==} - modern-screenshot@4.6.5: - resolution: {integrity: sha512-0sDePJ9ssXWDO7V+yW9lwAxAu8jmVp4CXlBbjskSqrDxkIrcZO2EGqwD2mLtfTTinqZjmP4X/V6INOvNM1K7CQ==} + modern-screenshot@4.6.8: + resolution: {integrity: sha512-GJkv/yWPOJTlxj1LZDU2k474cDyOWL+LVaqTdDWQwQ5d8zIuTz1892+1cV9V0ZpK6HYZFo/+BNLBbierO9d2TA==} module-definition@6.0.0: resolution: {integrity: sha512-sEGP5nKEXU7fGSZUML/coJbrO+yQtxcppDAYWRE9ovWsTbFoUHB2qDUx564WUzDaBHXsD46JBbIK5WVTwCyu3w==} @@ -13064,6 +13077,13 @@ snapshots: tailwindcss: 4.1.18 vite: 7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1) + '@tanstack/solid-table@8.21.3(solid-js@1.9.10)': + dependencies: + '@tanstack/table-core': 8.21.3 + solid-js: 1.9.10 + + '@tanstack/table-core@8.21.3': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.27.1 @@ -13497,23 +13517,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@4.0.15(vitest@4.0.15(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1))': - dependencies: - '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.0.15 - ast-v8-to-istanbul: 0.3.8 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 - istanbul-reports: 3.2.0 - magicast: 0.5.1 - obug: 2.1.1 - std-env: 3.10.0 - tinyrainbow: 3.0.3 - vitest: 4.0.15(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1) - transitivePeerDependencies: - - supports-color - '@vitest/expect@4.0.15': dependencies: '@standard-schema/spec': 1.0.0 @@ -17901,7 +17904,7 @@ snapshots: mobx@6.13.1: {} - modern-screenshot@4.6.5: {} + modern-screenshot@4.6.8: {} module-definition@6.0.0: dependencies: @@ -20694,45 +20697,6 @@ snapshots: - tsx - yaml - vitest@4.0.15(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1): - dependencies: - '@vitest/expect': 4.0.15 - '@vitest/mocker': 4.0.15(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1)) - '@vitest/pretty-format': 4.0.15 - '@vitest/runner': 4.0.15 - '@vitest/snapshot': 4.0.15 - '@vitest/spy': 4.0.15 - '@vitest/utils': 4.0.15 - es-module-lexer: 1.7.0 - expect-type: 1.2.2 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 3.10.0 - tinybench: 2.9.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 - vite: 7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 24.9.1 - happy-dom: 20.0.10 - jsdom: 27.4.0 - transitivePeerDependencies: - - jiti - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml - vlq@0.2.3: {} w3c-xmlserializer@5.0.0: