-
Notifications
You must be signed in to change notification settings - Fork 507
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
base: main
Are you sure you want to change the base?
Changes from all commits
f22d5a3
b490e6d
47f641f
4b76dde
cbd4b03
ba6383c
8507e66
1205468
6021c9c
b9f8de3
f61bf3d
1fe9b1a
175630c
ca63ce4
0c4a5bc
8df9581
3deea61
6430413
ba6864c
0acb827
cc3c47e
2ab0c2b
60b620a
7beaa11
933a302
f1e29c8
f9977b1
fe6e294
05d7fbe
48c97d3
8c33280
6272ea9
03bfd01
5a0a185
a85f55b
206215d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
george-hub331 marked this conversation as resolved.
Show resolved
Hide resolved
george-hub331 marked this conversation as resolved.
Show resolved
Hide resolved
george-hub331 marked this conversation as resolved.
Show resolved
Hide resolved
george-hub331 marked this conversation as resolved.
Show resolved
Hide resolved
george-hub331 marked this conversation as resolved.
Show resolved
Hide resolved
|
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; | ||
} |
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> |
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; | ||
} |
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> |
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; | ||
} |
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> |
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,6 @@ | ||
/** | ||
* @type {Record<string, string>} | ||
*/ | ||
const extToType = { | ||
wav: 'audio', | ||
bwf: 'audio', | ||
|
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 | ||
} |
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> | ||
) |
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}`) | ||
}) | ||
}) |
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 | ||
} |
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. attempt at an explanation is here: #2349 |
||
] | ||
} |
There was a problem hiding this comment.
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"