diff --git a/.storybook/preview.js b/.storybook/preview.js index 764545437..a98b125e5 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -9,7 +9,7 @@ import getStore from '../src/bundles/index.js' import i18n from '../src/i18n.js' import DndBackend from '../src/lib/dnd-backend.js' import { HeliaProvider, ExploreProvider } from 'ipld-explorer-components/providers' - +import { ShortcutsProvider } from '../src/contexts/ShortcutsContext.js' /** * @type {import('@storybook/addons').BaseAnnotations} */ @@ -21,7 +21,9 @@ const baseAnnotations = { - + + + diff --git a/package-lock.json b/package-lock.json index eb91cb7c2..d293be8e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -108,6 +108,8 @@ "@types/jest": "^29.5.14", "@types/node": "^14.18.36", "@types/path-browserify": "^1.0.0", + "@types/prop-types": "^15.7.14", + "@types/react-overlays": "^3.1.0", "@typescript-eslint/parser": "^5.62.0", "aegir": "^42.2.2", "autoprefixer": "^10.4.7", @@ -18245,9 +18247,9 @@ "license": "MIT" }, "node_modules/@types/prop-types": { - "version": "15.7.13", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", - "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", + "version": "15.7.14", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", "license": "MIT" }, "node_modules/@types/q": { @@ -18299,6 +18301,17 @@ "@types/reactcss": "*" } }, + "node_modules/@types/react-overlays": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/react-overlays/-/react-overlays-3.1.0.tgz", + "integrity": "sha512-NzZZHFLj7M7+I+p5rDdVHtm6AeVYQPShVxALiLYhR9leJSX8XujPsYuY+vh7/mzFjv6XR7PxHBAdlFGNaN6QDQ==", + "deprecated": "This is a stub types definition. react-overlays provides its own type definitions, so you do not need this installed.", + "dev": true, + "license": "MIT", + "dependencies": { + "react-overlays": "*" + } + }, "node_modules/@types/react-syntax-highlighter": { "version": "11.0.4", "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-11.0.4.tgz", diff --git a/package.json b/package.json index a3eb69bdf..d0dd69f78 100644 --- a/package.json +++ b/package.json @@ -138,6 +138,8 @@ "@types/jest": "^29.5.14", "@types/node": "^14.18.36", "@types/path-browserify": "^1.0.0", + "@types/prop-types": "^15.7.14", + "@types/react-overlays": "^3.1.0", "@typescript-eslint/parser": "^5.62.0", "aegir": "^42.2.2", "autoprefixer": "^10.4.7", diff --git a/public/locales/en/app.json b/public/locales/en/app.json index c40e0a833..311723785 100644 --- a/public/locales/en/app.json +++ b/public/locales/en/app.json @@ -105,5 +105,13 @@ "skip": "Skip", "tooltip": "Click this button any time for a guided tour on the current page." }, + "shortcutModal": { + "title": "Keyboard Shortcuts", + "description": "The following keyboard shortcuts are available in the Files section:", + "showShortcuts": "Show keyboard shortcuts", + "general": "General", + "tourHelp": "Show tour help", + "ipfsPath": "Enter QmHash or CID" + }, "startTourHelper": "Start tour" } diff --git a/public/locales/en/files.json b/public/locales/en/files.json index 4a2dee8bd..fce82687f 100644 --- a/public/locales/en/files.json +++ b/public/locales/en/files.json @@ -49,28 +49,6 @@ "checkboxRemoveLocalPin": "Also remove local pin (recommended)", "checkboxUnpinFromServices": "Unpin from all pinning services" }, - "shortcutModal": { - "title": "Keyboard Shortcuts", - "description": "The following keyboard shortcuts are available in the Files section:", - "navigation": "Navigation", - "selection": "Selection", - "actions": "Actions", - "other": "Other", - "moveDown": "Move down", - "moveUp": "Move up", - "moveLeft": "Move left", - "moveRight": "Move right", - "navigate": "Navigate to selected item", - "rename": "Rename selected item", - "delete": "Delete selected item(s)", - "toggleSelection": "Toggle selection", - "selectAll": "Select all items", - "deselectAll": "Deselect all items", - "copy": "Copy selected item(s)", - "paste": "Paste item(s)", - "cut": "Cut selected item(s)", - "showShortcuts": "Show keyboard shortcuts" - }, "pinningModal": { "title": "Select where you would like to pin these items.", "complianceLabel": "🔍 Check pinning services' compliance", @@ -185,5 +163,24 @@ }, "noPinsInProgress": "All done, no remote pins in progress.", "remotePinningInProgress": "Remote pinning in progress:", - "selectAllEntries": "Select all entries" + "selectAllEntries": "Select all entries", + "shortcutModal": { + "navigation": "Navigation", + "selection": "Selection", + "actions": "Actions", + "other": "Other", + "moveDown": "Move down", + "moveUp": "Move up", + "moveLeft": "Move left", + "moveRight": "Move right", + "navigate": "Navigate to selected item", + "rename": "Rename selected item", + "delete": "Delete selected item(s)", + "toggleSelection": "Toggle selection", + "selectAll": "Select all items", + "deselectAll": "Deselect all items", + "copy": "Copy selected item(s)", + "paste": "Paste item(s)", + "cut": "Cut selected item(s)" + } } diff --git a/public/locales/en/peers.json b/public/locales/en/peers.json index 4ccec8abf..6591fe759 100644 --- a/public/locales/en/peers.json +++ b/public/locales/en/peers.json @@ -3,6 +3,7 @@ "localNetwork": "Local Network", "nearby": "nearby", "protocols": "Open streams", + "filterPeers": "Filter peers", "addConnection": "Add connection", "insertPeerAddress": "Insert the peer address you want to connect to.", "addPermanentPeer": "Add to the permanent peering configuration", diff --git a/src/components/modal/Modal.js b/src/components/modal/Modal.js index 05695bb3d..0989f4013 100644 --- a/src/components/modal/Modal.js +++ b/src/components/modal/Modal.js @@ -1,7 +1,9 @@ -import React from 'react' +// @ts-nocheck +import * as React from 'react' import PropTypes from 'prop-types' -import CancelIcon from '../../icons/GlyphSmallCancel.js' +import CancelIcon from '../../icons/GlyphSmallCancel' +// @ts-ignore export const ModalActions = ({ justify = 'between', className = '', children, ...props }) => (
{ children } @@ -10,9 +12,11 @@ export const ModalActions = ({ justify = 'between', className = '', children, .. ModalActions.propTypes = { justify: PropTypes.string, - className: PropTypes.string + className: PropTypes.string, + children: PropTypes.node } +// @ts-ignore export const ModalBody = ({ className = '', Icon, title, children, ...props }) => (
{ Icon && ( @@ -32,9 +36,11 @@ ModalBody.propTypes = { title: PropTypes.oneOfType([ PropTypes.string, PropTypes.node - ]) + ]), + children: PropTypes.node } +// @ts-ignore export const Modal = ({ onCancel, children, className, ...props }) => { return (
diff --git a/src/components/notify/Toast.js b/src/components/notify/Toast.js index b4e9ea89e..d9e169046 100644 --- a/src/components/notify/Toast.js +++ b/src/components/notify/Toast.js @@ -1,5 +1,5 @@ import React from 'react' -import CancelIcon from '../../icons/GlyphSmallCancel.js' +import CancelIcon from '../../icons/GlyphSmallCancel' const Toast = ({ error, children, onDismiss }) => { const bg = error ? 'bg-yellow' : 'bg-green' diff --git a/src/components/overlay/Overlay.js b/src/components/overlay/Overlay.js deleted file mode 100644 index 3820ed355..000000000 --- a/src/components/overlay/Overlay.js +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' -import { Modal } from 'react-overlays' - -function Overlay ({ children, show, onLeave, className, hidden, ...props }) { - const handleKeyDown = (e) => { - if (e.key !== 'Escape') return - - e.stopPropagation() - e.nativeEvent.stopImmediatePropagation() - - onLeave() - } - - const renderBackdrop = (props) => ( - diff --git a/src/files/modals/Modals.js b/src/files/modals/Modals.js index 04f66f8fd..e09348fa1 100644 --- a/src/files/modals/Modals.js +++ b/src/files/modals/Modals.js @@ -12,7 +12,6 @@ import RemoveModal from './remove-modal/RemoveModal.js' import AddByPathModal from './add-by-path-modal/AddByPathModal.js' import BulkImportModal from './bulk-import-modal/bulk-import-modal.tsx' import PublishModal from './publish-modal/PublishModal.js' -import ShortcutModal from './shortcut-modal/shortcut-modal.js' import CliTutorMode from '../../components/cli-tutor-mode/CliTutorMode.js' import { cliCommandList, cliCmdKeys } from '../../bundles/files/consts.js' import { realMfsPath } from '../../bundles/files/actions.js' @@ -28,7 +27,6 @@ const BULK_CID_IMPORT = 'bulk_cid_import' const CLI_TUTOR_MODE = 'cli_tutor_mode' const PINNING = 'pinning' const PUBLISH = 'publish' -const SHORTCUTS = 'shortcuts' export { NEW_FOLDER, @@ -40,8 +38,7 @@ export { BULK_CID_IMPORT, CLI_TUTOR_MODE, PINNING, - PUBLISH, - SHORTCUTS + PUBLISH } class Modals extends React.Component { @@ -196,9 +193,6 @@ class Modals extends React.Component { publish: { file } }) } - case SHORTCUTS: - this.setState({ readyToShow: true }) - break default: // do nothing } @@ -311,12 +305,6 @@ class Modals extends React.Component { onLeave={this.leave} onSubmit={this.publish} /> - - - -
) } diff --git a/src/files/modals/shortcut-modal/shortcut-modal.js b/src/files/modals/shortcut-modal/shortcut-modal.js deleted file mode 100644 index 0caeab681..000000000 --- a/src/files/modals/shortcut-modal/shortcut-modal.js +++ /dev/null @@ -1,184 +0,0 @@ -import React, { useEffect, useState } from 'react' -import PropTypes from 'prop-types' -import { useTranslation } from 'react-i18next' -import { Modal } from '../../../components/modal/Modal.js' -import CancelIcon from '../../../icons/GlyphSmallCancel.js' - -const keySymbols = { - ArrowUp: '↑', - ArrowDown: '↓', - ArrowLeft: '←', - ArrowRight: '→', - Enter: '↵', - Space: '␣', - Escape: 'Esc', - Delete: 'Del', - Backspace: '⌫', - mac: { - Meta: '⌘', - Alt: '⌥', - Shift: '⇧', - Control: '⌃', - Ctrl: '⌃' - }, - other: { - Meta: 'Win', - Alt: 'Alt', - Shift: 'Shift', - Control: 'Ctrl', - Ctrl: 'Ctrl' - } -} - -const KeyboardKey = ({ children, platform }) => { - const getKeySymbol = (key) => { - if (keySymbols[key]) return keySymbols[key] - if (platform === 'mac' && keySymbols.mac[key]) return keySymbols.mac[key] - if (platform !== 'mac' && keySymbols.other[key]) return keySymbols.other[key] - return key - } - - return ( - - {getKeySymbol(children)} - - ) -} - -KeyboardKey.propTypes = { - children: PropTypes.node.isRequired, - platform: PropTypes.string.isRequired -} - -const ShortcutItem = ({ shortcut, description, platform }) => ( -
-
{description}
-
- {Array.isArray(shortcut) - ? shortcut.map((key, i) => ( - - {key} - {i < shortcut.length - 1 && +} - )) - : {shortcut}} -
-
-) - -ShortcutItem.propTypes = { - shortcut: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.array - ]).isRequired, - description: PropTypes.string.isRequired, - platform: PropTypes.string.isRequired -} - -const ShortcutSection = ({ title, shortcuts, platform }) => ( -
-

{title}

-
- {shortcuts.map((shortcut, i) => ( - - ))} -
-
-) - -ShortcutSection.propTypes = { - title: PropTypes.string.isRequired, - shortcuts: PropTypes.array.isRequired, - platform: PropTypes.string.isRequired -} - -const ShortcutModal = ({ onLeave, className, ...props }) => { - const [platform, setPlatform] = useState('other') - const { t } = useTranslation('files') - - // Detect platform on component mount - useEffect(() => { - const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0 || - (navigator.userAgent.includes('Mac') && !navigator.userAgent.includes('Mobile')) - setPlatform(isMac ? 'mac' : 'other') - }, []) - - const navigationShortcuts = [ - { shortcut: 'ArrowDown', description: t('shortcutModal.moveDown') }, - { shortcut: 'ArrowUp', description: t('shortcutModal.moveUp') }, - { shortcut: 'ArrowLeft', description: t('shortcutModal.moveLeft') }, - { shortcut: 'ArrowRight', description: t('shortcutModal.moveRight') }, - { shortcut: 'Enter', description: t('shortcutModal.navigate') } - ] - - const selectionShortcuts = [ - { shortcut: 'Space', description: t('shortcutModal.toggleSelection') }, - { shortcut: 'Escape', description: t('shortcutModal.deselectAll') } - ] - - const actionShortcuts = [ - { shortcut: 'F2', description: t('shortcutModal.rename') }, - { shortcut: 'Delete', description: t('shortcutModal.delete') } - ] - - const otherShortcuts = [ - { shortcut: ['Shift', '?'], description: t('shortcutModal.showShortcuts') } - ] - - return ( - -
-

{t('shortcutModal.title')}

- -
- -
-
-
- - - -
- -
- - - -
-
-
-
- ) -} - -ShortcutModal.propTypes = { - onLeave: PropTypes.func.isRequired, - className: PropTypes.string -} - -ShortcutModal.defaultProps = { - className: '' -} - -export default ShortcutModal diff --git a/src/files/modals/shortcut-modal/shortcut-modal.tsx b/src/files/modals/shortcut-modal/shortcut-modal.tsx new file mode 100644 index 000000000..b9e63debe --- /dev/null +++ b/src/files/modals/shortcut-modal/shortcut-modal.tsx @@ -0,0 +1,191 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +// @ts-ignore +import { Modal } from '../../../components/modal/Modal' +import { useShortcuts } from '../../../contexts/ShortcutsContext' + +interface KeySymbols { + [key: string]: string | { + [key: string]: string + } + mac: { + [key: string]: string + } + other: { + [key: string]: string + } +} + +const keySymbols: KeySymbols = { + ArrowUp: '↑', + ArrowDown: '↓', + ArrowLeft: '←', + ArrowRight: '→', + Enter: '↵', + Space: 'Space', + Escape: 'Esc', + Delete: 'Del', + Backspace: '⌫', + mac: { + Meta: '⌘', + Alt: '⌥', + Shift: 'Shift', + Control: 'Ctrl', + Ctrl: 'Ctrl' + }, + other: { + Meta: 'Win', + Alt: 'Alt', + Shift: 'Shift', + Control: 'Ctrl', + Ctrl: 'Ctrl' + } +} + +type PlatformType = 'mac' | 'other' + +interface KeyboardKeyProps { + children: string + platform: PlatformType +} + +const KeyboardKey: React.FC = ({ children, platform }) => { + const getKeySymbol = (key: string): string => { + if (keySymbols[key]) return keySymbols[key] as string + if (platform === 'mac' && keySymbols.mac[key]) return keySymbols.mac[key] + if (platform !== 'mac' && keySymbols.other[key]) return keySymbols.other[key] + return key + } + + return ( + + {getKeySymbol(children)} + + ) +} + +interface ShortcutItemProps { + shortcut: string | string[] + description: string + platform: PlatformType, + hidden?: boolean +} + +const ShortcutItem: React.FC = ({ shortcut, description, platform }) => ( +
+
{description}
+
+ {Array.isArray(shortcut) + ? shortcut.map((key, i) => ( + + {key} + {i < shortcut.length - 1 && +} + )) + : {shortcut}} +
+
+) + +interface ShortcutData { + shortcut: string | string[] + description: string + hidden?: boolean +} + +interface ShortcutSectionProps { + title: string + shortcuts: ShortcutData[] + platform: PlatformType +} + +const ShortcutSection: React.FC = ({ title, shortcuts, platform }) => ( +
+

{title}

+
+ {shortcuts.filter(shortcut => !shortcut.hidden).map((shortcut, i) => ( + + ))} +
+
+) + +interface ShortcutModalProps { + onLeave: () => void + className?: string +} + +const ShortcutModal: React.FC = ({ onLeave, className = '', ...props }) => { + const [platform, setPlatform] = useState('other') + const { t } = useTranslation('app') + const shortcuts = useShortcuts() + + useEffect(() => { + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0 || + (navigator.userAgent.includes('Mac') && !navigator.userAgent.includes('Mobile')) + setPlatform(isMac ? 'mac' : 'other') + }, []) + + const groupedShortcuts = useMemo(() => { + return shortcuts.reduce((acc, shortcut) => { + const group = shortcut.group || 'Other' + if (!acc[group]) { + acc[group] = [] + } + acc[group].push({ + shortcut: shortcut.keys, + description: shortcut.label, + hidden: shortcut.hidden + }) + return acc + }, {} as Record) + }, [shortcuts]) + + const groupOrder = ['General', 'Navigation', 'Selection', 'Actions', 'Other'] + + const sortedGroups = useMemo(() => { + return Object.entries(groupedShortcuts) + .sort(([a], [b]) => { + const indexA = groupOrder.indexOf(a) + const indexB = groupOrder.indexOf(b) + if (indexA === -1) return 1 + if (indexB === -1) return -1 + return indexA - indexB + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [groupedShortcuts]) + + return ( + +
+

{t('shortcutModal.title')}

+
+ +
+
+ {sortedGroups.map(([group, shortcuts]) => ( + + ))} +
+
+
+ ) +} + +export default ShortcutModal diff --git a/src/icons/GlyphSmallCancel.js b/src/icons/GlyphSmallCancel.tsx similarity index 74% rename from src/icons/GlyphSmallCancel.js rename to src/icons/GlyphSmallCancel.tsx index f745ab6ef..3739f83d0 100644 --- a/src/icons/GlyphSmallCancel.js +++ b/src/icons/GlyphSmallCancel.tsx @@ -1,6 +1,6 @@ -import * as React from 'react' +import { SVGProps } from 'react' -function SvgGlyphSmallCancel (props) { +function SvgGlyphSmallCancel (props: SVGProps) { return ( diff --git a/src/index.js b/src/index.js index 72b53a1ee..4e7e522d2 100644 --- a/src/index.js +++ b/src/index.js @@ -11,7 +11,7 @@ import i18n from './i18n.js' import { DndProvider } from 'react-dnd' import DndBackend from './lib/dnd-backend.js' import { HeliaProvider, ExploreProvider } from 'ipld-explorer-components/providers' - +import { ShortcutsProvider } from './contexts/ShortcutsContext.js' const appVersion = process.env.REACT_APP_VERSION const gitRevision = process.env.REACT_APP_GIT_REV @@ -37,7 +37,9 @@ async function render () { - + + + diff --git a/src/peers/AddConnection/AddConnection.js b/src/peers/AddConnection/AddConnection.js index c33dcfc15..eb3a24598 100644 --- a/src/peers/AddConnection/AddConnection.js +++ b/src/peers/AddConnection/AddConnection.js @@ -23,19 +23,12 @@ const multiaddrIsValid = (addrString) => { class AddConnection extends React.Component { state = { - open: false, loading: false, isValid: false, permanent: true, maddr: '' } - toggleModal = () => { - this.setState({ - open: !this.state.open - }) - } - onPeeringToggle = () => { this.setState({ permanent: !this.state.permanent }) } @@ -61,7 +54,7 @@ class AddConnection extends React.Component { if (errored) return this.setState({ isValid: false, permanent: true, maddr: '' }) - this.toggleModal() + this.props.setIsOpen(false) } onKeyPress = (event) => { @@ -101,17 +94,17 @@ class AddConnection extends React.Component { } render () { - const { open, loading } = this.state - const { t } = this.props + const { loading } = this.state + const { t, isOpen, setIsOpen } = this.props return (
- - - + setIsOpen(false)}> + setIsOpen(false)}> { this.description } @@ -132,7 +125,7 @@ class AddConnection extends React.Component { - + diff --git a/src/peers/PeersPage.js b/src/peers/PeersPage.js index 4f2bcc266..c657fe9f0 100644 --- a/src/peers/PeersPage.js +++ b/src/peers/PeersPage.js @@ -14,16 +14,39 @@ import PeersTable from './PeersTable/PeersTable.js' import AddConnection from './AddConnection/AddConnection.js' import CliTutorMode from '../components/cli-tutor-mode/CliTutorMode.js' import { cliCmdKeys, cliCommandList } from '../bundles/files/consts.js' +import { useShortcuts } from '../contexts/ShortcutsContext.js' -const PeersPage = ({ t, toursEnabled, handleJoyrideCallback }) => ( -
+const PeersPage = ({ t, toursEnabled, handleJoyrideCallback }) => { + const [isOpen, setIsOpen] = React.useState(false) + + useShortcuts([{ + keys: ['Meta', 'c'], + label: t('addConnection'), + action: () => { + setIsOpen((prev) => !prev) + }, + group: t('app:shortcutModal.general') + }, { + keys: ['Shift', 'F'], + label: t('peers:filterPeers'), + action: () => { + const filterInput = document.getElementById('peers-filter') + + if (filterInput) { + filterInput.focus() + } + }, + group: t('app:shortcutModal.general') + }]) + + return (
{t('title')} | IPFS
- +
@@ -40,8 +63,8 @@ const PeersPage = ({ t, toursEnabled, handleJoyrideCallback }) => ( scrollToFirstStep locale={getJoyrideLocales(t)} showProgress /> -
-) +
) +} export default connect( 'selectToursEnabled', diff --git a/src/peers/PeersTable/PeersTable.js b/src/peers/PeersTable/PeersTable.js index 93de11880..f34c64289 100644 --- a/src/peers/PeersTable/PeersTable.js +++ b/src/peers/PeersTable/PeersTable.js @@ -108,6 +108,7 @@ const FilterInput = ({ setFilter, t, filteredCount }) => { className='input-reset ba b--black-20 pa2 mb2 db w-100' type='text' placeholder='Filter peers' + id='peers-filter' onChange={(e) => setFilter(e.target.value)} /> {/* Now to display the total number of peers filtered out on the right side of the inside of the input */} diff --git a/src/types/react-overlays.d.ts b/src/types/react-overlays.d.ts new file mode 100644 index 000000000..e066bdbee --- /dev/null +++ b/src/types/react-overlays.d.ts @@ -0,0 +1,12 @@ +declare module 'react-overlays' { + import * as React from 'react' + export interface ModalProps { + show?: boolean + className?: string + renderBackdrop?: (props: any) => React.ReactNode + onKeyDown?: (e: React.KeyboardEvent) => void + onBackdropClick?: () => void + [key: string]: unknown + } + export class Modal extends React.Component {} +} diff --git a/tsconfig.json b/tsconfig.json index ac9429f20..855b1889c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -101,6 +101,11 @@ "src/icons/GlyphPinCloud.js", "src/icons/GlyphPin.js", "src/icons/StrokeCube.js", - "src/files/type-from-ext/extToType.js" + "src/files/type-from-ext/extToType.js", + "src/contexts/ShortcutsContext.tsx", + "src/files/modals/shortcut-modal/shortcut-modal.tsx", + "src/icons/GlyphSmallCancel.tsx", + "src/components/overlay/Overlay.tsx", + "src/icons/GlyphSmallCancel.tsx" ] }