Add support for loading files from Google Drive

#vibecoded
This commit is contained in:
Przemek Więch
2026-05-19 00:38:16 +02:00
parent 60e076fca1
commit 55a1626cf4
26 changed files with 2005 additions and 107 deletions

View File

@@ -0,0 +1,189 @@
import {useEffect, useRef, useState} from 'react';
import {FormattedMessage} from 'react-intl';
import {Button, Header, Icon, Message, Modal} from 'semantic-ui-react';
import {googleDriveService} from '../datasource/google_drive_service';
interface Props {
/** The Google Drive file ID that initially failed to load. */
failedFileId: string;
/** Callback triggered when the authentication and file selection are successful. */
onAuthSuccess: (fileId: string) => void;
/** Callback triggered when the user cancels the modal. */
onCancel: () => void;
}
/**
* Modal dialog that prompts the user to authenticate and select a specific file
* from Google Drive when direct file loading fails due to insufficient permissions.
*/
export function GoogleAuthModal(props: Props) {
const [loading, setLoading] = useState(false);
const [showPickerInstructions, setShowPickerInstructions] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
async function completeAuthFlow(token: string) {
const response = await window.fetch(
`https://www.googleapis.com/drive/v3/files/${props.failedFileId}?alt=media`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
if (response.status === 200) {
setLoading(false);
props.onAuthSuccess(props.failedFileId);
return;
}
setShowPickerInstructions(true);
googleDriveService.showPicker(
(pickedFileId) => {
setLoading(false);
props.onAuthSuccess(pickedFileId);
},
() => {
setLoading(false);
},
);
}
async function handleConnect() {
setLoading(true);
setErrorMessage(null);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setLoading(false);
timeoutRef.current = null;
}, 20000);
try {
const token = await googleDriveService.requestToken(false);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
await completeAuthFlow(token);
} catch (err: unknown) {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
console.error('Auth error in fallback modal:', err);
setErrorMessage(
(err as Error).message || 'An error occurred during authentication.',
);
setLoading(false);
}
}
async function handleSwitchAccount() {
setLoading(true);
setErrorMessage(null);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setLoading(false);
timeoutRef.current = null;
}, 20000);
try {
const token = await googleDriveService.requestToken(true);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
await completeAuthFlow(token);
} catch (err: unknown) {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
console.error('Switch account error in fallback modal:', err);
setErrorMessage(
(err as Error).message || 'An error occurred during authentication.',
);
setLoading(false);
}
}
return (
<Modal open={true} onClose={props.onCancel} centered={true} size="tiny">
<Header>
<Icon name="google drive" />
<FormattedMessage
id="google_auth.title"
defaultMessage="Google Drive Access Required"
/>
</Header>
<Modal.Content>
<p>
<FormattedMessage
id="google_auth.instructions"
defaultMessage="To view this file, you must authenticate and select the file from your Google Drive to grant permissions."
/>
</p>
{showPickerInstructions && (
<Message warning>
<Message.Header>
<FormattedMessage
id="google_auth.picker_instructions_header"
defaultMessage="Permissions Required"
/>
</Message.Header>
<p>
<FormattedMessage
id="google_auth.picker_instructions"
defaultMessage="The application does not have permission to read this file. Please select it in the file browser popup to grant access. If this is a shared file that doesn't show up, try adding a shortcut to your Drive first."
/>
</p>
</Message>
)}
{errorMessage && (
<Message negative>
<p>{errorMessage}</p>
</Message>
)}
</Modal.Content>
<Modal.Actions>
<Button secondary onClick={props.onCancel}>
<FormattedMessage id="google_auth.cancel" defaultMessage="Cancel" />
</Button>
{showPickerInstructions && (
<Button basic onClick={handleSwitchAccount} loading={loading}>
<FormattedMessage
id="google_auth.switch_account_button"
defaultMessage="Switch Account"
/>
</Button>
)}
<Button primary onClick={handleConnect} loading={loading}>
<FormattedMessage
id="google_auth.grant_button"
defaultMessage="Grant Access & Select File"
/>
</Button>
</Modal.Actions>
</Modal>
);
}

View File

@@ -0,0 +1,59 @@
import queryString from 'query-string';
import {FormattedMessage} from 'react-intl';
import {useNavigate} from 'react-router';
import {Icon} from 'semantic-ui-react';
import {
googleDriveService,
isGoogleDriveConfigured,
} from '../datasource/google_drive_service';
import {MenuItem, MenuType} from './menu_item';
interface Props {
menuType: MenuType;
onTokenAcquired?: () => void;
}
/**
* Component representing the Google Drive menu option.
* It handles authenticating the user and launching the Google Picker to select files.
*/
export function GoogleDriveMenu(props: Props) {
const navigate = useNavigate();
// Do not render the menu item if Google Drive API credentials are not configured.
if (!isGoogleDriveConfigured()) {
return null;
}
async function handleClick() {
try {
// 1. Request access token
await googleDriveService.requestToken();
if (props.onTokenAcquired) {
props.onTokenAcquired();
}
// 2. Open Google Picker
await googleDriveService.showPicker((fileId) => {
navigate({
pathname: '/view',
search: queryString.stringify({
source: 'google-drive',
fileId,
}),
});
});
} catch (err) {
console.error('Failed Google Drive login/file picking:', err);
}
}
return (
<MenuItem onClick={handleClick} menuType={props.menuType}>
<Icon name="google drive" />
<FormattedMessage
id="menu.load_from_google_drive"
defaultMessage="Load from Google Drive"
/>
</MenuItem>
);
}

View File

@@ -3,8 +3,10 @@ import {FormattedMessage} from 'react-intl';
import {Link, useLocation, useNavigate} from 'react-router';
import {Dropdown, Icon, Menu} from 'semantic-ui-react';
import {IndiInfo, JsonGedcomData} from 'topola';
import {isGoogleDriveConfigured} from '../datasource/google_drive_service';
import {Media} from '../util/media';
import {MenuType} from './menu_item';
import {GoogleDriveMenu} from './google_drive_menu';
import {MenuItem, MenuType} from './menu_item';
import {SearchBar} from './search';
import {UploadMenu} from './upload_menu';
import {UrlMenu} from './url_menu';
@@ -35,6 +37,12 @@ interface Props {
eventHandlers: EventHandlers;
/** Whether to show additional WikiTree menus. */
showWikiTreeMenus: boolean;
/** Whether the user has authorized Google Drive and has an active token. */
hasGoogleToken: boolean;
/** Callback to sign out of Google Drive. */
onGoogleSignOut?: () => void;
/** Callback triggered when a new Google Drive token is acquired. */
onGoogleTokenAcquired?: () => void;
}
export function TopBar(props: Props) {
@@ -202,6 +210,43 @@ export function TopBar(props: Props) {
);
}
function googleDriveDisconnectMenu(screenSize: ScreenSize) {
if (!props.hasGoogleToken || !isGoogleDriveConfigured()) {
return null;
}
return (
<>
<MenuItem
menuType={
screenSize === ScreenSize.SMALL ? MenuType.Dropdown : MenuType.Menu
}
onClick={props.onGoogleSignOut}
>
<Icon name="sign out" />
<FormattedMessage
id="menu.google_sign_out"
defaultMessage="Disconnect Google Drive"
/>
</MenuItem>
{screenSize === ScreenSize.SMALL ? <Dropdown.Divider /> : null}
</>
);
}
function fileMenuItems(menuType: MenuType) {
return (
<>
<UploadMenu menuType={menuType} {...props} />
<UrlMenu menuType={menuType} {...props} />
<WikiTreeMenu menuType={menuType} {...props} />
<GoogleDriveMenu
menuType={menuType}
onTokenAcquired={props.onGoogleTokenAcquired}
/>
</>
);
}
function fileMenus(screenSize: ScreenSize) {
// In standalone WikiTree mode, show only the "Select WikiTree ID" menu.
if (!props.standalone && props.showWikiTreeMenus) {
@@ -237,26 +282,16 @@ export function TopBar(props: Props) {
}
className="item"
>
<Dropdown.Menu>
<UploadMenu menuType={MenuType.Dropdown} {...props} />
<UrlMenu menuType={MenuType.Dropdown} {...props} />
<WikiTreeMenu menuType={MenuType.Dropdown} {...props} />
</Dropdown.Menu>
<Dropdown.Menu>{fileMenuItems(MenuType.Dropdown)}</Dropdown.Menu>
</Dropdown>
) : (
<>
<UploadMenu menuType={MenuType.Menu} {...props} />
<UrlMenu menuType={MenuType.Menu} {...props} />
<WikiTreeMenu menuType={MenuType.Menu} {...props} />
</>
fileMenuItems(MenuType.Menu)
);
case ScreenSize.SMALL:
return (
<>
<UploadMenu menuType={MenuType.Dropdown} {...props} />
<UrlMenu menuType={MenuType.Dropdown} {...props} />
<WikiTreeMenu menuType={MenuType.Dropdown} {...props} />
{fileMenuItems(MenuType.Dropdown)}
<Dropdown.Divider />
</>
);
@@ -295,6 +330,7 @@ export function TopBar(props: Props) {
<Dropdown.Menu>
{fileMenus(ScreenSize.SMALL)}
{chartMenus(ScreenSize.SMALL)}
{googleDriveDisconnectMenu(ScreenSize.SMALL)}
{wikiTreeLoginMenu(ScreenSize.SMALL)}
<Dropdown.Item
@@ -330,6 +366,7 @@ export function TopBar(props: Props) {
{fileMenus(ScreenSize.LARGE)}
{chartMenus(ScreenSize.LARGE)}
<Menu.Menu position="right">
{googleDriveDisconnectMenu(ScreenSize.LARGE)}
{wikiTreeLoginMenu(ScreenSize.LARGE)}
<Menu.Item
href="https://github.com/PeWu/topola-viewer"