Skip to content

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

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

Merged
merged 36 commits into from
Apr 4, 2025
Merged
Show file tree
Hide file tree
Changes from 28 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
Expand Up @@ -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>;
Expand Down Expand Up @@ -94,9 +95,9 @@ declare module 'ipfs' {
}

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

declare export type PinEntry = {
cid: CID,
Expand Down
11 changes: 10 additions & 1 deletion config-overrides.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
28 changes: 27 additions & 1 deletion public/locales/en/files.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -153,5 +178,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
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion src/bundles/files/consts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
16 changes: 14 additions & 2 deletions src/components/checkbox/Checkbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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>
)
}
Expand Down
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'
Expand All @@ -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,
Expand All @@ -35,6 +40,8 @@ const FilesPage = ({
translateY: 0,
file: null
})
const [viewMode, setViewMode] = useState('list')
const [selected, setSelected] = useState([])

useEffect(() => {
doFetchPinningServices()
Expand All @@ -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.keyCode === 191) && 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(() => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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') {
Expand All @@ -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) => {
Expand Down Expand Up @@ -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 ${viewMode === 'list' ? 'selected-item' : 'gray'}`}
onClick={() => setViewMode('list')}
title={t('viewList')}
style={{
height: '24px'
}}
>
<ViewList width="24" height="24" />
</button>
<button
className={`pointer ${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}/>

Expand Down
Loading