diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index a7f5d5cdf9f1..000000000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Claude Code Review - -on: - pull_request: - types: [opened, synchronize, ready_for_review, reopened] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" - -jobs: - claude-review: - if: >- - contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association) - - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - fetch-depth: 1 - - - name: Run Claude Code Review - id: claude-review - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - plugin_marketplaces: "https://github.com/anthropics/claude-code.git" - plugins: "code-review@claude-code-plugins" - prompt: "/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}" - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options diff --git a/.github/workflows/monkey-ci.yml b/.github/workflows/monkey-ci.yml index ccc3d825135f..933051c0fa59 100644 --- a/.github/workflows/monkey-ci.yml +++ b/.github/workflows/monkey-ci.yml @@ -49,7 +49,7 @@ jobs: - 'backend/package.json' fe-src: - 'frontend/**/*.{ts,scss,html}' - - 'frontend/package.json' + - 'frontend/**/package.json' pkg-src: - 'packages/**/*' anti-cheat: diff --git a/frontend/__tests__/test/events/stats.spec.ts b/frontend/__tests__/test/events/stats.spec.ts index a30a63e339f9..6a155295f95e 100644 --- a/frontend/__tests__/test/events/stats.spec.ts +++ b/frontend/__tests__/test/events/stats.spec.ts @@ -13,6 +13,7 @@ vi.mock("../../../src/ts/test/test-state", () => ({ vi.mock("../../../src/ts/config/store", () => ({ Config: { mode: "words", funbox: [] as string[] }, + getConfig: {}, })); vi.mock("../../../src/ts/test/test-words", () => { diff --git a/frontend/src/html/pages/account-settings.html b/frontend/src/html/pages/account-settings.html index 6ef41fa45ad2..f809e0eac9cd 100644 --- a/frontend/src/html/pages/account-settings.html +++ b/frontend/src/html/pages/account-settings.html @@ -246,25 +246,7 @@ + + {({ queryData }) => ( + + You don‘t have any friends :( + + } + /> + )} + + + ); +} + +function getColumns({ + format, +}: { + format: Formatting; +}): DataTableColumnDef[] { + const defineColumn = createColumnHelper().accessor; + const cols = [ + defineColumn("name", { + enableSorting: true, + cell: (info) => ( + + ), + }), + defineColumn("lastModified", { + enableSorting: true, + header: "friends for", + cell: ({ getValue }) => + getValue() === undefined ? "-" : formatAge(getValue(), "short"), + meta: { + cellMeta: ({ value }) => + value === undefined + ? {} + : { + "data-balloon-pos": "up", + "aria-label": `since ${dateFormat(value, "dd MMM yyy HH:mm")}`, + }, + }, + }), + defineColumn("xp", { + header: "level", + enableSorting: true, + cell: ({ getValue }) => getXpDetails(getValue() ?? 0).level, + meta: { + cellMeta: ({ value }) => + value === undefined + ? {} + : { + "data-balloon-pos": "up", + "aria-label": `total xp: ${formatXp(value)}`, + }, + }, + }), + defineColumn("completedTests", { + enableSorting: true, + header: "tests", + cell: (info) => `${info.getValue()}/${info.row.original.startedTests}`, + meta: { + breakpoint: "lg", + cellMeta: ({ row }) => { + const testStats = formatTypingStatsRatio(row); + + return { + "data-balloon-pos": "up", + "aria-label": `${testStats.completedPercentage}% (${ + testStats.restartRatio + } restarts per completed test)`, + }; + }, + }, + }), + defineColumn("timeTyping", { + header: "time typing", + enableSorting: true, + cell: ({ getValue }) => + secondsToString(Math.round(getValue() ?? 0), true, true), + meta: { + breakpoint: "sm", + }, + }), + + defineColumn("streak.length", { + header: "streak", + enableSorting: true, + cell: ({ getValue }) => formatStreak(getValue()), + meta: { + breakpoint: "sm", + cellMeta: ({ row }) => { + const value = row.streak.maxLength as number | undefined; + return value === undefined + ? {} + : { + "data-balloon-pos": "up", + "aria-label": formatStreak(value, "longest streak"), + }; + }, + }, + }), + + defineColumn("top15.wpm", { + header: "time 15 pb", + + enableSorting: true, + cell: (info) => { + const pb = formatPb(info.row.original.top15, { format }); + return ( + <> + {pb?.wpm ?? "-"} +
{pb?.acc ?? "-"}
+ + ); + }, + meta: { + breakpoint: "lg", + cellMeta: ({ row }) => ({ + class: "text-xs sm:text-xs md:text-xs xl:text-sm", + "data-balloon-pos": "up", + "data-balloon-break": "", + "aria-label": formatPb(row.top15 as PersonalBest, { format }) + ?.details, + }), + }, + }), + defineColumn("top60.wpm", { + header: "time 60 pb", + enableSorting: true, + cell: (info) => { + const pb = formatPb(info.row.original.top60, { format }); + return ( + <> + {pb?.wpm ?? "-"} +
{pb?.acc ?? "-"}
+ + ); + }, + meta: { + breakpoint: "lg", + cellMeta: ({ row }) => ({ + class: "text-xs sm:text-xs md:text-xs xl:text-sm", + "data-balloon-pos": "up", + "data-balloon-break": "", + "aria-label": formatPb(row.top60, { format })?.details, + }), + }, + }), + + defineColumn("connectionId", { + header: "", + cell: (info) => + //check the row is our own user + info.getValue() !== undefined ? ( + - - - `, - ); - table?.appendHtml(content.join()); -} - -element.onChild("click", "table button.delete", async (e) => { - const row = (e.childTarget as HTMLElement).closest("tr") as HTMLElement; - const id = row?.dataset["id"]; - - if (id === undefined) { - throw new Error("Cannot find id of target."); - } - - row.querySelectorAll("button").forEach((button) => (button.disabled = true)); - - const response = await Ape.connections.delete({ params: { id } }); - if (response.status !== 200) { - showErrorNotification(`Cannot unblock user: ${response.body.message}`); - } else { - blockedUsers = blockedUsers.filter((it) => it._id !== id); - refreshList(); - - const snapshot = DB.getSnapshot(); - if (snapshot) { - const uid = row.dataset["uid"]; - if (uid === undefined) { - throw new Error("Cannot find uid of target."); - } - - // oxlint-disable-next-line no-dynamic-delete, no-unsafe-member-access - delete snapshot.connections[uid]; - DB.setSnapshot(snapshot); - } - } -}); diff --git a/frontend/src/ts/pages/account-settings.ts b/frontend/src/ts/pages/account-settings.ts index acf963bb1f9d..6452541bc1f4 100644 --- a/frontend/src/ts/pages/account-settings.ts +++ b/frontend/src/ts/pages/account-settings.ts @@ -8,7 +8,6 @@ import Ape from "../ape"; import * as StreakHourOffsetModal from "../modals/streak-hour-offset"; import { showLoaderBar } from "../states/loader-bar"; import * as ApeKeyTable from "../elements/account-settings/ape-key-table"; -import * as BlockedUserTable from "../elements/account-settings/blocked-user-table"; import { showErrorNotification } from "../states/notifications"; import { z } from "zod"; import { authEvent } from "../events/auth"; @@ -134,7 +133,6 @@ function updateTabs(): void { pageElement.qsa(".tab")?.removeClass("active"); pageElement.qs(`.tab[data-tab="${state.tab}"]`)?.addClass("active"); if (state.tab === "apeKeys") void ApeKeyTable.update(updateUI); - if (state.tab === "blockedUsers") void BlockedUserTable.update(); }, ); pageElement.qsa("button")?.removeClass("active"); diff --git a/frontend/src/ts/pages/friends.ts b/frontend/src/ts/pages/friends.ts deleted file mode 100644 index 3edf4962187e..000000000000 --- a/frontend/src/ts/pages/friends.ts +++ /dev/null @@ -1,544 +0,0 @@ -import Page from "./page"; -import * as Skeleton from "../utils/skeleton"; -import Ape from "../ape"; -import { - intervalToDuration, - format as dateFormat, - formatDuration, - formatDistanceToNow, - format, -} from "date-fns"; -import { - showNoticeNotification, - showErrorNotification, - showSuccessNotification, -} from "../states/notifications"; -import { isSafeNumber } from "@monkeytype/util/numbers"; -import { getHTMLById as getBadgeHTMLbyId } from "../controllers/badge-controller"; -import { formatXp, getXpDetails } from "../utils/levels"; -import { secondsToString } from "../utils/date-and-time"; -import { PersonalBest } from "@monkeytype/schemas/shared"; -import Format from "../singletons/format"; -import { getHtmlByUserFlags } from "../controllers/user-flag-controller"; -import { SortedTable, SortSchema } from "../utils/sorted-table"; -import { getAvatarElement } from "../utils/discord-avatar"; -import { formatTypingStatsRatio } from "../utils/misc"; -import { getLanguageDisplayString } from "../utils/strings"; -import * as DB from "../db"; -import { addFriend, getReceiverUid } from "../db"; -import { getAuthenticatedUser } from "../firebase"; -import * as ServerConfiguration from "../ape/server-configuration"; -import { authEvent } from "../events/auth"; -import { Connection } from "@monkeytype/schemas/connections"; -import { UserNameWithoutFilterSchema, Friend } from "@monkeytype/schemas/users"; - -import { showLoaderBar, hideLoaderBar } from "../states/loader-bar"; -import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; -import { remoteValidation } from "../utils/remote-validation"; -import { qs, qsr, onDOMReady } from "../utils/dom"; -import { showSimpleModal } from "../states/simple-modal"; -import { z } from "zod"; - -let friendsTable: SortedTable | undefined = undefined; - -let pendingRequests: Connection[] | undefined; -let friendsList: Friend[] | undefined; - -async function fetchPendingConnections(): Promise { - const result = await Ape.connections.get({ - query: { status: "pending", type: "incoming" }, - }); - - if (result.status !== 200) { - showErrorNotification(`Error getting connections: ${result.body.message}`); - pendingRequests = undefined; - } else { - pendingRequests = result.body.data; - DB.mergeConnections(pendingRequests); - } -} - -function updatePendingConnections(): void { - qs(".pageFriends .pendingRequests")?.hide(); - - if (pendingRequests === undefined || pendingRequests.length === 0) { - qs(".pageFriends .pendingRequests")?.hide(); - } else { - qs(".pageFriends .pendingRequests")?.show(); - - const html = pendingRequests - .map( - (item) => ` - ${item.initiatorName} - - - ${formatAge(item.lastModified)} ago - - - - - - - - `, - ) - .join("\n"); - - qs(".pageFriends .pendingRequests tbody")?.setHtml(html); - } -} - -async function fetchFriends(): Promise { - const result = await Ape.users.getFriends(); - if (result.status !== 200) { - showErrorNotification(`Error getting friends: ${result.body.message}`); - friendsList = undefined; - } else { - friendsList = result.body.data; - } -} - -function updateFriends(): void { - qs(".pageFriends .friends .nodata")?.hide(); - qs(".pageFriends .friends table")?.hide(); - - qs(".pageFriends .friends .error")?.hide(); - - if (friendsList === undefined || friendsList.length === 0) { - qs(".pageFriends .friends table")?.hide(); - qs(".pageFriends .friends .nodata")?.show(); - } else { - qs(".pageFriends .friends table")?.show(); - qs(".pageFriends .friends .nodata")?.hide(); - - if (friendsTable === undefined) { - friendsTable = new SortedTable({ - table: qsr(".pageFriends .friends table"), - data: friendsList, - buildRow: buildFriendRow, - persistence: new LocalStorageWithSchema({ - key: "friendsListSort", - schema: SortSchema, - fallback: { property: "name", descending: false }, - }), - }); - } else { - friendsTable.setData(friendsList); - } - friendsTable.updateBody(); - } -} - -function buildFriendRow(entry: Friend): HTMLTableRowElement { - const xpDetails = getXpDetails(entry.xp ?? 0); - const testStats = formatTypingStatsRatio(entry); - - const top15 = formatPb(entry.top15); - const top60 = formatPb(entry.top60); - - const element = document.createElement("tr"); - element.dataset["connectionId"] = entry.connectionId; - - const isMe = entry.uid === getAuthenticatedUser()?.uid; - - let actions = ""; - if (isMe) { - element.classList.add("me"); - } else { - actions = ``; - } - element.innerHTML = ` - -
-
- ${ - entry.name - }
- ${getHtmlByUserFlags(entry)} - ${ - isSafeNumber(entry.badgeId) - ? getBadgeHTMLbyId(entry.badgeId) - : "" - } -
-
- - ${ - entry.lastModified !== undefined - ? formatAge(entry.lastModified, "short") - : "-" - } - - ${xpDetails.level} - - ${ - entry.completedTests - }/${entry.startedTests} - ${secondsToString( - Math.round(entry.timeTyping ?? 0), - true, - true, - )} - - ${formatStreak(entry.streak?.length)} - - ${ - top15?.wpm ?? "-" - }
${top15?.acc ?? "-"}
- ${ - top60?.wpm ?? "-" - }
${top60?.acc ?? "-"}
- - ${actions} - - - `; - - element - .querySelector(".avatarPlaceholder") - ?.replaceWith(getAvatarElement(entry)); - return element; -} - -function formatAge( - timestamp: number | undefined, - format?: "short" | "full", -): string { - if (timestamp === undefined) return ""; - let formatted = ""; - const duration = intervalToDuration({ start: timestamp, end: Date.now() }); - - if (format === undefined || format === "full") { - formatted = formatDuration(duration, { - format: ["years", "months", "days", "hours", "minutes"], - }); - } else { - formatted = formatDistanceToNow(timestamp); - } - - return formatted !== "" ? formatted : "less then a minute"; -} - -function formatPb(entry?: PersonalBest): - | { - wpm: string; - acc: string; - raw: string; - con: string; - details: string; - } - | undefined { - if (entry === undefined) { - return undefined; - } - const result = { - wpm: Format.typingSpeed(entry.wpm, { showDecimalPlaces: true }), - acc: Format.percentage(entry.acc, { showDecimalPlaces: true }), - raw: Format.typingSpeed(entry.raw, { showDecimalPlaces: true }), - con: Format.percentage(entry.consistency, { showDecimalPlaces: true }), - details: "", - }; - - const details = [ - `${getLanguageDisplayString(entry.language)}`, - `${result.wpm} wpm`, - ]; - - if (isSafeNumber(entry.acc)) { - details.push(`${result.acc} acc`); - } - if (isSafeNumber(entry.raw)) { - details.push(`${result.raw} raw`); - } - if (isSafeNumber(entry.consistency)) { - details.push(`${result.con} con`); - } - if (isSafeNumber(entry.timestamp)) { - details.push(`${dateFormat(entry.timestamp, "dd MMM yyyy")}`); - } - - result.details = details.join("\n"); - - return result; -} - -function formatStreak(length?: number, prefix?: string): string { - if (length === 1) return "-"; - return isSafeNumber(length) - ? `${prefix !== undefined ? `${prefix} ` : ""}${length} days` - : "-"; -} - -qs(".pageFriends button.friendAdd")?.on("click", () => - showSimpleModal({ - title: "Add a friend", - buttonText: "request", - schema: z.object({ receiverName: UserNameWithoutFilterSchema }), - inputs: { - receiverName: { - placeholder: "user name", - type: "text", - validation: { - isValid: remoteValidation( - async (name: string) => - Ape.users.getNameAvailability({ params: { name } }), - { check: (data) => !data.available || "Unknown user" }, - ), - debounceDelay: 1000, - }, - }, - }, - - execFn: async ({ receiverName }) => { - const result = await addFriend(receiverName); - - if (result === true) { - updatePendingConnections(); - return { - status: "success", - message: `Request sent to ${receiverName}`, - }; - } - - let status: "error" | "notice" | "success" = "error"; - let message: string = "Unknown error"; - - if (result.includes("already exists")) { - status = "notice"; - message = `You are already friends with ${receiverName}`; - } else if (result.includes("request already sent")) { - status = "notice"; - message = `You have already sent a friend request to ${receiverName}`; - } else if (result.includes("blocked by initiator")) { - status = "notice"; - message = `You have blocked ${receiverName}`; - } else if (result.includes("blocked by receiver")) { - status = "notice"; - message = `${receiverName} has blocked you`; - } - - return { status, message, alwaysHide: true }; - }, - }), -); - -// need to set the listener for action buttons on the table because the table content is getting replaced -qs(".pageFriends .pendingRequests table")?.on("click", async (e) => { - const target = e.target as HTMLElement; - const action = Array.from(target.classList).find((it) => - ["accepted", "rejected", "blocked"].includes(it), - ) as "accepted" | "rejected" | "blocked"; - - if (action === undefined) return; - - const row = target.closest("tr") as HTMLElement; - const id = row.dataset["id"]; - if (id === undefined) { - throw new Error("Cannot find id of target."); - } - row.querySelectorAll("button").forEach((button) => (button.disabled = true)); - - showLoaderBar(); - const result = - action === "rejected" - ? await Ape.connections.delete({ - params: { id }, - }) - : await Ape.connections.update({ - params: { id }, - body: { status: action }, - }); - hideLoaderBar(); - - if (result.status !== 200) { - showErrorNotification( - `Cannot update friend request: ${result.body.message}`, - ); - } else { - //remove from cache - pendingRequests = pendingRequests?.filter((it) => it._id !== id); - updatePendingConnections(); - - const snapshot = DB.getSnapshot(); - if (snapshot) { - const receiverUid = row.dataset["receiverUid"]; - if (receiverUid === undefined) { - throw new Error("Cannot find receiverUid of target."); - } - - if (action === "rejected") { - // oxlint-disable-next-line no-dynamic-delete, no-unsafe-member-access - delete snapshot.connections[receiverUid]; - } else { - snapshot.connections[receiverUid] = action; - } - DB.setSnapshot(snapshot); - } - - if (action === "blocked") { - showNoticeNotification(`User has been blocked`); - } - if (action === "accepted") { - showSuccessNotification(`Request accepted`); - } - if (action === "rejected") { - showNoticeNotification(`Request rejected`); - } - - if (action === "accepted") { - showSpinner(); - await fetchFriends(); - updateFriends(); - hideSpinner(); - } - } -}); -// need to set the listener for action buttons on the table because the table content is getting replaced -qs(".pageFriends .friends table")?.on("click", async (e) => { - const target = e.target as HTMLElement; - const action = Array.from(target.classList).find((it) => - ["remove"].includes(it), - ); - - if (action === undefined) return; - - const row = target.closest("tr") as HTMLElement; - const connectionId = row.dataset["connectionId"]; - if (connectionId === undefined) { - throw new Error("Cannot find id of target."); - } - - if (action === "remove") { - const name = row.querySelector("a.entryName")?.textContent ?? ""; - - showSimpleModal({ - title: "Remove friend", - buttonText: "remove friend", - text: `Are you sure you want to remove ${name} as a friend?`, - - execFn: async () => { - const result = await Ape.connections.delete({ - params: { id: connectionId }, - }); - if (result.status !== 200) { - return { status: "error", message: result.body.message }; - } else { - friendsList = friendsList?.filter( - (it) => it.connectionId !== connectionId, - ); - friendsTable?.setData(friendsList ?? []); - friendsTable?.updateBody(); - return { status: "success", message: `Friend removed` }; - } - }, - }); - } -}); - -function showSpinner(): void { - document.querySelector(".friends .spinner")?.classList.remove("hidden"); -} - -function hideSpinner(): void { - document.querySelector(".friends .spinner")?.classList.add("hidden"); -} - -function update(): void { - updatePendingConnections(); - updateFriends(); -} - -export const page = new Page({ - id: "friends", - display: "Friends", - element: qsr(".page.pageFriends"), - path: "/friends", - loadingOptions: { - loadingMode: () => { - if (!getAuthenticatedUser()) { - return "none"; - } - const hasCache = - friendsList !== undefined && pendingRequests !== undefined; - - if (hasCache) { - return { - mode: "async", - beforeLoading: showSpinner, - afterLoading: () => { - hideSpinner(); - update(); - }, - }; - } else { - return "sync"; - } - }, - - loadingPromise: async () => { - await ServerConfiguration.configurationPromise; - const serverConfig = ServerConfiguration.get(); - if (!serverConfig?.connections.enabled) { - throw new Error("Connectins are disabled."); - } - - await Promise.all([fetchPendingConnections(), fetchFriends()]); - }, - style: "bar", - keyframes: [ - { percentage: 50, durationMs: 1500, text: "Downloading friends..." }, - { - percentage: 50, - durationMs: 1500, - text: "Downloading friend requests...", - }, - ], - }, - - afterHide: async (): Promise => { - Skeleton.remove("pageFriends"); - }, - beforeShow: async (): Promise => { - Skeleton.append("pageFriends", "main"); - update(); - }, -}); - -onDOMReady(() => { - Skeleton.save("pageFriends"); -}); - -authEvent.subscribe((event) => { - if (event.type === "authStateChanged" && !event.data.isUserSignedIn) { - pendingRequests = undefined; - friendsList = undefined; - } -}); diff --git a/frontend/src/ts/queries/friends.ts b/frontend/src/ts/queries/friends.ts new file mode 100644 index 000000000000..8cc9560bd10a --- /dev/null +++ b/frontend/src/ts/queries/friends.ts @@ -0,0 +1,29 @@ +import { queryOptions } from "@tanstack/solid-query"; +import { baseKey } from "./utils/keys"; +import Ape from "../ape"; +import { queryClient } from "."; + +const queryKeys = { + root: () => baseKey("friendsList", { isUserSpecific: true }), +}; + +// oxlint-disable-next-line typescript/explicit-function-return-type +export const getFriendsListQuery = () => + queryOptions({ + queryKey: queryKeys.root(), + queryFn: async () => { + const response = await Ape.users.getFriends(); + if (response.status !== 200) { + throw new Error( + `Failed to load friends list: ${response.body.message}`, + ); + } + return response.body.data; + }, + }); + +export async function invalidateFriendsList(): Promise { + await queryClient.invalidateQueries({ + queryKey: queryKeys.root(), + }); +} diff --git a/frontend/src/ts/states/core.ts b/frontend/src/ts/states/core.ts index 6e500553b8ca..806485607e63 100644 --- a/frontend/src/ts/states/core.ts +++ b/frontend/src/ts/states/core.ts @@ -1,7 +1,9 @@ -import { createSignal } from "solid-js"; +import { createMemo, createSignal } from "solid-js"; import { CommandlineSubgroupKey } from "../commandline/types"; import { PageName } from "../pages/page"; import { showModal } from "./modals"; +import { Formatting } from "../utils/format"; +import { getConfig } from "../config/store"; export const [getActivePage, setActivePage] = createSignal("loading"); export const [getVersion, setVersion] = createSignal<{ @@ -49,3 +51,10 @@ export function showCommandLineForConfig( export const [getCustomTextIndicator, setCustomTextIndicator] = createSignal< { name: string; isLong: boolean } | undefined >(undefined); + +export const getFormatting = createMemo(() => { + return new Formatting({ + alwaysShowDecimalPlaces: getConfig.alwaysShowDecimalPlaces, + typingSpeedUnit: getConfig.typingSpeedUnit, + }); +}); diff --git a/frontend/src/ts/utils/date-and-time.ts b/frontend/src/ts/utils/date-and-time.ts index b31ebfbf77fc..ceb8910f0985 100644 --- a/frontend/src/ts/utils/date-and-time.ts +++ b/frontend/src/ts/utils/date-and-time.ts @@ -1,5 +1,10 @@ import { roundTo2 } from "@monkeytype/util/numbers"; -import { Day } from "date-fns"; +import { + Day, + formatDistanceToNow, + formatDuration, + intervalToDuration, +} from "date-fns"; /** * Converts seconds to a human-readable string representation of time. @@ -251,3 +256,22 @@ export function getFirstDayOfTheWeek(): Day { return 0; //start on sunday } + +export function formatAge( + timestamp: number | undefined, + format?: "short" | "full", +): string { + if (timestamp === undefined) return ""; + let formatted = ""; + const duration = intervalToDuration({ start: timestamp, end: Date.now() }); + + if (format === undefined || format === "full") { + formatted = formatDuration(duration, { + format: ["years", "months", "days", "hours", "minutes"], + }); + } else { + formatted = formatDistanceToNow(timestamp); + } + + return formatted !== "" ? formatted : "less than a minute"; +} diff --git a/frontend/storybook/stories/AnimatedModal.stories.tsx b/frontend/storybook/stories/AnimatedModal.stories.tsx index d21fafd9b624..4163ffb94db0 100644 --- a/frontend/storybook/stories/AnimatedModal.stories.tsx +++ b/frontend/storybook/stories/AnimatedModal.stories.tsx @@ -1,3 +1,5 @@ +import { Component } from "solid-js"; + import preview from "#.storybook/preview"; import { AnimatedModal } from "../../src/ts/components/common/AnimatedModal"; @@ -23,71 +25,52 @@ function ModalTrigger(props: { modalId: ModalId; label: string }) { const meta = preview.meta({ title: "Common/AnimatedModal", - component: AnimatedModal, + component: AnimatedModal as Component, parameters: { layout: "fullscreen", }, tags: ["autodocs"], - argTypes: { - mode: { control: "select", options: ["modal", "dialog"] }, - animationMode: { - control: "select", - options: ["none", "both", "modalOnly"], - }, - title: { control: "text" }, - modalClass: { control: "text" }, - wrapperClass: { control: "text" }, - }, - decorators: [ - (Story, context) => { - // oxlint-disable-next-line typescript/no-unsafe-member-access -- storybook decorator context is untyped - const modalId = context.args.id as ModalId; - // oxlint-disable-next-line typescript/no-unsafe-member-access -- storybook decorator context is untyped - const title = (context.args.title as string) ?? "Modal"; - return ( -
- - -
- ); - }, - ], }); export const Default = meta.story({ - args: { - id: "Contact", - title: "Example Modal", - children: ( -
-

This is modal content.

-
- ), - }, + render: () => ( + <> + + +
+

This is modal content.

+
+
+ + ), }); export const NoAnimation = meta.story({ - args: { - id: "Support", - title: "No Animation Modal", - animationMode: "none", - children: ( -
-

This modal has no animation.

-
- ), - }, + render: () => ( + <> + + +
+

This modal has no animation.

+
+
+ + ), }); export const DialogMode = meta.story({ - args: { - id: "DevOptions", - mode: "dialog", - title: "Dialog Mode", - children: ( -
-

This uses dialog mode instead of modal.

-
- ), - }, + render: () => ( + <> + + +
+

This uses dialog mode instead of modal.

+
+
+ + ), }); diff --git a/frontend/storybook/stories/AnimeShow.stories.tsx b/frontend/storybook/stories/AnimeShow.stories.tsx index f9ba885c30bc..1688a9a7db5f 100644 --- a/frontend/storybook/stories/AnimeShow.stories.tsx +++ b/frontend/storybook/stories/AnimeShow.stories.tsx @@ -1,49 +1,36 @@ -import preview from "#.storybook/preview"; import { Component, createSignal } from "solid-js"; +import preview from "#.storybook/preview"; + import { AnimeShow } from "../../src/ts/components/common/anime/AnimeShow"; const meta = preview.meta({ title: "Common/Anime/AnimeShow", - component: AnimeShow as Component<{ - when: boolean; - slide?: true; - duration?: number; - class?: string; - }>, + component: AnimeShow as Component, parameters: { layout: "centered", }, tags: ["autodocs"], - argTypes: { - when: { control: "boolean" }, - slide: { control: "boolean" }, - duration: { control: "number" }, - }, }); export const FadeToggle = meta.story({ - args: { - when: true, - children: ( + render: () => ( +
This content fades in and out
- ), - }, +
+ ), }); export const SlideToggle = meta.story({ - args: { - when: true, - slide: true, - duration: 250, - children: ( + render: () => ( +
This content slides in and out
- ), - }, +
+ ), }); export const InteractiveDemo = meta.story({ diff --git a/frontend/storybook/stories/AutoShrink.stories.tsx b/frontend/storybook/stories/AutoShrink.stories.tsx index d34d7f4f03df..538d3ebd34a5 100644 --- a/frontend/storybook/stories/AutoShrink.stories.tsx +++ b/frontend/storybook/stories/AutoShrink.stories.tsx @@ -1,18 +1,16 @@ +import { Component } from "solid-js"; + import preview from "#.storybook/preview"; import { AutoShrink } from "../../src/ts/components/common/AutoShrink"; const meta = preview.meta({ title: "Common/AutoShrink", - component: AutoShrink, + component: AutoShrink as Component, parameters: { layout: "centered", }, tags: ["autodocs"], - argTypes: { - upperLimitRem: { control: "number" }, - class: { control: "text" }, - }, decorators: [ (Story) => (
@@ -70,22 +68,17 @@ const meta = preview.meta({ }); export const Default = meta.story({ - args: { - upperLimitRem: 2, - children: "Short", - }, + render: () => Short, }); export const LongText = meta.story({ - args: { - upperLimitRem: 2, - children: "This is a much longer piece of text that should shrink to fit", - }, + render: () => ( + + This is a much longer piece of text that should shrink to fit + + ), }); export const LargeUpperLimit = meta.story({ - args: { - upperLimitRem: 4, - children: "Big text", - }, + render: () => Big text, }); diff --git a/frontend/storybook/stories/Bar.stories.tsx b/frontend/storybook/stories/Bar.stories.tsx index e220e26a6179..47ea5bf8c54b 100644 --- a/frontend/storybook/stories/Bar.stories.tsx +++ b/frontend/storybook/stories/Bar.stories.tsx @@ -1,53 +1,32 @@ +import { Component } from "solid-js"; + import preview from "#.storybook/preview"; import { Bar } from "../../src/ts/components/common/Bar"; const meta = preview.meta({ title: "Common/Bar", - component: Bar, + component: Bar as Component, parameters: { layout: "padded", }, tags: ["autodocs"], - argTypes: { - percent: { control: { type: "range", min: 0, max: 100 } }, - fill: { control: "select", options: ["main", "text"] }, - bg: { control: "select", options: ["bg", "sub-alt"] }, - showPercentageOnHover: { control: "boolean" }, - animationDuration: { control: "number" }, - }, }); export const Default = meta.story({ - args: { - percent: 50, - fill: "main", - bg: "sub-alt", - }, + render: () => , }); export const Full = meta.story({ - args: { - percent: 100, - fill: "main", - bg: "sub-alt", - }, + render: () => , }); export const HalfWithHover = meta.story({ - args: { - percent: 50, - fill: "main", - bg: "sub-alt", - - showPercentageOnHover: true, - }, + render: () => ( + + ), }); export const TextFill = meta.story({ - args: { - percent: 75, - fill: "text", - bg: "sub-alt", - }, + render: () => , }); diff --git a/frontend/storybook/stories/Button.stories.tsx b/frontend/storybook/stories/Button.stories.tsx index 9d7f4c13a882..2e6e77d4ad1c 100644 --- a/frontend/storybook/stories/Button.stories.tsx +++ b/frontend/storybook/stories/Button.stories.tsx @@ -1,3 +1,4 @@ +import { Component } from "solid-js"; import { fn } from "storybook/test"; import preview from "#.storybook/preview"; @@ -6,50 +7,15 @@ import { Button } from "../../src/ts/components/common/Button"; const meta = preview.meta({ title: "Common/Button", - component: Button, + component: Button as Component, parameters: { layout: "centered", }, tags: ["autodocs"], - argTypes: { - active: { - control: "boolean", - }, - disabled: { - control: "boolean", - }, - text: { - control: "text", - }, - fa: { - control: "object", - }, - balloon: { - control: "object", - }, - class: { - control: "text", - }, - "router-link": { - control: "boolean", - }, - href: { - control: "text", - }, - sameTarget: { - control: "boolean", - }, - }, - args: { - onClick: fn(), - }, }); export const Default = meta.story({ - args: { - text: "Button", - type: "button", - }, + render: () =>