diff --git a/packages/pl-api/lib/client.ts b/packages/pl-api/lib/client.ts index 8285dff92..2eeb08b69 100644 --- a/packages/pl-api/lib/client.ts +++ b/packages/pl-api/lib/client.ts @@ -5948,12 +5948,12 @@ class PlApiClient { return response; }, - moveFolder: async (id: string, targetFolderId: string) => { + moveFolder: async (id: string, targetFolderId?: string) => { await this.#getIceshrimpAccessToken(); const response = await this.request(`/api/iceshrimp/drive/folder/${id}/move`, { method: 'POST', - body: { folderId: targetFolderId }, + body: { folderId: targetFolderId || null }, }); return v.parse(driveFolderSchema, response.json); @@ -6001,12 +6001,12 @@ class PlApiClient { return response; }, - moveFile: async (id: string, targetFolderId: string) => { + moveFile: async (id: string, targetFolderId?: string) => { await this.#getIceshrimpAccessToken(); const response = await this.request(`/api/iceshrimp/drive/${id}/move`, { method: 'POST', - body: { folderId: targetFolderId }, + body: { folderId: targetFolderId || null }, }); return v.parse(driveFileSchema, response.json); diff --git a/packages/pl-fe/src/components/dropdown-navigation.tsx b/packages/pl-fe/src/components/dropdown-navigation.tsx index d9521402d..60009b0a3 100644 --- a/packages/pl-fe/src/components/dropdown-navigation.tsx +++ b/packages/pl-fe/src/components/dropdown-navigation.tsx @@ -266,6 +266,15 @@ const DropdownNavigation: React.FC = React.memo((): JSX.Element | null => { /> )} + {features.drive && ( + } + onClick={closeSidebar} + /> + )} + {features.events && ( = React.memo(({ shrink }) text={} /> + {features.drive && } + />} + import('pl-fe/modals/reply-mentions-modal')), REPORT: lazy(() => import('pl-fe/modals/report-modal')), SELECT_BOOKMARK_FOLDER: lazy(() => import('pl-fe/modals/select-bookmark-folder-modal')), + SELECT_DRIVE_FILE: lazy(() => import('pl-fe/modals/select-drive-file-modal')), TEXT_FIELD: lazy(() => import('pl-fe/modals/text-field-modal')), UNAUTHORIZED: lazy(() => import('pl-fe/modals/unauthorized-modal')), }; diff --git a/packages/pl-fe/src/locales/en.json b/packages/pl-fe/src/locales/en.json index 680a26727..001d46aff 100644 --- a/packages/pl-fe/src/locales/en.json +++ b/packages/pl-fe/src/locales/en.json @@ -709,6 +709,7 @@ "directory.recently_active": "Recently active", "draft_status.cancel": "Delete", "draft_status.edit": "Edit", + "drive.breadcrumbs.home": "Home", "drive.empty": "There are no files or folders in this folder.", "drive.file.delete": "Delete file", "drive.file.delete.confirm": "Delete", @@ -721,6 +722,9 @@ "drive.file.mark_sensitive.error": "Failed to mark file as sensitive.", "drive.file.mark_sensitive.success": "File marked as sensitive.", "drive.file.move": "Move file", + "drive.file.move.error": "Failed to move file.", + "drive.file.move.heading": "Select move destination", + "drive.file.move.success": "File moved successfully.", "drive.file.rename": "Rename file", "drive.file.rename.confirm": "Rename", "drive.file.rename.error": "Failed to rename file.", @@ -746,6 +750,9 @@ "drive.folder.delete.success": "Folder deleted successfully.", "drive.folder.delete.text": "Are you sure you want to delete this folder? This action cannot be undone.", "drive.folder.dropdown": "Folder menu", + "drive.folder.move": "Move folder", + "drive.folder.move.error": "Failed to move folder.", + "drive.folder.move.success": "Folder moved successfully.", "drive.folder.new": "New folder", "drive.folder.new.error": "Failed to create folder.", "drive.folder.new.placeholder": "Folder name", @@ -756,6 +763,10 @@ "drive.folder.rename.placeholder": "New folder name", "drive.folder.rename.success": "Folder renamed successfully.", "drive.folder.view": "View folder", + "drive.select_file.confirm": "Select file", + "drive.select_file.heading": "Select file", + "drive.select_folder.confirm": "Select folder", + "drive.select_folder.heading": "Select folder", "edit_bookmark_folder_modal.confirm": "Save", "edit_bookmark_folder_modal.header_title": "Edit folder", "edit_email.header": "Change email", diff --git a/packages/pl-fe/src/modals/select-drive-file-modal.tsx b/packages/pl-fe/src/modals/select-drive-file-modal.tsx new file mode 100644 index 000000000..bd8a72ed5 --- /dev/null +++ b/packages/pl-fe/src/modals/select-drive-file-modal.tsx @@ -0,0 +1,189 @@ +import defaultIcon from '@phosphor-icons/core/regular/paperclip.svg'; +import clsx from 'clsx'; +import React, { useMemo } from 'react'; +import { FormattedMessage } from 'react-intl'; + +import ScrollableList from 'pl-fe/components/scrollable-list'; +import Icon from 'pl-fe/components/ui/icon'; +import Modal from 'pl-fe/components/ui/modal'; +import { MIMETYPE_ICONS } from 'pl-fe/components/upload'; +import { Breadcrumbs } from 'pl-fe/pages/drive/drive'; +import { useDriveFolderQuery } from 'pl-fe/queries/drive/use-drive-folder'; + +import type { DriveFile, DriveFolder } from 'pl-api'; +import type { BaseModalProps } from 'pl-fe/features/ui/components/modal-root'; + +type SelectDriveFileModalProps = { + disabled?: Array; + title?: React.ReactNode; +} & ({ + type: 'file'; + onSelect: (file: DriveFile) => void; +} | { + type: 'folder'; + onSelect: (folder: DriveFolder) => void; +}); + +interface IFolder { + folder: DriveFolder; + active?: boolean; + disabled?: boolean; + onSelect?: (folder: DriveFolder) => void; + onDoubleClick?: (folder: DriveFolder) => void; +} + +const Folder: React.FC = ({ folder, active, disabled, onSelect, onDoubleClick }) => { + return ( + + ); +}; + +interface IFile { + file: DriveFile; + active?: boolean; + disabled?: boolean; + onSelect?: (file: DriveFile) => void; +} + +const File: React.FC = ({ file, active, disabled, onSelect }) => { + const isMedia = file.content_type.match(/image|video|audio/); + + return ( + + ); +}; + +const SelectDriveFileModal: React.FC = ({ onClose, onSelect, type, disabled, title }) => { + const onClickClose = () => { + onClose('SELECT_DRIVE_FILE'); + }; + + const [currentFolder, setCurrentFolder] = React.useState(); + const [selectedFile, setSelectedFile] = React.useState(); + + const { data: folder } = useDriveFolderQuery(currentFolder); + + const handleConfirm = () => { + if (!folder) return; + + if (type === 'file') { + const file = folder.files.find(({ id }) => id === selectedFile); + if (file) { + onSelect(file); + } + } else { + const selectedFolder = folder.folders.find(({ id }) => id === selectedFile); + if (selectedFolder) { + onSelect(selectedFolder); + } else if (!disabled?.includes(folder?.id || null)) { + onSelect(folder); + } + } + + onClose('SELECT_DRIVE_FILE'); + }; + + const files = useMemo(() => { + const children: React.ReactNode[] = []; + + if (!folder) return children; + + for (const subfolder of folder.folders) { + children.push( + { + if (type === 'folder') { + setSelectedFile(id || undefined); + } + }} + onDoubleClick={({ id }) => { + setCurrentFolder(id || undefined); + }} + />, + ); + } + + for (const file of folder.files) { + children.push( + { + if (type === 'file') { + setSelectedFile(id); + } + }} + />, + ); + } + + return children; + }, [folder, selectedFile]); + + return ( + : )} + onClose={onClickClose} + confirmationAction={handleConfirm} + confirmationText={type === 'folder' ? : } + confirmationDisabled={!selectedFile && type !== 'folder'} + > +
+ setCurrentFolder(folderId)} /> +
+ + {files} + +
+ ); +}; + +export { SelectDriveFileModal as default, type SelectDriveFileModalProps }; diff --git a/packages/pl-fe/src/pages/drive/drive.tsx b/packages/pl-fe/src/pages/drive/drive.tsx index c2ff9e7f2..5c77fab7d 100644 --- a/packages/pl-fe/src/pages/drive/drive.tsx +++ b/packages/pl-fe/src/pages/drive/drive.tsx @@ -1,7 +1,8 @@ import defaultIcon from '@phosphor-icons/core/regular/paperclip.svg'; +import { clsx } from 'clsx'; import React, { useMemo } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { useHistory } from 'react-router-dom'; +import { Link, useHistory } from 'react-router-dom'; import DropdownMenu, { Menu } from 'pl-fe/components/dropdown-menu'; import { EmptyMessage } from 'pl-fe/components/empty-message'; @@ -10,8 +11,8 @@ import Icon from 'pl-fe/components/ui/icon'; import IconButton from 'pl-fe/components/ui/icon-button'; import { MIMETYPE_ICONS } from 'pl-fe/components/upload'; import ColumnLoading from 'pl-fe/features/ui/components/column-loading'; -import { useCreateDriveFileMutation, useDeleteDriveFileMutation, useUpdateDriveFileMutation } from 'pl-fe/queries/drive/use-drive-file'; -import { useCreateDriveFolderMutation, useDeleteDriveFolderMutation, useDriveFolderQuery, useUpdateDriveFolderMutation } from 'pl-fe/queries/drive/use-drive-folder'; +import { useCreateDriveFileMutation, useDeleteDriveFileMutation, useMoveDriveFileMutation, useUpdateDriveFileMutation } from 'pl-fe/queries/drive/use-drive-file'; +import { useCreateDriveFolderMutation, useDeleteDriveFolderMutation, useDriveFolderQuery, useMoveDriveFolderMutation, useUpdateDriveFolderMutation } from 'pl-fe/queries/drive/use-drive-folder'; import { useModalsActions } from 'pl-fe/stores/modals'; import toast from 'pl-fe/toast'; import { download } from 'pl-fe/utils/download'; @@ -26,6 +27,9 @@ const messages = defineMessages({ folderRenamePlaceholder: { id: 'drive.folder.rename.placeholder', defaultMessage: 'New folder name' }, folderRenameSuccess: { id: 'drive.folder.rename.success', defaultMessage: 'Folder renamed successfully.' }, folderRenameError: { id: 'drive.folder.rename.error', defaultMessage: 'Failed to rename folder.' }, + folderMove: { id: 'drive.folder.move', defaultMessage: 'Move folder' }, + folderMoveSuccess: { id: 'drive.folder.move.success', defaultMessage: 'Folder moved successfully.' }, + folderMoveError: { id: 'drive.folder.move.error', defaultMessage: 'Failed to move folder.' }, folderDelete: { id: 'drive.folder.delete', defaultMessage: 'Delete folder' }, folderDeleteSuccess: { id: 'drive.folder.delete.success', defaultMessage: 'Folder deleted successfully.' }, folderDeleteError: { id: 'drive.folder.delete.error', defaultMessage: 'Failed to delete folder.' }, @@ -47,6 +51,8 @@ const messages = defineMessages({ unmarkSensitiveSuccess: { id: 'drive.file.unmark_sensitive.success', defaultMessage: 'File unmarked as sensitive.' }, unmarkSensitiveError: { id: 'drive.file.unmark_sensitive.error', defaultMessage: 'Failed to unmark file as sensitive.' }, fileMove: { id: 'drive.file.move', defaultMessage: 'Move file' }, + fileMoveSuccess: { id: 'drive.file.move.success', defaultMessage: 'File moved successfully.' }, + fileMoveError: { id: 'drive.file.move.error', defaultMessage: 'Failed to move file.' }, fileDelete: { id: 'drive.file.delete', defaultMessage: 'Delete file' }, fileDeleteSuccess: { id: 'drive.file.delete.success', defaultMessage: 'File deleted successfully.' }, fileDeleteError: { id: 'drive.file.delete.error', defaultMessage: 'Failed to delete file.' }, @@ -59,6 +65,86 @@ const messages = defineMessages({ newFolderError: { id: 'drive.folder.new.error', defaultMessage: 'Failed to create folder.' }, }); +interface IBreadcrumbs { + folderId?: string; + depth?: number; + onClick?: (folderId?: string) => void; +} + +const Breadcrumbs: React.FC = ({ folderId, depth = 0, onClick }) => { + const { data } = useDriveFolderQuery(folderId); + + if (!folderId) { + const label = depth === 0 && ; + + if (onClick || depth === 0) { + return ( + + ); + } else { + return ( + + + {label} + + ); + } + } + + if (!data) return null; + + const spacer = ( +
+ +
+ ); + + const button = onClick ? ( + + ) : ( + + {data.name} + + ); + + if (depth === 2 && data?.parent_id) { + return ( + <> + + {spacer} +
+ +
+ {spacer} + {button} + + ); + } + + return ( + <> + + {spacer} + {button} + + ); +}; + interface IFile { file: DriveFile; } @@ -69,6 +155,7 @@ const File: React.FC = ({ file }) => { const { openModal } = useModalsActions(); const { mutate: updateFile } = useUpdateDriveFileMutation(file.id); const { mutate: deleteFile } = useDeleteDriveFileMutation(file.id); + const { mutate: moveFile } = useMoveDriveFileMutation(file.id); const isMedia = file.content_type.match(/image|video|audio/); @@ -158,6 +245,20 @@ const File: React.FC = ({ file }) => { }); }; + const handleMove = () => { + openModal('SELECT_DRIVE_FILE', { + type: 'folder', + onSelect: (targetFolder) => { + moveFile(targetFolder.id || undefined, { + onSuccess: () => toast.success(messages.fileMoveSuccess), + onError: () => toast.error(messages.fileMoveError), + }); + }, + disabled: [file.id], + title: , + }); + }; + const handleDelete = () => { openModal('CONFIRM', { heading: , @@ -202,10 +303,11 @@ const File: React.FC = ({ file }) => { action: handleToggleSensitive, }, null, - // { - // text: intl.formatMessage(messages.fileMove), - // icon: require('@phosphor-icons/core/regular/folders.svg'), - // }, + { + text: intl.formatMessage(messages.fileMove), + icon: require('@phosphor-icons/core/regular/folders.svg'), + action: handleMove, + }, { text: intl.formatMessage(messages.fileDelete), icon: require('@phosphor-icons/core/regular/trash.svg'), @@ -257,6 +359,7 @@ const Folder: React.FC = ({ folder }) => { const { openModal } = useModalsActions(); const { mutate: deleteFolder } = useDeleteDriveFolderMutation(folder.id!); const { mutate: updateFolder } = useUpdateDriveFolderMutation(folder.id!); + const { mutate: moveFolder } = useMoveDriveFolderMutation(folder.id!); const handleEnterFolder = () => { history.push(`/drive/${folder.id}`); @@ -293,6 +396,20 @@ const Folder: React.FC = ({ folder }) => { }); }; + const handleMove = () => { + openModal('SELECT_DRIVE_FILE', { + type: 'folder', + onSelect: (targetFolder) => { + moveFolder(targetFolder.id || undefined, { + onSuccess: () => toast.success(messages.folderMoveSuccess), + onError: () => toast.error(messages.folderMoveError), + }); + }, + disabled: [folder.id], + title: , + }); + }; + return [ { text: intl.formatMessage(messages.folderView), @@ -304,6 +421,11 @@ const Folder: React.FC = ({ folder }) => { icon: require('@phosphor-icons/core/regular/cursor-text.svg'), action: handleRename, }, + { + text: intl.formatMessage(messages.folderMove), + icon: require('@phosphor-icons/core/regular/folders.svg'), + action: handleMove, + }, { text: intl.formatMessage(messages.folderDelete), icon: require('@phosphor-icons/core/regular/trash.svg'), @@ -396,6 +518,9 @@ const DrivePage: React.FC = ({ params }) => { backHref={data?.id === null ? '/drive' : data?.parent_id ? `/drive/${data.parent_id}` : undefined} action={} > +
+ +
{isEmpty ? ( } @@ -411,4 +536,4 @@ const DrivePage: React.FC = ({ params }) => { ); }; -export { DrivePage as default }; +export { DrivePage as default, Breadcrumbs }; diff --git a/packages/pl-fe/src/queries/drive/use-drive-file.ts b/packages/pl-fe/src/queries/drive/use-drive-file.ts index 8c2aac6bd..17e48f257 100644 --- a/packages/pl-fe/src/queries/drive/use-drive-file.ts +++ b/packages/pl-fe/src/queries/drive/use-drive-file.ts @@ -63,7 +63,7 @@ const useMoveDriveFileMutation = (fileId: string) => { return useMutation({ mutationKey: ['drive', 'files'], - mutationFn: (folderId: string) => client.drive.moveFile(fileId, folderId), + mutationFn: (folderId?: string) => client.drive.moveFile(fileId, folderId), onSuccess: (file) => { queryClient.invalidateQueries({ queryKey: ['drive', 'folders'], exact: false }); queryClient.setQueryData(['drive', 'files', file.id], file); diff --git a/packages/pl-fe/src/queries/drive/use-drive-folder.ts b/packages/pl-fe/src/queries/drive/use-drive-folder.ts index 7df33e8a9..716d80e45 100644 --- a/packages/pl-fe/src/queries/drive/use-drive-folder.ts +++ b/packages/pl-fe/src/queries/drive/use-drive-folder.ts @@ -87,7 +87,7 @@ const useMoveDriveFolderMutation = (folderId: string) => { return useMutation({ mutationKey: ['drive', 'folders'], - mutationFn: (targetFolderId: string) => { + mutationFn: (targetFolderId?: string) => { const oldFolder = queryClient.getQueryData(['drive', 'folders', folderId]); if (oldFolder) { previousParentId = oldFolder.parent_id; diff --git a/packages/pl-fe/src/stores/modals.ts b/packages/pl-fe/src/stores/modals.ts index 59c782d74..b9fd2834a 100644 --- a/packages/pl-fe/src/stores/modals.ts +++ b/packages/pl-fe/src/stores/modals.ts @@ -35,6 +35,7 @@ import type { ReblogsModalProps } from 'pl-fe/modals/reblogs-modal'; import type { ReplyMentionsModalProps } from 'pl-fe/modals/reply-mentions-modal'; import type { ReportModalProps } from 'pl-fe/modals/report-modal'; import type { SelectBookmarkFolderModalProps } from 'pl-fe/modals/select-bookmark-folder-modal'; +import type { SelectDriveFileModalProps } from 'pl-fe/modals/select-drive-file-modal'; import type { TextFieldModalProps } from 'pl-fe/modals/text-field-modal'; import type { UnauthorizedModalProps } from 'pl-fe/modals/unauthorized-modal'; @@ -73,6 +74,7 @@ type OpenModalProps = | [type: 'REPLY_MENTIONS', props: ReplyMentionsModalProps] | [type: 'REPORT', props: ReportModalProps] | [type: 'SELECT_BOOKMARK_FOLDER', props: SelectBookmarkFolderModalProps] + | [type: 'SELECT_DRIVE_FILE', props: SelectDriveFileModalProps] | [type: 'TEXT_FIELD', props: TextFieldModalProps] | [type: 'UNAUTHORIZED', props?: UnauthorizedModalProps]; diff --git a/packages/pl-fe/src/styles/new/drive.scss b/packages/pl-fe/src/styles/new/drive.scss index 90f5ed808..ba45ef453 100644 --- a/packages/pl-fe/src/styles/new/drive.scss +++ b/packages/pl-fe/src/styles/new/drive.scss @@ -1,3 +1,5 @@ +@use 'mixins'; + .⁂-drive-page { &__files { display: flex; @@ -37,6 +39,7 @@ width: 6rem; overflow: hidden; object-fit: cover; + border-radius: 0.5rem; } &__icon { @@ -68,4 +71,109 @@ color: #fff; } } +} + +.⁂-drive-breadcrumbs { + display: flex; + align-items: center; + margin-bottom: 0.5rem; + gap: 0.125rem; + flex-wrap: wrap; + + &__spacer { + padding: 0.25rem; + } + + &__item { + padding: 0.25rem; + border-radius: 0.25rem; + + &--current { + padding: 0.25rem 0.5rem; + background: rgb(var(--color-gray-200)); + + &:is(.dark *) { + background: rgb(var(--color-primary-700)); + } + + &:is(.dark.black *) { + background: rgb(var(--color-gray-800)); + } + } + } + + &__home { + display: flex; + align-items: center; + gap: 0.5rem; + + span { + @include mixins.text($size: sm); + } + } +} + +.⁂-drive-file-list { + .⁂-drive-file { + display: flex; + align-items: center; + gap: 0.25rem; + width: 100%; + + img { + pointer-events: none; + margin: 0.5rem; + height: 2rem; + width: 2rem; + overflow: hidden; + object-fit: cover; + border-radius: 0.5rem; + } + + &__icon { + margin: 0.5rem; + height: 2rem; + width: 2rem; + color: rgb(var(--color-gray-800)); + + &:is(.dark *) { + color: rgb(var(--color-gray-200)); + } + } + + &__label { + // margin-top: auto; + // overflow: hidden; + // display: inline; + // display: -webkit-box; + // -webkit-box-orient: vertical; + // -webkit-line-clamp: 3; + // max-width: 100%; + // text-overflow: ellipsis; + // border-radius: 0.25rem; + // background-color: rgb(var(--color-gray-900)); + // padding: 0.25rem 0.5rem; + // font-size: 0.75rem; + // line-height: 1rem; + // font-weight: 500; + // color: #fff; + } + + &--active { + background: rgb(var(--color-primary-100)); + + &:is(.dark *) { + background: rgb(var(--color-primary-800)); + } + + &:is(.dark.black *) { + background: rgb(var(--color-gray-900)); + } + } + + &:disabled { + pointer-events: none; + opacity: 0.5; + } + } } \ No newline at end of file