mirror of
https://github.com/actualbudget/actual.git
synced 2026-04-30 10:14:53 -05:00
493 lines
11 KiB
TypeScript
493 lines
11 KiB
TypeScript
import React, { useState, useRef, type CSSProperties } from 'react';
|
|
import { Trans, useTranslation } from 'react-i18next';
|
|
import { useDispatch, useSelector } from 'react-redux';
|
|
|
|
import {
|
|
closeAndDownloadBudget,
|
|
closeAndLoadBudget,
|
|
createBudget,
|
|
downloadBudget,
|
|
getUserData,
|
|
loadAllFiles,
|
|
loadBudget,
|
|
pushModal,
|
|
} from 'loot-core/client/actions';
|
|
import { isNonProductionEnvironment } from 'loot-core/src/shared/environment';
|
|
import {
|
|
type File,
|
|
type LocalFile,
|
|
type SyncableLocalFile,
|
|
type SyncedLocalFile,
|
|
} from 'loot-core/types/file';
|
|
|
|
import { useInitialMount } from '../../hooks/useInitialMount';
|
|
import { useMetadataPref } from '../../hooks/useMetadataPref';
|
|
import { AnimatedLoading } from '../../icons/AnimatedLoading';
|
|
import {
|
|
SvgCloudCheck,
|
|
SvgCloudDownload,
|
|
SvgDotsHorizontalTriple,
|
|
SvgFileDouble,
|
|
} from '../../icons/v1';
|
|
import { SvgCloudUnknown, SvgKey, SvgRefreshArrow } from '../../icons/v2';
|
|
import { useResponsive } from '../../ResponsiveProvider';
|
|
import { styles, theme } from '../../style';
|
|
import { tokens } from '../../tokens';
|
|
import { Button } from '../common/Button2';
|
|
import { Menu } from '../common/Menu';
|
|
import { Popover } from '../common/Popover';
|
|
import { Text } from '../common/Text';
|
|
import { View } from '../common/View';
|
|
|
|
function getFileDescription(file: File, t: (key: string) => string) {
|
|
if (file.state === 'unknown') {
|
|
return t(
|
|
'This is a cloud-based file but its state is unknown because you ' +
|
|
'are offline.',
|
|
);
|
|
}
|
|
|
|
if (file.encryptKeyId) {
|
|
if (file.hasKey) {
|
|
return t('This file is encrypted and you have key to access it.');
|
|
}
|
|
return t('This file is encrypted and you do not have the key for it.');
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function FileMenu({
|
|
onDelete,
|
|
onClose,
|
|
}: {
|
|
onDelete: () => void;
|
|
onClose: () => void;
|
|
}) {
|
|
function onMenuSelect(type: string) {
|
|
onClose();
|
|
|
|
switch (type) {
|
|
case 'delete':
|
|
onDelete();
|
|
break;
|
|
default:
|
|
}
|
|
}
|
|
|
|
const { t } = useTranslation();
|
|
|
|
const items = [{ name: 'delete', text: t('Delete') }];
|
|
const { isNarrowWidth } = useResponsive();
|
|
|
|
const defaultMenuItemStyle = isNarrowWidth
|
|
? {
|
|
...styles.mobileMenuItem,
|
|
color: theme.menuItemText,
|
|
borderRadius: 0,
|
|
borderTop: `1px solid ${theme.pillBorder}`,
|
|
}
|
|
: {};
|
|
|
|
return (
|
|
<Menu
|
|
getItemStyle={() => defaultMenuItemStyle}
|
|
onMenuSelect={onMenuSelect}
|
|
items={items}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function FileMenuButton({ onDelete }: { onDelete: () => void }) {
|
|
const triggerRef = useRef(null);
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
|
|
return (
|
|
<View>
|
|
<Button
|
|
ref={triggerRef}
|
|
variant="bare"
|
|
aria-label="Menu"
|
|
onPress={() => {
|
|
setMenuOpen(true);
|
|
}}
|
|
>
|
|
<SvgDotsHorizontalTriple style={{ width: 16, height: 16 }} />
|
|
</Button>
|
|
|
|
<Popover
|
|
triggerRef={triggerRef}
|
|
isOpen={menuOpen}
|
|
onOpenChange={() => setMenuOpen(false)}
|
|
>
|
|
<FileMenu onDelete={onDelete} onClose={() => setMenuOpen(false)} />
|
|
</Popover>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function FileState({ file }: { file: File }) {
|
|
const { t } = useTranslation();
|
|
|
|
let Icon;
|
|
let status;
|
|
let color;
|
|
|
|
switch (file.state) {
|
|
case 'unknown':
|
|
Icon = SvgCloudUnknown;
|
|
status = t('Network unavailable');
|
|
color = theme.buttonNormalDisabledText;
|
|
break;
|
|
case 'remote':
|
|
Icon = SvgCloudDownload;
|
|
status = t('Available for download');
|
|
break;
|
|
case 'local':
|
|
case 'broken':
|
|
Icon = SvgFileDouble;
|
|
status = t('Local');
|
|
break;
|
|
default:
|
|
Icon = SvgCloudCheck;
|
|
status = t('Syncing');
|
|
break;
|
|
}
|
|
|
|
return (
|
|
<View
|
|
style={{
|
|
color,
|
|
alignItems: 'center',
|
|
flexDirection: 'row',
|
|
marginTop: 8,
|
|
}}
|
|
>
|
|
<Icon
|
|
style={{
|
|
width: 18,
|
|
height: 18,
|
|
color: 'currentColor',
|
|
}}
|
|
/>
|
|
|
|
<Text style={{ marginLeft: 5 }}>{status}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function FileItem({
|
|
file,
|
|
quickSwitchMode,
|
|
onSelect,
|
|
onDelete,
|
|
}: {
|
|
file: File;
|
|
quickSwitchMode: boolean;
|
|
onSelect: (file: File) => void;
|
|
onDelete: (file: File) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
|
|
const selecting = useRef(false);
|
|
|
|
async function _onSelect(file: File) {
|
|
// Never allow selecting the file while uploading/downloading, and
|
|
// make sure to never allow duplicate clicks
|
|
if (!selecting.current) {
|
|
selecting.current = true;
|
|
await onSelect(file);
|
|
selecting.current = false;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<View
|
|
onClick={() => _onSelect(file)}
|
|
title={getFileDescription(file, t) || ''}
|
|
style={{
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
...styles.shadow,
|
|
margin: 10,
|
|
padding: '12px 15px',
|
|
backgroundColor: theme.buttonNormalBackground,
|
|
borderRadius: 6,
|
|
flexShrink: 0,
|
|
cursor: 'pointer',
|
|
':hover': {
|
|
backgroundColor: theme.menuItemBackgroundHover,
|
|
},
|
|
}}
|
|
>
|
|
<View style={{ alignItems: 'flex-start' }}>
|
|
<Text style={{ fontSize: 16, fontWeight: 700 }}>{file.name}</Text>
|
|
|
|
<FileState file={file} />
|
|
</View>
|
|
|
|
<View
|
|
style={{ flex: '0 0 auto', flexDirection: 'row', alignItems: 'center' }}
|
|
>
|
|
{file.encryptKeyId && (
|
|
<SvgKey
|
|
style={{
|
|
width: 13,
|
|
height: 13,
|
|
marginRight: 8,
|
|
color: file.hasKey
|
|
? theme.formLabelText
|
|
: theme.buttonNormalDisabledText,
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{!quickSwitchMode && <FileMenuButton onDelete={() => onDelete(file)} />}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function BudgetFiles({
|
|
files,
|
|
quickSwitchMode,
|
|
onSelect,
|
|
onDelete,
|
|
}: {
|
|
files: File[];
|
|
quickSwitchMode: boolean;
|
|
onSelect: (file: File) => void;
|
|
onDelete: (file: File) => void;
|
|
}) {
|
|
function isLocalFile(file: File): file is LocalFile {
|
|
return file.state === 'local';
|
|
}
|
|
|
|
return (
|
|
<View
|
|
style={{
|
|
flexGrow: 1,
|
|
[`@media (min-width: ${tokens.breakpoint_small})`]: {
|
|
flexGrow: 0,
|
|
maxHeight: 310,
|
|
},
|
|
overflow: 'auto',
|
|
'& *': { userSelect: 'none' },
|
|
}}
|
|
>
|
|
{!files || files.length === 0 ? (
|
|
<Text
|
|
style={{
|
|
...styles.mediumText,
|
|
textAlign: 'center',
|
|
color: theme.pageTextSubdued,
|
|
}}
|
|
>
|
|
<Trans>No budget files</Trans>
|
|
</Text>
|
|
) : (
|
|
files.map(file => (
|
|
<FileItem
|
|
key={isLocalFile(file) ? file.id : file.cloudFileId}
|
|
file={file}
|
|
quickSwitchMode={quickSwitchMode}
|
|
onSelect={onSelect}
|
|
onDelete={onDelete}
|
|
/>
|
|
))
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function RefreshButton({
|
|
style,
|
|
onRefresh,
|
|
}: {
|
|
style?: CSSProperties;
|
|
onRefresh: () => void;
|
|
}) {
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
async function _onRefresh() {
|
|
setLoading(true);
|
|
await onRefresh();
|
|
setLoading(false);
|
|
}
|
|
|
|
const Icon = loading ? AnimatedLoading : SvgRefreshArrow;
|
|
|
|
return (
|
|
<Button
|
|
variant="bare"
|
|
aria-label="Refresh"
|
|
style={{ padding: 10, ...style }}
|
|
onPress={_onRefresh}
|
|
>
|
|
<Icon style={{ width: 18, height: 18 }} />
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
function BudgetListHeader({
|
|
quickSwitchMode,
|
|
onRefresh,
|
|
}: {
|
|
quickSwitchMode: boolean;
|
|
onRefresh: () => void;
|
|
}) {
|
|
return (
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
margin: 20,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
...styles.veryLargeText,
|
|
}}
|
|
>
|
|
<Trans>Files</Trans>
|
|
</Text>
|
|
{!quickSwitchMode && <RefreshButton onRefresh={onRefresh} />}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export function BudgetList({ showHeader = true, quickSwitchMode = false }) {
|
|
const dispatch = useDispatch();
|
|
const allFiles = useSelector(state => state.budgets.allFiles || []);
|
|
const [id] = useMetadataPref('id');
|
|
|
|
// Remote files do not have the 'id' field
|
|
function isNonRemoteFile(
|
|
file: File,
|
|
): file is LocalFile | SyncableLocalFile | SyncedLocalFile {
|
|
return file.state !== 'remote';
|
|
}
|
|
const nonRemoteFiles = allFiles.filter(isNonRemoteFile);
|
|
const files = id ? nonRemoteFiles.filter(f => f.id !== id) : allFiles;
|
|
|
|
const [creating, setCreating] = useState(false);
|
|
const { isNarrowWidth } = useResponsive();
|
|
const narrowButtonStyle = isNarrowWidth
|
|
? {
|
|
height: styles.mobileMinHeight,
|
|
}
|
|
: {};
|
|
|
|
const onCreate = ({ testMode = false } = {}) => {
|
|
if (!creating) {
|
|
setCreating(true);
|
|
dispatch(createBudget({ testMode }));
|
|
}
|
|
};
|
|
|
|
const refresh = () => {
|
|
dispatch(getUserData());
|
|
dispatch(loadAllFiles());
|
|
};
|
|
|
|
const initialMount = useInitialMount();
|
|
if (initialMount && quickSwitchMode) {
|
|
refresh();
|
|
}
|
|
|
|
const onSelect = async (file: File): Promise<void> => {
|
|
const isRemoteFile = file.state === 'remote';
|
|
|
|
if (!id) {
|
|
if (isRemoteFile) {
|
|
await dispatch(downloadBudget(file.cloudFileId));
|
|
} else {
|
|
await dispatch(loadBudget(file.id));
|
|
}
|
|
} else if (!isRemoteFile && file.id !== id) {
|
|
await dispatch(closeAndLoadBudget(file.id));
|
|
} else if (isRemoteFile) {
|
|
await dispatch(closeAndDownloadBudget(file.cloudFileId));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
...(!quickSwitchMode && {
|
|
marginTop: 20,
|
|
width: '100vw',
|
|
}),
|
|
[`@media (min-width: ${tokens.breakpoint_small})`]: {
|
|
maxWidth: tokens.breakpoint_small,
|
|
width: '100%',
|
|
},
|
|
}}
|
|
>
|
|
{showHeader && (
|
|
<BudgetListHeader
|
|
quickSwitchMode={quickSwitchMode}
|
|
onRefresh={refresh}
|
|
/>
|
|
)}
|
|
<BudgetFiles
|
|
files={files}
|
|
quickSwitchMode={quickSwitchMode}
|
|
onSelect={onSelect}
|
|
onDelete={file => dispatch(pushModal('delete-budget', { file }))}
|
|
/>
|
|
{!quickSwitchMode && (
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
justifyContent: 'flex-end',
|
|
alignItems: 'center',
|
|
padding: 25,
|
|
}}
|
|
>
|
|
<Button
|
|
variant="bare"
|
|
style={{
|
|
...narrowButtonStyle,
|
|
marginLeft: 10,
|
|
color: theme.pageTextLight,
|
|
}}
|
|
onPress={() => {
|
|
dispatch(pushModal('import'));
|
|
}}
|
|
>
|
|
<Trans>Import file</Trans>
|
|
</Button>
|
|
|
|
<Button
|
|
variant="primary"
|
|
onPress={() => onCreate()}
|
|
style={{
|
|
...narrowButtonStyle,
|
|
marginLeft: 10,
|
|
}}
|
|
>
|
|
<Trans>Create new file</Trans>
|
|
</Button>
|
|
|
|
{isNonProductionEnvironment() && (
|
|
<Button
|
|
variant="primary"
|
|
onPress={() => onCreate({ testMode: true })}
|
|
style={{
|
|
...narrowButtonStyle,
|
|
marginLeft: 10,
|
|
}}
|
|
>
|
|
<Trans>Create test file</Trans>
|
|
</Button>
|
|
)}
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|