diff --git a/src/renderer/src/assets/styles/animation.scss b/src/renderer/src/assets/styles/animation.scss index 5d02acfc..eb6cb359 100644 --- a/src/renderer/src/assets/styles/animation.scss +++ b/src/renderer/src/assets/styles/animation.scss @@ -1,4 +1,4 @@ -@keyframes pulse { +@keyframes animation-pulse { 0% { box-shadow: 0 0 0 0 rgba(var(--pulse-color), 0.5); } @@ -14,5 +14,5 @@ .animation-pulse { --pulse-color: 59, 130, 246; --pulse-size: 8px; - animation: pulse 1.5s infinite; + animation: animation-pulse 1.5s infinite; } diff --git a/src/renderer/src/components/CustomCollapse.tsx b/src/renderer/src/components/CustomCollapse.tsx new file mode 100644 index 00000000..dc15babf --- /dev/null +++ b/src/renderer/src/components/CustomCollapse.tsx @@ -0,0 +1,44 @@ +import { Collapse } from 'antd' +import { FC, memo } from 'react' + +interface CustomCollapseProps { + label: string + extra: React.ReactNode + children: React.ReactNode +} + +const CustomCollapse: FC = ({ label, extra, children }) => { + const CollapseStyle = { + background: 'transparent', + border: '0.5px solid var(--color-border)' + } + const CollapseItemStyles = { + header: { + padding: '8px 16px', + alignItems: 'center', + justifyContent: 'space-between' + }, + body: { + borderTop: '0.5px solid var(--color-border)' + } + } + return ( + + ) +} + +export default memo(CustomCollapse) diff --git a/src/renderer/src/pages/files/FileItem.tsx b/src/renderer/src/pages/files/FileItem.tsx new file mode 100644 index 00000000..79baa857 --- /dev/null +++ b/src/renderer/src/pages/files/FileItem.tsx @@ -0,0 +1,145 @@ +import { + FileExcelFilled, + FileImageFilled, + FileMarkdownFilled, + FilePdfFilled, + FilePptFilled, + FileTextFilled, + FileUnknownFilled, + FileWordFilled, + FileZipFilled, + FolderOpenFilled, + GlobalOutlined, + LinkOutlined +} from '@ant-design/icons' +import { Flex } from 'antd' +import React, { memo } from 'react' +import styled from 'styled-components' + +interface FileItemProps { + fileInfo: { + name: React.ReactNode | string + ext: string + extra?: React.ReactNode | string + actions: React.ReactNode + } +} + +const getFileIcon = (type?: string) => { + if (!type) return + + const ext = type.toLowerCase() + + if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'].includes(ext)) { + return + } + + if (['.doc', '.docx'].includes(ext)) { + return + } + if (['.xls', '.xlsx'].includes(ext)) { + return + } + if (['.ppt', '.pptx'].includes(ext)) { + return + } + if (ext === '.pdf') { + return + } + if (['.md', '.markdown'].includes(ext)) { + return + } + + if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) { + return + } + + if (['.txt', '.json', '.log', '.yml', '.yaml', '.xml', '.csv'].includes(ext)) { + return + } + + if (['.url'].includes(ext)) { + return + } + + if (['.sitemap'].includes(ext)) { + return + } + + if (['.folder'].includes(ext)) { + return + } + + return +} + +const FileItem: React.FC = ({ fileInfo }) => { + const { name, ext, extra, actions } = fileInfo + + return ( + + + {getFileIcon(ext)} + + {name} + {extra && {extra}} + + {actions} + + + ) +} + +const FileItemCard = styled.div` + background: rgba(255, 255, 255, 0.04); + border-radius: 8px; + overflow: hidden; + border: 0.5px solid var(--color-border); + flex-shrink: 0; + transition: box-shadow 0.2s ease; + --shadow-color: rgba(0, 0, 0, 0.05); + &:hover { + box-shadow: + 0 10px 15px -3px var(--shadow-color), + 0 4px 6px -4px var(--shadow-color); + } + body[theme-mode='dark'] & { + --shadow-color: rgba(255, 255, 255, 0.02); + } +` + +const CardContent = styled.div` + padding: 8px 16px; + display: flex; + align-items: center; + gap: 16px; +` + +const FileIcon = styled.div` + color: var(--color-text-3); + font-size: 32px; +` + +const FileName = styled.div` + font-size: 15px; + font-weight: bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + transition: color 0.2s ease; + span { + font-size: 15px; + font-weight: bold; + } + &:hover { + color: var(--color-primary); + } +` + +const FileInfo = styled.div` + font-size: 13px; + color: var(--color-text-2); +` + +export default memo(FileItem) diff --git a/src/renderer/src/pages/files/FileList.tsx b/src/renderer/src/pages/files/FileList.tsx new file mode 100644 index 00000000..d81b5d50 --- /dev/null +++ b/src/renderer/src/pages/files/FileList.tsx @@ -0,0 +1,167 @@ +import FileManager from '@renderer/services/FileManager' +import { FileType, FileTypes } from '@renderer/types' +import { formatFileSize } from '@renderer/utils' +import { Col, Image, Row, Spin } from 'antd' +import { t } from 'i18next' +import VirtualList from 'rc-virtual-list' +import React, { memo } from 'react' +import styled from 'styled-components' + +import FileItem from './FileItem' +import GeminiFiles from './GeminiFiles' + +interface FileItemProps { + id: FileTypes | 'all' | string + list: { + key: FileTypes | 'all' | string + file: React.ReactNode + files?: FileType[] + count?: number + size: string + ext: string + created_at: string + actions: React.ReactNode + }[] + files?: FileType[] +} + +const FileList: React.FC = ({ id, list, files }) => { + if (id === FileTypes.IMAGE && files?.length && files?.length > 0) { + return ( +
+ + + {files?.map((file) => ( + + + + + + { + const img = e.target as HTMLImageElement + img.parentElement?.classList.add('loaded') + }} + /> + +
{formatFileSize(file.size)}
+
+
+ + ))} +
+
+
+ ) + } + + if (id.startsWith('gemini_')) { + return + } + + return ( + + {(item) => ( +
+ +
+ )} +
+ ) +} + +const ImageWrapper = styled.div` + position: relative; + aspect-ratio: 1; + overflow: hidden; + border-radius: 8px; + background-color: var(--color-background-soft); + display: flex; + align-items: center; + justify-content: center; + border: 0.5px solid var(--color-border); + + .ant-image { + height: 100%; + width: 100%; + opacity: 0; + transition: + opacity 0.3s ease, + transform 0.3s ease; + + &.loaded { + opacity: 1; + } + } + + &:hover { + .ant-image.loaded { + transform: scale(1.05); + } + + div:last-child { + opacity: 1; + } + } +` + +const LoadingWrapper = styled.div` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--color-background-soft); +` + +const ImageInfo = styled.div` + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: rgba(0, 0, 0, 0.6); + color: white; + padding: 5px 8px; + opacity: 0; + transition: opacity 0.3s ease; + font-size: 12px; + + > div:first-child { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +` + +export default memo(FileList) diff --git a/src/renderer/src/pages/files/FilesPage.tsx b/src/renderer/src/pages/files/FilesPage.tsx index 4ad8f731..bbbb770a 100644 --- a/src/renderer/src/pages/files/FilesPage.tsx +++ b/src/renderer/src/pages/files/FilesPage.tsx @@ -1,14 +1,15 @@ import { DeleteOutlined, EditOutlined, - EllipsisOutlined, + ExclamationCircleOutlined, FileImageOutlined, FilePdfOutlined, - FileTextOutlined + FileTextOutlined, + SortAscendingOutlined, + SortDescendingOutlined } from '@ant-design/icons' import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' import TextEditPopup from '@renderer/components/Popups/TextEditPopup' -import Scrollbar from '@renderer/components/Scrollbar' import db from '@renderer/databases' import { useProviders } from '@renderer/hooks/useProvider' import FileManager from '@renderer/services/FileManager' @@ -16,18 +17,23 @@ import store from '@renderer/store' import { FileType, FileTypes } from '@renderer/types' import { formatFileSize } from '@renderer/utils' import type { MenuProps } from 'antd' -import { Button, Dropdown, Menu } from 'antd' +import { Button, Empty, Flex, Menu, Popconfirm } from 'antd' import dayjs from 'dayjs' import { useLiveQuery } from 'dexie-react-hooks' -import { FC, useMemo, useState } from 'react' +import { FC, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -import ContentView from './ContentView' +import FileList from './FileList' + +type SortField = 'created_at' | 'size' | 'name' +type SortOrder = 'asc' | 'desc' const FilesPage: FC = () => { const { t } = useTranslation() const [fileType, setFileType] = useState('document') + const [sortField, setSortField] = useState('created_at') + const [sortOrder, setSortOrder] = useState('desc') const { providers } = useProviders() const geminiProviders = providers.filter((provider) => provider.type === 'gemini') @@ -42,6 +48,24 @@ const FilesPage: FC = () => { }) } + const sortFiles = (files: FileType[]) => { + return [...files].sort((a, b) => { + let comparison = 0 + switch (sortField) { + case 'created_at': + comparison = dayjs(a.created_at).unix() - dayjs(b.created_at).unix() + break + case 'size': + comparison = a.size - b.size + break + case 'name': + comparison = a.origin_name.localeCompare(b.origin_name) + break + } + return sortOrder === 'asc' ? comparison : -comparison + }) + } + const files = useLiveQuery(() => { if (fileType === 'all') { return db.files.orderBy('count').toArray().then(tempFilesSort) @@ -49,6 +73,8 @@ const FilesPage: FC = () => { return db.files.where('type').equals(fileType).sortBy('count').then(tempFilesSort) }, [fileType]) + const sortedFiles = files ? sortFiles(files) : [] + const handleDelete = async (fileId: string) => { const file = await FileManager.getFile(fileId) @@ -89,95 +115,34 @@ const FilesPage: FC = () => { } } - const getActionMenu = (fileId: string): MenuProps['items'] => [ - { - key: 'rename', - icon: , - label: t('files.edit'), - onClick: () => handleRename(fileId) - }, - { - key: 'delete', - icon: , - label: t('files.delete'), - danger: true, - onClick: () => { - window.modal.confirm({ - title: t('files.delete.title'), - content: t('files.delete.content'), - centered: true, - okButtonProps: { danger: true }, - onOk: () => handleDelete(fileId) - }) - } - } - ] - - const dataSource = files?.map((file) => { + const dataSource = sortedFiles?.map((file) => { return { key: file.id, - file: ( - window.api.file.openPath(file.path)}> - {FileManager.formatFileName(file)} - - ), + file: window.api.file.openPath(file.path)}>{FileManager.formatFileName(file)}, size: formatFileSize(file.size), size_bytes: file.size, count: file.count, + path: file.path, + ext: file.ext, created_at: dayjs(file.created_at).format('MM-DD HH:mm'), created_at_unix: dayjs(file.created_at).unix(), actions: ( - -