Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add thumbnails support for files in grid view #2337

Open
wants to merge 36 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
f22d5a3
feat: Add grid view mode with thumbnails for files page
george-hub331 Feb 20, 2025
b490e6d
feat: Enhance files page with multi-select and keyboard navigation
george-hub331 Feb 21, 2025
47f641f
fix: Improve FilesGrid and GridFile styling and linting
george-hub331 Feb 22, 2025
4b76dde
feat: Improve file selection and view mode in Files Page
george-hub331 Feb 22, 2025
cbd4b03
feat: Add text preview for files in grid view
george-hub331 Feb 23, 2025
ba6383c
Merge branch 'main' into thumbnail-addition
george-hub331 Feb 26, 2025
8507e66
chore: remove yarn.lock file
george-hub331 Mar 1, 2025
1205468
feat: use larger icon size when preview is not available for grids
george-hub331 Mar 1, 2025
6021c9c
feat: Improve view mode UI and file hash display
george-hub331 Mar 1, 2025
b9f8de3
feat: Add view mode translations and adjust UI styling
george-hub331 Mar 1, 2025
f61bf3d
feat: Improve FilesGrid keyboard navigation and responsiveness
george-hub331 Mar 1, 2025
1fe9b1a
style: Reduce border width from 2px to 1px in default state
george-hub331 Mar 1, 2025
175630c
fix: resolve issue with storybook test
george-hub331 Mar 3, 2025
ca63ce4
feat: Add keyboard shortcuts and drag-and-drop support for gridfiles
george-hub331 Mar 4, 2025
0c4a5bc
Merge branch 'main' into thumbnail-addition
george-hub331 Mar 4, 2025
8df9581
fix: avoid jiggling ui
lidel Mar 4, 2025
3deea61
fix: avoid changing unrelated translations
lidel Mar 4, 2025
6430413
fix: remove shadow on hover
lidel Mar 4, 2025
ba6864c
Merge branch 'main' into thumbnail-addition
george-hub331 Mar 12, 2025
0acb827
chore: remove translation edits
SgtPooki Mar 17, 2025
cc3c47e
Merge branch 'main' into thumbnail-addition
SgtPooki Mar 17, 2025
2ab0c2b
chore: rename shortcut model filename
SgtPooki Mar 17, 2025
60b620a
chore: fix mem leak and side effects
SgtPooki Mar 17, 2025
7beaa11
chore: rename new files to kebab case
SgtPooki Mar 17, 2025
933a302
fix: grid keyboard nav
SgtPooki Mar 17, 2025
f1e29c8
fix: migrate to typescript
SgtPooki Mar 17, 2025
f9977b1
chore: fix lint failures
SgtPooki Mar 17, 2025
fe6e294
chore: fix runtime error
SgtPooki Mar 17, 2025
05d7fbe
fix: simplify keyboard shortcut condition
george-hub331 Mar 19, 2025
48c97d3
fix: resolve issue with enter shortcut on grid
george-hub331 Mar 19, 2025
8c33280
chore: remove default props on FilesList
george-hub331 Mar 19, 2025
6272ea9
feat: add grid view step to files tour
george-hub331 Mar 19, 2025
03bfd01
fix: improve keyboard navigation and refactor grid file component
george-hub331 Mar 22, 2025
5a0a185
feat: add e2e tests for grid view functionality
george-hub331 Mar 22, 2025
a85f55b
chore: remove e2e unnecessary setup script
george-hub331 Mar 22, 2025
206215d
fix: resolve error with grid e2e tests
george-hub331 Mar 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions @types/ipfs/index.d.ts
Original file line number Diff line number Diff line change
@@ -16,6 +16,7 @@ declare module 'ipfs' {
}

declare export interface CoreService {
// TODO: cat returns AsyncIterable<Uint8Array>. see https://github.com/ipfs/js-kubo-rpc-client/blob/1ab7941819dd1a48df653ee159e6983608e72132/src/index.ts#L353C50-L353C75
cat(pathOrCID: string | CID, options?: CatOptions): AsyncIterable<Buffer>;
ls(pathOrCID: string | CID, options?: ListOptions): AsyncIterable<ListEntry>;
add(file: FileContent | FileObject, options?: AddOptions): Promise<UnixFSEntry>;
@@ -94,9 +95,9 @@ declare module 'ipfs' {
}

declare export type PinType =
| "recursive"
| "direct"
| "indirect"
| 'recursive'
| 'direct'
| 'indirect'

declare export type PinEntry = {
cid: CID,
11 changes: 10 additions & 1 deletion config-overrides.js
Original file line number Diff line number Diff line change
@@ -121,7 +121,16 @@ function webpackOverride (config) {
fullySpecified: false
}
})
config.resolve.extensions = ['.js', '.jsx', '.tsx', '.ts', '...']

// Make sure .tsx and .ts extensions are properly prioritized
// This ordering allows imports like './file.js' to resolve to './file.ts' or './file.tsx'
config.resolve.extensions = ['.js', '.jsx', '.ts', '.tsx', '...']

// Enable resolving .js imports to .ts/.tsx files without specific aliases
config.resolve.extensionAlias = {
'.js': ['.js', '.ts', '.tsx'],
'.jsx': ['.jsx', '.tsx']
}
Comment on lines +124 to +133
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this fixes an issue where TS and webpack and eslint fought against each other about import extensions..

ideally all imports should end in file extension ".js"


// Instrument for code coverage in development mode
const REACT_APP_ENV = process.env.REACT_APP_ENV ?? process.env.NODE_ENV ?? 'development'
34 changes: 33 additions & 1 deletion public/locales/en/files.json
Original file line number Diff line number Diff line change
@@ -17,7 +17,10 @@
"addByCar": "From CAR",
"bulkImport": "Bulk import",
"newFolder": "New folder",
"viewList": "Show items in list",
"viewGrid": "Show items in grid",
"generating": "Generating…",
"dropHere": "Drop here to move",
"actions": {
"copyHash": "Copy CID",
"share": "Share link",
@@ -46,6 +49,28 @@
"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",
@@ -81,6 +106,7 @@
"description": "Insert the name of the folder you want to create."
},
"filesListLabel": "Files list",
"filesGridLabel": "Files grid",
"filesList": {
"noFiles": "<0>No files in this directory. Click the “Import” button to add some.</0>"
},
@@ -117,6 +143,11 @@
"paragraph1": "Finally, the listing where you can find your files and folders. Drag and drop files or folders to add them.",
"paragraph2": "You can share or download files, inspect them in the IPLD Explorer, rename or remove them!",
"paragraph3": "Pro tip: drag and drop a file from any other page of the Web UI to add them to the root of your MFS."
},
"step6": {
"title": "Files grid",
"paragraph1": "The grid view displays your files and folders as thumbnails, making it easy to visually browse your content.",
"paragraph2": "You can share or download files, inspect them in the IPLD Explorer, rename or remove them!"
}
},
"previewLimitReached": "This preview is limited to 10 KiB. Click the download button to access the full file.",
@@ -153,5 +184,6 @@
"pleaseWait": "Please wait while the initial 20 copies of the updated IPNS record are stored with the help of DHT peers…"
},
"noPinsInProgress": "All done, no remote pins in progress.",
"remotePinningInProgress": "Remote pinning in progress:"
"remotePinningInProgress": "Remote pinning in progress:",
"selectAllEntries": "Select all entries"
}
13 changes: 13 additions & 0 deletions src/bundles/files/actions.js
Original file line number Diff line number Diff line change
@@ -185,6 +185,19 @@ const actions = () => ({
}
},

/**
* Reads data from a CID with optional offset and length.
* @param {import('multiformats/cid').CID} cid - The CID to read from
* @param {number} [offset] - The starting point to read from
* @param {number} [length] - The number of bytes to read
*/
doRead: (cid, offset = 0, length) => perform(ACTIONS.READ_FILE, async (ipfs) => {
if (!ipfs) {
throw new Error('IPFS is not available')
}
return ipfs.cat(cid, { offset, length })
}),

/**
* Fetches conten for the currently selected path. And updates
* `state.pageContent` on succesful completion.
4 changes: 3 additions & 1 deletion src/bundles/files/consts.js
Original file line number Diff line number Diff line change
@@ -42,7 +42,9 @@ export const ACTIONS = {
/** @type {'FILES_WRITE_UPDATED'} */
WRITE_UPDATED: ('FILES_WRITE_UPDATED'),
/** @type {'FILES_UPDATE_SORT'} */
UPDATE_SORT: ('FILES_UPDATE_SORT')
UPDATE_SORT: ('FILES_UPDATE_SORT'),
/** @type {'FILES_READ'} */
READ_FILE: ('FILES_READ')
}

export const SORTING = {
16 changes: 14 additions & 2 deletions src/components/checkbox/Checkbox.js
Original file line number Diff line number Diff line change
@@ -3,12 +3,24 @@ import PropTypes from 'prop-types'
import Tick from '../../icons/GlyphSmallTick.js'
import './Checkbox.css'

/**
* @param {Object} props
* @param {string} [props.className]
* @param {React.ReactNode} [props.label]
* @param {boolean} [props.disabled]
* @param {boolean} [props.checked]
* @param {function(boolean): void} props.onChange
* @returns {React.ReactElement}
*/
const Checkbox = ({ className, label, disabled, checked, onChange, ...props }) => {
className = `Checkbox dib sans-serif ${className}`
if (!disabled) {
className += ' pointer'
}

/**
* @param {React.ChangeEvent<HTMLInputElement>} event
*/
const change = (event) => {
onChange(event.target.checked)
}
@@ -19,9 +31,9 @@ const Checkbox = ({ className, label, disabled, checked, onChange, ...props }) =
<span className='dib v-mid br1 w1 h1 pointer'>
<Tick className='w1 h1 o-0 fill-aqua' viewBox='25 25 50 50' />
</span>
<span className='v-mid pl2'>
{Boolean(label) && <span className='v-mid pl2'>
{label}
</span>
</span>}
</label>
)
}
2 changes: 1 addition & 1 deletion src/components/text-input-modal/TextInputModal.js
Original file line number Diff line number Diff line change
@@ -123,7 +123,7 @@ class TextInputModal extends React.Component {
onKeyPress={this.onKeyPress}
value={this.state.value}
required
className={`input-reset charcoal ba b--black-20 br1 pa2 mb2 db w-90 center focus-outline ${this.inputClass}`}
className={`input-reset charcoal ba b--black-20 br1 pa2 mb2 db w-90 center focus-outline modal-input ${this.inputClass}`}
type='text' />
</ModalBody>

176 changes: 152 additions & 24 deletions src/files/FilesPage.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { findDOMNode } from 'react-dom'
import { Helmet } from 'react-helmet'
import { connect } from 'redux-bundler-react'
@@ -12,13 +12,18 @@ import withTour from '../components/tour/withTour.js'
import InfoBoxes from './info-boxes/InfoBoxes.js'
import FilePreview from './file-preview/FilePreview.js'
import FilesList from './files-list/FilesList.js'
import FilesGrid from './files-grid/files-grid.js'
import { ViewList, ViewModule } from '../icons/stroke-icons.js'
import { getJoyrideLocales } from '../helpers/i8n.js'

// Icons
import Modals, { DELETE, NEW_FOLDER, ADD_BY_CAR, SHARE, RENAME, ADD_BY_PATH, BULK_CID_IMPORT, CLI_TUTOR_MODE, PINNING, PUBLISH } from './modals/Modals.js'
import Modals, { DELETE, NEW_FOLDER, SHARE, ADD_BY_CAR, RENAME, ADD_BY_PATH, BULK_CID_IMPORT, SHORTCUTS, CLI_TUTOR_MODE, PINNING, PUBLISH } from './modals/Modals.js'

import Header from './header/Header.js'
import FileImportStatus from './file-import-status/FileImportStatus.js'
import { useExplore } from 'ipld-explorer-components/providers'
import SelectedActions from './selected-actions/SelectedActions.js'
import Checkbox from '../components/checkbox/Checkbox.js'

const FilesPage = ({
doFetchPinningServices, doFilesFetch, doPinsFetch, doFilesSizeGet, doFilesDownloadLink, doFilesDownloadCarLink, doFilesWrite, doAddCarFile, doFilesBulkCidImport, doFilesAddPath, doUpdateHash,
@@ -35,6 +40,8 @@ const FilesPage = ({
translateY: 0,
file: null
})
const [viewMode, setViewMode] = useState('list')
const [selected, setSelected] = useState([])

useEffect(() => {
doFetchPinningServices()
@@ -49,6 +56,22 @@ const FilesPage = ({
}
}, [ipfsConnected, filesPathInfo, doFilesFetch])

useEffect(() => {
const handleKeyDown = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
return
}

if (e.key === '?' && e.shiftKey) {
e.preventDefault()
showModal(SHORTCUTS)
}
}

document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [])

/* TODO: uncomment below if we ever want automatic remote pin check
* (it was disabled for now due to https://github.com/ipfs/ipfs-desktop/issues/1954)
useEffect(() => {
@@ -90,6 +113,12 @@ const FilesPage = ({
const onInspect = (cid) => doUpdateHash(`/explore/${cid}`)
const showModal = (modal, files = null) => setModals({ show: modal, files })
const hideModal = () => setModals({})
/**
* @param {React.MouseEvent} ev
* @param {string} clickType
* @param {ContextMenuFile} file
* @param {Pick<DOMRect, 'y' | 'right' | 'bottom'>} [pos]
*/
const handleContextMenu = (ev, clickType, file, pos) => {
// This is needed to disable the native OS right-click menu
// and deal with the clicking on the ContextMenu options
@@ -132,6 +161,17 @@ const FilesPage = ({
}

const MainView = ({ t, files, remotePins, pendingPins, failedPins, doExploreUserProvidedPath }) => {
const selectedFiles = useMemo(() =>
selected
.map(name => files?.content?.find(el => el.name === name))
.filter(n => n)
.map(file => ({
...file,
pinned: files?.pins?.map(p => p.toString())?.includes(file.cid.toString())
}))
/* eslint-disable-next-line react-hooks/exhaustive-deps */
, [files?.content, files?.pins, selected])

if (!files || files.type === 'file') return (<div/>)

if (files.type === 'unknown') {
@@ -146,27 +186,69 @@ const FilesPage = ({
)
}

return (
<FilesList
key={window.encodeURIComponent(files.path)}
updateSorting={doFilesUpdateSorting}
files={files.content}
remotePins={remotePins}
pendingPins={pendingPins}
failedPins={failedPins}
upperDir={files.upper}
onShare={(files) => showModal(SHARE, files)}
onRename={(files) => showModal(RENAME, files)}
onRemove={(files) => showModal(DELETE, files)}
onSetPinning={(files) => showModal(PINNING, files)}
onInspect={onInspect}
onRemotePinClick={onRemotePinClick}
onDownload={onDownload}
onAddFiles={onAddFiles}
onNavigate={doFilesNavigateTo}
onMove={doFilesMove}
handleContextMenuClick={handleContextMenu} />
)
const commonProps = {
key: window.encodeURIComponent(files.path),
updateSorting: doFilesUpdateSorting,
files: files.content || [],
pins: files.pins || [],
remotePins: remotePins || [],
pendingPins: pendingPins || [],
failedPins: failedPins || [],
filesPathInfo,
selected,
onSelect: (name, isSelected) => {
if (Array.isArray(name)) {
if (isSelected) {
setSelected(name)
} else {
setSelected([])
}
} else {
if (isSelected) {
setSelected(prev => [...prev, name])
} else {
setSelected(prev => prev.filter(n => n !== name))
}
}
},
onShare: (files) => showModal(SHARE, files),
onRename: (files) => showModal(RENAME, files),
onRemove: (files) => showModal(DELETE, files),
onSetPinning: (files) => showModal(PINNING, files),
onInspect,
onRemotePinClick,
onDownload,
onAddFiles,
onNavigate: doFilesNavigateTo,
onMove: doFilesMove,
handleContextMenuClick: handleContextMenu,
// TODO: Implement this
onDismissFailedPin: () => {}
}

return <>
{viewMode === 'list'
? <FilesList {...commonProps} />
: <FilesGrid {...commonProps} />}

{selectedFiles.length !== 0 && <SelectedActions
className={'fixed bottom-0 right-0'}
style={{
zIndex: 20
}}
animateOnStart={selectedFiles.length === 1}
unselect={() => setSelected([])}
remove={() => showModal(DELETE, selectedFiles)}
rename={() => showModal(RENAME, selectedFiles)}
share={() => showModal(SHARE, selectedFiles)}
setPinning={() => showModal(PINNING, selectedFiles)}
download={() => onDownload(selectedFiles)}
inspect={() => onInspect(selectedFiles[0].cid)}
count={selectedFiles.length}
isMfs={filesPathInfo.isMfs}
size={selectedFiles.reduce((a, b) => a + (b.size || 0), 0)} />
}
</>
}

const getTitle = (filesPathInfo, t) => {
@@ -224,7 +306,53 @@ const FilesPage = ({
onBulkCidImport={(files) => showModal(BULK_CID_IMPORT, files)}
onNewFolder={(files) => showModal(NEW_FOLDER, files)}
onCliTutorMode={() => showModal(CLI_TUTOR_MODE)}
handleContextMenu={(...args) => handleContextMenu(...args, true)} />
handleContextMenu={(...args) => handleContextMenu(...args, true)}
>
<div className="flex items-center justify-end">
<button
className={`pointer filelist-view ${viewMode === 'list' ? 'selected-item' : 'gray'}`}
onClick={() => setViewMode('list')}
title={t('viewList')}
style={{
height: '24px'
}}
>
<ViewList width="24" height="24" />
</button>
<button
className={`pointer filegrid-view ${viewMode === 'grid' ? 'selected-item' : 'gray'}`}
onClick={() => setViewMode('grid')}
title={t('viewGrid')}
style={{
height: '24px'
}}
>
<ViewModule width="24" height="24" />
</button>
</div>
</Header>

{(files && files.type !== 'file') && <div className="flex items-center justify-between">
<div>
{viewMode === 'grid' && files?.content?.length > 0
? (
<Checkbox
className='pv3 pl3 pr1 bg-white flex-none'
onChange={(checked) => {
if (checked) {
setSelected(files.content.map(f => f.name))
} else {
setSelected([])
}
}}
checked={files?.content?.length > 0 && selected.length === files.content.length}
label={<span className='fw5 f6'>{t('selectAllEntries')}</span>}
/>
)
: null
}
</div>
</div>}

<MainView t={t} files={files} remotePins={remotePins} pendingPins={pendingPins} failedPins={failedPins} doExploreUserProvidedPath={doExploreUserProvidedPath}/>

10 changes: 10 additions & 0 deletions src/files/PendingAnimation.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
.breadheader {
margin-bottom: 1rem;
}

@media only screen and (min-width: 60rem) {
.breadheader {
margin-bottom: 0;
}
}

.PendingAnimation {
animation: PendingAnimation 2.5s ease-in-out infinite;
}
8 changes: 5 additions & 3 deletions src/files/file-icon/FileIcon.js
Original file line number Diff line number Diff line change
@@ -10,9 +10,11 @@ import DocPicture from '../../icons/GlyphDocPicture.js'
import DocText from '../../icons/GlyphDocText.js'
import Cube from '../../icons/StrokeCube.js'

const style = { width: 36 }

export default function FileIcon ({ name, type, cls = '' }) {
/**
* @param {{ name: string, type: string, style?: React.CSSProperties, cls?: string }} props
* @returns {React.ReactElement}
*/
export default function FileIcon ({ name, type, style = { width: 36 }, cls = '' }) {
if (type === 'directory') {
return <Folder className={`fill-aqua ${cls}`} style={style} />
}
84 changes: 84 additions & 0 deletions src/files/file-preview/file-thumbnail.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
.file-thumbnail {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
border-radius: 4px;
overflow: hidden;
}

.file-thumbnail img {
width: 100%;
height: 100%;
object-fit: contain;
transition: opacity 0.2s ease;
opacity: 1;
position: relative;
z-index: 1;
}

.file-thumbnail-loading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(90deg, #f5f5f5 0%, #e9e9e9 50%, #f5f5f5 100%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 0;
}

.selected-item {
color: #0b3a53;
}

.file-thumbnail.is-loading .file-thumbnail-loading {
opacity: 1;
}

.file-thumbnail.is-loading img {
opacity: 0;
}

@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}

.file-thumbnail-text {
width: 100%;
height: 100%;
padding: 1rem;
background-color: #f8f9fa;
}

.file-thumbnail-content {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
opacity: 0.6;
overflow: hidden;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace;
font-size: 0.70rem;
line-height: 1.4;
color: #24292e;
white-space: pre-wrap;
word-break: break-all;
display: -webkit-box;
line-clamp: 8;
-webkit-line-clamp: 8;
-webkit-box-orient: vertical;
}
82 changes: 82 additions & 0 deletions src/files/file-preview/file-thumbnail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { CID } from 'multiformats/cid'
import { useState, useEffect, useCallback, type FC } from 'react'
// @ts-expect-error - redux-bundler-react is not typed
import { connect } from 'redux-bundler-react'
import typeFromExt from '../type-from-ext/index.js'
import './file-thumbnail.css'

export interface FileThumbnailProps {
name: string
cid: CID
textPreview: string | null
onLoad: () => void
}

interface FileThumbnailPropsConnected extends FileThumbnailProps {
availableGatewayUrl: string
}

const FileThumbnail: FC<FileThumbnailPropsConnected> = ({ name, cid, availableGatewayUrl, textPreview, onLoad }) => {
const [error, setError] = useState(false)
const [imageLoaded, setImageLoaded] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const type = typeFromExt(name)

const handleImageError = useCallback(() => {
setError(true)
setImageLoaded(false)
setIsLoading(false)
}, [])

const handleImageLoad = useCallback(() => {
setImageLoaded(true)
setIsLoading(false)
onLoad?.()
}, [onLoad])

useEffect(() => {
setImageLoaded(false)
setError(false)
setIsLoading(false)
}, [cid, name])

if (error || (!textPreview && !type.startsWith('image'))) {
return null
}

if (type === 'image') {
const src = `${availableGatewayUrl}/ipfs/${cid}?filename=${encodeURIComponent(name)}`
return (
<div className={`file-thumbnail ${!imageLoaded || isLoading ? 'is-loading' : ''}`}>
<div className="file-thumbnail-loading" />
<img
className="w-100 h-100 object-contain br2"
alt={name}
src={src}
onError={handleImageError}
onLoad={handleImageLoad}
loading="lazy"
decoding="async"
/>
</div>
)
}

if (textPreview) {
return (
<div className={`file-thumbnail file-thumbnail-text ${(isLoading || textPreview.length === 0) ? 'is-loading' : ''}`}>
<div className="file-thumbnail-loading" />
<pre className="file-thumbnail-content">
{textPreview}
</pre>
</div>
)
}

return null
}

export default connect(
'selectAvailableGatewayUrl',
FileThumbnail
) as FC<FileThumbnailProps>
29 changes: 29 additions & 0 deletions src/files/files-grid/files-grid.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
.files-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
position: relative;
z-index: 0;
padding: 1rem;
min-height: 200px;
}

.files-grid--drop-target {
background-color: rgba(117, 189, 255, 0.1);
border: 2px dashed #75bdff;
border-radius: 8px;
}

.files-grid-empty {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
text-align: center;
}

.files-grid:focus,
.files-grid:focus-visible {
outline: none;
}
212 changes: 212 additions & 0 deletions src/files/files-grid/files-grid.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { useRef, useState, useEffect, useCallback, type FC, type MouseEvent } from 'react'
import { Trans, withTranslation } from 'react-i18next'
import { useDrop } from 'react-dnd'
import { NativeTypes } from 'react-dnd-html5-backend'
import { ExtendedFile, FileStream, normalizeFiles } from '../../lib/files.js'
import GridFile from './grid-file.jsx'
// @ts-expect-error - redux-bundler-react is not typed
import { connect } from 'redux-bundler-react'
import './files-grid.css'
import { TFunction } from 'i18next'
import type { ContextMenuFile } from 'src/files/types.js'
import type { CID } from 'multiformats/cid'

export interface FilesGridProps {
files: ContextMenuFile[]
pins: string[]
remotePins: string[]
pendingPins: string[]
failedPins: string[]
}

type SetPinningProps = { cid: CID, pinned: boolean }

interface FilesGridPropsConnected extends FilesGridProps {
filesPathInfo: { isMfs: boolean }
t: TFunction
onRemove: (files: ContextMenuFile[]) => void
onRename: (files: ContextMenuFile[]) => void
onNavigate: (props: { path: string, cid: CID }) => void
onAddFiles: (files: FileStream[]) => void
onMove: (src: string, dst: string) => void
onSetPinning: (props: SetPinningProps[]) => void
onDismissFailedPin: (cid?: CID) => void
handleContextMenuClick: (e: MouseEvent, clickType: string, file: ContextMenuFile, pos?: { x: number, y: number }) => void
onSelect: (fileName: string | string[], isSelected: boolean) => void
filesIsFetching: boolean
selected: string[]
}

const FilesGrid = ({
files, pins = [], remotePins = [], pendingPins = [], failedPins = [], filesPathInfo, t, onRemove, onRename, onNavigate, onAddFiles,
onMove, handleContextMenuClick, filesIsFetching, onSetPinning, onDismissFailedPin, selected = [], onSelect
}: FilesGridPropsConnected) => {
const [focused, setFocused] = useState<string | null>(null)
const filesRefs = useRef<Record<string, HTMLDivElement>>({})
const gridRef = useRef<HTMLDivElement | null>(null)

const [{ isOver, canDrop }, drop] = useDrop({
accept: NativeTypes.FILE,
drop: (_, monitor) => {
if (monitor.didDrop()) return
const { filesPromise } = monitor.getItem()
addFiles(filesPromise, onAddFiles)
},
collect: (monitor) => ({
isOver: monitor.isOver({ shallow: true }),
canDrop: filesPathInfo?.isMfs
})
})

const addFiles = async (filesPromise: Promise<ExtendedFile[]>, onAddFiles: (files: FileStream[]) => void) => {
const files = await filesPromise
onAddFiles(normalizeFiles(files))
}

const handleSelect = useCallback((fileName: string, isSelected: boolean) => {
onSelect(fileName, isSelected)
}, [onSelect])

const keyHandler = useCallback((e: KeyboardEvent) => {
const focusedFile = focused == null ? null : files.find(el => el.name === focused)

gridRef.current?.focus?.()

if (e.key === 'Escape') {
onSelect([], false)
setFocused(null)
return
}

if ((e.key === 'F2') && focusedFile != null) {
return onRename([focusedFile])
}

if ((e.key === 'Delete' || e.key === 'Backspace') && selected.length > 0) {
const selectedFiles = files.filter(f => selected.includes(f.name))
return onRemove(selectedFiles)
}

if ((e.key === ' ') && focusedFile != null) {
e.preventDefault()
handleSelect(focusedFile.name, !selected.includes(focusedFile.name))
return
}

if (focusedFile != null && ((e.key === 'Enter') || (e.key === 'ArrowRight' && e.metaKey) || (e.key === 'NumpadEnter'))) {
return onNavigate({ path: focusedFile.path, cid: focusedFile.cid })
}

const isArrowKey = ['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'].includes(e.key)

if (isArrowKey) {
e.preventDefault()
const columns = Math.floor((gridRef.current?.clientWidth || window.innerWidth) / 220)
const currentIndex = files.findIndex(el => el.name === focusedFile?.name)
let newIndex = currentIndex

switch (e.key) {
case 'ArrowDown':
if (currentIndex === -1) {
newIndex = files.length - 1 // if no focused file, set to last file
} else {
newIndex = currentIndex + columns
}
break
case 'ArrowUp':
if (currentIndex === -1) {
newIndex = 0 // if no focused file, set to first file
} else {
newIndex = currentIndex - columns
}
break
case 'ArrowRight':
if (currentIndex === -1 || currentIndex === files.length - 1) {
newIndex = 0 // if no focused file, set to last file
} else {
newIndex = currentIndex + 1
}
break
case 'ArrowLeft':
if (currentIndex === -1 || currentIndex === 0) {
newIndex = files.length - 1 // if no focused file, set to last file
} else {
newIndex = currentIndex - 1
}
break
default:
break
}

if (newIndex >= 0 && newIndex < files.length) {
const name = files[newIndex].name
setFocused(name)
const element = filesRefs.current[name]
if (element && element.scrollIntoView) {
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
const checkbox: HTMLInputElement | null = element.querySelector('input[type="checkbox"]')
if (checkbox != null) checkbox.focus()
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [files, focused])

useEffect(() => {
if (filesIsFetching) return
document.addEventListener('keydown', keyHandler)
return () => {
document.removeEventListener('keydown', keyHandler)
}
}, [keyHandler, filesIsFetching])

const gridClassName = `files-grid${isOver && canDrop ? ' files-grid--drop-target' : ''}`

return (
<div ref={(el) => {
drop(el)
gridRef.current = el
}} className={gridClassName} tabIndex={0} role="grid" aria-label={t('filesGridLabel')}>
{files.map(file => (
<GridFile
key={file.name}
{...file}
refSetter={(r: HTMLDivElement | null) => { filesRefs.current[file.name] = r as HTMLDivElement }}
selected={selected.includes(file.name)}
focused={focused === file.name}
pinned={pins?.includes(file.cid?.toString())}
isRemotePin={remotePins?.includes(file.cid?.toString())}
isPendingPin={pendingPins?.includes(file.cid?.toString())}
isFailedPin={failedPins?.some(p => p?.includes(file.cid?.toString()))}
isMfs={filesPathInfo?.isMfs}
onNavigate={() => onNavigate({ path: file.path, cid: file.cid })}
onAddFiles={onAddFiles}
onMove={onMove}
onSetPinning={onSetPinning}
onDismissFailedPin={onDismissFailedPin}
handleContextMenuClick={handleContextMenuClick}
onSelect={handleSelect}
/>
))}
{files.length === 0 && (
<Trans i18nKey='filesList.noFiles' t={t}>
<div className='pv3 b--light-gray files-grid-empty bt tc gray f6'>
There are no available files. Add some!
</div>
</Trans>
)}
</div>
)
}

export default connect(
'selectPins',
'selectPinningServices',
'doFetchRemotePins',
'selectFilesIsFetching',
'selectFilesSorting',
'selectFilesPathInfo',
'selectShowLoadingAnimation',
'doDismissFailedPin',
withTranslation('files')(FilesGrid)
) as FC<FilesGridProps>
243 changes: 243 additions & 0 deletions src/files/files-grid/grid-file.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
.grid-file {
background: white;
border-radius: 8px;
border: 1px solid #eee;
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
width: 100%;
text-align: left;
padding: 0;
display: block;
outline: none;
}

.grid-file:focus-within,
.grid-file.focused {
border-color: #9ad4db;
box-shadow: 0 0 0 2px rgba(154, 212, 219, 0.3);
}

.grid-file.selected {
background-color: #F0F6FA;
border-color: #9ad4db;
border-style: dashed;
}

.grid-file.drop-target {
background-color: #e6f7f9;
border: 2px dashed #2aaac1;
box-shadow: 0 0 0 4px rgba(42, 170, 193, 0.2);
transform: translateY(-2px);
z-index: 10;
}

.grid-file.drop-target .grid-file-preview {
background-color: #d1f0f4;
}

.grid-file.drop-target::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(42, 170, 193, 0.05);
z-index: 1;
pointer-events: none;
}

/* Drop indicator */
.drop-indicator {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(42, 170, 193, 0.15);
z-index: 5;
animation: pulse 1.5s infinite ease-in-out;
}

.drop-indicator span {
background-color: #2aaac1;
color: white;
padding: 8px 16px;
border-radius: 20px;
font-weight: bold;
font-size: 14px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}

@keyframes pulse {
0% {
background-color: rgba(42, 170, 193, 0.1);
}
50% {
background-color: rgba(42, 170, 193, 0.25);
}
100% {
background-color: rgba(42, 170, 193, 0.1);
}
}

.grid-file-checkbox {
position: absolute;
top: 8px;
left: 8px;
z-index: 1;
opacity: 0;
transition: opacity 0.2s ease;
background: rgba(255, 255, 255, 0.9);
padding: 4px;
border-radius: 4px;
}

.grid-file:hover .grid-file-checkbox,
.grid-file:focus-within .grid-file-checkbox,
.grid-file.focused .grid-file-checkbox,
.grid-file.selected .grid-file-checkbox {
opacity: 1;
}

.grid-file-content {
width: 100%;
background: none;
border: none;
padding: 0;
text-align: left;
cursor: pointer;
outline: none;
}

.grid-file:hover,
.grid-file:focus-within,
.grid-file.focused {
outline: none;
border-color: #9ad4db;
border-style: solid;
}

.grid-file:hover .grid-file-dots,
.grid-file:focus-within .grid-file-dots,
.grid-file.focused .grid-file-dots {
opacity: 1;
visibility: visible;
}

.grid-file-preview {
position: relative;
width: 100%;
height: 140px;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}

.grid-file-preview .file-thumbnail {
position: absolute !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
}

.grid-file-preview .file-thumbnail img {
width: 100% !important;
height: 100% !important;
object-fit: cover !important;
background-color: #f5f5f5;
opacity: 0.8;
z-index: 0;
}

.grid-file-dots {
position: absolute;
top: 8px;
right: 8px;
background: rgba(255, 255, 255, 0.95);
border: 1px solid #eee;
border-radius: 4px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
z-index: 1;
}

.grid-file-dots:hover,
.grid-file-dots:focus {
background: white;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
outline: none;
border-color: #9ad4db;
}

.grid-file-dots svg {
width: 16px;
height: 16px;
fill: #7f8491;
}

.grid-file-dots:hover svg,
.grid-file-dots:focus svg {
fill: #5a5f6d;
}

.grid-file-info {
padding: 0.75rem;
}

.grid-file-name {
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.grid-file-details {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.25rem;
}

.grid-file-size {
font-size: 0.75rem;
color: #666;
}

.grid-file-pin {
background: none;
border: none;
padding: 0;
cursor: pointer;
}

.grid-file-pin:hover,
.grid-file-pin:focus {
outline: none;
}

.grid-file-hash {
font-size: 0.75rem;
color: #666;
font-family: monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
264 changes: 264 additions & 0 deletions src/files/files-grid/grid-file.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
import React, { useRef, useState, useEffect, type FC } from 'react'
import { withTranslation } from 'react-i18next'
import { useDrag, useDrop, type DropTargetMonitor } from 'react-dnd'
import { FileStream, humanSize, normalizeFiles } from '../../lib/files.js'
import { CID } from 'multiformats/cid'
import { isBinary } from 'istextorbinary'
import FileIcon from '../file-icon/FileIcon.js'
// @ts-expect-error - redux-bundler-react is not typed
import { connect } from 'redux-bundler-react'
import FileThumbnail from '../file-preview/file-thumbnail.js'
import PinIcon from '../pin-icon/PinIcon.js'
import GlyphDots from '../../icons/GlyphDots.js'
import Checkbox from '../../components/checkbox/Checkbox.js'
import { NativeTypes } from 'react-dnd-html5-backend'
import { join, basename } from 'path'
import './grid-file.css'
import { TFunction } from 'i18next'
import { ContextMenuFile } from '../types.js'

type SetPinningProps = { cid: CID, pinned: boolean }

export interface GridFileProps {
name: string
type: string
size: number
cid: CID
path: string
pinned: boolean
selected: boolean
focused: boolean
isRemotePin: boolean
isPendingPin: boolean
isFailedPin: boolean
refSetter?: (ref: HTMLDivElement | null) => void
isMfs: boolean
onNavigate: ({ path, cid }: { path: string, cid: CID }) => void
onSetPinning: (props: SetPinningProps[]) => void
onDismissFailedPin: (cid?: CID) => void
handleContextMenuClick: (ev: React.MouseEvent, clickType: string, file: ContextMenuFile, pos?: { x: number, y: number }) => void
onSelect: (name: string, isSelected: boolean) => void
onMove: (src: string, dst: string) => void
onAddFiles: (files: FileStream[], path: string) => void
}

interface GridFilePropsConnected extends GridFileProps {
doRead: (cid: CID, offset: number, length: number) => Promise<AsyncIterable<Uint8Array>>
t: TFunction
}

const GridFile: FC<GridFilePropsConnected> = ({
name, type, size, cid, path, pinned, t, selected, focused,
isRemotePin, isPendingPin, isFailedPin, isMfs, refSetter,
onNavigate, onSetPinning, doRead, onDismissFailedPin, handleContextMenuClick, onSelect, onMove, onAddFiles
}) => {
const MAX_TEXT_LENGTH = 400 // This is the maximum characters to show in text preview
const dotsWrapper = useRef<HTMLButtonElement | null>(null)
const fileRef = useRef<HTMLDivElement | null>(null)

const [, drag, preview] = useDrag({
item: { name, size, cid, path, pinned, type: 'FILE' },
canDrag: isMfs,
collect: (monitor) => ({
isDragging: monitor.isDragging()
})
})

const checkIfDir = (monitor: DropTargetMonitor) => {
if (!isMfs) return false
if (type !== 'directory') return false

const item = monitor.getItem()
if (!item) return false

if (item.name) {
return name !== item.name && !selected
}

return true
}

const [{ isOver, canDrop }, drop] = useDrop({
accept: [NativeTypes.FILE, 'FILE'],
drop: (_, monitor) => {
const item = monitor.getItem()

if (item.files) {
(async () => {
const files = await item.filesPromise
onAddFiles(normalizeFiles(files), path)
})()
} else {
const src = item.path
const dst = join(path, basename(item.path))

onMove(src, dst)
}
},
canDrop: (_, monitor) => checkIfDir(monitor),
collect: (monitor) => ({
canDrop: checkIfDir(monitor),
isOver: monitor.isOver()
})
})

const [hasPreview, setHasPreview] = useState(false)
const [textPreview, setTextPreview] = useState<string | null>(null)

useEffect(() => {
const fetchTextPreview = async () => {
const isTextFile = type.startsWith('text/') ||
type === 'txt' ||
/\.(txt|md|js|jsx|ts|tsx|json|css|html|xml|yaml|yml|ini|conf|sh|py|rb|java|c|cpp|h|hpp)$/i.test(name)

if (isTextFile && cid) {
try {
const chunks: Uint8Array[] = []
let size = 0
const content: AsyncIterable<Uint8Array> = await doRead(cid, 0, MAX_TEXT_LENGTH)
if (!content) return

for await (const chunk of content) {
chunks.push(chunk)
size += chunk.length
if (size >= MAX_TEXT_LENGTH) break
}
// TODO: Buffer does not exist in browsers, we need to use Uint8Array instead
const fullBuffer = Buffer.concat(chunks)

const decoder = new TextDecoder()
const text = decoder.decode(fullBuffer)
if (!isBinary(name, fullBuffer)) {
setTextPreview(text)
}
} catch (err) {
console.error('Failed to load text preview:', err)
}
}
}

if (doRead != null) {
fetchTextPreview()
}
}, [doRead, type, cid, name])

const handleContextMenu = (ev: React.MouseEvent<HTMLDivElement>) => {
ev.preventDefault()
handleContextMenuClick(ev, 'RIGHT', { name, size, type, cid, path, pinned })
}

const handleDotsClick = (ev: React.MouseEvent<HTMLButtonElement>) => {
ev.stopPropagation()
const pos = dotsWrapper.current?.getBoundingClientRect()
handleContextMenuClick(ev, 'TOP', { name, size, type, cid, path, pinned }, pos)
}

const handleCheckboxClick = () => {
onSelect(name, !selected)
}

const formattedSize = humanSize(size, { round: 0 })
const hash = cid.toString() || t('hashUnavailable')

const setRefs = (el: HTMLDivElement) => {
fileRef.current = el

drag(el)

if (type === 'directory') {
drop(el)
}

preview(el)
}

const fileClassName = `grid-file ${selected ? 'selected' : ''} ${focused ? 'focused' : ''} ${isOver && canDrop ? 'drop-target' : ''}`

return (
<div
className={fileClassName}
onContextMenu={handleContextMenu}
role="button"
ref={refSetter}
data-type={type}
tabIndex={0}
title={`${name}`}
aria-label={t('fileLabel', { name, type, size: formattedSize })}
>
<div className="grid-file-checkbox">
<Checkbox
disabled={false}
checked={selected}
onChange={handleCheckboxClick}
aria-label={t('checkboxLabel', { name })}
/>
</div>
<div
ref={setRefs}
className="grid-file-content"
onClick={() => onNavigate({ path, cid })}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
onNavigate({ path, cid })
}
}}
role="button"
tabIndex={0}
aria-label={t('fileLabel', { name, type, size: formattedSize })}
>
<div className="grid-file-preview">
{isOver && canDrop && (
<div className="drop-indicator">
<span>{t('dropHere', { defaultValue: 'Drop here' })}</span>
</div>
)}
<FileThumbnail
name={name}
cid={cid}
textPreview={textPreview}
onLoad={() => setHasPreview(true)}
/>
{!hasPreview && <FileIcon style={{ width: 80 }} name={name} type={type} />}
<button
ref={dotsWrapper}
className="grid-file-dots"
onClick={handleDotsClick}
aria-label={t('checkboxLabel', { name })}
type="button"
>
<GlyphDots />
</button>
</div>
<div className="grid-file-info">
<div className="grid-file-name" title={name}>{name}</div>
<div className="grid-file-details">
<span className="grid-file-size">{formattedSize}</span>
<button
className="grid-file-pin"
onClick={(e) => {
e.stopPropagation()
isFailedPin ? onDismissFailedPin() : onSetPinning([{ cid, pinned }])
}}
type="button"
aria-label={t(isFailedPin ? 'dismissFailedPin' : pinned ? 'unpin' : 'pin')}
>
<PinIcon
isFailedPin={isFailedPin}
isPendingPin={isPendingPin}
isRemotePin={isRemotePin}
pinned={pinned}
/>
</button>
</div>
<div className="grid-file-hash" title={hash}>{hash.slice(0, 10)}...{hash.slice(-10)}</div>
</div>
</div>
</div>
)
}

export default connect(
'doRead',
withTranslation('files')(GridFile)
) as FC<GridFileProps>
137 changes: 57 additions & 80 deletions src/files/files-list/FilesList.js
Original file line number Diff line number Diff line change
@@ -13,7 +13,7 @@ import { NativeTypes } from 'react-dnd-html5-backend'
import { useDrop } from 'react-dnd'
// Components
import Checkbox from '../../components/checkbox/Checkbox.js'
import SelectedActions from '../selected-actions/SelectedActions.js'
// import SelectedActions from '../selected-actions/SelectedActions.js'
import File from '../file/File.js'
import LoadingAnimation from '../../components/loading-animation/LoadingAnimation.js'

@@ -51,16 +51,15 @@ const mergeRemotePinsIntoFiles = (files, remotePins = [], pendingPins = [], fail
}

export const FilesList = ({
className, files, pins, pinningServices, remotePins, pendingPins, failedPins, filesSorting, updateSorting, filesIsFetching, filesPathInfo, showLoadingAnimation,
onShare, onSetPinning, onInspect, onDownload, onRemove, onRename, onNavigate, onRemotePinClick, onAddFiles, onMove, doFetchRemotePins, doDismissFailedPin, handleContextMenuClick, t
className = '', files, pins, pinningServices, remotePins = [], pendingPins = [], failedPins = [], filesSorting, updateSorting, filesIsFetching, filesPathInfo, showLoadingAnimation,
onShare, onSetPinning, selected, onSelect, onInspect, onDownload, onRemove, onRename, onNavigate, onRemotePinClick, onAddFiles, onMove, doFetchRemotePins, doDismissFailedPin, handleContextMenuClick, t
}) => {
const [selected, setSelected] = useState([])
const [focused, setFocused] = useState(null)
const [firstVisibleRow, setFirstVisibleRow] = useState(null)
const [allFiles, setAllFiles] = useState(mergeRemotePinsIntoFiles(files, remotePins, pendingPins, failedPins))
const listRef = useRef()
const filesRefs = useRef([])
const refreshPinCache = true // manually clicking on Pin Status column skips cache and updates remote status
const refreshPinCache = true

filesPathInfo = filesPathInfo ?? {}
const [{ canDrop, isOver, isDragging }, drop] = useDrop({
@@ -70,7 +69,6 @@ export const FilesList = ({
return
}
const { filesPromise } = monitor.getItem()

addFiles(filesPromise, onAddFiles)
},
collect: (monitor) => ({
@@ -90,7 +88,11 @@ export const FilesList = ({
}))
, [allFiles, pins, selected])

const keyHandler = (e) => {
const toggleOne = useCallback((name, check) => {
onSelect(name, check)
}, [onSelect])

const keyHandler = useCallback((e) => {
const focusedFile = files.find(el => el.name === focused)

// Disable keyboard controls if fetching files
@@ -99,9 +101,9 @@ export const FilesList = ({
}

if (e.key === 'Escape') {
setSelected([])
onSelect([], false)
setFocused(null)
return listRef.current.forceUpdateGrid()
return listRef.current?.forceUpdateGrid?.()
}

if (e.key === 'F2' && focused !== null) {
@@ -130,70 +132,54 @@ export const FilesList = ({
index = (e.key === 'ArrowDown') ? prev + 1 : prev - 1
}

if (index === -1) {
if (index === -1 || index >= files.length) {
return
}

if (index < files.length) {
let name = files[index].name

// If the file we are going to focus is out of view (removed
// from the DOM by react-virtualized), focus the first visible file
if (!filesRefs.current[name]) {
name = files[firstVisibleRow].name
}
let name = files[index].name

setFocused(name)
const domNode = findDOMNode(filesRefs.current[name])
domNode.scrollIntoView({ behaviour: 'smooth', block: 'center' })
domNode.querySelector('input[type="checkbox"]').focus()
// If the file we are going to focus is out of view (removed
// from the DOM by react-virtualized), focus the first visible file
if (!filesRefs.current[name]) {
name = files[firstVisibleRow].name
}

listRef.current.forceUpdateGrid()
setFocused(name)
}
}
}, [
files,
focused,
firstVisibleRow,
filesIsFetching,
onNavigate,
onRemove,
onRename,
onSelect,
selected.length,
selectedFiles,
toggleOne,
listRef
])

useEffect(() => {
document.addEventListener('keyup', keyHandler)
return () => document.removeEventListener('keyup', keyHandler)
}, /* eslint-disable-next-line react-hooks/exhaustive-deps */
[])
document.addEventListener('keydown', keyHandler)
return () => {
document.removeEventListener('keydown', keyHandler)
}
}, [keyHandler])

useEffect(() => {
setAllFiles(mergeRemotePinsIntoFiles(files, remotePins, pendingPins, failedPins))
}, [files, remotePins, filesSorting, pendingPins, failedPins])

useEffect(() => {
const selectedFiles = selected.filter(name => files.find(el => el.name === name))

if (selectedFiles.length !== selected.length) {
setSelected(selected)
}
}, [files, selected])

const toggleAll = (checked) => {
let selected = []

if (checked) {
selected = files.map(file => file.name)
onSelect(allFiles.map(file => file.name), true)
} else {
onSelect([], false)
}

setSelected(selected)
listRef.current.forceUpdateGrid()
}

const toggleOne = useCallback((name, check) => {
const index = selected.indexOf(name)

if (check && index < 0) {
setSelected([...selected, name].sort())
} else if (index >= 0) {
setSelected(selected.filter(selected => selected !== name).sort())
}

listRef.current.forceUpdateGrid()
}, [selected])

const move = (src, dst) => {
if (selectedFiles.length > 0) {
const parts = dst.split('/')
@@ -236,7 +222,7 @@ export const FilesList = ({
updateSorting(order, true)
}

listRef.current.forceUpdateGrid()
listRef.current?.forceUpdateGrid?.()
}

const emptyRowsRenderer = () => (
@@ -288,6 +274,21 @@ export const FilesList = ({
'o-70': !allSelected
}, ['pl2 w2 glow'])

// Add a separate useEffect to handle scrolling when focus changes
const currentFilesRef = filesRefs.current[focused]
useEffect(() => {
if (focused) {
const domNode = currentFilesRef && findDOMNode(currentFilesRef)
if (domNode) {
domNode.scrollIntoView({ behavior: 'smooth', block: 'center' })
const checkbox = domNode.querySelector('input[type="checkbox"]')
if (checkbox) checkbox.focus()
}

listRef.current?.forceUpdateGrid?.()
}
}, [currentFilesRef, focused, listRef])

return (
<section ref={drop} className={classnames('FilesList no-select sans-serif border-box w-100 flex flex-column', className)}>
{ showLoadingAnimation
@@ -340,24 +341,7 @@ export const FilesList = ({
</div>
)}
</WindowScroller>
{ selectedFiles.length !== 0 && <SelectedActions
className={'fixed bottom-0 right-0'}
style={{
zIndex: 20
}}
animateOnStart={selectedFiles.length === 1}
unselect={() => toggleAll(false)}
remove={() => onRemove(selectedFiles)}
rename={() => onRename(selectedFiles)}
share={() => onShare(selectedFiles)}
setPinning={() => onSetPinning(selectedFiles)}
download={() => onDownload(selectedFiles)}
inspect={() => onInspect(selectedFiles[0].cid)}
count={selectedFiles.length}
isMfs={filesPathInfo.isMfs}
size={selectedFiles.reduce((a, b) => a + (b.size || 0), 0)} />
}
</Fragment> }
</Fragment>}
</section>
)
}
@@ -391,13 +375,6 @@ FilesList.propTypes = {
tReady: PropTypes.bool
}

FileList.defaultProps = {
className: '',
remotePins: [],
pendingPins: [],
failedPins: []
}

export default connect(
'selectPins',
'selectPinningServices',
9 changes: 8 additions & 1 deletion src/files/files-list/FilesList.stories.js
Original file line number Diff line number Diff line change
@@ -33,6 +33,11 @@ export default {
root: '/',
filesPathInfo: { isMfs: true },
pins: [],
remotePins: [],
pendingPins: [],
failedPins: [],
selected: [],
onSelect: action('Select'),
filesIsFetching: boolean('filesIsFetching', false),
onShare: action('Share'),
onInspect: action('Inspect'),
@@ -47,7 +52,9 @@ export default {
handleContextMenuClick: action('Context Menu Click'),
maxWidth: '100%',
filesSorting: { by: 'name', asc: true },
updateSorting
updateSorting,
doFetchRemotePins: action('Fetch Remote Pins'),
doDismissFailedPin: action('Dismiss Failed Pin')
}
}

15 changes: 9 additions & 6 deletions src/files/header/Header.js
Original file line number Diff line number Diff line change
@@ -52,20 +52,23 @@ class Header extends React.Component {
pendingPins,
failedPins,
completedPins,
t
t,
children
} = this.props

const pinsInQueue = pendingPins.length + failedPins.length + completedPins.length

return (
<div className='db flex-l justify-between items-center'>
<div className='mb3 overflow-hidden mr2'>
<Breadcrumbs className="joyride-files-breadcrumbs" path={files ? files.path : '/404'}
<div className='db flex-l justify-between items-center mb3'>
<div className='flex items-center w-100 justify-between mr3'>
<div className='breadheader overflow-hidden mr1'>
<Breadcrumbs className="joyride-files-breadcrumbs" path={files ? files.path : '/404'}
onClick={onNavigate} onContextMenuHandle={(...args) => this.handleBreadCrumbsContextMenu(...args)}
onAddFiles={this.props.onAddFiles} onMove={this.props.onMove}/>
</div>

<div className='mb3 flex justify-between items-center bg-snow-muted joyride-files-add'>
{ children }
</div>
<div className='flex justify-between items-center bg-snow-muted joyride-files-add'>
{ pinsInQueue > 0 && <a href='#/pins' alt={t('pinningQueue')} title={t('pinningQueue')} className='ml3'>
<GlyphPinCloud
style={{ width: '3rem' }}
14 changes: 13 additions & 1 deletion src/files/modals/Modals.js
Original file line number Diff line number Diff line change
@@ -12,6 +12,7 @@ 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'
@@ -27,6 +28,7 @@ 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,
@@ -38,7 +40,8 @@ export {
BULK_CID_IMPORT,
CLI_TUTOR_MODE,
PINNING,
PUBLISH
PUBLISH,
SHORTCUTS
}

class Modals extends React.Component {
@@ -193,6 +196,9 @@ class Modals extends React.Component {
publish: { file }
})
}
case SHORTCUTS:
this.setState({ readyToShow: true })
break
default:
// do nothing
}
@@ -305,6 +311,12 @@ class Modals extends React.Component {
onLeave={this.leave}
onSubmit={this.publish} />
</Overlay>

<Overlay show={show === SHORTCUTS && readyToShow} onLeave={this.leave}>
<ShortcutModal
className='outline-0'
onLeave={this.leave} />
</Overlay>
</div>
)
}
184 changes: 184 additions & 0 deletions src/files/modals/shortcut-modal/shortcut-modal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
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]
Comment on lines +36 to +37
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lidel do you have key recommendations for linux here?

return key
}

return (
<kbd className="dib v-mid lh-solid br2 charcoal ba b--gray br2 f7 fw6 " style={{ minWidth: 'fit-content', padding: '6px', height: 'fit-content', textAlign: 'center' }}>
{getKeySymbol(children)}
</kbd>
)
}

KeyboardKey.propTypes = {
children: PropTypes.node.isRequired,
platform: PropTypes.string.isRequired
}

const ShortcutItem = ({ shortcut, description, platform }) => (
<div className="flex items-center justify-between pa2 bb b--black-10">
<div className="w-60 black f7">{description}</div>
<div className="w-40 tr">
{Array.isArray(shortcut)
? shortcut.map((key, i) => (
<React.Fragment key={i}>
<KeyboardKey platform={platform}>{key}</KeyboardKey>
{i < shortcut.length - 1 && <span className="mr1 gray">+</span>}
</React.Fragment>))
: <KeyboardKey platform={platform}>{shortcut}</KeyboardKey>}
</div>
</div>
)

ShortcutItem.propTypes = {
shortcut: PropTypes.oneOfType([
PropTypes.string,
PropTypes.array
]).isRequired,
description: PropTypes.string.isRequired,
platform: PropTypes.string.isRequired
}

const ShortcutSection = ({ title, shortcuts, platform }) => (
<div className="mb2 ba b--black-20">
<h3 className="f7 fw6 bb b--black-20 black pa2 ma0">{title}</h3>
<div className="br1">
{shortcuts.map((shortcut, i) => (
<ShortcutItem key={i} shortcut={shortcut.shortcut} description={shortcut.description} platform={platform} />
))}
</div>
</div>
)

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 (
<Modal {...props} className={`${className} bg-near-black white`}>
<div className="flex items-center justify-between pa2 bb b--black-20">
<h2 className="ma0 f5 fw6 black">{t('shortcutModal.title')}</h2>
<button
onClick={onLeave}
className="button-reset bn bg-transparent pa0 pointer white"
aria-label="Close"
>
<CancelIcon className='pointer w2 h2 top-0 right-0 fill-gray' onClick={onLeave} />
</button>
</div>

<div className="pa2 overflow-auto" style={{ maxHeight: '70vh' }}>
<div className="flex flex-wrap">
<div className="w-100 w-50-l pa1">
<ShortcutSection
title={t('shortcutModal.navigation')}
shortcuts={navigationShortcuts}
platform={platform}
/>

<ShortcutSection
title={t('shortcutModal.selection')}
shortcuts={selectionShortcuts}
platform={platform}
/>
</div>

<div className="w-100 w-50-l pa1">
<ShortcutSection
title={t('shortcutModal.actions')}
shortcuts={actionShortcuts}
platform={platform}
/>

<ShortcutSection
title={t('shortcutModal.other')}
shortcuts={otherShortcuts}
platform={platform}
/>
</div>
</div>
</div>
</Modal>
)
}

ShortcutModal.propTypes = {
onLeave: PropTypes.func.isRequired,
className: PropTypes.string
}

ShortcutModal.defaultProps = {
className: ''
}

export default ShortcutModal
4 changes: 4 additions & 0 deletions src/files/pin-icon/PinIcon.js
Original file line number Diff line number Diff line change
@@ -4,6 +4,10 @@ import GlyphPin from '../../icons/GlyphPin.js'
import GlyphPinCloud from '../../icons/GlyphPinCloud.js'
import '../PendingAnimation.css'

/**
* @param {{ t: (key: string) => string, isFailedPin: boolean, isPendingPin: boolean, isRemotePin: boolean, pinned: boolean }} props
* @returns {React.ReactElement}
*/
const PinningIcon = ({ t, isFailedPin, isPendingPin, isRemotePin, pinned }) => {
if (isFailedPin) {
return (
2 changes: 1 addition & 1 deletion src/files/selected-actions/SelectedActions.js
Original file line number Diff line number Diff line change
@@ -98,7 +98,7 @@ class SelectedActions extends React.Component {
</div>
</div>
</div>
<div className='flex' role="menu" aria-label={t('menuOptions')} ref={ this.containerRef } tabIndex="0">
<div className='flex' role="menu" aria-label={t('menuOptions')} ref={ this.containerRef }>
<button role="menuitem" className='tc mh2' onClick={share}>
<StrokeShare className='w3 hover-fill-navy-muted' fill='#A4BFCC' aria-hidden="true"/>
<p className='ma0 f6'>{t('actions.share')}</p>
3 changes: 3 additions & 0 deletions src/files/type-from-ext/extToType.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
/**
* @type {Record<string, string>}
*/
const extToType = {
wav: 'audio',
bwf: 'audio',
5 changes: 5 additions & 0 deletions src/files/type-from-ext/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
// @ts-expect-error - file-extension is not typed
import fileExtension from 'file-extension'
import extToType from './extToType.js'

/**
* @param {string} filename
* @returns {string}
*/
function fileType (filename) {
const ext = fileExtension(filename)
return extToType[ext] || ext
10 changes: 10 additions & 0 deletions src/files/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { CID } from 'multiformats/cid'

export interface ContextMenuFile {
name: string
size: number
type: string
cid: CID
path: string
pinned: boolean
}
4 changes: 4 additions & 0 deletions src/icons/GlyphDocCalc.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import * as React from 'react'

/**
* @param {React.SVGProps<SVGSVGElement>} props
* @returns {React.ReactElement}
*/
function SvgGlyphDocCalc (props) {
return (
<svg
4 changes: 4 additions & 0 deletions src/icons/GlyphDocGeneric.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import * as React from 'react'

/**
* @param {React.SVGProps<SVGSVGElement>} props
* @returns {React.ReactElement}
*/
function SvgGlyphDocGeneric (props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18.42 21" {...props}>
4 changes: 4 additions & 0 deletions src/icons/GlyphDocMovie.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import * as React from 'react'

/**
* @param {React.SVGProps<SVGSVGElement>} props
* @returns {React.ReactElement}
*/
function SvgGlyphDocMovie (props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18.42 21" {...props}>
4 changes: 4 additions & 0 deletions src/icons/GlyphDocMusic.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import * as React from 'react'

/**
* @param {React.SVGProps<SVGSVGElement>} props
* @returns {React.ReactElement}
*/
function SvgGlyphDocMusic (props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18.42 21" {...props}>
4 changes: 4 additions & 0 deletions src/icons/GlyphDocPicture.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import * as React from 'react'

/**
* @param {React.SVGProps<SVGSVGElement>} props
* @returns {React.ReactElement}
*/
function SvgGlyphDocPicture (props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18.42 21" {...props}>
4 changes: 4 additions & 0 deletions src/icons/GlyphDocText.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import * as React from 'react'

/**
* @param {React.SVGProps<SVGSVGElement>} props
* @returns {React.ReactElement}
*/
function SvgGlyphDocText (props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18.42 21" {...props}>
4 changes: 4 additions & 0 deletions src/icons/GlyphDots.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import * as React from 'react'

/**
* @param {React.SVGProps<SVGSVGElement>} props
* @returns {React.ReactElement}
*/
function SvgGlyphDots (props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" {...props}>
4 changes: 4 additions & 0 deletions src/icons/GlyphFolder.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import * as React from 'react'

/**
* @param {React.SVGProps<SVGSVGElement>} props
* @returns {React.ReactElement}
*/
function SvgGlyphFolder (props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18.42 21" {...props}>
4 changes: 4 additions & 0 deletions src/icons/GlyphPin.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import * as React from 'react'

/**
* @param {React.SVGProps<SVGSVGElement>} props
* @returns {React.ReactElement}
*/
function SvgGlyphPin (props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" {...props}>
4 changes: 4 additions & 0 deletions src/icons/GlyphPinCloud.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import * as React from 'react'

/**
* @param {React.SVGProps<SVGSVGElement>} props
* @returns {React.ReactElement}
*/
function SvgGlyphPinCloud (props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" {...props}>
4 changes: 4 additions & 0 deletions src/icons/GlyphSmallTick.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import * as React from 'react'

/**
* @param {React.SVGProps<SVGSVGElement>} props
* @returns {React.ReactElement}
*/
function SvgGlyphSmallTick (props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" {...props}>
4 changes: 4 additions & 0 deletions src/icons/StrokeCube.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import * as React from 'react'

/**
* @param {React.SVGProps<SVGSVGElement>} props
* @returns {React.ReactElement}
*/
function SvgStrokeCube (props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" {...props}>
19 changes: 19 additions & 0 deletions src/icons/stroke-icons.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import React from 'react'

export const ViewList = ({ width = 24, height = 24, className = '' }) => (
<svg width={width} height={height} viewBox="0 0 24 24" className={className}>
<path
fill="currentColor"
d="M4 5h16a1 1 0 0 1 0 2H4a1 1 0 1 1 0-2zm0 6h16a1 1 0 0 1 0 2H4a1 1 0 0 1 0-2zm0 6h16a1 1 0 0 1 0 2H4a1 1 0 0 1 0-2z"
/>
</svg>
)

export const ViewModule = ({ width = 24, height = 24, className = '' }) => (
<svg width={width} height={height} viewBox="0 0 24 24" className={className}>
<path
fill="currentColor"
d="M4 5h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1zm8 0h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1zm8 0h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1zM4 13h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1zm8 0h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1zm8 0h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1z"
/>
</svg>
)
13 changes: 11 additions & 2 deletions src/lib/tours.js
Original file line number Diff line number Diff line change
@@ -149,6 +149,15 @@ export const filesTour = {
placement: 'bottom',
target: '.joyride-files-add'
},
{
content: <div className='montserrat charcoal'>
<h2 className='f3 fw4'>{t('tour.step6.title')}</h2>
<p className='tl f6'>{t('tour.step6.paragraph1')}</p>
<p className='tl f6'>{t('tour.step6.paragraph2')}</p>
</div>,
placement: 'bottom',
target: '.filegrid-view'
},
{
content: <div className='montserrat charcoal'>
<h2 className='f3 fw4'>{t('tour.step5.title')}</h2>
@@ -157,8 +166,8 @@ export const filesTour = {
<p className='tl f6'>{t('tour.step5.paragraph3')}</p>
</div>,
locale: { last: t('tour.finish') },
placement: 'center',
target: 'body'
placement: 'bottom',
target: '.filelist-view'
}
],
styles: {
150 changes: 150 additions & 0 deletions test/e2e/grid-view.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { test, expect } from '@playwright/test'
import { navigateToFilesPage, addTestFiles, selectViewMode } from '../helpers/grid'

test.describe('Files grid view', () => {
test.beforeEach(async ({ page }) => {
// Navigate to files page and switch to grid view
await navigateToFilesPage(page)

await selectViewMode(page, 'grid')

// ensure we have test files to work with
const fileCount = await page.locator('.grid-file, .file-row').count()
if (fileCount < 3) {
// Only add test files if we don't have enough
await addTestFiles(page, 'files', 5) // Adds 5 test files
}
})

test('should display files in grid view', async ({ page }) => {
// Check that the grid container is visible
const gridContainer = page.locator('.files-grid')
await expect(gridContainer).toBeVisible()

// Check that at least one grid item is visible
const gridItems = page.locator('.grid-file')
await expect(gridItems.first()).toBeVisible()
})

test('should focus and navigate with arrow keys', async ({ page }) => {
// Press right arrow to focus the first item
await page.keyboard.press('ArrowRight')

// Check if the first item is focused
const firstFocusedItem = page.locator('.grid-file.focused')
await expect(firstFocusedItem).toBeVisible()

// Navigate using arrow keys (right, down, left, up)
await page.keyboard.press('ArrowRight')
await expect(page.locator('.grid-file.focused')).toBeVisible()
await page.keyboard.press('ArrowDown')
await expect(page.locator('.grid-file.focused')).toBeVisible()
await page.keyboard.press('ArrowLeft')
await expect(page.locator('.grid-file.focused')).toBeVisible()
await page.keyboard.press('ArrowUp')
await expect(page.locator('.grid-file.focused')).toBeVisible()
})

test('should select files with space key', async ({ page }) => {
// Navigate to first item
await page.keyboard.press('ArrowRight')

await page.keyboard.press('Space')

// Verify selection
const selectedCount = await page.locator('.selected').count()
expect(selectedCount).toBe(1)

// Move to another item
await page.keyboard.press('ArrowRight')
await page.keyboard.press('ArrowRight')

// Select multiple items
await page.keyboard.press('Space')

// Verify multiple selection
const multiSelectedCount = await page.locator('.selected').count()
expect(multiSelectedCount).toBe(2)
})

test('should deselect files with space key', async ({ page }) => {
// Focus and select first item
await page.keyboard.press('ArrowRight')
await page.keyboard.press('Space')

// Verify selection
let selectedCount = await page.locator('.selected').count()
expect(selectedCount).toBe(1)

await page.keyboard.press('ArrowRight')

// Deselect with space
await page.keyboard.press('Space')

// Verify deselection
selectedCount = await page.locator('.selected').count()
expect(selectedCount).toBe(0)
})

test('should scroll into view when focusing files out of viewport', async ({ page }) => {
// Make sure we have enough files to create scrolling
if (await page.locator('.grid-file').count() < 20) {
await addTestFiles(page, 'files', 20)
}
await page.reload()
await selectViewMode(page, 'grid')

// Navigate to first item
await page.keyboard.press('ArrowRight')

// Press down many times to get to items that would be out of view
for (let i = 0; i < 10; i++) {
await page.keyboard.press('ArrowDown')
}

// Verify the focused item is visible in viewport
const focusedItem = page.locator('.grid-file.focused')
await expect(focusedItem).toBeVisible()
})

test('should enter folder with Enter key', async ({ page }) => {
// Check if a folder exists, if not create one
const folderExists = await page.locator('.grid-file[data-type="directory"]').count() > 0

if (!folderExists) {
// Create a folder if none exists
await page.locator('button[aria-label="Import"], button:has-text("Import")').click()
await page.locator('button#add-new-folder').click()
await page.locator('input.modal-input').fill('test-folder')
await page.locator('button', { hasText: 'Create' }).click()

// Wait for folder to appear
await page.waitForSelector('.grid-file[title="test-folder"]')
}

// Find the first folder
const folder = page.locator('.grid-file[title$="/"], .grid-file[data-type="directory"]').first()
const folderName = await folder.getAttribute('title')

await page.keyboard.press('ArrowRight')
// Navigate to the folder (may need multiple presses)
for (let i = 0; i < 5; i++) {
const focusedItem = page.locator('.grid-file.focused')
const focusedTitle = await focusedItem.getAttribute('title') || ''

if (focusedTitle === folderName || focusedTitle.endsWith('/')) {
break
}
await page.keyboard.press('ArrowRight')
}

// Press Enter to open folder
await page.keyboard.press('Enter')

// Verify we're inside a folder (wait for navigation)
await page.waitForTimeout(1000)

// Verify navigation happened (URL changed or breadcrumb updated)
await expect(page.locator('.joyride-files-breadcrumbs')).toContainText(`Files/${folderName}`)
})
})
79 changes: 79 additions & 0 deletions test/helpers/grid.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
const webuiPort = 3001
const webuiUrl = `http://localhost:${webuiPort}`
const waitForIpfsStats = globalThis.waitForIpfsStats || (async () => {
await new Promise(resolve => setTimeout(resolve, 1000))
})

/**
* Select view mode (grid or list)
* @param {import('@playwright/test').Page} page - The Playwright page object
* @param {string} mode - The view mode to select ('grid' or 'list')
*/
const selectViewMode = async (page, mode) => {
if (mode === 'grid') {
await page.locator('button[title="Show items in grid"]').click()
await page.waitForSelector('.files-grid')
} else {
await page.locator('button[title="Show items in list"]').click()
await page.waitForSelector('.FilesList')
}
}

/**
* Navigate to the Files page
* @param {import('@playwright/test').Page} page - The Playwright page object
*/
const navigateToFilesPage = async (page) => {
await page.goto(webuiUrl + '#/files')
await waitForIpfsStats()
await page.waitForSelector('.files-grid, .FilesList')
}

/**
* Add test files to the current directory
* @param {import('@playwright/test').Page} page - The Playwright page object
* @param {string} directoryName - The name of the directory to add files to
* @param {number} numFiles - Number of files to add
*/
const addTestFiles = async (page, directoryName, numFiles = 5) => {
await navigateToFilesPage(page)

// Navigate to the directory if not already there
const currentBreadcrumb = await page.locator('.joyride-files-breadcrumbs').textContent()
if (!currentBreadcrumb.includes(directoryName)) {
// Find and click the directory in the path
await page.locator(`.joyride-files-breadcrumbs:has-text("${directoryName}")`).click()
}

// Create test file content
const testFiles = []
for (let i = 0; i < numFiles; i++) {
testFiles.push({
name: `test-file-${i}.txt`,
content: `Test file content ${i}`
})
}

await page.locator('button[aria-label="Import"], button:has-text("Import")').click()

await page.locator('button#add-file').click()

await page.setInputFiles('input[type="file"]',
await Promise.all(testFiles.map(async file => {
// Create a temporary file for each test file
await page.evaluate(fileData => {
const blob = new Blob([fileData.content], { type: 'text/plain' })
return URL.createObjectURL(blob)
}, file)
return { name: file.name, mimeType: 'text/plain', buffer: Buffer.from(file.content) }
}))
)

await page.waitForSelector('[title*="test-file-"]')
}

export {
navigateToFilesPage,
addTestFiles,
selectViewMode
}
30 changes: 28 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -48,14 +48,18 @@
"isolatedModules": true,
"jsx": "react-jsx",
"baseUrl": ".",
"module": "esnext"
"module": "esnext",
"paths": {
"*": ["*", "*.ts", "*.tsx"]
}
},
"exclude": [
"./src/test",
"src/**/*.test.js",
"src/**/*.test.js"
],
"include": [
"**/*.ts",
// "**/*.tsx",
"@types",
// "src/**/*.js", // TODO: Include all js files when typecheck passes
"src/bundles/files/**/*.js",
@@ -76,5 +80,27 @@
"src/env.js",
"src/lib/hofs/**/*.js",
"src/lib/guards.js",
"src/files/file-preview/file-thumbnail.tsx",
"src/files/type-from-ext/index.js",
"src/files/files-grid/files-grid.tsx",
"src/files/files-grid/grid-file.tsx",
"src/files/file-icon/FileIcon.js",
"src/files/pin-icon/PinIcon.js",
"src/icons/GlyphDots.js",
"src/components/checkbox/Checkbox.js",
"src/icons/GlyphPin.js",
"src/icons/GlyphPinCloud.js",
"src/icons/GlyphSmallTick.js",
"src/icons/GlyphDocText.js",
"src/icons/GlyphDocPicture.js",
"src/icons/GlyphDocMusic.js",
"src/icons/GlyphDocMovie.js",
"src/icons/GlyphDocGeneric.js",
"src/icons/GlyphDocCalc.js",
"src/icons/GlyphFolder.js",
"src/icons/GlyphPinCloud.js",
"src/icons/GlyphPin.js",
"src/icons/StrokeCube.js",
"src/files/type-from-ext/extToType.js"
Comment on lines +83 to +104
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ts needed these files in the project because they're being imported by other files.. we really need to finish up the TS migration in this project because it's getting messy

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

attempt at an explanation is here: #2349

]
}