Co-authored-by: crazywoola <427733928@qq.com> Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>tags/0.14.0
| import type { FC } from 'react' | |||||
| import { createPortal } from 'react-dom' | |||||
| import { RiCloseLine } from '@remixicon/react' | |||||
| import React from 'react' | |||||
| import { useHotkeys } from 'react-hotkeys-hook' | |||||
| type AudioPreviewProps = { | |||||
| url: string | |||||
| title: string | |||||
| onCancel: () => void | |||||
| } | |||||
| const AudioPreview: FC<AudioPreviewProps> = ({ | |||||
| url, | |||||
| title, | |||||
| onCancel, | |||||
| }) => { | |||||
| useHotkeys('esc', onCancel) | |||||
| return createPortal( | |||||
| <div | |||||
| className='fixed inset-0 p-8 flex items-center justify-center bg-black/80 z-[1000]' | |||||
| onClick={e => e.stopPropagation()} | |||||
| tabIndex={-1} | |||||
| > | |||||
| <div> | |||||
| <audio controls title={title} autoPlay={false} preload="metadata"> | |||||
| <source | |||||
| type="audio/mpeg" | |||||
| src={url} | |||||
| className='max-w-full max-h-full' | |||||
| /> | |||||
| </audio> | |||||
| </div> | |||||
| <div | |||||
| className='absolute top-6 right-6 flex items-center justify-center w-8 h-8 bg-white/[0.08] rounded-lg backdrop-blur-[2px] cursor-pointer' | |||||
| onClick={onCancel} | |||||
| > | |||||
| <RiCloseLine className='w-4 h-4 text-gray-500'/> | |||||
| </div> | |||||
| </div> | |||||
| , | |||||
| document.body, | |||||
| ) | |||||
| } | |||||
| export default AudioPreview |
| <div className={cn('border-[2px] border-effects-image-frame shadow-xs', className)}> | <div className={cn('border-[2px] border-effects-image-frame shadow-xs', className)}> | ||||
| <img | <img | ||||
| className={cn('w-full h-full object-cover', showDownloadAction && 'cursor-pointer')} | className={cn('w-full h-full object-cover', showDownloadAction && 'cursor-pointer')} | ||||
| alt={alt} | |||||
| alt={alt || 'Preview'} | |||||
| onLoad={onLoad} | onLoad={onLoad} | ||||
| onError={onError} | onError={onError} | ||||
| src={imageUrl} | src={imageUrl} |
| <> | <> | ||||
| <div | <div | ||||
| className='group/file-image relative cursor-pointer' | className='group/file-image relative cursor-pointer' | ||||
| onClick={() => canPreview && setImagePreviewUrl(url || '')} | |||||
| onClick={() => canPreview && setImagePreviewUrl(base64Url || url || '')} | |||||
| > | > | ||||
| { | { | ||||
| showDeleteAction && ( | showDeleteAction && ( |
| RiCloseLine, | RiCloseLine, | ||||
| RiDownloadLine, | RiDownloadLine, | ||||
| } from '@remixicon/react' | } from '@remixicon/react' | ||||
| import { useState } from 'react' | |||||
| import { | import { | ||||
| downloadFile, | downloadFile, | ||||
| fileIsUploaded, | fileIsUploaded, | ||||
| import { ReplayLine } from '@/app/components/base/icons/src/vender/other' | import { ReplayLine } from '@/app/components/base/icons/src/vender/other' | ||||
| import ActionButton from '@/app/components/base/action-button' | import ActionButton from '@/app/components/base/action-button' | ||||
| import Button from '@/app/components/base/button' | import Button from '@/app/components/base/button' | ||||
| import PdfPreview from '@/app/components/base/file-uploader/pdf-preview' | |||||
| import AudioPreview from '@/app/components/base/file-uploader/audio-preview' | |||||
| import VideoPreview from '@/app/components/base/file-uploader/video-preview' | |||||
| type FileItemProps = { | type FileItemProps = { | ||||
| file: FileEntity | file: FileEntity | ||||
| showDeleteAction?: boolean | showDeleteAction?: boolean | ||||
| showDownloadAction?: boolean | showDownloadAction?: boolean | ||||
| canPreview?: boolean | |||||
| onRemove?: (fileId: string) => void | onRemove?: (fileId: string) => void | ||||
| onReUpload?: (fileId: string) => void | onReUpload?: (fileId: string) => void | ||||
| } | } | ||||
| showDownloadAction = true, | showDownloadAction = true, | ||||
| onRemove, | onRemove, | ||||
| onReUpload, | onReUpload, | ||||
| canPreview, | |||||
| }: FileItemProps) => { | }: FileItemProps) => { | ||||
| const { id, name, type, progress, url, base64Url, isRemote } = file | const { id, name, type, progress, url, base64Url, isRemote } = file | ||||
| const [previewUrl, setPreviewUrl] = useState('') | |||||
| const ext = getFileExtension(name, type, isRemote) | const ext = getFileExtension(name, type, isRemote) | ||||
| const uploadError = progress === -1 | const uploadError = progress === -1 | ||||
| let tmp_preview_url = url || base64Url | |||||
| if (!tmp_preview_url && file?.originalFile) | |||||
| tmp_preview_url = URL.createObjectURL(file.originalFile.slice()).toString() | |||||
| return ( | return ( | ||||
| <div | |||||
| className={cn( | |||||
| 'group/file-item relative p-2 w-[144px] h-[68px] rounded-lg border-[0.5px] border-components-panel-border bg-components-card-bg shadow-xs', | |||||
| !uploadError && 'hover:bg-components-card-bg-alt', | |||||
| uploadError && 'border border-state-destructive-border bg-state-destructive-hover', | |||||
| uploadError && 'hover:border-[0.5px] hover:border-state-destructive-border bg-state-destructive-hover-alt', | |||||
| )} | |||||
| > | |||||
| { | |||||
| showDeleteAction && ( | |||||
| <Button | |||||
| className='hidden group-hover/file-item:flex absolute -right-1.5 -top-1.5 p-0 w-5 h-5 rounded-full z-[11]' | |||||
| onClick={() => onRemove?.(id)} | |||||
| > | |||||
| <RiCloseLine className='w-4 h-4 text-components-button-secondary-text' /> | |||||
| </Button> | |||||
| ) | |||||
| } | |||||
| <> | |||||
| <div | <div | ||||
| className='mb-1 h-8 line-clamp-2 system-xs-medium text-text-tertiary break-all' | |||||
| title={name} | |||||
| className={cn( | |||||
| 'group/file-item relative p-2 w-[144px] h-[68px] rounded-lg border-[0.5px] border-components-panel-border bg-components-card-bg shadow-xs', | |||||
| !uploadError && 'hover:bg-components-card-bg-alt', | |||||
| uploadError && 'border border-state-destructive-border bg-state-destructive-hover', | |||||
| uploadError && 'hover:border-[0.5px] hover:border-state-destructive-border bg-state-destructive-hover-alt', | |||||
| )} | |||||
| > | > | ||||
| {name} | |||||
| </div> | |||||
| <div className='relative flex items-center justify-between'> | |||||
| <div className='flex items-center system-2xs-medium-uppercase text-text-tertiary'> | |||||
| <FileTypeIcon | |||||
| size='sm' | |||||
| type={getFileAppearanceType(name, type)} | |||||
| className='mr-1' | |||||
| /> | |||||
| { | |||||
| showDeleteAction && ( | |||||
| <Button | |||||
| className='hidden group-hover/file-item:flex absolute -right-1.5 -top-1.5 p-0 w-5 h-5 rounded-full z-[11]' | |||||
| onClick={() => onRemove?.(id)} | |||||
| > | |||||
| <RiCloseLine className='w-4 h-4 text-components-button-secondary-text' /> | |||||
| </Button> | |||||
| ) | |||||
| } | |||||
| <div | |||||
| className='mb-1 h-8 line-clamp-2 system-xs-medium text-text-tertiary break-all cursor-pointer' | |||||
| title={name} | |||||
| onClick={() => canPreview && setPreviewUrl(tmp_preview_url || '')} | |||||
| > | |||||
| {name} | |||||
| </div> | |||||
| <div className='relative flex items-center justify-between'> | |||||
| <div className='flex items-center system-2xs-medium-uppercase text-text-tertiary'> | |||||
| <FileTypeIcon | |||||
| size='sm' | |||||
| type={getFileAppearanceType(name, type)} | |||||
| className='mr-1' | |||||
| /> | |||||
| { | |||||
| ext && ( | |||||
| <> | |||||
| {ext} | |||||
| <div className='mx-1'>·</div> | |||||
| </> | |||||
| ) | |||||
| } | |||||
| { | |||||
| !!file.size && formatFileSize(file.size) | |||||
| } | |||||
| </div> | |||||
| { | |||||
| showDownloadAction && tmp_preview_url && ( | |||||
| <ActionButton | |||||
| size='m' | |||||
| className='hidden group-hover/file-item:flex absolute -right-1 -top-1' | |||||
| onClick={(e) => { | |||||
| e.stopPropagation() | |||||
| downloadFile(tmp_preview_url || '', name) | |||||
| }} | |||||
| > | |||||
| <RiDownloadLine className='w-3.5 h-3.5 text-text-tertiary' /> | |||||
| </ActionButton> | |||||
| ) | |||||
| } | |||||
| { | { | ||||
| ext && ( | |||||
| <> | |||||
| {ext} | |||||
| <div className='mx-1'>·</div> | |||||
| </> | |||||
| progress >= 0 && !fileIsUploaded(file) && ( | |||||
| <ProgressCircle | |||||
| percentage={progress} | |||||
| size={12} | |||||
| className='shrink-0' | |||||
| /> | |||||
| ) | ) | ||||
| } | } | ||||
| { | { | ||||
| !!file.size && formatFileSize(file.size) | |||||
| uploadError && ( | |||||
| <ReplayLine | |||||
| className='w-4 h-4 text-text-tertiary' | |||||
| onClick={() => onReUpload?.(id)} | |||||
| /> | |||||
| ) | |||||
| } | } | ||||
| </div> | </div> | ||||
| { | |||||
| showDownloadAction && url && ( | |||||
| <ActionButton | |||||
| size='m' | |||||
| className='hidden group-hover/file-item:flex absolute -right-1 -top-1' | |||||
| onClick={(e) => { | |||||
| e.stopPropagation() | |||||
| downloadFile(url || base64Url || '', name) | |||||
| }} | |||||
| > | |||||
| <RiDownloadLine className='w-3.5 h-3.5 text-text-tertiary' /> | |||||
| </ActionButton> | |||||
| ) | |||||
| } | |||||
| { | |||||
| progress >= 0 && !fileIsUploaded(file) && ( | |||||
| <ProgressCircle | |||||
| percentage={progress} | |||||
| size={12} | |||||
| className='shrink-0' | |||||
| /> | |||||
| ) | |||||
| } | |||||
| { | |||||
| uploadError && ( | |||||
| <ReplayLine | |||||
| className='w-4 h-4 text-text-tertiary' | |||||
| onClick={() => onReUpload?.(id)} | |||||
| /> | |||||
| ) | |||||
| } | |||||
| </div> | </div> | ||||
| </div> | |||||
| { | |||||
| type.split('/')[0] === 'audio' && canPreview && previewUrl && ( | |||||
| <AudioPreview | |||||
| title={name} | |||||
| url={previewUrl} | |||||
| onCancel={() => setPreviewUrl('')} | |||||
| /> | |||||
| ) | |||||
| } | |||||
| { | |||||
| type.split('/')[0] === 'video' && canPreview && previewUrl && ( | |||||
| <VideoPreview | |||||
| title={name} | |||||
| url={previewUrl} | |||||
| onCancel={() => setPreviewUrl('')} | |||||
| /> | |||||
| ) | |||||
| } | |||||
| { | |||||
| type.split('/')[1] === 'pdf' && canPreview && previewUrl && ( | |||||
| <PdfPreview url={previewUrl} onCancel={() => { setPreviewUrl('') }} /> | |||||
| ) | |||||
| } | |||||
| </> | |||||
| ) | ) | ||||
| } | } | ||||
| onRemove, | onRemove, | ||||
| showDeleteAction = true, | showDeleteAction = true, | ||||
| showDownloadAction = false, | showDownloadAction = false, | ||||
| canPreview, | |||||
| canPreview = true, | |||||
| }: FileListProps) => { | }: FileListProps) => { | ||||
| return ( | return ( | ||||
| <div className={cn('flex flex-wrap gap-2', className)}> | <div className={cn('flex flex-wrap gap-2', className)}> | ||||
| showDownloadAction={showDownloadAction} | showDownloadAction={showDownloadAction} | ||||
| onRemove={onRemove} | onRemove={onRemove} | ||||
| onReUpload={onReUpload} | onReUpload={onReUpload} | ||||
| canPreview={canPreview} | |||||
| /> | /> | ||||
| ) | ) | ||||
| }) | }) |
| import type { FC } from 'react' | |||||
| import { createPortal } from 'react-dom' | |||||
| import 'react-pdf-highlighter/dist/style.css' | |||||
| import { PdfHighlighter, PdfLoader } from 'react-pdf-highlighter' | |||||
| import { t } from 'i18next' | |||||
| import { RiCloseLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react' | |||||
| import React, { useState } from 'react' | |||||
| import { useHotkeys } from 'react-hotkeys-hook' | |||||
| import Loading from '@/app/components/base/loading' | |||||
| import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' | |||||
| import Tooltip from '@/app/components/base/tooltip' | |||||
| type PdfPreviewProps = { | |||||
| url: string | |||||
| onCancel: () => void | |||||
| } | |||||
| const PdfPreview: FC<PdfPreviewProps> = ({ | |||||
| url, | |||||
| onCancel, | |||||
| }) => { | |||||
| const media = useBreakpoints() | |||||
| const [scale, setScale] = useState(1) | |||||
| const [position, setPosition] = useState({ x: 0, y: 0 }) | |||||
| const isMobile = media === MediaType.mobile | |||||
| const zoomIn = () => { | |||||
| setScale(prevScale => Math.min(prevScale * 1.2, 15)) | |||||
| setPosition({ x: position.x - 50, y: position.y - 50 }) | |||||
| } | |||||
| const zoomOut = () => { | |||||
| setScale((prevScale) => { | |||||
| const newScale = Math.max(prevScale / 1.2, 0.5) | |||||
| if (newScale === 1) | |||||
| setPosition({ x: 0, y: 0 }) | |||||
| else | |||||
| setPosition({ x: position.x + 50, y: position.y + 50 }) | |||||
| return newScale | |||||
| }) | |||||
| } | |||||
| useHotkeys('esc', onCancel) | |||||
| useHotkeys('up', zoomIn) | |||||
| useHotkeys('down', zoomOut) | |||||
| return createPortal( | |||||
| <div | |||||
| className={`fixed inset-0 flex items-center justify-center bg-black/80 z-[1000] ${!isMobile && 'p-8'}`} | |||||
| onClick={e => e.stopPropagation()} | |||||
| tabIndex={-1} | |||||
| > | |||||
| <div | |||||
| className='h-[95vh] w-[100vw] max-w-full max-h-full overflow-hidden' | |||||
| style={{ transform: `scale(${scale})`, transformOrigin: 'center', scrollbarWidth: 'none', msOverflowStyle: 'none' }} | |||||
| > | |||||
| <PdfLoader | |||||
| url={url} | |||||
| beforeLoad={<div className='flex justify-center items-center h-64'><Loading type='app' /></div>} | |||||
| > | |||||
| {(pdfDocument) => { | |||||
| return ( | |||||
| <PdfHighlighter | |||||
| pdfDocument={pdfDocument} | |||||
| enableAreaSelection={event => event.altKey} | |||||
| scrollRef={() => { }} | |||||
| onScrollChange={() => { }} | |||||
| onSelectionFinished={() => null} | |||||
| highlightTransform={() => { return <div/> }} | |||||
| highlights={[]} | |||||
| /> | |||||
| ) | |||||
| }} | |||||
| </PdfLoader> | |||||
| </div> | |||||
| <Tooltip popupContent={t('common.operation.zoomOut')}> | |||||
| <div className='absolute top-6 right-24 flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer' | |||||
| onClick={zoomOut}> | |||||
| <RiZoomOutLine className='w-4 h-4 text-gray-500'/> | |||||
| </div> | |||||
| </Tooltip> | |||||
| <Tooltip popupContent={t('common.operation.zoomIn')}> | |||||
| <div className='absolute top-6 right-16 flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer' | |||||
| onClick={zoomIn}> | |||||
| <RiZoomInLine className='w-4 h-4 text-gray-500'/> | |||||
| </div> | |||||
| </Tooltip> | |||||
| <Tooltip popupContent={t('common.operation.cancel')}> | |||||
| <div | |||||
| className='absolute top-6 right-6 flex items-center justify-center w-8 h-8 bg-white/8 rounded-lg backdrop-blur-[2px] cursor-pointer' | |||||
| onClick={onCancel}> | |||||
| <RiCloseLine className='w-4 h-4 text-gray-500'/> | |||||
| </div> | |||||
| </Tooltip> | |||||
| </div>, | |||||
| document.body, | |||||
| ) | |||||
| } | |||||
| export default PdfPreview |
| import type { FC } from 'react' | |||||
| import { createPortal } from 'react-dom' | |||||
| import { RiCloseLine } from '@remixicon/react' | |||||
| import React from 'react' | |||||
| import { useHotkeys } from 'react-hotkeys-hook' | |||||
| type VideoPreviewProps = { | |||||
| url: string | |||||
| title: string | |||||
| onCancel: () => void | |||||
| } | |||||
| const VideoPreview: FC<VideoPreviewProps> = ({ | |||||
| url, | |||||
| title, | |||||
| onCancel, | |||||
| }) => { | |||||
| useHotkeys('esc', onCancel) | |||||
| return createPortal( | |||||
| <div | |||||
| className='fixed inset-0 p-8 flex items-center justify-center bg-black/80 z-[1000]' | |||||
| onClick={e => e.stopPropagation()} | |||||
| tabIndex={-1} | |||||
| > | |||||
| <div> | |||||
| <video controls title={title} autoPlay={false} preload="metadata"> | |||||
| <source | |||||
| type="video/mp4" | |||||
| src={url} | |||||
| className='max-w-full max-h-full' | |||||
| /> | |||||
| </video> | |||||
| </div> | |||||
| <div | |||||
| className='absolute top-6 right-6 flex items-center justify-center w-8 h-8 bg-white/[0.08] rounded-lg backdrop-blur-[2px] cursor-pointer' | |||||
| onClick={onCancel} | |||||
| > | |||||
| <RiCloseLine className='w-4 h-4 text-gray-500'/> | |||||
| </div> | |||||
| </div> | |||||
| , document.body, | |||||
| ) | |||||
| } | |||||
| export default VideoPreview |
| import { t } from 'i18next' | import { t } from 'i18next' | ||||
| import { createPortal } from 'react-dom' | import { createPortal } from 'react-dom' | ||||
| import { RiAddBoxLine, RiCloseLine, RiDownloadCloud2Line, RiFileCopyLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react' | import { RiAddBoxLine, RiCloseLine, RiDownloadCloud2Line, RiFileCopyLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react' | ||||
| import { useHotkeys } from 'react-hotkeys-hook' | |||||
| import Tooltip from '@/app/components/base/tooltip' | import Tooltip from '@/app/components/base/tooltip' | ||||
| import Toast from '@/app/components/base/toast' | import Toast from '@/app/components/base/toast' | ||||
| url: string | url: string | ||||
| title: string | title: string | ||||
| onCancel: () => void | onCancel: () => void | ||||
| onPrev?: () => void | |||||
| onNext?: () => void | |||||
| } | } | ||||
| const isBase64 = (str: string): boolean => { | const isBase64 = (str: string): boolean => { | ||||
| url, | url, | ||||
| title, | title, | ||||
| onCancel, | onCancel, | ||||
| onPrev, | |||||
| onNext, | |||||
| }) => { | }) => { | ||||
| const [scale, setScale] = useState(1) | const [scale, setScale] = useState(1) | ||||
| const [position, setPosition] = useState({ x: 0, y: 0 }) | const [position, setPosition] = useState({ x: 0, y: 0 }) | ||||
| const imgRef = useRef<HTMLImageElement>(null) | const imgRef = useRef<HTMLImageElement>(null) | ||||
| const dragStartRef = useRef({ x: 0, y: 0 }) | const dragStartRef = useRef({ x: 0, y: 0 }) | ||||
| const [isCopied, setIsCopied] = useState(false) | const [isCopied, setIsCopied] = useState(false) | ||||
| const containerRef = useRef<HTMLDivElement>(null) | |||||
| const openInNewTab = () => { | const openInNewTab = () => { | ||||
| // Open in a new window, considering the case when the page is inside an iframe | // Open in a new window, considering the case when the page is inside an iframe | ||||
| }) | }) | ||||
| } | } | ||||
| } | } | ||||
| const downloadImage = () => { | const downloadImage = () => { | ||||
| // Open in a new window, considering the case when the page is inside an iframe | // Open in a new window, considering the case when the page is inside an iframe | ||||
| if (url.startsWith('http') || url.startsWith('https')) { | if (url.startsWith('http') || url.startsWith('https')) { | ||||
| } | } | ||||
| }, [handleMouseUp]) | }, [handleMouseUp]) | ||||
| useEffect(() => { | |||||
| const handleKeyDown = (event: KeyboardEvent) => { | |||||
| if (event.key === 'Escape') | |||||
| onCancel() | |||||
| } | |||||
| window.addEventListener('keydown', handleKeyDown) | |||||
| // Set focus to the container element | |||||
| if (containerRef.current) | |||||
| containerRef.current.focus() | |||||
| // Cleanup function | |||||
| return () => { | |||||
| window.removeEventListener('keydown', handleKeyDown) | |||||
| } | |||||
| }, [onCancel]) | |||||
| useHotkeys('esc', onCancel) | |||||
| useHotkeys('up', zoomIn) | |||||
| useHotkeys('down', zoomOut) | |||||
| useHotkeys('left', onPrev || (() => {})) | |||||
| useHotkeys('right', onNext || (() => {})) | |||||
| return createPortal( | return createPortal( | ||||
| <div className='fixed inset-0 p-8 flex items-center justify-center bg-black/80 z-[1000] image-preview-container' | <div className='fixed inset-0 p-8 flex items-center justify-center bg-black/80 z-[1000] image-preview-container' |
| "react-easy-crop": "^5.0.8", | "react-easy-crop": "^5.0.8", | ||||
| "react-error-boundary": "^4.0.2", | "react-error-boundary": "^4.0.2", | ||||
| "react-hook-form": "^7.51.4", | "react-hook-form": "^7.51.4", | ||||
| "react-hotkeys-hook": "^4.6.1", | |||||
| "react-i18next": "^12.2.0", | "react-i18next": "^12.2.0", | ||||
| "react-infinite-scroll-component": "^6.1.0", | "react-infinite-scroll-component": "^6.1.0", | ||||
| "react-markdown": "^8.0.6", | "react-markdown": "^8.0.6", | ||||
| "react-multi-email": "^1.0.14", | "react-multi-email": "^1.0.14", | ||||
| "react-papaparse": "^4.1.0", | "react-papaparse": "^4.1.0", | ||||
| "react-pdf-highlighter": "^8.0.0-rc.0", | |||||
| "react-slider": "^2.0.4", | "react-slider": "^2.0.4", | ||||
| "react-sortablejs": "^6.1.4", | "react-sortablejs": "^6.1.4", | ||||
| "react-syntax-highlighter": "^15.5.0", | "react-syntax-highlighter": "^15.5.0", |