pl-fe: more drive works, allow moving folders and files

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2025-11-27 01:53:06 +01:00
parent d4aa52b7e6
commit 5749341067
11 changed files with 466 additions and 14 deletions

View File

@ -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);

View File

@ -266,6 +266,15 @@ const DropdownNavigation: React.FC = React.memo((): JSX.Element | null => {
/>
)}
{features.drive && (
<DropdownNavigationLink
to='/drive'
icon={require('@phosphor-icons/core/regular/cloud.svg')}
text={<FormattedMessage id='column.drive' defaultMessage='Drive' />}
onClick={closeSidebar}
/>
)}
{features.events && (
<DropdownNavigationLink
to='/events'

View File

@ -291,6 +291,13 @@ const SidebarNavigation: React.FC<ISidebarNavigation> = React.memo(({ shrink })
text={<FormattedMessage id='tabs_bar.profile' defaultMessage='Profile' />}
/>
{features.drive && <SidebarNavigationLink
to='/drive'
icon={require('@phosphor-icons/core/regular/cloud.svg')}
activeIcon={require('@phosphor-icons/core/fill/cloud-fill.svg')}
text={<FormattedMessage id='column.drive' defaultMessage='Drive' />}
/>}
<SidebarNavigationLink
to='/settings'
icon={require('@phosphor-icons/core/regular/sliders-horizontal.svg')}

View File

@ -45,6 +45,7 @@ const MODAL_COMPONENTS = {
REPLY_MENTIONS: lazy(() => 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')),
};

View File

@ -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",

View File

@ -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<string | null>;
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<IFolder> = ({ folder, active, disabled, onSelect, onDoubleClick }) => {
return (
<button
className={clsx('⁂-drive-file ⁂-drive-folder', { '⁂-drive-file--active': active, '⁂-drive-file--disabled': disabled })}
tabIndex={0}
onDoubleClick={disabled ? undefined : () => onDoubleClick?.(folder)}
onClick={disabled ? undefined : () => onSelect?.(folder)}
disabled={disabled}
>
<Icon
className='⁂-drive-file__icon'
src={require('@phosphor-icons/core/regular/folder.svg')}
/>
<span className='⁂-drive-file__label'>
{folder.name}
</span>
</button>
);
};
interface IFile {
file: DriveFile;
active?: boolean;
disabled?: boolean;
onSelect?: (file: DriveFile) => void;
}
const File: React.FC<IFile> = ({ file, active, disabled, onSelect }) => {
const isMedia = file.content_type.match(/image|video|audio/);
return (
<button
className={clsx('⁂-drive-file', { '⁂-drive-file--active': active, '⁂-drive-file--disabled': disabled })}
tabIndex={0}
onClick={disabled ? undefined : () => onSelect?.(file)}
disabled={disabled}
>
{file.thumbnail_url && isMedia ? (
<img
src={file.thumbnail_url}
alt={file.description || undefined}
/>
) : (
<Icon
className='⁂-drive-file__icon'
src={MIMETYPE_ICONS[file.content_type || ''] || defaultIcon}
/>
)}
<span className='⁂-drive-file__label'>
{file.filename}
</span>
</button>
);
};
const SelectDriveFileModal: React.FC<SelectDriveFileModalProps & BaseModalProps> = ({ onClose, onSelect, type, disabled, title }) => {
const onClickClose = () => {
onClose('SELECT_DRIVE_FILE');
};
const [currentFolder, setCurrentFolder] = React.useState<string>();
const [selectedFile, setSelectedFile] = React.useState<string>();
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(
<Folder
key={subfolder.id}
folder={subfolder}
active={selectedFile === subfolder.id}
disabled={disabled?.includes(subfolder.id)}
onSelect={({ id }) => {
if (type === 'folder') {
setSelectedFile(id || undefined);
}
}}
onDoubleClick={({ id }) => {
setCurrentFolder(id || undefined);
}}
/>,
);
}
for (const file of folder.files) {
children.push(
<File
key={file.id}
file={file}
active={selectedFile === file.id}
disabled={type === 'folder' || disabled?.includes(file.id)}
onSelect={({ id }) => {
if (type === 'file') {
setSelectedFile(id);
}
}}
/>,
);
}
return children;
}, [folder, selectedFile]);
return (
<Modal
title={title ?? (type === 'folder' ? <FormattedMessage id='drive.select_folder.heading' defaultMessage='Select folder' /> : <FormattedMessage id='drive.select_file.heading' defaultMessage='Select file' />)}
onClose={onClickClose}
confirmationAction={handleConfirm}
confirmationText={type === 'folder' ? <FormattedMessage id='drive.select_folder.confirm' defaultMessage='Select folder' /> : <FormattedMessage id='drive.select_file.confirm' defaultMessage='Select file' />}
confirmationDisabled={!selectedFile && type !== 'folder'}
>
<div className='⁂-drive-breadcrumbs'>
<Breadcrumbs folderId={currentFolder} onClick={(folderId) => setCurrentFolder(folderId)} />
</div>
<ScrollableList
listClassName='⁂-drive-file-list divide-y divide-solid divide-gray-200 black:divide-gray-800 dark:divide-primary-800'
style={{ minHeight: 'calc(80vh - 192px)' }}
isLoading={!folder}
showLoading={!folder}
useWindowScroll={false}
>
{files}
</ScrollableList>
</Modal>
);
};
export { SelectDriveFileModal as default, type SelectDriveFileModalProps };

View File

@ -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<IBreadcrumbs> = ({ folderId, depth = 0, onClick }) => {
const { data } = useDriveFolderQuery(folderId);
if (!folderId) {
const label = depth === 0 && <span><FormattedMessage id='drive.breadcrumbs.home' defaultMessage='Home' /></span>;
if (onClick || depth === 0) {
return (
<button
className={clsx('⁂-drive-breadcrumbs__item ⁂-drive-breadcrumbs__home', { '⁂-drive-breadcrumbs__item--current': depth === 0 })}
onClick={() => onClick?.()}
disabled={depth === 0}
>
<Icon src={require('@phosphor-icons/core/regular/house.svg')} />
{label}
</button>
);
} else {
return (
<Link to='/drive' className='⁂-drive-breadcrumbs__home'>
<Icon src={require('@phosphor-icons/core/regular/house.svg')} />
{label}
</Link>
);
}
}
if (!data) return null;
const spacer = (
<div className='⁂-drive-breadcrumbs__spacer' aria-hidden>
<Icon src={require('@phosphor-icons/core/regular/caret-right.svg')} />
</div>
);
const button = onClick ? (
<button
className={clsx('⁂-drive-breadcrumbs__item', { '⁂-drive-breadcrumbs__item--current': depth === 0 })}
onClick={() => onClick?.(folderId)}
>
{data.name}
</button>
) : (
<Link
to={`/drive/${folderId}`}
className={clsx('⁂-drive-breadcrumbs__item', { '⁂-drive-breadcrumbs__item--current': depth === 0 })}
>
{data.name}
</Link>
);
if (depth === 2 && data?.parent_id) {
return (
<>
<Breadcrumbs depth={depth + 1} onClick={onClick} />
{spacer}
<div className='⁂-drive-breadcrumbs__spacer' aria-hidden>
<Icon src={require('@phosphor-icons/core/regular/dots-three.svg')} />
</div>
{spacer}
{button}
</>
);
}
return (
<>
<Breadcrumbs folderId={data.parent_id || undefined} depth={depth + 1} onClick={onClick} />
{spacer}
{button}
</>
);
};
interface IFile {
file: DriveFile;
}
@ -69,6 +155,7 @@ const File: React.FC<IFile> = ({ 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<IFile> = ({ 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: <FormattedMessage id='drive.file.move.heading' defaultMessage='Select move destination' />,
});
};
const handleDelete = () => {
openModal('CONFIRM', {
heading: <FormattedMessage id='drive.file.delete' defaultMessage='Delete file' />,
@ -202,10 +303,11 @@ const File: React.FC<IFile> = ({ 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<IFolder> = ({ 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<IFolder> = ({ 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: <FormattedMessage id='drive.file.move.heading' defaultMessage='Select move destination' />,
});
};
return [
{
text: intl.formatMessage(messages.folderView),
@ -304,6 +421,11 @@ const Folder: React.FC<IFolder> = ({ 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<IDrivePage> = ({ params }) => {
backHref={data?.id === null ? '/drive' : data?.parent_id ? `/drive/${data.parent_id}` : undefined}
action={<DropdownMenu items={items} src={require('@phosphor-icons/core/regular/dots-three-vertical.svg')} />}
>
<div className='⁂-drive-breadcrumbs'>
<Breadcrumbs folderId={params?.folderId} />
</div>
{isEmpty ? (
<EmptyMessage
text={<FormattedMessage id='drive.empty' defaultMessage='There are no files or folders in this folder.' />}
@ -411,4 +536,4 @@ const DrivePage: React.FC<IDrivePage> = ({ params }) => {
);
};
export { DrivePage as default };
export { DrivePage as default, Breadcrumbs };

View File

@ -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);

View File

@ -87,7 +87,7 @@ const useMoveDriveFolderMutation = (folderId: string) => {
return useMutation({
mutationKey: ['drive', 'folders'],
mutationFn: (targetFolderId: string) => {
mutationFn: (targetFolderId?: string) => {
const oldFolder = queryClient.getQueryData<DriveFolder>(['drive', 'folders', folderId]);
if (oldFolder) {
previousParentId = oldFolder.parent_id;

View File

@ -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];

View File

@ -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;
}
}
}