mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-07-24 12:41:50 +00:00
Add support for loading files from Google Drive
#vibecoded
This commit is contained in:
189
src/menu/google_auth_modal.tsx
Normal file
189
src/menu/google_auth_modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
src/menu/google_drive_menu.tsx
Normal file
59
src/menu/google_drive_menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user