mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-11 12:11:45 +00:00
Sheet mobile (#2502)
* feat: make mobile popovers as sheet * feat: sheets for other big modals mobile * (fix):multiselectpopover, prevent placeholder re-render * fix: google drive thing * chore: push new notification text * revert: remove vite dev server overrides Drops the host/allowedHosts block added for local Tailscale testing — it shouldn't have been committed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: ManishMadan2882 <manishmadan321@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
14 files changed
+282
-167
No files matched your search
@@ -18,7 +18,7 @@ class GoogleDriveAuth(BaseConnectorAuth):
|
||||
"""
|
||||
|
||||
SCOPES = [
|
||||
'https://www.googleapis.com/auth/drive.file'
|
||||
'https://www.googleapis.com/auth/drive.readonly'
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
@@ -43,23 +43,27 @@ class GoogleDriveAuth(BaseConnectorAuth):
|
||||
"redirect_uris": [self.redirect_uri]
|
||||
}
|
||||
},
|
||||
scopes=self.SCOPES
|
||||
scopes=self.SCOPES,
|
||||
# Flow is rebuilt at exchange time, so an auto-generated
|
||||
# code_verifier wouldn't survive; confidential client doesn't
|
||||
# need PKCE anyway.
|
||||
autogenerate_code_verifier=False,
|
||||
)
|
||||
flow.redirect_uri = self.redirect_uri
|
||||
|
||||
|
||||
authorization_url, _ = flow.authorization_url(
|
||||
access_type='offline',
|
||||
prompt='consent',
|
||||
include_granted_scopes='false',
|
||||
state=state
|
||||
)
|
||||
|
||||
|
||||
return authorization_url
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error generating authorization URL: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def exchange_code_for_tokens(self, authorization_code: str) -> Dict[str, Any]:
|
||||
try:
|
||||
if not authorization_code:
|
||||
@@ -75,7 +79,8 @@ class GoogleDriveAuth(BaseConnectorAuth):
|
||||
"redirect_uris": [self.redirect_uri]
|
||||
}
|
||||
},
|
||||
scopes=self.SCOPES
|
||||
scopes=self.SCOPES,
|
||||
autogenerate_code_verifier=False,
|
||||
)
|
||||
flow.redirect_uri = self.redirect_uri
|
||||
|
||||
|
||||
@@ -175,7 +175,8 @@ class GoogleDriveLoader(BaseConnectorLoader):
|
||||
try:
|
||||
file_metadata = self.service.files().get(
|
||||
fileId=file_id,
|
||||
fields='id,name,mimeType,size,createdTime,modifiedTime,parents'
|
||||
fields='id,name,mimeType,size,createdTime,modifiedTime,parents',
|
||||
supportsAllDrives=True
|
||||
).execute()
|
||||
|
||||
return self._process_file(file_metadata, load_content=load_content)
|
||||
@@ -228,7 +229,9 @@ class GoogleDriveLoader(BaseConnectorLoader):
|
||||
fields='nextPageToken,files(id,name,mimeType,size,createdTime,modifiedTime,parents)',
|
||||
pageToken=page_token,
|
||||
pageSize=page_size,
|
||||
orderBy='name'
|
||||
orderBy='name',
|
||||
supportsAllDrives=True,
|
||||
includeItemsFromAllDrives=True
|
||||
).execute()
|
||||
|
||||
items = results.get('files', [])
|
||||
@@ -308,7 +311,7 @@ class GoogleDriveLoader(BaseConnectorLoader):
|
||||
mimeType=export_mime_type
|
||||
)
|
||||
else:
|
||||
request = self.service.files().get_media(fileId=file_id)
|
||||
request = self.service.files().get_media(fileId=file_id, supportsAllDrives=True)
|
||||
|
||||
file_io = io.BytesIO()
|
||||
downloader = MediaIoBaseDownload(file_io, request)
|
||||
@@ -378,7 +381,8 @@ class GoogleDriveLoader(BaseConnectorLoader):
|
||||
def _download_single_file(self, file_id: str, local_dir: str) -> bool:
|
||||
file_metadata = self.service.files().get(
|
||||
fileId=file_id,
|
||||
fields='name,mimeType'
|
||||
fields='name,mimeType',
|
||||
supportsAllDrives=True
|
||||
).execute()
|
||||
|
||||
file_name = file_metadata['name']
|
||||
@@ -400,7 +404,7 @@ class GoogleDriveLoader(BaseConnectorLoader):
|
||||
if not full_path.endswith(extension):
|
||||
full_path += extension
|
||||
else:
|
||||
request = self.service.files().get_media(fileId=file_id)
|
||||
request = self.service.files().get_media(fileId=file_id, supportsAllDrives=True)
|
||||
|
||||
with open(full_path, 'wb') as f:
|
||||
downloader = MediaIoBaseDownload(f, request)
|
||||
@@ -423,7 +427,9 @@ class GoogleDriveLoader(BaseConnectorLoader):
|
||||
q=query,
|
||||
fields='nextPageToken, files(id, name, mimeType)',
|
||||
pageToken=page_token,
|
||||
pageSize=1000
|
||||
pageSize=1000,
|
||||
supportsAllDrives=True,
|
||||
includeItemsFromAllDrives=True
|
||||
).execute()
|
||||
|
||||
items = results.get('files', [])
|
||||
@@ -516,7 +522,8 @@ class GoogleDriveLoader(BaseConnectorLoader):
|
||||
try:
|
||||
folder_metadata = self.service.files().get(
|
||||
fileId=folder_id,
|
||||
fields='name'
|
||||
fields='name',
|
||||
supportsAllDrives=True
|
||||
).execute()
|
||||
folder_name = folder_metadata.get('name', '')
|
||||
folder_path = os.path.join(local_dir, folder_name)
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
VITE_BASE_URL=http://localhost:5173
|
||||
VITE_API_HOST=http://127.0.0.1:7091
|
||||
VITE_API_STREAMING=true
|
||||
VITE_NOTIFICATION_TEXT="What's new in 0.15.0 — Changelog"
|
||||
VITE_NOTIFICATION_LINK="https://blog.docsgpt.cloud/docsgpt-0-15-masters-long-term-memory-and-tooling/"
|
||||
VITE_NOTIFICATION_TEXT="What's new in 0.17 — Changelog"
|
||||
VITE_NOTIFICATION_LINK="https://blog.docsgpt.cloud/docsgpt-0-17-production-grade-from-the-ground-up/"
|
||||
VITE_GOOGLE_CLIENT_ID=896376503572-u46l78n8ctgtdr4dlei4u06jv6rbpqc5.apps.googleusercontent.com
|
||||
@@ -199,6 +199,7 @@ export default function ScheduleFormModal({
|
||||
: t('agents.schedules.modal.create')
|
||||
}
|
||||
size="md"
|
||||
mobileVariant="sheet"
|
||||
className="w-[min(560px,92vw)] sm:p-6"
|
||||
contentClassName="max-h-[80vh]"
|
||||
>
|
||||
|
||||
@@ -78,81 +78,109 @@ const ConnectorAuth: React.FC<ConnectorAuthProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuth = async () => {
|
||||
try {
|
||||
completedRef.current = false;
|
||||
// Close any popup left over from a previous click before wiping
|
||||
// the ref — otherwise the old window keeps living with no
|
||||
// interval watching it and no listener handling its messages.
|
||||
if (authWindowRef.current && !authWindowRef.current.closed) {
|
||||
authWindowRef.current.close();
|
||||
}
|
||||
authWindowRef.current = null;
|
||||
cleanup();
|
||||
|
||||
const authResponse = await userService.getConnectorAuthUrl(
|
||||
provider,
|
||||
token,
|
||||
);
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
if (!authResponse.ok) {
|
||||
throw new Error(
|
||||
`${t('modals.uploadDoc.connectors.auth.authUrlFailed')}: ${authResponse.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const authData = await authResponse.json();
|
||||
if (!mountedRef.current) return;
|
||||
if (!authData.success || !authData.authorization_url) {
|
||||
throw new Error(
|
||||
authData.error || t('modals.uploadDoc.connectors.auth.authUrlFailed'),
|
||||
);
|
||||
}
|
||||
|
||||
const authWindow = window.open(
|
||||
authData.authorization_url,
|
||||
`${provider}-auth`,
|
||||
'width=500,height=600,scrollbars=yes,resizable=yes',
|
||||
);
|
||||
if (!authWindow) {
|
||||
throw new Error(t('modals.uploadDoc.connectors.auth.popupBlocked'));
|
||||
}
|
||||
authWindowRef.current = authWindow;
|
||||
|
||||
messageHandlerRef.current = handleAuthMessage;
|
||||
window.addEventListener('message', handleAuthMessage as any);
|
||||
|
||||
const checkClosed = window.setInterval(() => {
|
||||
if (authWindow.closed) {
|
||||
clearInterval(checkClosed);
|
||||
intervalRef.current = null;
|
||||
if (messageHandlerRef.current) {
|
||||
window.removeEventListener(
|
||||
'message',
|
||||
messageHandlerRef.current as any,
|
||||
);
|
||||
messageHandlerRef.current = null;
|
||||
}
|
||||
authWindowRef.current = null;
|
||||
if (!completedRef.current) {
|
||||
onError(t('modals.uploadDoc.connectors.auth.authCancelled'));
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
intervalRef.current = checkClosed;
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
onError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('modals.uploadDoc.connectors.auth.authFailed'),
|
||||
);
|
||||
const handleAuth = () => {
|
||||
completedRef.current = false;
|
||||
// Close any popup left over from a previous click before wiping
|
||||
// the ref — otherwise the old window keeps living with no
|
||||
// interval watching it and no listener handling its messages.
|
||||
if (authWindowRef.current && !authWindowRef.current.closed) {
|
||||
authWindowRef.current.close();
|
||||
}
|
||||
authWindowRef.current = null;
|
||||
cleanup();
|
||||
|
||||
// Synchronous popup inside the click; navigated below after the
|
||||
// async URL fetch resolves. Otherwise Safari blocks it as non-gesture.
|
||||
const authWindow = window.open(
|
||||
'about:blank',
|
||||
`${provider}-auth`,
|
||||
'width=500,height=600,scrollbars=yes,resizable=yes',
|
||||
);
|
||||
if (!authWindow) {
|
||||
onError(t('modals.uploadDoc.connectors.auth.popupBlocked'));
|
||||
return;
|
||||
}
|
||||
authWindowRef.current = authWindow;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const authResponse = await userService.getConnectorAuthUrl(
|
||||
provider,
|
||||
token,
|
||||
);
|
||||
if (!mountedRef.current) {
|
||||
authWindow.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authResponse.ok) {
|
||||
authWindow.close();
|
||||
throw new Error(
|
||||
`${t('modals.uploadDoc.connectors.auth.authUrlFailed')}: ${authResponse.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const authData = await authResponse.json();
|
||||
if (!mountedRef.current) {
|
||||
authWindow.close();
|
||||
return;
|
||||
}
|
||||
if (!authData.success || !authData.authorization_url) {
|
||||
authWindow.close();
|
||||
throw new Error(
|
||||
authData.error ||
|
||||
t('modals.uploadDoc.connectors.auth.authUrlFailed'),
|
||||
);
|
||||
}
|
||||
|
||||
if (authWindow.closed) {
|
||||
// User closed the placeholder before we resolved.
|
||||
authWindowRef.current = null;
|
||||
onError(t('modals.uploadDoc.connectors.auth.authCancelled'));
|
||||
return;
|
||||
}
|
||||
authWindow.location.href = authData.authorization_url;
|
||||
|
||||
messageHandlerRef.current = handleAuthMessage;
|
||||
window.addEventListener('message', handleAuthMessage as any);
|
||||
|
||||
const checkClosed = window.setInterval(() => {
|
||||
if (authWindow.closed) {
|
||||
clearInterval(checkClosed);
|
||||
intervalRef.current = null;
|
||||
if (messageHandlerRef.current) {
|
||||
window.removeEventListener(
|
||||
'message',
|
||||
messageHandlerRef.current as any,
|
||||
);
|
||||
messageHandlerRef.current = null;
|
||||
}
|
||||
authWindowRef.current = null;
|
||||
if (!completedRef.current) {
|
||||
onError(t('modals.uploadDoc.connectors.auth.authCancelled'));
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
intervalRef.current = checkClosed;
|
||||
} catch (error) {
|
||||
if (!authWindow.closed) {
|
||||
authWindow.close();
|
||||
}
|
||||
authWindowRef.current = null;
|
||||
if (!mountedRef.current) return;
|
||||
onError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('modals.uploadDoc.connectors.auth.authFailed'),
|
||||
);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
// Release interval, message listener, and popup on unmount only.
|
||||
useEffect(() => {
|
||||
// Re-arm on mount; React 19 strict-mode's double-invoke would otherwise
|
||||
// leave this stuck at false from the prior cleanup, aborting later awaits.
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
cleanup();
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useDrivePicker from 'react-google-drive-picker';
|
||||
import drivePickerImport from 'react-google-drive-picker';
|
||||
|
||||
// Vite 8 CJS interop returns the namespace object for this lib instead of
|
||||
// unwrapping `default`; tolerate both shapes.
|
||||
const useDrivePicker = ((
|
||||
drivePickerImport as unknown as { default?: typeof drivePickerImport }
|
||||
).default ?? drivePickerImport) as typeof drivePickerImport;
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
import ConnectorAuth from './ConnectorAuth';
|
||||
@@ -116,6 +122,8 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
|
||||
|
||||
try {
|
||||
const clientId: string = import.meta.env.VITE_GOOGLE_CLIENT_ID;
|
||||
const developerKey: string =
|
||||
import.meta.env.VITE_GOOGLE_PICKER_API_KEY ?? '';
|
||||
|
||||
// Derive appId from clientId (extract numeric part before first dash)
|
||||
const appId = clientId ? clientId.split('-')[0] : null;
|
||||
@@ -129,7 +137,7 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
|
||||
|
||||
openPicker({
|
||||
clientId: clientId,
|
||||
developerKey: '',
|
||||
developerKey,
|
||||
appId: appId,
|
||||
setSelectFolderEnabled: false,
|
||||
viewId: 'DOCS',
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import CheckmarkIcon from '../assets/checkmark.svg';
|
||||
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
|
||||
import NoFilesIcon from '../assets/no-files.svg';
|
||||
import { useDarkTheme } from '../hooks';
|
||||
import { useDarkTheme, useMediaQuery } from '../hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import Spinner from './Spinner';
|
||||
import {
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
CommandList,
|
||||
} from './ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
|
||||
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from './ui/sheet';
|
||||
|
||||
export type MultiSelectPopoverItem = {
|
||||
id: string;
|
||||
@@ -84,6 +85,7 @@ export function MultiSelectPopover({
|
||||
}: MultiSelectPopoverProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkTheme] = useDarkTheme();
|
||||
const { isMobile } = useMediaQuery();
|
||||
const selectedSet = React.useMemo(() => new Set(selectedIds), [selectedIds]);
|
||||
|
||||
const orderedItems = React.useMemo(() => {
|
||||
@@ -175,6 +177,86 @@ export function MultiSelectPopover({
|
||||
</div>
|
||||
);
|
||||
|
||||
const commandBody = (
|
||||
<Command shouldFilter={searchable} className="bg-transparent">
|
||||
{title && (
|
||||
<div className="shrink-0 px-4 pt-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
{searchable && (
|
||||
<div className="shrink-0 px-4 pt-4">
|
||||
<CommandInput placeholder={effectivePlaceholder} className="h-10" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-foreground flex items-center justify-center py-8 dark:text-white">
|
||||
<Spinner size="small" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="dark:border-border mx-4 my-4 min-h-0 grow overflow-hidden rounded-md border border-[#D9D9D9]">
|
||||
<CommandList className="max-h-full overflow-y-auto">
|
||||
<CommandEmpty>{renderEmptyState()}</CommandEmpty>
|
||||
{hasGroups ? (
|
||||
grouped.groupOrder.map((groupKey) => {
|
||||
const groupItems = grouped.map.get(groupKey) || [];
|
||||
if (groupItems.length === 0) return null;
|
||||
return (
|
||||
<CommandGroup
|
||||
key={`group-${groupKey || 'ungrouped'}`}
|
||||
heading={groupKey || undefined}
|
||||
className="[&_[cmdk-group-heading]]:bg-muted/50 [&_[cmdk-group-heading]]:dark:bg-card [&_[cmdk-group-heading]]:text-muted-foreground p-0 [&_[cmdk-group-heading]]:sticky [&_[cmdk-group-heading]]:top-0 [&_[cmdk-group-heading]]:z-10 [&_[cmdk-group-heading]]:border-b [&_[cmdk-group-heading]]:border-[#D9D9D9] [&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:dark:border-[#2E2E2E]"
|
||||
>
|
||||
{groupItems.map(renderItem)}
|
||||
</CommandGroup>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<CommandGroup className="p-0">
|
||||
{orderedItems.map(renderItem)}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{footer && (
|
||||
<div className="border-border dark:border-border shrink-0 border-t px-4 py-4">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</Command>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetTrigger asChild>{trigger}</SheetTrigger>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
showCloseButton={false}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
className={cn(
|
||||
'border-border bg-background dark:border-border dark:bg-card flex max-h-[85vh] flex-col gap-0 overflow-hidden rounded-t-2xl p-0 pb-[env(safe-area-inset-bottom)]',
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
<SheetTitle className="sr-only">
|
||||
{title || effectivePlaceholder}
|
||||
</SheetTitle>
|
||||
<div
|
||||
className="mx-auto mt-2 mb-1 h-1.5 w-12 shrink-0 rounded-full bg-gray-300 dark:bg-gray-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{commandBody}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
@@ -186,62 +268,7 @@ export function MultiSelectPopover({
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
<Command shouldFilter={searchable} className="bg-transparent">
|
||||
{title && (
|
||||
<div className="shrink-0 px-4 pt-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
{searchable && (
|
||||
<div className="shrink-0 px-4 pt-4">
|
||||
<CommandInput
|
||||
placeholder={effectivePlaceholder}
|
||||
className="h-10"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-foreground flex items-center justify-center py-8 dark:text-white">
|
||||
<Spinner size="small" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="dark:border-border mx-4 my-4 grow overflow-hidden rounded-md border border-[#D9D9D9]">
|
||||
<CommandList className="max-h-none">
|
||||
<CommandEmpty>{renderEmptyState()}</CommandEmpty>
|
||||
{items.length === 0 ? (
|
||||
renderEmptyState()
|
||||
) : hasGroups ? (
|
||||
grouped.groupOrder.map((groupKey) => {
|
||||
const groupItems = grouped.map.get(groupKey) || [];
|
||||
if (groupItems.length === 0) return null;
|
||||
return (
|
||||
<CommandGroup
|
||||
key={`group-${groupKey || 'ungrouped'}`}
|
||||
heading={groupKey || undefined}
|
||||
className="[&_[cmdk-group-heading]]:bg-muted/50 [&_[cmdk-group-heading]]:dark:bg-card [&_[cmdk-group-heading]]:text-muted-foreground p-0 [&_[cmdk-group-heading]]:sticky [&_[cmdk-group-heading]]:top-0 [&_[cmdk-group-heading]]:z-10 [&_[cmdk-group-heading]]:border-b [&_[cmdk-group-heading]]:border-[#D9D9D9] [&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:dark:border-[#2E2E2E]"
|
||||
>
|
||||
{groupItems.map(renderItem)}
|
||||
</CommandGroup>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<CommandGroup className="p-0">
|
||||
{orderedItems.map(renderItem)}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{footer && (
|
||||
<div className="border-border dark:border-border shrink-0 border-t px-4 py-4">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</Command>
|
||||
{commandBody}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -12,10 +12,12 @@ import {
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useMediaQuery } from '../../hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
|
||||
type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
type ModalMobileVariant = 'modal' | 'sheet';
|
||||
|
||||
const SIZE_CLASSES: Record<ModalSize, string> = {
|
||||
sm: 'sm:max-w-sm',
|
||||
@@ -38,6 +40,7 @@ export type ModalProps = {
|
||||
showCloseButton?: boolean;
|
||||
isPerformingTask?: boolean;
|
||||
size?: ModalSize;
|
||||
mobileVariant?: ModalMobileVariant;
|
||||
};
|
||||
|
||||
const Modal = React.forwardRef<HTMLDivElement, ModalProps>(function Modal(
|
||||
@@ -54,9 +57,12 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(function Modal(
|
||||
showCloseButton = true,
|
||||
isPerformingTask = false,
|
||||
size = 'md',
|
||||
mobileVariant = 'modal',
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { isMobile } = useMediaQuery();
|
||||
const isMobileSheet = mobileVariant === 'sheet' && isMobile;
|
||||
const shouldShowCloseButton = showCloseButton && !isPerformingTask;
|
||||
|
||||
// When a task is performing, block click-outside / pointer-outside to
|
||||
@@ -94,8 +100,12 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(function Modal(
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="modal-content"
|
||||
data-mobile-sheet={isMobileSheet ? '' : undefined}
|
||||
onPointerDownOutside={blockOutsideInteractions}
|
||||
onInteractOutside={blockOutsideInteractions}
|
||||
onOpenAutoFocus={
|
||||
isMobileSheet ? (event) => event.preventDefault() : undefined
|
||||
}
|
||||
// Radix portals this to <body> in the DOM, but React still bubbles
|
||||
// synthetic events through the JSX tree. Stop the bubble at the
|
||||
// modal boundary so consumers mounted inside clickable cards (e.g.
|
||||
@@ -103,25 +113,39 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(function Modal(
|
||||
// onClick when the user interacts inside the modal.
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className={cn(
|
||||
'bg-card text-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 shadow-modal fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl p-8 duration-200 outline-none',
|
||||
SIZE_CLASSES[size],
|
||||
className,
|
||||
'bg-card text-foreground data-[state=open]:animate-in data-[state=closed]:animate-out shadow-modal fixed z-50 duration-200 outline-none',
|
||||
isMobileSheet
|
||||
? 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 flex max-h-[90vh] w-full flex-col gap-3 rounded-t-2xl px-4 pt-2 pb-[max(env(safe-area-inset-bottom),1rem)]'
|
||||
: cn(
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 top-[50%] left-[50%] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl p-8',
|
||||
SIZE_CLASSES[size],
|
||||
className,
|
||||
),
|
||||
)}
|
||||
>
|
||||
{isMobileSheet && (
|
||||
<div
|
||||
className="mx-auto h-1.5 w-12 shrink-0 rounded-full bg-gray-300 dark:bg-gray-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{titleNode}
|
||||
{descriptionNode}
|
||||
<div
|
||||
className={cn(
|
||||
'no-scrollbar text-foreground overflow-y-auto',
|
||||
// overflow-y-auto forces overflow-x:auto, clipping focus-ring
|
||||
// box-shadows; px-1 keeps them inside the clip rect.
|
||||
isMobileSheet && 'min-h-0 grow px-1',
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{footer ? (
|
||||
<div className="flex justify-end gap-2">{footer}</div>
|
||||
<div className="flex shrink-0 justify-end gap-2">{footer}</div>
|
||||
) : null}
|
||||
{shouldShowCloseButton && (
|
||||
{shouldShowCloseButton && !isMobileSheet && (
|
||||
<DialogClose
|
||||
className="ring-offset-background focus:ring-ring absolute top-3 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
|
||||
aria-label="Close"
|
||||
|
||||
+25
-15
@@ -3,7 +3,7 @@ layer(base);
|
||||
|
||||
@import 'tailwindcss';
|
||||
|
||||
@import "tw-animate-css";
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@@ -55,12 +55,12 @@ layer(base);
|
||||
|
||||
/* Light theme scrollbar */
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #E2E8F0;
|
||||
background: #e2e8f0;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: #8C9198;
|
||||
background: #8c9198;
|
||||
}
|
||||
|
||||
/* Dark theme scrollbar */
|
||||
@@ -70,11 +70,11 @@ layer(base);
|
||||
}
|
||||
|
||||
.dark &::-webkit-scrollbar-thumb:hover {
|
||||
background: #F0F0F0;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #E2E8F0 transparent;
|
||||
scrollbar-color: #e2e8f0 transparent;
|
||||
|
||||
.dark & {
|
||||
scrollbar-color: #949494 transparent;
|
||||
@@ -97,11 +97,11 @@ layer(base);
|
||||
}
|
||||
|
||||
&:hover::-webkit-scrollbar-thumb {
|
||||
background: #E2E8F0;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
&:hover::-webkit-scrollbar-thumb:hover {
|
||||
background: #8C9198;
|
||||
background: #8c9198;
|
||||
}
|
||||
|
||||
.dark &::-webkit-scrollbar-thumb {
|
||||
@@ -114,7 +114,7 @@ layer(base);
|
||||
}
|
||||
|
||||
.dark &:hover::-webkit-scrollbar-thumb:hover {
|
||||
background: #F0F0F0;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
/* Standard scrollbar properties (Chrome 121+, Firefox) */
|
||||
@@ -122,7 +122,7 @@ layer(base);
|
||||
scrollbar-color: transparent transparent;
|
||||
|
||||
&:hover {
|
||||
scrollbar-color: #E2E8F0 transparent;
|
||||
scrollbar-color: #e2e8f0 transparent;
|
||||
}
|
||||
|
||||
.dark & {
|
||||
@@ -173,7 +173,7 @@ layer(base);
|
||||
& th,
|
||||
& td {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #E2E8F0 transparent;
|
||||
scrollbar-color: #e2e8f0 transparent;
|
||||
}
|
||||
|
||||
.dark & th,
|
||||
@@ -206,24 +206,24 @@ layer(base);
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #E2E8F0;
|
||||
background: #e2e8f0;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #8C9198;
|
||||
background: #8c9198;
|
||||
}
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: #949494;
|
||||
}
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: #F0F0F0;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
/* Firefox: base scrollbar styles (Firefox ignores ::-webkit-scrollbar) */
|
||||
@supports (-moz-appearance: none) {
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #E2E8F0 transparent;
|
||||
scrollbar-color: #e2e8f0 transparent;
|
||||
}
|
||||
.dark * {
|
||||
scrollbar-color: #949494 transparent;
|
||||
@@ -741,7 +741,9 @@ Avoid over-scrolling in mobile browsers
|
||||
}
|
||||
|
||||
@keyframes thinkingBounce {
|
||||
0%, 80%, 100% {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0.6);
|
||||
opacity: 0.4;
|
||||
}
|
||||
@@ -902,3 +904,11 @@ Avoid over-scrolling in mobile browsers
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Picker portals outside Radix's tree; force interactivity + raise z so
|
||||
clicks land when a Modal/Sheet is also open. */
|
||||
.picker-dialog,
|
||||
.picker-dialog-bg {
|
||||
pointer-events: auto !important;
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
@@ -297,6 +297,7 @@ export default function CustomModelModal({
|
||||
: t('settings.customModels.addTitle')
|
||||
}
|
||||
size="lg"
|
||||
mobileVariant="sheet"
|
||||
className="max-w-[600px] md:w-[80vw] lg:w-[60vw]"
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
|
||||
@@ -593,6 +593,7 @@ export default function MCPServerModal({
|
||||
: t('settings.tools.mcp.addServer')
|
||||
}
|
||||
size="lg"
|
||||
mobileVariant="sheet"
|
||||
className="max-w-[600px] md:w-[80vw] lg:w-[60vw]"
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
|
||||
@@ -157,6 +157,7 @@ export default function SearchConversationsModal({
|
||||
hideTitle
|
||||
title={t('modals.searchConversations.searchPlaceholder')}
|
||||
showCloseButton={false}
|
||||
mobileVariant="sheet"
|
||||
className="w-[92vw] !max-w-xl !p-0"
|
||||
contentClassName="max-h-[70vh]"
|
||||
>
|
||||
|
||||
@@ -703,7 +703,8 @@ export default function PromptsModal({
|
||||
hideTitle
|
||||
title={type === 'ADD' ? 'Add Prompt' : 'Edit Prompt'}
|
||||
size="lg"
|
||||
className="bg-card dark:bg-card mx-4 mt-16 w-[95vw] max-w-[650px] rounded-2xl px-4 py-4 sm:px-6 sm:py-6 md:max-w-[860px] md:px-8 md:py-6 lg:max-w-[980px]"
|
||||
mobileVariant="sheet"
|
||||
className="bg-card dark:bg-card w-[95vw] max-w-[650px] rounded-2xl px-4 py-4 sm:px-6 sm:py-6 md:max-w-[860px] md:px-8 md:py-6 lg:max-w-[980px]"
|
||||
contentClassName="!overflow-visible"
|
||||
>
|
||||
{view}
|
||||
|
||||
@@ -897,6 +897,7 @@ function Upload({
|
||||
hideTitle
|
||||
title={t('modals.uploadDoc.label')}
|
||||
size="lg"
|
||||
mobileVariant="sheet"
|
||||
className="max-h-[90vh] w-11/12 sm:max-h-none sm:w-auto sm:min-w-[600px] md:min-w-[700px]"
|
||||
contentClassName="max-h-[80vh] sm:max-h-none"
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user