diff --git a/.eslintrc b/.eslintrc index 0c9597ce98..e9539865dd 100644 --- a/.eslintrc +++ b/.eslintrc @@ -29,6 +29,7 @@ "tsx": "never" } ], + "import/prefer-default-export": "off", "react/jsx-filename-extension": [1, { "extensions": [".jsx", ".tsx"] }], "comma-dangle": 0, // not sure why airbnb turned this on. gross! "default-param-last": 0, @@ -41,6 +42,7 @@ "no-restricted-exports": 1, "no-underscore-dangle": 0, "no-useless-catch": 2, + "no-plusplus": "off", "prefer-object-spread": 0, "max-len": [1, 120, 2, {"ignoreComments": true, "ignoreTemplateLiterals": true}], "max-classes-per-file": 0, @@ -131,7 +133,9 @@ "rules": { "no-use-before-define": "off", "import/no-extraneous-dependencies": "off", - "no-unused-vars": "off" + "no-unused-vars": "off", + "import/no-default-export": "warn", + "no-underscore-dangle": "warn", } }, { diff --git a/.prettierrc b/.prettierrc index e2da8bb25a..df6b0841b0 100644 --- a/.prettierrc +++ b/.prettierrc @@ -5,7 +5,6 @@ "insertPragma": false, "jsxBracketSameLine": false, "jsxSingleQuote": false, - "parser": "babel", "printWidth": 80, "proseWrap": "never", "requirePragma": false, diff --git a/README.md b/README.md index 5ac8968c49..2eaffbddde 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,13 @@ The p5.js Editor is a collaborative project created by many individuals, mostly 3. [All Contributors list on the p5.js repository](https://github.com/processing/p5.js?tab=readme-ov-file#contributors) - Explore the All Contributors list to see the wide range of contributions by our amazing community! +> **TypeScript Migration:** +> As of July 2025, we are working on migrating the repo to TypeScript as part of the **[p5.js Web Editor pr05 Grant](https://github.com/processing/pr05-grant/wiki/2025-pr05-Program-Page)**. +> This migration will occur in two phases: +> 1. **Grant Work (July – October 31, 2025)** – Setting up TypeScript configuration, tooling, and starting partial migration. Contributions will be **closed** during this period. +> 2. **Open Contribution (After October 31, 2025)** – TypeScript migration tasks will **open** to all contributors, with guidelines and tutorials available. +> +> For full details, see [TypeScript Migration Plan](./contributor_docs/typescript-migration.md). ## Acknowledgements 🙏 diff --git a/client/common/useKeyDownHandlers.js b/client/common/useKeyDownHandlers.js index 7259574e82..f47fbc7c8a 100644 --- a/client/common/useKeyDownHandlers.js +++ b/client/common/useKeyDownHandlers.js @@ -1,6 +1,7 @@ import { mapKeys } from 'lodash'; import PropTypes from 'prop-types'; import { useCallback, useEffect, useRef } from 'react'; +import { isMac } from '../utils/device'; /** * Attaches keydown handlers to the global document. @@ -30,8 +31,7 @@ export default function useKeyDownHandlers(keyHandlers) { */ const handleEvent = useCallback((e) => { if (!e.key) return; - const isMac = navigator.userAgent.toLowerCase().indexOf('mac') !== -1; - const isCtrl = isMac ? e.metaKey : e.ctrlKey; + const isCtrl = isMac() ? e.metaKey : e.ctrlKey; if (e.shiftKey && isCtrl) { handlers.current[ `ctrl-shift-${ diff --git a/client/components/SkipLink.tsx b/client/components/SkipLink.tsx index d70af6a999..c5b7b15e57 100644 --- a/client/components/SkipLink.tsx +++ b/client/components/SkipLink.tsx @@ -3,8 +3,8 @@ import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; type SkipLinkProps = { - targetId: string, - text: string + targetId: string; + text: string; }; const SkipLink = ({ targetId, text }: SkipLinkProps) => { diff --git a/client/i18n.js b/client/i18n.js index 25ce8f19d9..f37c9ed53f 100644 --- a/client/i18n.js +++ b/client/i18n.js @@ -21,7 +21,7 @@ import { enIN } from 'date-fns/locale'; -import getPreferredLanguage from './utils/language-utils'; +import { getPreferredLanguage } from './utils/language-utils'; const fallbackLng = ['en-US']; diff --git a/client/modules/IDE/actions/assets.js b/client/modules/IDE/actions/assets.js index ecd16fb7c6..0aa9085da3 100644 --- a/client/modules/IDE/actions/assets.js +++ b/client/modules/IDE/actions/assets.js @@ -1,4 +1,4 @@ -import apiClient from '../../../utils/apiClient'; +import { apiClient } from '../../../utils/apiClient'; import * as ActionTypes from '../../../constants'; import { startLoader, stopLoader } from '../reducers/loading'; import { assetsActions } from '../reducers/assets'; diff --git a/client/modules/IDE/actions/collections.js b/client/modules/IDE/actions/collections.js index 32790e681e..68a42f500d 100644 --- a/client/modules/IDE/actions/collections.js +++ b/client/modules/IDE/actions/collections.js @@ -1,5 +1,5 @@ import browserHistory from '../../../browserHistory'; -import apiClient from '../../../utils/apiClient'; +import { apiClient } from '../../../utils/apiClient'; import * as ActionTypes from '../../../constants'; import { startLoader, stopLoader } from '../reducers/loading'; import { setToastText, showToast } from './toast'; diff --git a/client/modules/IDE/actions/files.js b/client/modules/IDE/actions/files.js index 84b76b6f41..3d406bcff6 100644 --- a/client/modules/IDE/actions/files.js +++ b/client/modules/IDE/actions/files.js @@ -1,6 +1,6 @@ import objectID from 'bson-objectid'; import blobUtil from 'blob-util'; -import apiClient from '../../../utils/apiClient'; +import { apiClient } from '../../../utils/apiClient'; import * as ActionTypes from '../../../constants'; import { setUnsavedChanges, diff --git a/client/modules/IDE/actions/preferences.js b/client/modules/IDE/actions/preferences.js index ebaefd1625..f6e71504ee 100644 --- a/client/modules/IDE/actions/preferences.js +++ b/client/modules/IDE/actions/preferences.js @@ -1,5 +1,5 @@ import i18next from 'i18next'; -import apiClient from '../../../utils/apiClient'; +import { apiClient } from '../../../utils/apiClient'; import * as ActionTypes from '../../../constants'; function updatePreferences(formParams, dispatch) { diff --git a/client/modules/IDE/actions/project.js b/client/modules/IDE/actions/project.js index 1d8943336b..fcdd9f9366 100644 --- a/client/modules/IDE/actions/project.js +++ b/client/modules/IDE/actions/project.js @@ -2,8 +2,8 @@ import objectID from 'bson-objectid'; import each from 'async/each'; import { isEqual } from 'lodash'; import browserHistory from '../../../browserHistory'; -import apiClient from '../../../utils/apiClient'; -import getConfig from '../../../utils/getConfig'; +import { apiClient } from '../../../utils/apiClient'; +import { getConfig } from '../../../utils/getConfig'; import * as ActionTypes from '../../../constants'; import { showToast, setToastText } from './toast'; import { @@ -15,9 +15,11 @@ import { } from './ide'; import { clearState, saveState } from '../../../persistState'; -const ROOT_URL = getConfig('API_URL'); -const S3_BUCKET_URL_BASE = getConfig('S3_BUCKET_URL_BASE'); -const S3_BUCKET = getConfig('S3_BUCKET'); +const ROOT_URL = getConfig('API_URL', { throwErrorIfNotFound: true }); +const S3_BUCKET_URL_BASE = getConfig('S3_BUCKET_URL_BASE', { + throwErrorIfNotFound: true +}); +const S3_BUCKET = getConfig('S3_BUCKET', { throwErrorIfNotFound: true }); export function setProject(project) { return { @@ -307,6 +309,8 @@ export function cloneProject(project) { (file, callback) => { if ( file.url && + S3_BUCKET && + S3_BUCKET_URL_BASE && (file.url.includes(S3_BUCKET_URL_BASE) || file.url.includes(S3_BUCKET)) ) { diff --git a/client/modules/IDE/actions/projects.js b/client/modules/IDE/actions/projects.js index 34ca2a35bf..06c50cbbfa 100644 --- a/client/modules/IDE/actions/projects.js +++ b/client/modules/IDE/actions/projects.js @@ -1,4 +1,4 @@ -import apiClient from '../../../utils/apiClient'; +import { apiClient } from '../../../utils/apiClient'; import * as ActionTypes from '../../../constants'; import { startLoader, stopLoader } from '../reducers/loading'; diff --git a/client/modules/IDE/actions/uploader.js b/client/modules/IDE/actions/uploader.js index e2831df75f..8b79ef688b 100644 --- a/client/modules/IDE/actions/uploader.js +++ b/client/modules/IDE/actions/uploader.js @@ -1,13 +1,21 @@ import { TEXT_FILE_REGEX } from '../../../../server/utils/fileUtils'; -import apiClient from '../../../utils/apiClient'; -import getConfig from '../../../utils/getConfig'; +import { apiClient } from '../../../utils/apiClient'; +import { getConfig } from '../../../utils/getConfig'; +import { isTestEnvironment } from '../../../utils/checkTestEnv'; import { handleCreateFile } from './files'; +const s3BucketUrlBase = getConfig('S3_BUCKET_URL_BASE'); +const awsRegion = getConfig('AWS_REGION'); +const s3Bucket = getConfig('S3_BUCKET'); + +if (!isTestEnvironment && !s3BucketUrlBase && !(awsRegion && s3Bucket)) { + throw new Error(`S3 bucket address not configured. + Configure either S3_BUCKET_URL_BASE or both AWS_REGION & S3_BUCKET in env vars`); +} + export const s3BucketHttps = - getConfig('S3_BUCKET_URL_BASE') || - `https://s3-${getConfig('AWS_REGION')}.amazonaws.com/${getConfig( - 'S3_BUCKET' - )}/`; + s3BucketUrlBase || `https://s3-${awsRegion}.amazonaws.com/${s3Bucket}/`; + const MAX_LOCAL_FILE_SIZE = 80000; // bytes, aka 80 KB function isS3Upload(file) { diff --git a/client/modules/IDE/components/AssetSize.jsx b/client/modules/IDE/components/AssetSize.jsx index 853e9d3ea4..6e0a7d3266 100644 --- a/client/modules/IDE/components/AssetSize.jsx +++ b/client/modules/IDE/components/AssetSize.jsx @@ -1,10 +1,10 @@ import React from 'react'; import { useSelector } from 'react-redux'; import prettyBytes from 'pretty-bytes'; +import { getConfig } from '../../../utils/getConfig'; +import { parseNumber } from '../../../utils/parseStringToType'; -import getConfig from '../../../utils/getConfig'; - -const limit = getConfig('UPLOAD_LIMIT') || 250000000; +const limit = parseNumber(getConfig('UPLOAD_LIMIT')) || 250000000; const MAX_SIZE_B = limit; const formatPercent = (percent) => { diff --git a/client/modules/IDE/components/CollectionList/CollectionListRow.jsx b/client/modules/IDE/components/CollectionList/CollectionListRow.jsx index a2096fc788..35b0ae6b09 100644 --- a/client/modules/IDE/components/CollectionList/CollectionListRow.jsx +++ b/client/modules/IDE/components/CollectionList/CollectionListRow.jsx @@ -11,7 +11,7 @@ import * as ProjectActions from '../../actions/project'; import * as CollectionsActions from '../../actions/collections'; import * as IdeActions from '../../actions/ide'; import * as ToastActions from '../../actions/toast'; -import dates from '../../../../utils/formatDate'; +import { formatDateToString } from '../../../../utils/formatDate'; import { remSize, prop } from '../../../../theme'; const SketchsTableRow = styled.tr` @@ -93,7 +93,7 @@ const SketchlistDropdownColumn = styled.td` } `; const formatDateCell = (date, mobile = false) => - dates.format(date, { showTime: !mobile }); + formatDateToString(date, { showTime: !mobile }); const CollectionListRowBase = (props) => { const [renameOpen, setRenameOpen] = useState(false); diff --git a/client/modules/IDE/components/Header/Nav.jsx b/client/modules/IDE/components/Header/Nav.jsx index 151e2d8212..f8f19cdb9f 100644 --- a/client/modules/IDE/components/Header/Nav.jsx +++ b/client/modules/IDE/components/Header/Nav.jsx @@ -7,7 +7,8 @@ import { useTranslation } from 'react-i18next'; import MenubarSubmenu from '../../../../components/Menubar/MenubarSubmenu'; import MenubarItem from '../../../../components/Menubar/MenubarItem'; import { availableLanguages, languageKeyToLabel } from '../../../../i18n'; -import getConfig from '../../../../utils/getConfig'; +import { getConfig } from '../../../../utils/getConfig'; +import { parseBoolean } from '../../../../utils/parseStringToType'; import { showToast } from '../../actions/toast'; import { setLanguage } from '../../actions/preferences'; import Menubar from '../../../../components/Menubar/Menubar'; @@ -80,8 +81,14 @@ LeftLayout.defaultProps = { layout: 'project' }; +const isLoginEnabled = parseBoolean(getConfig('LOGIN_ENABLED'), true); +const isUiCollectionsEnabled = parseBoolean( + getConfig('UI_COLLECTIONS_ENABLED'), + true +); +const isExamplesEnabled = parseBoolean(getConfig('EXAMPLES_ENABLED'), true); + const UserMenu = () => { - const isLoginEnabled = getConfig('LOGIN_ENABLED'); const isAuthenticated = useSelector(getAuthenticated); if (isLoginEnabled && isAuthenticated) { @@ -177,7 +184,7 @@ const ProjectMenu = () => { id="file-save" isDisabled={ !user.authenticated || - !getConfig('LOGIN_ENABLED') || + !isLoginEnabled || (project?.owner && !isUserOwner) } onClick={() => saveSketch(cmRef.current)} @@ -216,9 +223,7 @@ const ProjectMenu = () => { @@ -226,7 +231,7 @@ const ProjectMenu = () => { {t('Nav.File.Examples')} @@ -370,7 +375,7 @@ const AuthenticatedUserMenu = () => { {t('Nav.Auth.MyCollections')} diff --git a/client/modules/IDE/components/PreviewFrame.jsx b/client/modules/IDE/components/PreviewFrame.jsx index 47bd38a1df..c3e6c2dbf3 100644 --- a/client/modules/IDE/components/PreviewFrame.jsx +++ b/client/modules/IDE/components/PreviewFrame.jsx @@ -1,7 +1,7 @@ import React, { useRef, useEffect } from 'react'; import PropTypes from 'prop-types'; import styled from 'styled-components'; -import getConfig from '../../../utils/getConfig'; +import { getConfig } from '../../../utils/getConfig'; import { registerFrame } from '../../../utils/dispatcher'; const Frame = styled.iframe` @@ -13,7 +13,7 @@ const Frame = styled.iframe` function PreviewFrame({ fullView, isOverlayVisible }) { const iframe = useRef(); - const previewUrl = getConfig('PREVIEW_URL'); + const previewUrl = getConfig('PREVIEW_URL', { throwErrorIfNotFound: true }); useEffect(() => { const unsubscribe = registerFrame(iframe.current.contentWindow, previewUrl); return () => { diff --git a/client/modules/IDE/components/ShareModal.jsx b/client/modules/IDE/components/ShareModal.jsx index ec7f61fbe8..5e77af0a6a 100644 --- a/client/modules/IDE/components/ShareModal.jsx +++ b/client/modules/IDE/components/ShareModal.jsx @@ -2,7 +2,6 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import CopyableInput from './CopyableInput'; -// import getConfig from '../../../utils/getConfig'; const ShareModal = () => { const { t } = useTranslation(); @@ -15,7 +14,6 @@ const ShareModal = () => { ); const hostname = window.location.origin; - // const previewUrl = getConfig('PREVIEW_URL'); return (

{projectName}

diff --git a/client/modules/IDE/components/SketchListRowBase.jsx b/client/modules/IDE/components/SketchListRowBase.jsx index 08dc185885..a3ff465d71 100644 --- a/client/modules/IDE/components/SketchListRowBase.jsx +++ b/client/modules/IDE/components/SketchListRowBase.jsx @@ -8,13 +8,13 @@ import * as ProjectActions from '../actions/project'; import * as IdeActions from '../actions/ide'; import TableDropdown from '../../../components/Dropdown/TableDropdown'; import MenuItem from '../../../components/Dropdown/MenuItem'; -import dates from '../../../utils/formatDate'; -import getConfig from '../../../utils/getConfig'; +import { formatDateToString } from '../../../utils/formatDate'; +import { getConfig } from '../../../utils/getConfig'; -const ROOT_URL = getConfig('API_URL'); +const ROOT_URL = getConfig('API_URL', { throwErrorIfNotFound: true }); const formatDateCell = (date, mobile = false) => - dates.format(date, { showTime: !mobile }); + formatDateToString(date, { showTime: !mobile }); const SketchListRowBase = ({ sketch, diff --git a/client/modules/IDE/components/Timer.jsx b/client/modules/IDE/components/Timer.jsx index e203835c18..2502e9f33c 100644 --- a/client/modules/IDE/components/Timer.jsx +++ b/client/modules/IDE/components/Timer.jsx @@ -3,7 +3,7 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; -import dates from '../../../utils/formatDate'; +import { distanceInWordsToNow } from '../../../utils/formatDate'; import useInterval from '../hooks/useInterval'; import { getIsUserOwner } from '../selectors/users'; @@ -17,16 +17,14 @@ const Timer = () => { // Update immediately upon saving. useEffect(() => { - setTimeAgo( - projectSavedTime ? dates.distanceInWordsToNow(projectSavedTime) : '' - ); + setTimeAgo(projectSavedTime ? distanceInWordsToNow(projectSavedTime) : ''); }, [projectSavedTime]); // Update every 10 seconds. useInterval( () => setTimeAgo( - projectSavedTime ? dates.distanceInWordsToNow(projectSavedTime) : '' + projectSavedTime ? distanceInWordsToNow(projectSavedTime) : '' ), 10000 ); diff --git a/client/modules/IDE/components/UploadFileModal.jsx b/client/modules/IDE/components/UploadFileModal.jsx index f1e0b90fef..72fc8cabc5 100644 --- a/client/modules/IDE/components/UploadFileModal.jsx +++ b/client/modules/IDE/components/UploadFileModal.jsx @@ -3,13 +3,14 @@ import { useDispatch, useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import prettyBytes from 'pretty-bytes'; -import getConfig from '../../../utils/getConfig'; +import { getConfig } from '../../../utils/getConfig'; import { closeUploadFileModal } from '../actions/ide'; import FileUploader from './FileUploader'; import { getreachedTotalSizeLimit } from '../selectors/users'; import Modal from './Modal'; +import { parseNumber } from '../../../utils/parseStringToType'; -const limit = getConfig('UPLOAD_LIMIT') || 250000000; +const limit = parseNumber(getConfig('UPLOAD_LIMIT')) || 250000000; const limitText = prettyBytes(limit); const UploadFileModal = () => { diff --git a/client/modules/IDE/selectors/users.js b/client/modules/IDE/selectors/users.js index 266644c271..14f81fd114 100644 --- a/client/modules/IDE/selectors/users.js +++ b/client/modules/IDE/selectors/users.js @@ -1,12 +1,13 @@ import { createSelector } from '@reduxjs/toolkit'; -import getConfig from '../../../utils/getConfig'; +import { getConfig } from '../../../utils/getConfig'; +import { parseNumber } from '../../../utils/parseStringToType'; export const getAuthenticated = (state) => state.user.authenticated; const getTotalSize = (state) => state.user.totalSize; const getAssetsTotalSize = (state) => state.assets.totalSize; export const getSketchOwner = (state) => state.project.owner; const getUserId = (state) => state.user.id; -const limit = getConfig('UPLOAD_LIMIT') || 250000000; +const limit = parseNumber(getConfig('UPLOAD_LIMIT')) || 250000000; export const getCanUploadMedia = createSelector( getAuthenticated, diff --git a/client/modules/Preview/EmbedFrame.jsx b/client/modules/Preview/EmbedFrame.jsx index fa01604ab7..82975bd9f7 100644 --- a/client/modules/Preview/EmbedFrame.jsx +++ b/client/modules/Preview/EmbedFrame.jsx @@ -6,7 +6,7 @@ import loopProtect from 'loop-protect'; import { JSHINT } from 'jshint'; import decomment from 'decomment'; import { resolvePathToFile } from '../../../server/utils/filePath'; -import getConfig from '../../utils/getConfig'; +import { getConfig } from '../../utils/getConfig'; import { MEDIA_FILE_QUOTED_REGEX, STRING_REGEX, @@ -232,9 +232,9 @@ p5.prototype.registerMethod('afterSetup', p5.prototype.ensureAccessibleCanvas);` } const previewScripts = sketchDoc.createElement('script'); - previewScripts.src = `${window.location.origin}${getConfig( - 'PREVIEW_SCRIPTS_URL' - )}`; + previewScripts.src = `${ + window.location.origin + }${getConfig('PREVIEW_SCRIPTS_URL', { nullishString: true })}`; previewScripts.setAttribute('crossorigin', ''); sketchDoc.head.appendChild(previewScripts); @@ -245,7 +245,9 @@ p5.prototype.registerMethod('afterSetup', p5.prototype.ensureAccessibleCanvas);` window.offs = ${JSON.stringify(scriptOffs)}; window.objectUrls = ${JSON.stringify(objectUrls)}; window.objectPaths = ${JSON.stringify(objectPaths)}; - window.editorOrigin = '${getConfig('EDITOR_URL')}'; + window.editorOrigin = '${getConfig('EDITOR_URL', { + throwErrorIfNotFound: true + })}'; `; addLoopProtect(sketchDoc); sketchDoc.head.prepend(consoleErrorsScript); diff --git a/client/modules/Preview/previewIndex.jsx b/client/modules/Preview/previewIndex.jsx index fe4b5dc153..4229811d3a 100644 --- a/client/modules/Preview/previewIndex.jsx +++ b/client/modules/Preview/previewIndex.jsx @@ -9,7 +9,7 @@ import { } from '../../utils/dispatcher'; import { filesReducer, setFiles } from './filesReducer'; import EmbedFrame from './EmbedFrame'; -import getConfig from '../../utils/getConfig'; +import { getConfig } from '../../utils/getConfig'; import { initialState } from '../IDE/reducers/files'; const GlobalStyle = createGlobalStyle` @@ -24,7 +24,10 @@ const App = () => { const [basePath, setBasePath] = useState(''); const [textOutput, setTextOutput] = useState(false); const [gridOutput, setGridOutput] = useState(false); - registerFrame(window.parent, getConfig('EDITOR_URL')); + registerFrame( + window.parent, + getConfig('EDITOR_URL', { throwErrorIfNotFound: true }) + ); function handleMessageEvent(message) { const { type, payload } = message; diff --git a/client/modules/User/actions.js b/client/modules/User/actions.js index db367d388d..20e6729b0d 100644 --- a/client/modules/User/actions.js +++ b/client/modules/User/actions.js @@ -1,7 +1,7 @@ import { FORM_ERROR } from 'final-form'; import * as ActionTypes from '../../constants'; import browserHistory from '../../browserHistory'; -import apiClient from '../../utils/apiClient'; +import { apiClient } from '../../utils/apiClient'; import { showErrorModal, justOpenedProject } from '../IDE/actions/ide'; import { setLanguage } from '../IDE/actions/preferences'; import { showToast, setToastText } from '../IDE/actions/toast'; diff --git a/client/modules/User/components/APIKeyList.jsx b/client/modules/User/components/APIKeyList.jsx index c7fdf15f4a..17cd857354 100644 --- a/client/modules/User/components/APIKeyList.jsx +++ b/client/modules/User/components/APIKeyList.jsx @@ -5,7 +5,10 @@ import { useTranslation } from 'react-i18next'; import { APIKeyPropType } from './APIKeyForm'; -import dates from '../../../utils/formatDate'; +import { + distanceInWordsToNow, + formatDateToString +} from '../../../utils/formatDate'; import TrashCanIcon from '../../../images/trash-can.svg'; function APIKeyList({ apiKeys, onRemove }) { @@ -23,13 +26,13 @@ function APIKeyList({ apiKeys, onRemove }) { {orderBy(apiKeys, ['createdAt'], ['desc']).map((key) => { const lastUsed = key.lastUsedAt - ? dates.distanceInWordsToNow(new Date(key.lastUsedAt)) + ? distanceInWordsToNow(new Date(key.lastUsedAt)) : t('APIKeyList.Never'); return ( {key.label} - {dates.format(key.createdAt)} + {formatDateToString(key.createdAt)} {lastUsed}