From 1b1746bbc0216b58f6979bbb03c55459ed15b771 Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 19 Jan 2026 14:17:24 +0100 Subject: [PATCH 1/2] chore: upgrade oxc also enable import sorting --- .oxfmtrc.json | 27 +- backend/package.json | 4 +- .../components/AnimatedModal.spec.tsx | 3 +- .../components/AsyncContent.spec.tsx | 3 +- frontend/__tests__/components/Button.spec.tsx | 3 +- .../__tests__/components/ScrollToTop.spec.tsx | 3 +- frontend/package.json | 4 +- .../ts/components/common/AnimatedModal.tsx | 7 +- .../src/ts/components/common/AsyncContent.tsx | 8 +- frontend/src/ts/components/common/Button.tsx | 1 + frontend/src/ts/components/common/ChartJs.tsx | 5 +- .../ts/components/layout/footer/Footer.tsx | 16 +- .../components/layout/footer/ScrollToTop.tsx | 3 +- .../layout/footer/ThemeIndicator.tsx | 9 +- .../layout/footer/VersionButton.tsx | 7 +- .../components/layout/overlays/FpsCounter.tsx | 4 +- .../components/layout/overlays/LoaderBar.tsx | 3 +- .../components/layout/overlays/Overlays.tsx | 4 +- .../src/ts/components/modals/ContactModal.tsx | 1 + frontend/src/ts/components/modals/Modals.tsx | 3 +- .../src/ts/components/modals/SupportModal.tsx | 7 +- .../components/modals/VersionHistoryModal.tsx | 9 +- frontend/src/ts/components/mount.tsx | 6 +- .../src/ts/components/pages/AboutPage.tsx | 23 +- .../utils/TailwindMediaQueryDebugger.tsx | 5 +- package.json | 6 +- packages/contracts/package.json | 4 +- packages/funbox/package.json | 4 +- packages/release/package.json | 4 +- packages/schemas/package.json | 4 +- packages/tsup-config/package.json | 4 +- packages/util/package.json | 4 +- pnpm-lock.yaml | 1396 ++++++++++------- 33 files changed, 944 insertions(+), 650 deletions(-) diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 5ffd11314766..5d8248473e09 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -16,8 +16,27 @@ "coverage", "*.md" ], - "experimentalTailwindcss": { - "stylesheet": "./frontend/src/styles/tailwind.css", - "functions": ["cn"] - } + "overrides": [ + { + "files": ["**/*.tsx"], + "options": { + "experimentalTailwindcss": { + "stylesheet": "./frontend/src/styles/tailwind.css", + "attributes": ["cn"], + "functions": ["cn"] + }, + "experimentalSortImports": { + "groups": [ + ["side-effect"], + ["builtin"], + ["external", "external-type"], + ["internal", "internal-type"], + ["parent", "parent-type"], + ["sibling", "sibling-type"], + ["index", "index-type"] + ] + } + } + } + ] } diff --git a/backend/package.json b/backend/package.json index 69eba1c1356b..7427efe98d69 100644 --- a/backend/package.json +++ b/backend/package.json @@ -80,8 +80,8 @@ "@vitest/coverage-v8": "4.0.15", "concurrently": "8.2.2", "openapi3-ts": "2.0.2", - "oxlint": "1.39.0", - "oxlint-tsgolint": "0.11.0", + "oxlint": "1.40.0", + "oxlint-tsgolint": "0.11.1", "readline-sync": "1.4.10", "supertest": "7.1.4", "testcontainers": "11.11.0", diff --git a/frontend/__tests__/components/AnimatedModal.spec.tsx b/frontend/__tests__/components/AnimatedModal.spec.tsx index 0237e8ffb20e..354cdf0f997c 100644 --- a/frontend/__tests__/components/AnimatedModal.spec.tsx +++ b/frontend/__tests__/components/AnimatedModal.spec.tsx @@ -1,5 +1,6 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; import { render } from "@solidjs/testing-library"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + import { AnimatedModal } from "../../src/ts/components/common/AnimatedModal"; describe("AnimatedModal", () => { diff --git a/frontend/__tests__/components/AsyncContent.spec.tsx b/frontend/__tests__/components/AsyncContent.spec.tsx index 613dd48d2851..0147be215dd3 100644 --- a/frontend/__tests__/components/AsyncContent.spec.tsx +++ b/frontend/__tests__/components/AsyncContent.spec.tsx @@ -1,6 +1,7 @@ -import { describe, it, expect } from "vitest"; import { render, screen, waitFor } from "@solidjs/testing-library"; import { createResource, Resource } from "solid-js"; +import { describe, it, expect } from "vitest"; + import AsyncContent from "../../src/ts/components/common/AsyncContent"; describe("AsyncContent", () => { diff --git a/frontend/__tests__/components/Button.spec.tsx b/frontend/__tests__/components/Button.spec.tsx index 5475867747c1..7f18597dfbee 100644 --- a/frontend/__tests__/components/Button.spec.tsx +++ b/frontend/__tests__/components/Button.spec.tsx @@ -1,5 +1,6 @@ -import { describe, it, expect, vi, afterEach } from "vitest"; import { render, cleanup } from "@solidjs/testing-library"; +import { describe, it, expect, vi, afterEach } from "vitest"; + import { Button } from "../../src/ts/components/common/Button"; describe("Button component", () => { diff --git a/frontend/__tests__/components/ScrollToTop.spec.tsx b/frontend/__tests__/components/ScrollToTop.spec.tsx index b7c4dc545c13..27a46015f451 100644 --- a/frontend/__tests__/components/ScrollToTop.spec.tsx +++ b/frontend/__tests__/components/ScrollToTop.spec.tsx @@ -1,6 +1,7 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; import { render } from "@solidjs/testing-library"; import { userEvent } from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + import { ScrollToTop } from "../../src/ts/components/layout/footer/ScrollToTop"; import * as CoreSignals from "../../src/ts/signals/core"; diff --git a/frontend/package.json b/frontend/package.json index 8e8d55784cdc..3d7999b67043 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -90,8 +90,8 @@ "madge": "8.0.0", "magic-string": "0.30.17", "normalize.css": "8.0.1", - "oxlint": "1.39.0", - "oxlint-tsgolint": "0.11.0", + "oxlint": "1.40.0", + "oxlint-tsgolint": "0.11.1", "postcss": "8.4.31", "sass": "1.70.0", "solid-js": "1.9.10", diff --git a/frontend/src/ts/components/common/AnimatedModal.tsx b/frontend/src/ts/components/common/AnimatedModal.tsx index ae665b68816c..63f5e32a1720 100644 --- a/frontend/src/ts/components/common/AnimatedModal.tsx +++ b/frontend/src/ts/components/common/AnimatedModal.tsx @@ -5,7 +5,7 @@ import { ParentProps, Show, } from "solid-js"; -import { applyReducedMotion } from "../../utils/misc"; + import { useRefWithUtils } from "../../hooks/useRefWithUtils"; import { hideModal as storeHideModal, @@ -14,6 +14,7 @@ import { isModalChained, } from "../../stores/modals"; import { cn } from "../../utils/cn"; +import { applyReducedMotion } from "../../utils/misc"; type AnimationParams = { opacity?: number | [number, number]; @@ -275,13 +276,13 @@ export function AnimatedModal(props: AnimatedModalProps): JSXElement {
-
{props.title}
+
{props.title}
{props.children}
diff --git a/frontend/src/ts/components/common/AsyncContent.tsx b/frontend/src/ts/components/common/AsyncContent.tsx index ef9e86bde1c0..e570a519ad4f 100644 --- a/frontend/src/ts/components/common/AsyncContent.tsx +++ b/frontend/src/ts/components/common/AsyncContent.tsx @@ -1,6 +1,8 @@ import { ErrorBoundary, JSXElement, Resource, Show, Suspense } from "solid-js"; -import { createErrorMessage } from "../../utils/misc"; + import * as Notifications from "../../elements/notifications"; +import { createErrorMessage } from "../../utils/misc"; + import { Conditional } from "./Conditional"; export default function AsyncContent( @@ -48,7 +50,7 @@ export default function AsyncContent( return ( <> -
+
@@ -62,7 +64,7 @@ export default function AsyncContent( > +
} diff --git a/frontend/src/ts/components/common/Button.tsx b/frontend/src/ts/components/common/Button.tsx index b1c6a68567a7..28c277170e3f 100644 --- a/frontend/src/ts/components/common/Button.tsx +++ b/frontend/src/ts/components/common/Button.tsx @@ -1,4 +1,5 @@ import { JSXElement, Show } from "solid-js"; + import { Conditional } from "./Conditional"; type BaseProps = { diff --git a/frontend/src/ts/components/common/ChartJs.tsx b/frontend/src/ts/components/common/ChartJs.tsx index 87f663b78e76..8e7d3f500c72 100644 --- a/frontend/src/ts/components/common/ChartJs.tsx +++ b/frontend/src/ts/components/common/ChartJs.tsx @@ -1,4 +1,3 @@ -import { onMount, onCleanup, createEffect, JSXElement } from "solid-js"; import { Chart, ChartType, @@ -6,8 +5,10 @@ import { ChartOptions, DefaultDataPoint, } from "chart.js"; -import { useRefWithUtils } from "../../hooks/useRefWithUtils"; +import { onMount, onCleanup, createEffect, JSXElement } from "solid-js"; + import { ChartWithUpdateColors } from "../../controllers/chart-controller"; +import { useRefWithUtils } from "../../hooks/useRefWithUtils"; import { getThemeColors } from "../../signals/theme"; type ChartJSProps< diff --git a/frontend/src/ts/components/layout/footer/Footer.tsx b/frontend/src/ts/components/layout/footer/Footer.tsx index 529f6aab7d1b..685612e9c706 100644 --- a/frontend/src/ts/components/layout/footer/Footer.tsx +++ b/frontend/src/ts/components/layout/footer/Footer.tsx @@ -1,19 +1,21 @@ import { JSXElement } from "solid-js"; -import { VersionButton } from "./VersionButton"; -import { Button } from "../../common/Button"; + +import { getFocus } from "../../../signals/core"; import { showModal } from "../../../stores/modals"; -import { ThemeIndicator } from "./ThemeIndicator"; +import { Button } from "../../common/Button"; + import { ScrollToTop } from "./ScrollToTop"; -import { getFocus } from "../../../signals/core"; +import { ThemeIndicator } from "./ThemeIndicator"; +import { VersionButton } from "./VersionButton"; export function Footer(): JSXElement { return ( -
+
{" "} + to change it and learn more about why. + + ), + important: true, + }); } const areConfigsEqual = diff --git a/frontend/src/ts/components/layout/overlays/Banners.tsx b/frontend/src/ts/components/layout/overlays/Banners.tsx new file mode 100644 index 000000000000..6ec5f545287b --- /dev/null +++ b/frontend/src/ts/components/layout/overlays/Banners.tsx @@ -0,0 +1,108 @@ +import { + createEffect, + For, + JSXElement, + on, + onCleanup, + onMount, +} from "solid-js"; +import { debounce } from "throttle-debounce"; + +import { useRefWithUtils } from "../../../hooks/useRefWithUtils"; +import { setGlobalOffsetTop } from "../../../signals/core"; +import { + Banner as BannerType, + getBanners, + removeBanner, +} from "../../../stores/banners"; +import { cn } from "../../../utils/cn"; +import { Conditional } from "../../common/Conditional"; + +function Banner(props: BannerType): JSXElement { + const remove = (): void => { + // document.startViewTransition(() => { + removeBanner(props.id); + // }); + }; + const icon = (): string => + props.icon === undefined || props.icon === "" + ? "fa fa-fw fa-bullhorn" + : props.icon; + + return ( +
+
+ + + + + } + else={} + /> + {props.customContent}
} + else={
{props.text}
} + /> + } + else={ + + } + /> +
+
+ ); +} + +export function Banners(): JSXElement { + const [ref, element] = useRefWithUtils(); + + const updateMargin = (): void => { + const height = element()?.getOffsetHeight() ?? 0; + setGlobalOffsetTop(height); + }; + + const debouncedMarginUpdate = debounce(100, updateMargin); + + onMount(() => { + window.addEventListener("resize", debouncedMarginUpdate); + }); + + onCleanup(() => { + window.removeEventListener("resize", debouncedMarginUpdate); + }); + + createEffect(on(() => getBanners().length, updateMargin)); + + return ( +
+ {(banner) => } +
+ ); +} diff --git a/frontend/src/ts/components/layout/overlays/Overlays.tsx b/frontend/src/ts/components/layout/overlays/Overlays.tsx index a9bded4bf1ba..f7626e5fe8f3 100644 --- a/frontend/src/ts/components/layout/overlays/Overlays.tsx +++ b/frontend/src/ts/components/layout/overlays/Overlays.tsx @@ -2,12 +2,14 @@ import { JSXElement } from "solid-js"; import { TailwindMediaQueryDebugger } from "../../utils/TailwindMediaQueryDebugger"; +import { Banners } from "./Banners"; import { FpsCounter } from "./FpsCounter"; import { LoaderBar } from "./LoaderBar"; export function Overlays(): JSXElement { return ( <> + diff --git a/frontend/src/ts/controllers/ad-controller.ts b/frontend/src/ts/controllers/ad-controller.ts index 4aa48f47e001..0c56463804eb 100644 --- a/frontend/src/ts/controllers/ad-controller.ts +++ b/frontend/src/ts/controllers/ad-controller.ts @@ -1,13 +1,12 @@ /* oxlint-disable no-unsafe-member-access */ import { debounce } from "throttle-debounce"; -// import * as Numbers from "@monkeytype/util/numbers"; import * as ConfigEvent from "../observables/config-event"; -import * as BannerEvent from "../observables/banner-event"; import Config from "../config"; import * as TestState from "../test/test-state"; import * as EG from "./eg-ad-controller"; import * as PW from "./pw-ad-controller"; import { onDOMReady, qs } from "../utils/dom"; +// import { createEffect } from "solid-js"; const breakpoint = 900; let widerThanBreakpoint = true; @@ -86,13 +85,6 @@ function removeResult(): void { qs("#ad-result-small-wrapper")?.remove(); } -function updateVerticalMargin(): void { - // const height = $("#bannerCenter").height() as number; - // const margin = height + Numbers.convertRemToPixels(2) + "px"; - // $("#ad-vertical-left-wrapper").css("margin-top", margin); - // $("#ad-vertical-right-wrapper").css("margin-top", margin); -} - function updateBreakpoint(noReinstate = false): void { const beforeUpdate = widerThanBreakpoint; @@ -286,12 +278,10 @@ export function destroyResult(): void { // $("#ad-result-small-wrapper").empty(); } -const debouncedMarginUpdate = debounce(500, updateVerticalMargin); const debouncedBreakpointUpdate = debounce(500, updateBreakpoint); const debouncedBreakpoint2Update = debounce(500, updateBreakpoint2); window.addEventListener("resize", () => { - debouncedMarginUpdate(); debouncedBreakpointUpdate(); debouncedBreakpoint2Update(); }); @@ -309,9 +299,14 @@ ConfigEvent.subscribe(({ key, newValue }) => { } }); -BannerEvent.subscribe(() => { - updateVerticalMargin(); -}); +// createEffect(() => { +// qs("#ad-vertical-left-wrapper")?.setStyle({ +// marginTop: getGlobalOffsetTop() + "px", +// }); +// qs("#ad-vertical-right-wrapper")?.setStyle({ +// marginTop: getGlobalOffsetTop() + "px", +// }); +// }); onDOMReady(() => { updateBreakpoint(true); diff --git a/frontend/src/ts/elements/merch-banner.ts b/frontend/src/ts/elements/merch-banner.ts deleted file mode 100644 index 9cf21f622b70..000000000000 --- a/frontend/src/ts/elements/merch-banner.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { z } from "zod"; -import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; -import * as Notifications from "./notifications"; - -const closed = new LocalStorageWithSchema({ - key: "merchBannerClosed3", - schema: z.boolean(), - fallback: false, -}); - -export function showIfNotClosedBefore(): void { - if (!closed.get()) { - Notifications.addBanner( - `New merch store now open, including a limited edition metal keycap! monkeytype.store`, - 1, - "./images/merch3.png", - false, - () => { - closed.set(true); - }, - true, - ); - } -} diff --git a/frontend/src/ts/elements/merch-banner.tsx b/frontend/src/ts/elements/merch-banner.tsx new file mode 100644 index 000000000000..8f9279b23f84 --- /dev/null +++ b/frontend/src/ts/elements/merch-banner.tsx @@ -0,0 +1,31 @@ +import { z } from "zod"; + +import { addBanner } from "../stores/banners"; +import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; + +const closed = new LocalStorageWithSchema({ + key: "merchBannerClosed3", + schema: z.boolean(), + fallback: false, +}); + +export function showIfNotClosedBefore(): void { + if (!closed.get()) { + addBanner({ + level: "success", + icon: "fas fa-fw fa-shopping-bag", + customContent: ( + <> + New merch store now open, including a limited edition metal keycap!{" "} + + monkeytype.store + + + ), + imagePath: "./images/merch3.png", + onClose: () => { + closed.set(true); + }, + }); + } +} diff --git a/frontend/src/ts/elements/notifications.ts b/frontend/src/ts/elements/notifications.ts index e85593a5d57d..31e14ec8f06c 100644 --- a/frontend/src/ts/elements/notifications.ts +++ b/frontend/src/ts/elements/notifications.ts @@ -1,31 +1,20 @@ -import { debounce } from "throttle-debounce"; import * as Misc from "../utils/misc"; -import * as BannerEvent from "../observables/banner-event"; import * as NotificationEvent from "../observables/notification-event"; -import { convertRemToPixels } from "../utils/numbers"; import { animate } from "animejs"; import { qsr } from "../utils/dom"; import { CommonResponsesType } from "@monkeytype/contracts/util/api"; +import { createEffect } from "solid-js"; +import { getGlobalOffsetTop } from "../signals/core"; const notificationCenter = qsr("#notificationCenter"); const notificationCenterHistory = notificationCenter.qsr(".history"); -const bannerCenter = qsr("#bannerCenter"); -const app = qsr("#app"); const clearAllButton = notificationCenter.qsr(".clearAll"); -function updateMargin(): void { - const height = bannerCenter.native.offsetHeight; - app.setStyle({ paddingTop: height + convertRemToPixels(2) + "px" }); - notificationCenter.setStyle({ marginTop: height + "px" }); -} - let visibleStickyNotifications = 0; let id = 0; -type NotificationType = "notification" | "banner" | "psa"; class Notification { id: number; - type: NotificationType; message: string; level: number; important: boolean; @@ -34,7 +23,6 @@ class Notification { customIcon?: string; closeCallback: () => void; constructor( - type: NotificationType, message: string, level: number, important: boolean | undefined, @@ -46,23 +34,20 @@ class Notification { }, allowHTML?: boolean, ) { - this.type = type; this.message = allowHTML ? message : Misc.escapeHTML(message); this.level = level; this.important = important ?? false; - if (type === "banner" || type === "psa") { - this.duration = duration as number; - } else { - if (duration === undefined) { - if (level === -1) { - this.duration = 0; - } else { - this.duration = 3000; - } + + if (duration === undefined) { + if (level === -1) { + this.duration = 0; } else { - this.duration = duration * 1000; + this.duration = 3000; } + } else { + this.duration = duration * 1000; } + this.customTitle = customTitle; this.customIcon = customIcon; this.id = id++; @@ -94,112 +79,52 @@ class Notification { title = this.customTitle; } - if (this.type === "banner" || this.type === "psa") { - icon = ``; - } - if (this.customIcon !== undefined) { icon = ``; } - if (this.type === "notification") { - // moveCurrentToHistory(); - if (this.duration === 0) { - visibleStickyNotifications++; - updateClearAllButton(); - } + // moveCurrentToHistory(); + if (this.duration === 0) { + visibleStickyNotifications++; + updateClearAllButton(); + } - notificationCenterHistory.prependHtml(` + notificationCenterHistory.prependHtml(`
${icon}
${title}
${this.message}
`); - const notif = notificationCenter.qs(`.notif[id='${this.id}']`); - if (notif === null) return; - - const notifHeight = notif.native.offsetHeight; - const duration = Misc.applyReducedMotion(250); - - animate(notif.native, { - opacity: [0, 1], - duration: duration / 2, - delay: duration / 2, - }); - notif?.on("click", () => { - this.hide(); - this.closeCallback(); - if (this.duration === 0) { - visibleStickyNotifications--; - } - updateClearAllButton(); - }); + const notif = notificationCenter.qs(`.notif[id='${this.id}']`); + if (notif === null) return; - animate(notificationCenterHistory.native, { - marginTop: { - from: "-=" + notifHeight, - to: 0, - }, - duration: duration / 2, - }); - notif?.on("hover", () => { - notif?.toggleClass("hover"); - }); - } else if (this.type === "banner" || this.type === "psa") { - let leftside = `
${icon}
`; + const notifHeight = notif.native.offsetHeight; + const duration = Misc.applyReducedMotion(250); - let withImage = false; - if (/images\/.*/.test(this.customIcon as string)) { - withImage = true; - leftside = `
`; + animate(notif.native, { + opacity: [0, 1], + duration: duration / 2, + delay: duration / 2, + }); + notif?.on("click", () => { + this.hide(); + this.closeCallback(); + if (this.duration === 0) { + visibleStickyNotifications--; } + updateClearAllButton(); + }); + + animate(notificationCenterHistory.native, { + marginTop: { + from: "-=" + notifHeight, + to: 0, + }, + duration: duration / 2, + }); + notif?.on("hover", () => { + notif?.toggleClass("hover"); + }); - bannerCenter.prependHtml(` -
-
- ${leftside} -
- ${this.message} -
- ${ - this.duration >= 0 - ? ` -
- -
- ` - : `
${icon}
` - } -
-
- `); - updateMargin(); - BannerEvent.dispatch(); - if (this.duration >= 0) { - bannerCenter - .qsa( - `.banner[id='${this.id}'] .closeButton, .psa[id='${this.id}'] .closeButton`, - ) - .on("click", () => { - this.hide(); - this.closeCallback(); - }); - } - // NOTE: This need to be changed if the update banner text is changed - if (/please ()?refresh/i.test(this.message)) { - // add pointer when refresh is needed - bannerCenter - .qsa(`.banner[id='${this.id}'], .psa[id='${this.id}']`) - .addClass("clickable"); - // refresh on clicking banner - bannerCenter - .qsa(`.banner[id='${this.id}'], .psa[id='${this.id}']`) - .on("click", () => { - window.location.reload(); - }); - } - } if (this.duration > 0) { setTimeout(() => { this.hide(); @@ -207,39 +132,31 @@ class Notification { } } hide(): void { - if (this.type === "notification") { - const notif = notificationCenter.qs(`.notif[id='${this.id}']`); + const notif = notificationCenter.qs(`.notif[id='${this.id}']`); - if (notif === null) return; + if (notif === null) return; - const duration = Misc.applyReducedMotion(250); + const duration = Misc.applyReducedMotion(250); - animate(notif.native, { - opacity: { - to: 0, - duration: duration, - }, - height: { - to: 0, - duration: duration / 2, - delay: duration / 2, - }, - marginBottom: { - to: 0, - duration: duration / 2, - delay: duration / 2, - }, - onComplete: () => { - notif.remove(); - }, - }); - } else if (this.type === "banner" || this.type === "psa") { - bannerCenter - .qsa(`.banner[id='${this.id}'], .psa[id='${this.id}']`) - .remove(); - updateMargin(); - BannerEvent.dispatch(); - } + animate(notif.native, { + opacity: { + to: 0, + duration: duration, + }, + height: { + to: 0, + duration: duration / 2, + delay: duration / 2, + }, + marginBottom: { + to: 0, + duration: duration / 2, + delay: duration / 2, + }, + onComplete: () => { + notif.remove(); + }, + }); } } @@ -301,7 +218,6 @@ export function add( }); new Notification( - "notification", message, level, options.important, @@ -313,64 +229,12 @@ export function add( ).show(); } -export function addBanner( - message: string, - level = -1, - customIcon = "bullhorn", - sticky = false, - closeCallback?: () => void, - allowHTML?: boolean, -): number { - const banner = new Notification( - "banner", - message, - level, - false, - sticky ? -1 : 0, - undefined, - customIcon, - closeCallback, - allowHTML, - ); - banner.show(); - return banner.id; -} - -export function addPSA( - message: string, - level = -1, - customIcon = "bullhorn", - sticky = false, - closeCallback?: () => void, - allowHTML?: boolean, -): number { - const psa = new Notification( - "psa", - message, - level, - false, - sticky ? -1 : 0, - undefined, - customIcon, - closeCallback, - allowHTML, - ); - psa.show(); - return psa.id; -} - export function clearAllNotifications(): void { notificationCenter.qsa(".notif").remove(); visibleStickyNotifications = 0; updateClearAllButton(); } -const debouncedMarginUpdate = debounce(100, updateMargin); - -window.addEventListener("resize", () => { - debouncedMarginUpdate(); -}); - notificationCenter.qs(".clearAll")?.on("click", () => { notificationCenter.qsa(".notif").forEach((element) => { element.native.click(); @@ -378,3 +242,9 @@ notificationCenter.qs(".clearAll")?.on("click", () => { visibleStickyNotifications = 0; updateClearAllButton(); }); + +createEffect(() => { + notificationCenter.setStyle({ + marginTop: getGlobalOffsetTop() + "px", + }); +}); diff --git a/frontend/src/ts/elements/psa.ts b/frontend/src/ts/elements/psa.tsx similarity index 68% rename from frontend/src/ts/elements/psa.ts rename to frontend/src/ts/elements/psa.tsx index 673c4e3ff813..46b5bab1c548 100644 --- a/frontend/src/ts/elements/psa.ts +++ b/frontend/src/ts/elements/psa.tsx @@ -1,16 +1,18 @@ -import Ape from "../ape"; -import { isDevEnvironment } from "../utils/misc"; -import { secondsToString } from "../utils/date-and-time"; -import * as Notifications from "./notifications"; -import { format } from "date-fns/format"; -import * as Alerts from "./alerts"; import { PSA } from "@monkeytype/schemas/psas"; -import { z } from "zod"; -import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; import { IdSchema } from "@monkeytype/schemas/util"; -import { tryCatch } from "@monkeytype/util/trycatch"; import { isSafeNumber } from "@monkeytype/util/numbers"; +import { tryCatch } from "@monkeytype/util/trycatch"; +import { format } from "date-fns/format"; +import { z } from "zod"; + +import Ape from "../ape"; import * as AuthEvent from "../observables/auth-event"; +import { addBanner } from "../stores/banners"; +import { secondsToString } from "../utils/date-and-time"; +import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; +import { isDevEnvironment } from "../utils/misc"; + +import * as Alerts from "./alerts"; const confirmedPSAs = new LocalStorageWithSchema({ key: "confirmedPSAs", @@ -37,12 +39,11 @@ async function getLatest(): Promise { if (response.status === 500) { if (isDevEnvironment()) { - Notifications.addPSA( - "Dev Info: Backend server not running", - 0, - "exclamation-triangle", - false, - ); + addBanner({ + level: "notice", + text: "Dev Info: Backend server not running", + icon: "fas fa-exclamation-triangle", + }); } else { type InstatusSummary = { page: { @@ -93,35 +94,52 @@ async function getLatest(): Promise { maintenanceData[0] !== undefined && maintenanceData[0].status === "INPROGRESS" ) { - Notifications.addPSA( - `Server is currently offline for scheduled maintenance. Check the status page for more info.`, - -1, - "bullhorn", - true, - undefined, - true, - ); + addBanner({ + level: "error", + customContent: ( + <> + Server is currently offline for scheduled maintenance.{" "} + + Check the status page + {" "} + for more info. + + ), + icon: "fas fa-bullhorn", + }); } else { - Notifications.addPSA( - "Looks like the server is experiencing unexpected down time.
Check the status page for more information.", - -1, - "exclamation-triangle", - false, - undefined, - true, - ); + addBanner({ + level: "error", + icon: "fas fa-exclamation-triangle", + customContent: ( + <> + Looks like the server is experiencing unexpected down time. +
+ Check the{" "} + + status page + {" "} + for more information. + + ), + }); } } return null; } else if (response.status === 503) { - Notifications.addPSA( - "Server is currently under maintenance. Check the status page for more info.", - -1, - "bullhorn", - true, - undefined, - true, - ); + addBanner({ + level: "error", + icon: "fas fa-bullhorn", + customContent: ( + <> + Server is currently under maintenance.{" "} + + Check the status page + {" "} + for more info. + + ), + }); return null; } else if (response.status !== 200) { return null; @@ -166,16 +184,24 @@ export async function show(): Promise { return; } - Notifications.addPSA( - psa.message, - psa.level, - "bullhorn", - psa.sticky, - () => { + let level: "error" | "notice" | "success"; + if (psa.level === -1) { + level = "error"; + } else if (psa.level === 1) { + level = "success"; + } else { + level = "notice"; + } + + addBanner({ + level, + text: psa.message, + icon: "fas fa-bullhorn", + important: psa.sticky ?? false, + onClose: () => { setMemory(psa._id); }, - true, - ); + }); }); } diff --git a/frontend/src/ts/firebase.ts b/frontend/src/ts/firebase.ts index dd2a6354201b..85b562673397 100644 --- a/frontend/src/ts/firebase.ts +++ b/frontend/src/ts/firebase.ts @@ -22,7 +22,6 @@ import { indexedDBLocalPersistence, getAdditionalUserInfo, } from "firebase/auth"; -import * as Notifications from "./elements/notifications"; import { createErrorMessage, isDevEnvironment, @@ -35,6 +34,7 @@ import { } from "firebase/analytics"; import { tryCatch } from "@monkeytype/util/trycatch"; import { dispatch as dispatchSignUpEvent } from "./observables/google-sign-up-event"; +import { addBanner } from "./stores/banners"; let app: FirebaseApp | undefined; let Auth: AuthType | undefined; @@ -84,12 +84,11 @@ export async function init(callback: ReadyCallback): Promise { console.error("Firebase failed to initialize", e); await callback(false, null); if (isDevEnvironment()) { - Notifications.addPSA( - createErrorMessage(e, "Firebase uninitialized"), - 0, - undefined, - false, - ); + addBanner({ + level: "notice", + text: "Dev Info: Firebase failed to initialize", + icon: "fas fa-exclamation-triangle", + }); } } finally { resolveAuthPromise(); diff --git a/frontend/src/ts/hooks/useTailwindBreakpoints.ts b/frontend/src/ts/hooks/useTailwindBreakpoints.ts new file mode 100644 index 000000000000..c155b2fbcc16 --- /dev/null +++ b/frontend/src/ts/hooks/useTailwindBreakpoints.ts @@ -0,0 +1,59 @@ +import { Accessor, createSignal, onCleanup, onMount } from "solid-js"; +import { debounce } from "throttle-debounce"; + +type Breakpoints = { + xxs: boolean; + xs: boolean; + sm: boolean; + md: boolean; + lg: boolean; + xl: boolean; + "2xl": boolean; +}; + +export function useTailwindBreakpoints( + debounceMs = 125, +): Accessor { + const [breakpoints, setBreakpoints] = createSignal( + undefined, + ); + + const updateBreakpoints = (): void => { + const styles = getComputedStyle(document.documentElement); + + const breakpoints = { + xxs: parseInt(styles.getPropertyValue("--breakpoint-xxs")), + xs: parseInt(styles.getPropertyValue("--breakpoint-xs")), + sm: parseInt(styles.getPropertyValue("--breakpoint-sm")), + md: parseInt(styles.getPropertyValue("--breakpoint-md")), + lg: parseInt(styles.getPropertyValue("--breakpoint-lg")), + xl: parseInt(styles.getPropertyValue("--breakpoint-xl")), + "2xl": parseInt(styles.getPropertyValue("--breakpoint-2xl")), + }; + + const currentWidth = window.innerWidth; + + setBreakpoints({ + xxs: true, + xs: currentWidth >= breakpoints.xs, + sm: currentWidth >= breakpoints.sm, + md: currentWidth >= breakpoints.md, + lg: currentWidth >= breakpoints.lg, + xl: currentWidth >= breakpoints.xl, + "2xl": currentWidth >= breakpoints["2xl"], + }); + }; + + const debouncedUpdate = debounce(debounceMs, updateBreakpoints); + + onMount(() => { + updateBreakpoints(); + window.addEventListener("resize", debouncedUpdate); + }); + + onCleanup(() => { + window.removeEventListener("resize", debouncedUpdate); + }); + + return breakpoints; +} diff --git a/frontend/src/ts/modals/simple-modals.ts b/frontend/src/ts/modals/simple-modals.ts index a0b2baca2d54..8c9f5e6bc616 100644 --- a/frontend/src/ts/modals/simple-modals.ts +++ b/frontend/src/ts/modals/simple-modals.ts @@ -46,7 +46,7 @@ import { goToPage } from "../pages/leaderboards"; import FileStorage from "../utils/file-storage"; import { z } from "zod"; import { remoteValidation } from "../utils/remote-validation"; -import { qs, qsr } from "../utils/dom"; +import { qsr } from "../utils/dom"; import { list, PopupKey, showPopup } from "./simple-modals-base"; export { list, showPopup }; @@ -1275,7 +1275,3 @@ list.lbGoToPage = new SimpleModal({ }; }, }); - -qs("#bannerCenter")?.onChild("click", ".banner .text .openNameChange", () => { - showPopup("updateName"); -}); diff --git a/frontend/src/ts/observables/banner-event.ts b/frontend/src/ts/observables/banner-event.ts deleted file mode 100644 index 6d6b72ccdca9..000000000000 --- a/frontend/src/ts/observables/banner-event.ts +++ /dev/null @@ -1,18 +0,0 @@ -type SubscribeFunction = () => void; - -const subscribers: SubscribeFunction[] = []; - -export function subscribe(fn: SubscribeFunction): void { - subscribers.push(fn); -} - -export function dispatch(): void { - subscribers.forEach((fn) => { - try { - fn(); - } catch (e) { - console.error("Banner event subscriber threw an error"); - console.error(e); - } - }); -} diff --git a/frontend/src/ts/signals/core.ts b/frontend/src/ts/signals/core.ts index 32d0d350a3b8..ff04891be873 100644 --- a/frontend/src/ts/signals/core.ts +++ b/frontend/src/ts/signals/core.ts @@ -23,3 +23,4 @@ export const [getCommandlineSubgroup, setCommandlineSubgroup] = createSignal< >(null); export const [getFocus, setFocus] = createSignal(false); +export const [getGlobalOffsetTop, setGlobalOffsetTop] = createSignal(0); diff --git a/frontend/src/ts/states/connection.ts b/frontend/src/ts/states/connection.ts index 9ceb511887d0..d5318df92700 100644 --- a/frontend/src/ts/states/connection.ts +++ b/frontend/src/ts/states/connection.ts @@ -2,7 +2,8 @@ import { debounce } from "throttle-debounce"; import * as Notifications from "../elements/notifications"; import * as ConnectionEvent from "../observables/connection-event"; import * as TestState from "../test/test-state"; -import { qs, onDOMReady } from "../utils/dom"; +import { onDOMReady } from "../utils/dom"; +import { addBanner, removeBanner } from "../stores/banners"; let state = navigator.onLine; @@ -16,16 +17,15 @@ let bannerAlreadyClosed = false; export function showOfflineBanner(): void { if (bannerAlreadyClosed) return; - noInternetBannerId ??= Notifications.addPSA( - "No internet connection", - 0, - "exclamation-triangle", - false, - () => { + noInternetBannerId ??= addBanner({ + level: "notice", + text: "No internet connection", + icon: "fas fa-exclamation-triangle", + onClose: () => { bannerAlreadyClosed = true; noInternetBannerId = undefined; }, - ); + }); } const throttledHandleState = debounce(5000, () => { @@ -34,9 +34,8 @@ const throttledHandleState = debounce(5000, () => { Notifications.add("You're back online", 1, { customTitle: "Connection", }); - qs( - `#bannerCenter .psa.notice[id="${noInternetBannerId}"] .closeButton`, - )?.dispatch("click"); + removeBanner(noInternetBannerId); + noInternetBannerId = undefined; } bannerAlreadyClosed = false; } else if (!TestState.isActive) { diff --git a/frontend/src/ts/stores/banners.ts b/frontend/src/ts/stores/banners.ts new file mode 100644 index 000000000000..04739348c6d7 --- /dev/null +++ b/frontend/src/ts/stores/banners.ts @@ -0,0 +1,43 @@ +import { JSXElement } from "solid-js"; +import { createStore } from "solid-js/store"; + +export type Banner = { + id: number; + level: "error" | "notice" | "success"; + icon?: string; + imagePath?: string; + important?: boolean; + onClose?: () => void; +} & ( + | { + text: string; + customContent?: undefined; + } + | { + customContent: JSXElement; + text?: undefined; + } +); + +let id = 0; +const [banners, setBanners] = createStore([]); + +export function addBanner(banner: Omit): number { + const newid = id++; + setBanners((prev) => [...prev, { ...banner, id: newid } as Banner]); + return newid; +} + +export function removeBanner(bannerId: number): void { + const banner = getBanner(bannerId); + banner?.onClose?.(); + setBanners((prev) => prev.filter((banner) => banner.id !== bannerId)); +} + +export function getBanner(bannerId: number): Banner | undefined { + return banners.find((banner) => banner.id === bannerId); +} + +export function getBanners(): Banner[] { + return banners; +} diff --git a/frontend/src/ts/ui.ts b/frontend/src/ts/ui.ts index 36de047bb40d..570e0c72ec55 100644 --- a/frontend/src/ts/ui.ts +++ b/frontend/src/ts/ui.ts @@ -5,13 +5,15 @@ import * as TestState from "./test/test-state"; import * as ConfigEvent from "./observables/config-event"; import { debounce, throttle } from "throttle-debounce"; import * as TestUI from "./test/test-ui"; -import { getActivePage } from "./signals/core"; +import { getActivePage, getGlobalOffsetTop } from "./signals/core"; import { isDevEnvironment } from "./utils/misc"; import { isCustomTextLong } from "./states/custom-text-name"; import { canQuickRestart } from "./utils/quick-restart"; import { FontName } from "@monkeytype/schemas/fonts"; import { applyFontFamily } from "./controllers/theme-controller"; -import { qs } from "./utils/dom"; +import { qs, qsr } from "./utils/dom"; +import { createEffect } from "solid-js"; +import { convertRemToPixels } from "./utils/numbers"; let isPreviewingFont = false; export function previewFontFamily(font: FontName): void { @@ -115,6 +117,12 @@ window.addEventListener("resize", () => { debouncedEvent(); }); +createEffect(() => { + qsr("#app").setStyle({ + paddingTop: getGlobalOffsetTop() + convertRemToPixels(2) + "px", + }); +}); + ConfigEvent.subscribe(async ({ key }) => { if (key === "quickRestart") updateKeytips(); if (key === "showKeyTips") {