fix: tauri folder bugs (#4589)

This commit is contained in:
Kilu.He 2024-02-08 14:22:44 +08:00 committed by GitHub
parent 9d71464f1a
commit 60fc5bb2c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
63 changed files with 1050 additions and 671 deletions

View File

@ -19,7 +19,10 @@
}, },
"fs": { "fs": {
"all": true, "all": true,
"scope": ["$APPLOCALDATA/**", "$APPLOCALDATA/images/*"], "scope": [
"$APPLOCALDATA/**",
"$APPLOCALDATA/images/*"
],
"readFile": true, "readFile": true,
"writeFile": true, "writeFile": true,
"readDir": true, "readDir": true,

View File

@ -1,64 +1,59 @@
import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { useCallback, useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { currentUserActions } from '$app_reducers/current-user/slice'; import { currentUserActions } from '$app_reducers/current-user/slice';
import { Theme as ThemeType, ThemeMode } from '$app/stores/reducers/current-user/slice'; import { Theme as ThemeType, ThemeMode } from '$app/stores/reducers/current-user/slice';
import { createTheme } from '@mui/material/styles'; import { createTheme } from '@mui/material/styles';
import { getDesignTokens } from '$app/utils/mui'; import { getDesignTokens } from '$app/utils/mui';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ThemeModePB } from '@/services/backend';
import { UserService } from '$app/application/user/user.service'; import { UserService } from '$app/application/user/user.service';
export function useUserSetting() { export function useUserSetting() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const {
const handleSystemThemeChange = useCallback(() => { themeMode = ThemeMode.System,
const mode = window.matchMedia('(prefers-color-scheme: dark)').matches ? ThemeMode.Dark : ThemeMode.Light; isDark = false,
theme: themeType = ThemeType.Default,
dispatch(currentUserActions.setUserSetting({ themeMode: mode })); } = useAppSelector((state) => {
}, [dispatch]);
const loadUserSetting = useCallback(async () => {
const settings = await UserService.getAppearanceSetting();
if (!settings) return;
dispatch(currentUserActions.setUserSetting(settings));
if (settings.themeMode === ThemeModePB.System) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
handleSystemThemeChange();
mediaQuery.addEventListener('change', handleSystemThemeChange);
}
await i18n.changeLanguage(settings.language);
}, [dispatch, handleSystemThemeChange, i18n]);
useEffect(() => {
void loadUserSetting();
}, [loadUserSetting]);
const { themeMode = ThemeMode.Light, theme: themeType = ThemeType.Default } = useAppSelector((state) => {
return state.currentUser.userSetting || {}; return state.currentUser.userSetting || {};
}); });
useEffect(() => { useEffect(() => {
const html = document.documentElement; void (async () => {
const settings = await UserService.getAppearanceSetting();
html?.setAttribute('data-dark-mode', String(themeMode === ThemeMode.Dark)); if (!settings) return;
html?.setAttribute('data-theme', themeType); dispatch(currentUserActions.setUserSetting(settings));
}, [themeType, themeMode]); await i18n.changeLanguage(settings.language);
})();
}, [dispatch, i18n]);
useEffect(() => { useEffect(() => {
return () => { const html = document.documentElement;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
html?.setAttribute('data-dark-mode', String(isDark));
}, [isDark]);
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemThemeChange = () => {
if (themeMode !== ThemeMode.System) return;
dispatch(
currentUserActions.setUserSetting({
isDark: mediaQuery.matches,
})
);
};
mediaQuery.addEventListener('change', handleSystemThemeChange);
return () => {
mediaQuery.removeEventListener('change', handleSystemThemeChange); mediaQuery.removeEventListener('change', handleSystemThemeChange);
}; };
}, [dispatch, handleSystemThemeChange]); }, [dispatch, themeMode]);
const muiTheme = useMemo(() => createTheme(getDesignTokens(themeMode)), [themeMode]); const muiTheme = useMemo(() => createTheme(getDesignTokens(isDark)), [isDark]);
return { return {
muiTheme, muiTheme,

View File

@ -138,6 +138,7 @@ export interface MentionPage {
id: string; id: string;
name: string; name: string;
layout: ViewLayoutPB; layout: ViewLayoutPB;
parentId: string;
icon?: { icon?: {
ty: ViewIconTypePB; ty: ViewIconTypePB;
value: string; value: string;

View File

@ -49,8 +49,7 @@ export const createOrphanPage = async (
return Promise.reject(result.val); return Promise.reject(result.val);
}; };
export const duplicatePage = async (id: string) => { export const duplicatePage = async (page: Page) => {
const page = await getPage(id);
const payload = ViewPB.fromObject(page); const payload = ViewPB.fromObject(page);
const result = await FolderEventDuplicateView(payload); const result = await FolderEventDuplicateView(payload);

View File

@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 8C5 8.55228 4.55228 9 4 9C3.44772 9 3 8.55228 3 8C3 7.44772 3.44772 7 4 7C4.55228 7 5 7.44772 5 8Z" fill="#78797D"/> <path d="M5 8C5 8.55228 4.55228 9 4 9C3.44772 9 3 8.55228 3 8C3 7.44772 3.44772 7 4 7C4.55228 7 5 7.44772 5 8Z" fill="currentColor"/>
<path d="M9 8C9 8.55228 8.55229 9 8 9C7.44772 9 7 8.55228 7 8C7 7.44772 7.44772 7 8 7C8.55229 7 9 7.44772 9 8Z" fill="#78797D"/> <path d="M9 8C9 8.55228 8.55229 9 8 9C7.44772 9 7 8.55228 7 8C7 7.44772 7.44772 7 8 7C8.55229 7 9 7.44772 9 8Z" fill="currentColor"/>
<path d="M12 9C12.5523 9 13 8.55228 13 8C13 7.44772 12.5523 7 12 7C11.4477 7 11 7.44772 11 8C11 8.55228 11.4477 9 12 9Z" fill="#78797D"/> <path d="M12 9C12.5523 9 13 8.55228 13 8C13 7.44772 12.5523 7 12 7C11.4477 7 11 7.44772 11 8C11 8.55228 11.4477 9 12 9Z" fill="currentColor"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 499 B

After

Width:  |  Height:  |  Size: 514 B

View File

@ -1,69 +0,0 @@
import React, { useCallback, useState } from 'react';
import { List, MenuItem, Popover, Portal, Theme } from '@mui/material';
import { PopoverOrigin } from '@mui/material/Popover/Popover';
import { SxProps } from '@mui/system';
interface ButtonPopoverListProps {
isVisible: boolean;
children: React.ReactNode;
popoverOptions: {
key: React.Key;
icon: React.ReactNode;
label: React.ReactNode | string;
onClick: () => void;
}[];
popoverOrigin: {
anchorOrigin: PopoverOrigin;
transformOrigin: PopoverOrigin;
};
onClose?: () => void;
sx?: SxProps<Theme>;
}
function ButtonPopoverList({ popoverOrigin, isVisible, children, popoverOptions, onClose, sx }: ButtonPopoverListProps) {
const [anchorEl, setAnchorEl] = useState<HTMLDivElement>();
const open = Boolean(anchorEl);
const visible = isVisible || open;
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = useCallback(() => {
setAnchorEl(undefined);
}, []);
return (
<>
{visible && <div onClick={handleClick}>{children}</div>}
<Portal>
<Popover
open={open}
{...popoverOrigin}
anchorEl={anchorEl}
onClose={() => {
handleClose();
onClose?.();
}}
>
<List sx={{ ...sx }}>
{popoverOptions.map((option) => (
<MenuItem
key={option.key}
onClick={() => {
option.onClick();
handleClose();
}}
className={'flex items-center gap-1 rounded-none px-2 text-xs font-medium'}
>
<span className={'text-base'}>{option.icon}</span>
<span>{option.label}</span>
</MenuItem>
))}
</List>
</Popover>
</Portal>
</>
);
}
export default ButtonPopoverList;

View File

@ -1,8 +1,9 @@
import React from 'react'; import React, { useCallback } from 'react';
import DialogContent from '@mui/material/DialogContent'; import DialogContent from '@mui/material/DialogContent';
import { Button, DialogActions, Divider } from '@mui/material'; import { Button, DialogActions, Divider } from '@mui/material';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Log } from '$app/utils/log';
interface Props { interface Props {
open: boolean; open: boolean;
@ -15,9 +16,36 @@ interface Props {
function DeleteConfirmDialog({ open, title, subtitle, onOk, onClose }: Props) { function DeleteConfirmDialog({ open, title, subtitle, onOk, onClose }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const onDone = useCallback(async () => {
try {
await onOk();
onClose();
} catch (e) {
Log.error(e);
}
}, [onClose, onOk]);
return ( return (
<Dialog keepMounted={false} onMouseDown={(e) => e.stopPropagation()} open={open} onClose={onClose}> <Dialog
<DialogContent className={'flex w-[540px] flex-col items-center justify-center'}> keepMounted={false}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onClose();
}
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
void onDone();
}
}}
onMouseDown={(e) => e.stopPropagation()}
open={open}
onClose={onClose}
>
<DialogContent className={'flex w-[340px] flex-col items-center justify-center gap-4'}>
<div className={'text-md font-medium'}>{title}</div> <div className={'text-md font-medium'}>{title}</div>
{subtitle && <div className={'m-1 text-sm text-text-caption'}>{subtitle}</div>} {subtitle && <div className={'m-1 text-sm text-text-caption'}>{subtitle}</div>}
</DialogContent> </DialogContent>
@ -26,15 +54,7 @@ function DeleteConfirmDialog({ open, title, subtitle, onOk, onClose }: Props) {
<Button variant={'outlined'} onClick={onClose}> <Button variant={'outlined'} onClick={onClose}>
{t('button.cancel')} {t('button.cancel')}
</Button> </Button>
<Button <Button variant={'contained'} onClick={onDone}>
variant={'contained'}
onClick={async () => {
try {
await onOk();
onClose();
} catch (e) {}
}}
>
{t('button.delete')} {t('button.delete')}
</Button> </Button>
</DialogActions> </DialogActions>

View File

@ -1,10 +1,10 @@
import React, { useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import DialogTitle from '@mui/material/DialogTitle'; import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent'; import DialogContent from '@mui/material/DialogContent';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import { Button, DialogActions } from '@mui/material'; import { Button, DialogActions, Divider } from '@mui/material';
function RenameDialog({ function RenameDialog({
defaultValue, defaultValue,
@ -25,20 +25,31 @@ function RenameDialog({
setValue(defaultValue); setValue(defaultValue);
setError(false); setError(false);
}, [defaultValue]); }, [defaultValue]);
const onDone = useCallback(async () => {
try {
await onOk(value);
onClose();
} catch (e) {
setError(true);
}
}, [onClose, onOk, value]);
return ( return (
<Dialog keepMounted={false} onMouseDown={(e) => e.stopPropagation()} open={open} onClose={onClose}> <Dialog keepMounted={false} onMouseDown={(e) => e.stopPropagation()} open={open} onClose={onClose}>
<DialogTitle>{t('menuAppHeader.renameDialog')}</DialogTitle> <DialogTitle className={'pb-2'}>{t('menuAppHeader.renameDialog')}</DialogTitle>
<DialogContent className={'flex w-[540px]'}> <DialogContent className={'flex'}>
<TextField <TextField
error={error} error={error}
autoFocus autoFocus
spellCheck={false} spellCheck={false}
value={value} value={value}
placeholder={t('dialogCreatePageNameHint')}
onKeyDown={(e) => { onKeyDown={(e) => {
e.stopPropagation(); e.stopPropagation();
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
void onOk(value); void onDone();
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
@ -54,18 +65,12 @@ function RenameDialog({
variant='standard' variant='standard'
/> />
</DialogContent> </DialogContent>
<Divider className={'mb-1'} />
<DialogActions> <DialogActions className={'mb-1 px-4'}>
<Button onClick={onClose}>{t('button.cancel')}</Button> <Button variant={'outlined'} onClick={onClose}>
<Button {t('button.cancel')}
onClick={async () => { </Button>
try { <Button variant={'contained'} onClick={onDone}>
await onOk(value);
} catch (e) {
setError(true);
}
}}
>
{t('button.ok')} {t('button.ok')}
</Button> </Button>
</DialogActions> </DialogActions>

View File

@ -24,8 +24,12 @@ export function useDrag(props: Props) {
setIsDraggingOver(false); setIsDraggingOver(false);
setIsDragging(false); setIsDragging(false);
setDropPosition(undefined); setDropPosition(undefined);
const currentTarget = e.currentTarget;
if (currentTarget.parentElement?.closest(`[data-drop-enabled="false"]`)) return;
if (currentTarget.closest(`[data-dragging="true"]`)) return;
const dragId = e.dataTransfer.getData('dragId'); const dragId = e.dataTransfer.getData('dragId');
const targetRect = e.currentTarget.getBoundingClientRect(); const targetRect = currentTarget.getBoundingClientRect();
const { clientY } = e; const { clientY } = e;
const position = calcPosition(targetRect, clientY); const position = calcPosition(targetRect, clientY);
@ -37,8 +41,12 @@ export function useDrag(props: Props) {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
if (isDragging) return; if (isDragging) return;
const currentTarget = e.currentTarget;
if (currentTarget.parentElement?.closest(`[data-drop-enabled="false"]`)) return;
if (currentTarget.closest(`[data-dragging="true"]`)) return;
setIsDraggingOver(true); setIsDraggingOver(true);
const targetRect = e.currentTarget.getBoundingClientRect(); const targetRect = currentTarget.getBoundingClientRect();
const { clientY } = e; const { clientY } = e;
const position = calcPosition(targetRect, clientY); const position = calcPosition(targetRect, clientY);

View File

@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MenuItem, Typography } from '@mui/material'; import { MenuItem, Typography } from '@mui/material';
import { scrollIntoView } from '$app/components/_shared/keyboard_navigation/utils'; import { scrollIntoView } from '$app/components/_shared/keyboard_navigation/utils';
import { ReactEditor, useSlateStatic } from 'slate-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
/** /**
@ -35,7 +34,7 @@ export interface KeyboardNavigationOption<T = string> {
* - onBlur: called when the keyboard navigation is blurred * - onBlur: called when the keyboard navigation is blurred
*/ */
export interface KeyboardNavigationProps<T> { export interface KeyboardNavigationProps<T> {
scrollRef: React.RefObject<HTMLDivElement>; scrollRef?: React.RefObject<HTMLDivElement>;
focusRef?: React.RefObject<HTMLElement>; focusRef?: React.RefObject<HTMLElement>;
options: KeyboardNavigationOption<T>[]; options: KeyboardNavigationOption<T>[];
onSelected?: (optionKey: T) => void; onSelected?: (optionKey: T) => void;
@ -68,7 +67,6 @@ function KeyboardNavigation<T>({
onFocus, onFocus,
}: KeyboardNavigationProps<T>) { }: KeyboardNavigationProps<T>) {
const { t } = useTranslation(); const { t } = useTranslation();
const editor = useSlateStatic();
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const mouseY = useRef<number | null>(null); const mouseY = useRef<number | null>(null);
const defaultKeyRef = useRef<T | undefined>(defaultFocusedKey); const defaultKeyRef = useRef<T | undefined>(defaultFocusedKey);
@ -108,7 +106,7 @@ function KeyboardNavigation<T>({
if (focusedKey === undefined) return; if (focusedKey === undefined) return;
onSelected?.(focusedKey); onSelected?.(focusedKey);
const scrollElement = scrollRef.current; const scrollElement = scrollRef?.current;
if (!scrollElement) return; if (!scrollElement) return;
@ -262,15 +260,15 @@ function KeyboardNavigation<T>({
let element: HTMLElement | null | undefined = focusRef?.current; let element: HTMLElement | null | undefined = focusRef?.current;
if (!element) { if (!element) {
element = ReactEditor.toDOMNode(editor, editor); element = document.activeElement as HTMLElement;
} }
element.addEventListener('keydown', onKeyDown); element?.addEventListener('keydown', onKeyDown);
return () => { return () => {
element?.removeEventListener('keydown', onKeyDown); element?.removeEventListener('keydown', onKeyDown);
}; };
} }
}, [disableFocus, editor, onKeyDown, focusRef]); }, [disableFocus, onKeyDown, focusRef]);
return ( return (
<div <div

View File

@ -13,6 +13,8 @@ import { useTranslation } from 'react-i18next';
import { ErrorCode, FolderNotification } from '@/services/backend'; import { ErrorCode, FolderNotification } from '@/services/backend';
import ExpandRecordModal from '$app/components/database/components/edit_record/ExpandRecordModal'; import ExpandRecordModal from '$app/components/database/components/edit_record/ExpandRecordModal';
import { subscribeNotifications } from '$app/application/notification'; import { subscribeNotifications } from '$app/application/notification';
import { Page } from '$app_reducers/pages/slice';
import { getPage } from '$app/application/folder/page.service';
interface Props { interface Props {
selectedViewId?: string; selectedViewId?: string;
@ -22,11 +24,12 @@ interface Props {
export const Database = forwardRef<HTMLDivElement, Props>(({ selectedViewId, setSelectedViewId }, ref) => { export const Database = forwardRef<HTMLDivElement, Props>(({ selectedViewId, setSelectedViewId }, ref) => {
const innerRef = useRef<HTMLDivElement>(); const innerRef = useRef<HTMLDivElement>();
const databaseRef = (ref ?? innerRef) as React.MutableRefObject<HTMLDivElement>; const databaseRef = (ref ?? innerRef) as React.MutableRefObject<HTMLDivElement>;
const viewId = useViewId(); const viewId = useViewId();
const [page, setPage] = useState<Page | null>(null);
const { t } = useTranslation(); const { t } = useTranslation();
const [notFound, setNotFound] = useState(false); const [notFound, setNotFound] = useState(false);
const [childViewIds, setChildViewIds] = useState<string[]>([]); const [childViews, setChildViews] = useState<Page[]>([]);
const [editRecordRowId, setEditRecordRowId] = useState<string | null>(null); const [editRecordRowId, setEditRecordRowId] = useState<string | null>(null);
const [openCollections, setOpenCollections] = useState<string[]>([]); const [openCollections, setOpenCollections] = useState<string[]>([]);
@ -34,7 +37,7 @@ export const Database = forwardRef<HTMLDivElement, Props>(({ selectedViewId, set
await databaseViewService await databaseViewService
.getDatabaseViews(viewId) .getDatabaseViews(viewId)
.then((value) => { .then((value) => {
setChildViewIds(value.map((view) => view.id)); setChildViews(value);
}) })
.catch((err) => { .catch((err) => {
if (err.code === ErrorCode.RecordNotFound) { if (err.code === ErrorCode.RecordNotFound) {
@ -43,10 +46,39 @@ export const Database = forwardRef<HTMLDivElement, Props>(({ selectedViewId, set
}); });
}, []); }, []);
const handleGetPage = useCallback(async () => {
try {
const page = await getPage(viewId);
setPage(page);
} catch (e) {
setNotFound(true);
}
}, [viewId]);
useEffect(() => { useEffect(() => {
void handleGetPage();
void handleResetDatabaseViews(viewId); void handleResetDatabaseViews(viewId);
const unsubscribePromise = subscribeNotifications( const unsubscribePromise = subscribeNotifications(
{ {
[FolderNotification.DidUpdateView]: (changeset) => {
setChildViews((prev) => {
const index = prev.findIndex((view) => view.id === changeset.id);
if (index === -1) {
return prev;
}
const newViews = [...prev];
newViews[index] = {
...newViews[index],
name: changeset.name,
};
return newViews;
});
},
[FolderNotification.DidUpdateChildViews]: (changeset) => { [FolderNotification.DidUpdateChildViews]: (changeset) => {
if (changeset.create_child_views.length === 0 && changeset.delete_child_views.length === 0) { if (changeset.create_child_views.length === 0 && changeset.delete_child_views.length === 0) {
return; return;
@ -61,11 +93,35 @@ export const Database = forwardRef<HTMLDivElement, Props>(({ selectedViewId, set
); );
return () => void unsubscribePromise.then((unsubscribe) => unsubscribe()); return () => void unsubscribePromise.then((unsubscribe) => unsubscribe());
}, [handleResetDatabaseViews, viewId]); }, [handleGetPage, handleResetDatabaseViews, viewId]);
useEffect(() => {
const parentId = page?.parentId;
if (!parentId) return;
const unsubscribePromise = subscribeNotifications(
{
[FolderNotification.DidUpdateChildViews]: (changeset) => {
if (changeset.delete_child_views.includes(viewId)) {
setNotFound(true);
}
},
},
{
id: parentId,
}
);
return () => void unsubscribePromise.then((unsubscribe) => unsubscribe());
}, [page, viewId]);
const value = useMemo(() => { const value = useMemo(() => {
return Math.max(0, childViewIds.indexOf(selectedViewId ?? viewId)); return Math.max(
}, [childViewIds, selectedViewId, viewId]); 0,
childViews.findIndex((view) => view.id === (selectedViewId ?? viewId))
);
}, [childViews, selectedViewId, viewId]);
const onToggleCollection = useCallback( const onToggleCollection = useCallback(
(id: string, forceOpen?: boolean) => { (id: string, forceOpen?: boolean) => {
@ -110,7 +166,7 @@ export const Database = forwardRef<HTMLDivElement, Props>(({ selectedViewId, set
pageId={viewId} pageId={viewId}
setSelectedViewId={setSelectedViewId} setSelectedViewId={setSelectedViewId}
selectedViewId={selectedViewId} selectedViewId={selectedViewId}
childViewIds={childViewIds} childViews={childViews}
/> />
<SwipeableViews <SwipeableViews
slideStyle={{ slideStyle={{
@ -120,19 +176,19 @@ export const Database = forwardRef<HTMLDivElement, Props>(({ selectedViewId, set
axis={'x'} axis={'x'}
index={value} index={value}
> >
{childViewIds.map((id, index) => ( {childViews.map((view, index) => (
<TabPanel className={'flex h-full w-full flex-col'} key={id} index={index} value={value}> <TabPanel className={'flex h-full w-full flex-col'} key={view.id} index={index} value={value}>
<DatabaseLoader viewId={id}> <DatabaseLoader viewId={view.id}>
{selectedViewId === id && ( {selectedViewId === view.id && (
<> <>
<Portal container={databaseRef.current}> <Portal container={databaseRef.current}>
<div className={'absolute right-16 top-0 py-1'}> <div className={'absolute right-16 top-0 py-1'}>
<DatabaseSettings <DatabaseSettings
onToggleCollection={(forceOpen?: boolean) => onToggleCollection(id, forceOpen)} onToggleCollection={(forceOpen?: boolean) => onToggleCollection(view.id, forceOpen)}
/> />
</div> </div>
</Portal> </Portal>
<DatabaseCollection open={openCollections.includes(id)} /> <DatabaseCollection open={openCollections.includes(view.id)} />
{editRecordRowId && ( {editRecordRowId && (
<ExpandRecordModal <ExpandRecordModal
rowId={editRecordRowId} rowId={editRecordRowId}

View File

@ -10,7 +10,7 @@ import { useViewId } from '$app/hooks';
import { fieldService } from '$app/application/database'; import { fieldService } from '$app/application/database';
import { OrderObjectPositionTypePB, FieldVisibility } from '@/services/backend'; import { OrderObjectPositionTypePB, FieldVisibility } from '@/services/backend';
import { MenuItem } from '@mui/material'; import { MenuItem } from '@mui/material';
import DeleteConfirmDialog from '$app/components/_shared/delete_confirm_dialog/DeleteConfirmDialog'; import DeleteConfirmDialog from '$app/components/_shared/confirm_dialog/DeleteConfirmDialog';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
export enum FieldAction { export enum FieldAction {

View File

@ -1,6 +1,5 @@
import { FC, FunctionComponent, SVGProps, useEffect, useState } from 'react'; import { FC, FunctionComponent, SVGProps, useEffect, useMemo, useState } from 'react';
import { ViewTabs, ViewTab } from './ViewTabs'; import { ViewTabs, ViewTab } from './ViewTabs';
import { useAppSelector } from '$app/stores/store';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import AddViewBtn from '$app/components/database/components/tab_bar/AddViewBtn'; import AddViewBtn from '$app/components/database/components/tab_bar/AddViewBtn';
import { ViewLayoutPB } from '@/services/backend'; import { ViewLayoutPB } from '@/services/backend';
@ -11,7 +10,7 @@ import ViewActions from '$app/components/database/components/tab_bar/ViewActions
import { Page } from '$app_reducers/pages/slice'; import { Page } from '$app_reducers/pages/slice';
export interface DatabaseTabBarProps { export interface DatabaseTabBarProps {
childViewIds: string[]; childViews: Page[];
selectedViewId?: string; selectedViewId?: string;
setSelectedViewId?: (viewId: string) => void; setSelectedViewId?: (viewId: string) => void;
pageId: string; pageId: string;
@ -26,26 +25,21 @@ const DatabaseIcons: {
[ViewLayoutPB.Calendar]: GridSvg, [ViewLayoutPB.Calendar]: GridSvg,
}; };
export const DatabaseTabBar: FC<DatabaseTabBarProps> = ({ pageId, childViewIds, selectedViewId, setSelectedViewId }) => { export const DatabaseTabBar: FC<DatabaseTabBarProps> = ({ pageId, childViews, selectedViewId, setSelectedViewId }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [contextMenuAnchorEl, setContextMenuAnchorEl] = useState<HTMLElement | null>(null); const [contextMenuAnchorEl, setContextMenuAnchorEl] = useState<HTMLElement | null>(null);
const [contextMenuView, setContextMenuView] = useState<Page | null>(null); const [contextMenuView, setContextMenuView] = useState<Page | null>(null);
const open = Boolean(contextMenuAnchorEl); const open = Boolean(contextMenuAnchorEl);
const views = useAppSelector((state) => {
const map = state.pages.pageMap;
return childViewIds.map((id) => map[id]).filter(Boolean);
});
const handleChange = (_: React.SyntheticEvent, newValue: string) => { const handleChange = (_: React.SyntheticEvent, newValue: string) => {
setSelectedViewId?.(newValue); setSelectedViewId?.(newValue);
}; };
useEffect(() => { useEffect(() => {
if (selectedViewId === undefined && views.length > 0) { if (selectedViewId === undefined && childViews.length > 0) {
setSelectedViewId?.(views[0].id); setSelectedViewId?.(childViews[0].id);
} }
}, [selectedViewId, setSelectedViewId, views]); }, [selectedViewId, setSelectedViewId, childViews]);
const openMenu = (view: Page) => { const openMenu = (view: Page) => {
return (e: React.MouseEvent<HTMLElement>) => { return (e: React.MouseEvent<HTMLElement>) => {
@ -56,16 +50,19 @@ export const DatabaseTabBar: FC<DatabaseTabBarProps> = ({ pageId, childViewIds,
}; };
}; };
const isSelected = useMemo(() => childViews.some((view) => view.id === selectedViewId), [childViews, selectedViewId]);
if (childViews.length === 0) return null;
return ( return (
<div className='-mb-px flex items-center px-16'> <div className='-mb-px flex items-center px-16'>
<div className='flex flex-1 items-center border-b border-line-divider'> <div className='flex flex-1 items-center border-b border-line-divider'>
<ViewTabs value={selectedViewId} onChange={handleChange}> <ViewTabs value={isSelected ? selectedViewId : childViews[0].id} onChange={handleChange}>
{views.map((view) => { {childViews.map((view) => {
const Icon = DatabaseIcons[view.layout]; const Icon = DatabaseIcons[view.layout];
return ( return (
<ViewTab <ViewTab
onContextMenu={openMenu(view)} onContextMenuCapture={openMenu(view)}
onDoubleClick={openMenu(view)} onDoubleClick={openMenu(view)}
key={view.id} key={view.id}
icon={<Icon />} icon={<Icon />}
@ -81,6 +78,7 @@ export const DatabaseTabBar: FC<DatabaseTabBarProps> = ({ pageId, childViewIds,
</div> </div>
{open && contextMenuView && ( {open && contextMenuView && (
<ViewActions <ViewActions
pageId={pageId}
view={contextMenuView} view={contextMenuView}
keepMounted={false} keepMounted={false}
open={open} open={open}

View File

@ -4,7 +4,7 @@ import { ReactComponent as DeleteSvg } from '$app/assets/delete.svg';
import { ReactComponent as EditSvg } from '$app/assets/edit.svg'; import { ReactComponent as EditSvg } from '$app/assets/edit.svg';
import { deleteView } from '$app/application/database/database_view/database_view_service'; import { deleteView } from '$app/application/database/database_view/database_view_service';
import { MenuItem, MenuProps, Menu } from '@mui/material'; import { MenuItem, MenuProps, Menu } from '@mui/material';
import RenameDialog from '$app/components/layout/nested_page/RenameDialog'; import RenameDialog from '$app/components/_shared/confirm_dialog/RenameDialog';
import { Page } from '$app_reducers/pages/slice'; import { Page } from '$app_reducers/pages/slice';
import { useAppDispatch } from '$app/stores/store'; import { useAppDispatch } from '$app/stores/store';
import { updatePageName } from '$app_reducers/pages/async_actions'; import { updatePageName } from '$app_reducers/pages/async_actions';
@ -14,7 +14,7 @@ enum ViewAction {
Delete, Delete,
} }
function ViewActions({ view, ...props }: { view: Page } & MenuProps) { function ViewActions({ view, pageId, ...props }: { pageId: string; view: Page } & MenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const viewId = view.id; const viewId = view.id;
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -31,6 +31,7 @@ function ViewActions({ view, ...props }: { view: Page } & MenuProps) {
{ {
id: ViewAction.Delete, id: ViewAction.Delete,
disabled: viewId === pageId,
label: t('button.delete'), label: t('button.delete'),
icon: <DeleteSvg />, icon: <DeleteSvg />,
action: async () => { action: async () => {
@ -48,31 +49,33 @@ function ViewActions({ view, ...props }: { view: Page } & MenuProps) {
<> <>
<Menu keepMounted={false} disableRestoreFocus={true} {...props}> <Menu keepMounted={false} disableRestoreFocus={true} {...props}>
{options.map((option) => ( {options.map((option) => (
<MenuItem key={option.id} onClick={option.action}> <MenuItem disabled={option.disabled} key={option.id} onClick={option.action}>
<div className={'mr-1.5'}>{option.icon}</div> <div className={'mr-1.5'}>{option.icon}</div>
{option.label} {option.label}
</MenuItem> </MenuItem>
))} ))}
</Menu> </Menu>
<RenameDialog {openRenameDialog && (
open={openRenameDialog} <RenameDialog
onClose={() => setOpenRenameDialog(false)} open={openRenameDialog}
onOk={async (val) => { onClose={() => setOpenRenameDialog(false)}
try { onOk={async (val) => {
await dispatch( try {
updatePageName({ await dispatch(
id: viewId, updatePageName({
name: val, id: viewId,
}) name: val,
); })
setOpenRenameDialog(false); );
props.onClose?.({}, 'backdropClick'); setOpenRenameDialog(false);
} catch (e) { props.onClose?.({}, 'backdropClick');
// toast.error(t('error.renameView')); } catch (e) {
} // toast.error(t('error.renameView'));
}} }
defaultValue={view.name} }}
/> defaultValue={view.name}
/>
)}
</> </>
); );
} }

View File

@ -19,7 +19,7 @@ export function DocumentHeader({ page }: DocumentHeaderProps) {
if (!page) return null; if (!page) return null;
return ( return (
<div className={'document-header px-16 pt-4'}> <div className={'document-header select-none px-16 pt-4'}>
<ViewTitle showTitle={false} onUpdateIcon={onUpdateIcon} view={page} /> <ViewTitle showTitle={false} onUpdateIcon={onUpdateIcon} view={page} />
</div> </div>
); );

View File

@ -1,24 +1,13 @@
import { useAppSelector } from '$app/stores/store'; import { useAppSelector } from '$app/stores/store';
import { useEffect, useState } from 'react';
import { Page } from '$app_reducers/pages/slice';
import { ViewLayoutPB } from '@/services/backend'; import { ViewLayoutPB } from '@/services/backend';
export function useLoadDatabaseList({ searchText, layout }: { searchText: string; layout: ViewLayoutPB }) { export function useLoadDatabaseList({ searchText, layout }: { searchText: string; layout: ViewLayoutPB }) {
const [list, setList] = useState<Page[]>([]); const list = useAppSelector((state) => {
const pages = useAppSelector((state) => state.pages.pageMap); return Object.values(state.pages.pageMap).filter((page) => {
if (page.layout !== layout) return false;
useEffect(() => { return page.name.toLowerCase().includes(searchText.toLowerCase());
const list = Object.values(pages) });
.map((page) => { });
return page;
})
.filter((page) => {
if (page.layout !== layout) return false;
return page.name.toLowerCase().includes(searchText.toLowerCase());
});
setList(list);
}, [layout, pages, searchText]);
return { return {
list, list,

View File

@ -21,7 +21,7 @@ export const Text = memo(
{renderIcon()} {renderIcon()}
<Placeholder isEmpty={isEmpty} node={node} /> <Placeholder isEmpty={isEmpty} node={node} />
<span className={'min-w-[4px]'}>{children}</span> <span className={'text-content min-w-[4px]'}>{children}</span>
</span> </span>
); );
}) })

View File

@ -59,7 +59,7 @@ export const InlineFormula = memo(
contentEditable={false} contentEditable={false}
onDoubleClick={handleClick} onDoubleClick={handleClick}
onClick={handleClick} onClick={handleClick}
className={`${attributes.className ?? ''} formula-inline relative rounded px-1 py-0.5 ${ className={`${attributes.className ?? ''} formula-inline relative cursor-pointer rounded px-1 py-0.5 ${
selected ? 'selected' : '' selected ? 'selected' : ''
}`} }`}
> >

View File

@ -6,17 +6,29 @@ import { useNavigate } from 'react-router-dom';
import { pageTypeMap } from '$app_reducers/pages/slice'; import { pageTypeMap } from '$app_reducers/pages/slice';
import { getPage } from '$app/application/folder/page.service'; import { getPage } from '$app/application/folder/page.service';
import { useSelected } from 'slate-react'; import { useSelected } from 'slate-react';
import { ReactComponent as EyeClose } from '$app/assets/eye_close.svg';
import { notify } from '$app/components/editor/components/tools/notify';
import { subscribeNotifications } from '$app/application/notification';
import { FolderNotification } from '@/services/backend';
export function MentionLeaf({ children, mention }: { mention: Mention; children: React.ReactNode }) { export function MentionLeaf({ children, mention }: { mention: Mention; children: React.ReactNode }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [page, setPage] = useState<MentionPage | null>(null); const [page, setPage] = useState<MentionPage | null>(null);
const [error, setError] = useState<boolean>(false);
const navigate = useNavigate(); const navigate = useNavigate();
const selected = useSelected(); const selected = useSelected();
const loadPage = useCallback(async () => { const loadPage = useCallback(async () => {
setError(true);
if (!mention.page) return; if (!mention.page) return;
const page = await getPage(mention.page); try {
const page = await getPage(mention.page);
setPage(page); setPage(page);
setError(false);
} catch {
setPage(null);
setError(true);
}
}, [mention.page]); }, [mention.page]);
useEffect(() => { useEffect(() => {
@ -24,26 +36,87 @@ export function MentionLeaf({ children, mention }: { mention: Mention; children:
}, [loadPage]); }, [loadPage]);
const openPage = useCallback(() => { const openPage = useCallback(() => {
if (!page) return; if (!page) {
notify.error(t('document.mention.deletedContent'));
return;
}
const pageType = pageTypeMap[page.layout]; const pageType = pageTypeMap[page.layout];
navigate(`/page/${pageType}/${page.id}`); navigate(`/page/${pageType}/${page.id}`);
}, [navigate, page]); }, [navigate, page, t]);
useEffect(() => {
if (!page) return;
const unsubscribePromise = subscribeNotifications(
{
[FolderNotification.DidUpdateView]: (changeset) => {
setPage((prev) => {
if (!prev) {
return prev;
}
return {
...prev,
name: changeset.name,
};
});
},
},
{
id: page.id,
}
);
return () => void unsubscribePromise.then((unsubscribe) => unsubscribe());
}, [page]);
useEffect(() => {
const parentId = page?.parentId;
if (!parentId) return;
const unsubscribePromise = subscribeNotifications(
{
[FolderNotification.DidUpdateChildViews]: (changeset) => {
if (changeset.delete_child_views.includes(page.id)) {
setPage(null);
setError(true);
}
},
},
{
id: parentId,
}
);
return () => void unsubscribePromise.then((unsubscribe) => unsubscribe());
}, [page]);
return ( return (
<span className={'relative'}> <span className={'relative'}>
{page && ( <span
<span className={'relative mx-1 inline-flex cursor-pointer items-center hover:rounded hover:bg-content-blue-100'}
className={'relative mx-1 inline-flex cursor-pointer items-center hover:rounded hover:bg-content-blue-100'} onClick={openPage}
onClick={openPage} style={{
style={{ backgroundColor: selected ? 'var(--content-blue-100)' : undefined,
backgroundColor: selected ? 'var(--content-blue-100)' : undefined, }}
}} >
> {page && (
<span className={'text-sx absolute left-0.5'}>{page.icon?.value || <DocumentSvg />}</span> <>
<span className={'ml-6 mr-0.5 underline'}>{page.name || t('document.title.placeholder')}</span> <span className={'text-sx absolute left-0.5'}>{page.icon?.value || <DocumentSvg />}</span>
</span> <span className={'ml-6 mr-0.5 underline'}>{page.name || t('document.title.placeholder')}</span>
)} </>
)}
{error && (
<>
<span className={'text-sx absolute left-0.5'}>
<EyeClose />
</span>
<span className={'ml-6 mr-0.5 text-text-caption underline'}>{t('document.mention.deleted')}</span>
</>
)}
</span>
<span className={'invisible'}>{children}</span> <span className={'invisible'}>{children}</span>
</span> </span>

View File

@ -22,9 +22,12 @@ const Toolbar = () => {
const handleOpen = useCallback(() => { const handleOpen = useCallback(() => {
if (!node || !node.blockId) return; if (!node || !node.blockId) return;
setOpenContextMenu(true); setOpenContextMenu(true);
const path = ReactEditor.findPath(editor, node);
editor.select(path);
selectedBlockContext.clear(); selectedBlockContext.clear();
selectedBlockContext.add(node.blockId); selectedBlockContext.add(node.blockId);
}, [node, selectedBlockContext]); }, [editor, node, selectedBlockContext]);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setOpenContextMenu(false); setOpenContextMenu(false);

View File

@ -12,7 +12,8 @@ import KeyboardNavigation, {
KeyboardNavigationOption, KeyboardNavigationOption,
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; } from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
import { Color } from '$app/components/editor/components/tools/block_actions/color'; import { Color } from '$app/components/editor/components/tools/block_actions/color';
import { getModifier } from '$app/components/editor/plugins/shortcuts'; import { getModifier } from '$app/utils/get_modifier';
import isHotkey from 'is-hotkey'; import isHotkey from 'is-hotkey';
import { EditorNodeType } from '$app/application/document/document.types'; import { EditorNodeType } from '$app/application/document/document.types';
import { EditorSelectedBlockContext } from '$app/components/editor/stores/selected'; import { EditorSelectedBlockContext } from '$app/components/editor/stores/selected';

View File

@ -25,10 +25,8 @@
.block-element.block-align-center { .block-element.block-align-center {
> div > .text-element { > div > .text-element {
justify-content: center; justify-content: center;
} }
} }
@ -52,12 +50,35 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) {
::selection { ::selection {
@apply bg-content-blue-100; @apply bg-content-blue-100;
} }
.text-content {
&::selection {
@apply bg-transparent;
}
span {
&::selection {
@apply bg-content-blue-100;
}
}
}
} }
[data-dark-mode="true"] [role="textbox"]{ [data-dark-mode="true"] [role="textbox"]{
::selection { ::selection {
background-color: #1e79a2; background-color: #1e79a2;
} }
.text-content {
&::selection {
@apply bg-transparent;
}
span {
&::selection {
background-color: #1e79a2;
}
}
}
} }

View File

@ -1,17 +1,5 @@
import { EditorMarkFormat } from '$app/application/document/document.types'; import { EditorMarkFormat } from '$app/application/document/document.types';
import { getModifier } from '$app/utils/get_modifier';
export const isMac = () => {
return navigator.userAgent.includes('Mac OS X');
};
const MODIFIERS = {
control: 'Ctrl',
meta: '⌘',
};
export const getModifier = () => {
return isMac() ? MODIFIERS.meta : MODIFIERS.control;
};
/** /**
* Hotkeys shortcuts * Hotkeys shortcuts

View File

@ -47,6 +47,7 @@ export function useShortcuts(editor: ReactEditor) {
if (format) { if (format) {
e.preventDefault(); e.preventDefault();
if (CustomEditor.selectionIncludeRoot(editor)) return;
return CustomEditor.toggleMark(editor, { return CustomEditor.toggleMark(editor, {
key: format, key: format,
value: true, value: true,
@ -66,7 +67,7 @@ export function useShortcuts(editor: ReactEditor) {
if (isHotkey(item.hotkey, e)) { if (isHotkey(item.hotkey, e)) {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
if (CustomEditor.selectionIncludeRoot(editor)) return;
if (item.markKey === EditorMarkFormat.Align) { if (item.markKey === EditorMarkFormat.Align) {
CustomEditor.toggleAlign(editor, item.markValue as string); CustomEditor.toggleAlign(editor, item.markValue as string);
return; return;

View File

@ -1,12 +1,12 @@
export const FooterPanel = () => { export const FooterPanel = () => {
return ( return (
<div className={'flex items-center justify-between px-2 py-2'}> <div className={'flex h-[48px] items-center justify-between px-2 py-2'}>
<div className={'text-xs text-text-caption'}> <div className={'text-xs text-text-caption'}>
&copy; 2024 AppFlowy. <a href={'https://github.com/AppFlowy-IO/AppFlowy'}>GitHub</a> &copy; 2024 AppFlowy. <a href={'https://github.com/AppFlowy-IO/AppFlowy'}>GitHub</a>
</div> </div>
<div> {/*<div>*/}
<button className={'h-8 w-8 rounded bg-content-blue-50 text-text-title hover:bg-content-blue-100'}>?</button> {/* <button className={'h-8 w-8 rounded bg-content-blue-50 text-text-title hover:bg-content-blue-100'}>?</button>*/}
</div> {/*</div>*/}
</div> </div>
); );
}; };

View File

@ -2,7 +2,7 @@ import React, { ReactNode, useEffect } from 'react';
import SideBar from '$app/components/layout/side_bar/SideBar'; import SideBar from '$app/components/layout/side_bar/SideBar';
import TopBar from '$app/components/layout/top_bar/TopBar'; import TopBar from '$app/components/layout/top_bar/TopBar';
import { useAppSelector } from '$app/stores/store'; import { useAppSelector } from '$app/stores/store';
import { FooterPanel } from '$app/components/layout/FooterPanel'; import './layout.scss';
function Layout({ children }: { children: ReactNode }) { function Layout({ children }: { children: ReactNode }) {
const { isCollapsed, width } = useAppSelector((state) => state.sidebar); const { isCollapsed, width } = useAppSelector((state) => state.sidebar);
@ -38,8 +38,6 @@ function Layout({ children }: { children: ReactNode }) {
> >
{children} {children}
</div> </div>
<FooterPanel />
</div> </div>
</div> </div>
</> </>

View File

@ -6,10 +6,11 @@ import Typography from '@mui/material/Typography';
import { Page, pageTypeMap } from '$app_reducers/pages/slice'; import { Page, pageTypeMap } from '$app_reducers/pages/slice';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getPageIcon } from '$app/hooks/page.hooks';
function Breadcrumb() { function Breadcrumb() {
const { t } = useTranslation(); const { t } = useTranslation();
const { pagePath, currentPage } = useLoadExpandedPages(); const { isTrash, pagePath, currentPage } = useLoadExpandedPages();
const navigate = useNavigate(); const navigate = useNavigate();
const parentPages = useMemo(() => pagePath.slice(1, -1).filter(Boolean) as Page[], [pagePath]); const parentPages = useMemo(() => pagePath.slice(1, -1).filter(Boolean) as Page[], [pagePath]);
@ -22,21 +23,35 @@ function Breadcrumb() {
[navigate] [navigate]
); );
if (!currentPage) {
if (isTrash) {
return <Typography color='text.primary'>{t('trash.text')}</Typography>;
}
return null;
}
return ( return (
<Breadcrumbs aria-label='breadcrumb'> <Breadcrumbs aria-label='breadcrumb'>
{parentPages?.map((page: Page) => ( {parentPages?.map((page: Page) => (
<Link <Link
key={page.id} key={page.id}
className={'flex cursor-pointer gap-1'}
underline='hover' underline='hover'
color='inherit' color='inherit'
onClick={() => { onClick={() => {
navigateToPage(page); navigateToPage(page);
}} }}
> >
<div>{getPageIcon(page)}</div>
{page.name || t('document.title.placeholder')} {page.name || t('document.title.placeholder')}
</Link> </Link>
))} ))}
<Typography color='text.primary'>{currentPage?.name || t('menuAppHeader.defaultNewPageName')}</Typography> <Typography className={'flex select-auto gap-1'} color='text.primary'>
<div>{getPageIcon(currentPage)}</div>
{currentPage?.name || t('menuAppHeader.defaultNewPageName')}
</Typography>
</Breadcrumbs> </Breadcrumbs>
); );
} }

View File

@ -2,11 +2,9 @@ import { useAppSelector } from '$app/stores/store';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useParams, useLocation } from 'react-router-dom'; import { useParams, useLocation } from 'react-router-dom';
import { Page } from '$app_reducers/pages/slice'; import { Page } from '$app_reducers/pages/slice';
import { useTranslation } from 'react-i18next';
import { getPage } from '$app/application/folder/page.service'; import { getPage } from '$app/application/folder/page.service';
export function useLoadExpandedPages() { export function useLoadExpandedPages() {
const { t } = useTranslation();
const params = useParams(); const params = useParams();
const location = useLocation(); const location = useLocation();
const isTrash = useMemo(() => location.pathname.includes('trash'), [location.pathname]); const isTrash = useMemo(() => location.pathname.includes('trash'), [location.pathname]);
@ -70,18 +68,9 @@ export function useLoadExpandedPages() {
}); });
}, [pageMap]); }, [pageMap]);
useEffect(() => {
if (isTrash) {
setPagePath([
{
name: t('trash.text'),
},
]);
}
}, [isTrash, t]);
return { return {
pagePath, pagePath,
currentPage, currentPage,
isTrash,
}; };
} }

View File

@ -1,22 +1,51 @@
import React from 'react'; import React, { useCallback, useEffect, useMemo } from 'react';
import { IconButton } from '@mui/material'; import { IconButton, Tooltip } from '@mui/material';
import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { sidebarActions } from '$app_reducers/sidebar/slice'; import { sidebarActions } from '$app_reducers/sidebar/slice';
import { ReactComponent as LeftSvg } from '$app/assets/left.svg'; import { ReactComponent as ShowMenuIcon } from '$app/assets/show-menu.svg';
import { ReactComponent as RightSvg } from '$app/assets/right.svg'; import { useTranslation } from 'react-i18next';
import { getModifier } from '$app/utils/get_modifier';
import isHotkey from 'is-hotkey';
function CollapseMenuButton() { function CollapseMenuButton() {
const isCollapsed = useAppSelector((state) => state.sidebar.isCollapsed); const isCollapsed = useAppSelector((state) => state.sidebar.isCollapsed);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const handleClick = () => { const handleClick = useCallback(() => {
dispatch(sidebarActions.toggleCollapse()); dispatch(sidebarActions.toggleCollapse());
}; }, [dispatch]);
const { t } = useTranslation();
const title = useMemo(() => {
return (
<div className={'flex flex-col gap-1 text-xs'}>
<div>{isCollapsed ? t('sideBar.openSidebar') : t('sideBar.closeSidebar')}</div>
<div>{`${getModifier()} + \\`}</div>
</div>
);
}, [isCollapsed, t]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (isHotkey('mod+\\', e)) {
e.preventDefault();
handleClick();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [handleClick]);
return ( return (
<IconButton size={'small'} className={'font-bold'} onClick={handleClick}> <Tooltip title={title}>
{isCollapsed ? <RightSvg /> : <LeftSvg />} <IconButton size={'small'} className={'font-bold text-text-title'} onClick={handleClick}>
</IconButton> {isCollapsed ? <ShowMenuIcon /> : <ShowMenuIcon className={'rotate-180 transform'} />}
</IconButton>
</Tooltip>
); );
} }

View File

@ -0,0 +1,12 @@
.workspaces {
::-webkit-scrollbar {
width: 0px;
}
}
.MuiPopover-root, .MuiPaper-root {
::-webkit-scrollbar {
width: 0;
height: 0;
}
}

View File

@ -1,63 +1,64 @@
import React, { useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import ButtonPopoverList from '$app/components/_shared/button_menu/ButtonMenu';
import { IconButton } from '@mui/material';
import { ReactComponent as AddSvg } from '$app/assets/add.svg'; import { ReactComponent as AddSvg } from '$app/assets/add.svg';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ReactComponent as DocumentSvg } from '$app/assets/document.svg'; import { ReactComponent as DocumentSvg } from '$app/assets/document.svg';
import { ReactComponent as GridSvg } from '$app/assets/grid.svg'; import { ReactComponent as GridSvg } from '$app/assets/grid.svg';
import { ViewLayoutPB } from '@/services/backend'; import { ViewLayoutPB } from '@/services/backend';
import OperationMenu from '$app/components/layout/nested_page/OperationMenu';
function AddButton({ isVisible, onAddPage }: { isVisible: boolean; onAddPage: (layout: ViewLayoutPB) => void }) { function AddButton({
isHovering,
setHovering,
onAddPage,
}: {
isHovering: boolean;
setHovering: (hovering: boolean) => void;
onAddPage: (layout: ViewLayoutPB) => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const onConfirm = useCallback(
(key: string) => {
switch (key) {
case 'document':
onAddPage(ViewLayoutPB.Document);
break;
case 'grid':
onAddPage(ViewLayoutPB.Grid);
break;
default:
break;
}
},
[onAddPage]
);
const options = useMemo( const options = useMemo(
() => [ () => [
{ {
key: 'add-document', key: 'document',
label: t('document.menuName'), title: t('document.menuName'),
icon: ( icon: <DocumentSvg className={'h-4 w-4'} />,
<div className={'h-5 w-5'}>
<DocumentSvg />
</div>
),
onClick: () => {
onAddPage(ViewLayoutPB.Document);
},
}, },
{ {
key: 'add-grid', key: 'grid',
label: t('grid.menuName'), title: t('grid.menuName'),
icon: ( icon: <GridSvg className={'h-4 w-4'} />,
<div className={'h-5 w-5'}>
<GridSvg />
</div>
),
onClick: () => {
onAddPage(ViewLayoutPB.Grid);
},
}, },
], ],
[onAddPage, t] [t]
); );
return ( return (
<ButtonPopoverList <OperationMenu
popoverOrigin={{ tooltip={t('menuAppHeader.addPageTooltip')}
anchorOrigin: { isHovering={isHovering}
vertical: 'bottom', onConfirm={onConfirm}
horizontal: 'left', setHovering={setHovering}
}, options={options}
transformOrigin: {
vertical: 'top',
horizontal: 'left',
},
}}
popoverOptions={options}
isVisible={isVisible}
> >
<IconButton size={'small'}> <AddSvg />
<AddSvg /> </OperationMenu>
</IconButton>
</ButtonPopoverList>
); );
} }

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ViewLayoutPB } from '@/services/backend'; import { ViewLayoutPB } from '@/services/backend';
import DeleteConfirmDialog from '$app/components/_shared/delete_confirm_dialog/DeleteConfirmDialog'; import DeleteConfirmDialog from '$app/components/_shared/confirm_dialog/DeleteConfirmDialog';
function DeleteDialog({ function DeleteDialog({
layout, layout,

View File

@ -1,25 +1,28 @@
import React, { useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IconButton } from '@mui/material';
import ButtonPopoverList from '$app/components/_shared/button_menu/ButtonMenu';
import { ReactComponent as DetailsSvg } from '$app/assets/details.svg'; import { ReactComponent as DetailsSvg } from '$app/assets/details.svg';
import { ReactComponent as EditSvg } from '$app/assets/edit.svg'; import { ReactComponent as EditSvg } from '$app/assets/edit.svg';
import { ReactComponent as CopySvg } from '$app/assets/copy.svg'; import { ReactComponent as CopySvg } from '$app/assets/copy.svg';
import { ReactComponent as TrashSvg } from '$app/assets/delete.svg'; import { ReactComponent as TrashSvg } from '$app/assets/delete.svg';
import RenameDialog from './RenameDialog'; import RenameDialog from '../../_shared/confirm_dialog/RenameDialog';
import { Page } from '$app_reducers/pages/slice'; import { Page } from '$app_reducers/pages/slice';
import DeleteDialog from '$app/components/layout/nested_page/DeleteDialog'; import DeleteDialog from '$app/components/layout/nested_page/DeleteDialog';
import OperationMenu from '$app/components/layout/nested_page/OperationMenu';
import { getModifier } from '$app/utils/get_modifier';
import isHotkey from 'is-hotkey';
function MoreButton({ function MoreButton({
isVisible,
onDelete, onDelete,
onDuplicate, onDuplicate,
onRename, onRename,
page, page,
isHovering,
setHovering,
}: { }: {
isVisible: boolean; isHovering: boolean;
setHovering: (hovering: boolean) => void;
onDelete: () => Promise<void>; onDelete: () => Promise<void>;
onDuplicate: () => Promise<void>; onDuplicate: () => Promise<void>;
onRename: (newName: string) => Promise<void>; onRename: (newName: string) => Promise<void>;
@ -29,84 +32,97 @@ function MoreButton({
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const onConfirm = useCallback(
(key: string) => {
switch (key) {
case 'rename':
setRenameDialogOpen(true);
break;
case 'delete':
setDeleteDialogOpen(true);
break;
case 'duplicate':
void onDuplicate();
break;
default:
break;
}
},
[onDuplicate]
);
const options = useMemo( const options = useMemo(
() => [ () => [
{ {
label: t('disclosureAction.rename'), title: t('button.rename'),
icon: <EditSvg className={'h-4 w-4'} />,
key: 'rename', key: 'rename',
icon: (
<div className={'h-5 w-5'}>
<EditSvg />
</div>
),
onClick: () => {
setRenameDialogOpen(true);
},
}, },
{ {
label: t('button.delete'),
key: 'delete', key: 'delete',
onClick: () => { title: t('button.delete'),
setDeleteDialogOpen(true); icon: <TrashSvg className={'h-4 w-4'} />,
}, caption: 'Del',
icon: (
<div className={'h-5 w-5'}>
<TrashSvg />
</div>
),
}, },
{ {
key: 'duplicate', key: 'duplicate',
label: t('button.duplicate'), title: t('button.duplicate'),
onClick: onDuplicate, icon: <CopySvg className={'h-4 w-4'} />,
icon: ( caption: `${getModifier()}+D`,
<div className={'h-5 w-5'}>
<CopySvg />
</div>
),
}, },
], ],
[onDuplicate, t] [t]
);
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
if (isHotkey('del', e) || isHotkey('backspace', e)) {
e.preventDefault();
e.stopPropagation();
onConfirm('delete');
return;
}
if (isHotkey('mod+d', e)) {
e.stopPropagation();
onConfirm('duplicate');
return;
}
},
[onConfirm]
); );
return ( return (
<> <>
<ButtonPopoverList <OperationMenu
isVisible={isVisible} tooltip={t('menuAppHeader.moreButtonToolTip')}
popoverOptions={options} isHovering={isHovering}
popoverOrigin={{ setHovering={setHovering}
anchorOrigin: { onConfirm={onConfirm}
vertical: 'bottom', options={options}
horizontal: 'left', onKeyDown={onKeyDown}
},
transformOrigin: {
vertical: 'top',
horizontal: 'left',
},
}}
> >
<IconButton size={'small'}> <DetailsSvg />
<DetailsSvg /> </OperationMenu>
</IconButton>
</ButtonPopoverList>
<RenameDialog
defaultValue={page.name}
open={renameDialogOpen}
onClose={() => setRenameDialogOpen(false)}
onOk={async (newName: string) => {
await onRename(newName);
setRenameDialogOpen(false);
}}
/>
<DeleteDialog <DeleteDialog
layout={page.layout} layout={page.layout}
open={deleteDialogOpen} open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)} onClose={() => {
onOk={async () => {
await onDelete();
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
}} }}
onOk={onDelete}
/> />
{renameDialogOpen && (
<RenameDialog
defaultValue={page.name}
open={renameDialogOpen}
onClose={() => setRenameDialogOpen(false)}
onOk={onRename}
/>
)}
</> </>
); );
} }

View File

@ -1,12 +1,11 @@
import { useCallback, useEffect, useMemo } from 'react'; import { useCallback, useEffect } from 'react';
import { Page, pagesActions, pageTypeMap } from '$app_reducers/pages/slice'; import { pagesActions, pageTypeMap, parserViewPBToPage } from '$app_reducers/pages/slice';
import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { FolderNotification, ViewLayoutPB } from '@/services/backend'; import { FolderNotification, ViewLayoutPB } from '@/services/backend';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { updatePageName } from '$app_reducers/pages/async_actions'; import { updatePageName } from '$app_reducers/pages/async_actions';
import { createPage, deletePage, duplicatePage, getChildPages } from '$app/application/folder/page.service'; import { createPage, deletePage, duplicatePage, getChildPages } from '$app/application/folder/page.service';
import { subscribeNotifications } from '$app/application/notification'; import { subscribeNotifications } from '$app/application/notification';
import debounce from 'lodash-es/debounce';
export function useLoadChildPages(pageId: string) { export function useLoadChildPages(pageId: string) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -20,14 +19,6 @@ export function useLoadChildPages(pageId: string) {
} }
}, [dispatch, pageId, collapsed]); }, [dispatch, pageId, collapsed]);
const onPageChanged = useMemo(() => {
return debounce((page: Page) => {
console.log('DidUpdateView');
dispatch(pagesActions.onPageChanged(page));
}, 200);
}, [dispatch]);
const loadPageChildren = useCallback( const loadPageChildren = useCallback(
async (pageId: string) => { async (pageId: string) => {
const childPages = await getChildPages(pageId); const childPages = await getChildPages(pageId);
@ -49,9 +40,19 @@ export function useLoadChildPages(pageId: string) {
useEffect(() => { useEffect(() => {
const unsubscribePromise = subscribeNotifications( const unsubscribePromise = subscribeNotifications(
{ {
[FolderNotification.DidUpdateView]: (_payload) => { [FolderNotification.DidUpdateView]: async (payload) => {
// const page = parserViewPBToPage(payload); const childViews = payload.child_views;
// onPageChanged(page);
if (childViews.length === 0) {
return;
}
dispatch(
pagesActions.addChildPages({
id: pageId,
childPages: childViews.map(parserViewPBToPage),
})
);
}, },
[FolderNotification.DidUpdateChildViews]: async (payload) => { [FolderNotification.DidUpdateChildViews]: async (payload) => {
if (payload.delete_child_views.length === 0 && payload.create_child_views.length === 0) { if (payload.delete_child_views.length === 0 && payload.create_child_views.length === 0) {
@ -67,7 +68,7 @@ export function useLoadChildPages(pageId: string) {
); );
return () => void unsubscribePromise.then((unsubscribe) => unsubscribe()); return () => void unsubscribePromise.then((unsubscribe) => unsubscribe());
}, [pageId, onPageChanged, loadPageChildren]); }, [pageId, loadPageChildren, dispatch]);
return { return {
toggleCollapsed, toggleCollapsed,
@ -79,13 +80,16 @@ export function useLoadChildPages(pageId: string) {
export function usePageActions(pageId: string) { export function usePageActions(pageId: string) {
const page = useAppSelector((state) => state.pages.pageMap[pageId]); const page = useAppSelector((state) => state.pages.pageMap[pageId]);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const params = useParams();
const currentPageId = params.id;
const navigate = useNavigate(); const navigate = useNavigate();
const onPageClick = useCallback(() => { const onPageClick = useCallback(() => {
if (!page) return;
const pageType = pageTypeMap[page.layout]; const pageType = pageTypeMap[page.layout];
navigate(`/page/${pageType}/${pageId}`); navigate(`/page/${pageType}/${pageId}`);
}, [navigate, page.layout, pageId]); }, [navigate, page, pageId]);
const onAddPage = useCallback( const onAddPage = useCallback(
async (layout: ViewLayoutPB) => { async (layout: ViewLayoutPB) => {
@ -95,6 +99,18 @@ export function usePageActions(pageId: string) {
parent_view_id: pageId, parent_view_id: pageId,
}); });
dispatch(
pagesActions.addPage({
page: {
id: newViewId,
parentId: pageId,
layout,
name: '',
},
isLast: true,
})
);
dispatch(pagesActions.expandPage(pageId)); dispatch(pagesActions.expandPage(pageId));
const pageType = pageTypeMap[layout]; const pageType = pageTypeMap[layout];
@ -104,12 +120,17 @@ export function usePageActions(pageId: string) {
); );
const onDeletePage = useCallback(async () => { const onDeletePage = useCallback(async () => {
if (currentPageId === pageId) {
navigate(`/`);
}
await deletePage(pageId); await deletePage(pageId);
}, [pageId]); dispatch(pagesActions.deletePages([pageId]));
}, [dispatch, currentPageId, navigate, pageId]);
const onDuplicatePage = useCallback(async () => { const onDuplicatePage = useCallback(async () => {
await duplicatePage(pageId); await duplicatePage(page);
}, [pageId]); }, [page]);
const onRenamePage = useCallback( const onRenamePage = useCallback(
async (name: string) => { async (name: string) => {

View File

@ -4,28 +4,44 @@ import { TransitionGroup } from 'react-transition-group';
import NestedPageTitle from '$app/components/layout/nested_page/NestedPageTitle'; import NestedPageTitle from '$app/components/layout/nested_page/NestedPageTitle';
import { useLoadChildPages, usePageActions } from '$app/components/layout/nested_page/NestedPage.hooks'; import { useLoadChildPages, usePageActions } from '$app/components/layout/nested_page/NestedPage.hooks';
import { useDrag } from 'src/appflowy_app/components/_shared/drag_block'; import { useDrag } from 'src/appflowy_app/components/_shared/drag_block';
import { useAppDispatch } from '$app/stores/store'; import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { movePageThunk } from '$app_reducers/pages/async_actions'; import { movePageThunk } from '$app_reducers/pages/async_actions';
import { ViewLayoutPB } from '@/services/backend';
function NestedPage({ pageId }: { pageId: string }) { function NestedPage({ pageId }: { pageId: string }) {
const { toggleCollapsed, collapsed, childPages } = useLoadChildPages(pageId); const { toggleCollapsed, collapsed, childPages } = useLoadChildPages(pageId);
const { onAddPage, onPageClick, onDeletePage, onDuplicatePage, onRenamePage } = usePageActions(pageId); const { onAddPage, onPageClick, onDeletePage, onDuplicatePage, onRenamePage } = usePageActions(pageId);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const page = useAppSelector((state) => state.pages.pageMap[pageId]);
const disableChildren = useAppSelector((state) => {
if (!page) return true;
const layout = state.pages.pageMap[page.parentId]?.layout;
return !(layout === undefined || layout === ViewLayoutPB.Document);
});
const children = useMemo(() => { const children = useMemo(() => {
if (disableChildren) {
return [];
}
return collapsed ? [] : childPages; return collapsed ? [] : childPages;
}, [collapsed, childPages]); }, [collapsed, childPages, disableChildren]);
const onDragFinished = useCallback( const onDragFinished = useCallback(
(result: { dragId: string; position: 'before' | 'after' | 'inside' }) => { (result: { dragId: string; position: 'before' | 'after' | 'inside' }) => {
const { dragId, position } = result;
if (dragId === pageId) return;
if (position === 'inside' && page?.layout !== ViewLayoutPB.Document) return;
void dispatch( void dispatch(
movePageThunk({ movePageThunk({
sourceId: result.dragId, sourceId: dragId,
targetId: pageId, targetId: pageId,
insertType: result.position, insertType: position,
}) })
); );
}, },
[dispatch, pageId] [dispatch, page?.layout, pageId]
); );
const { onDrop, dropPosition, onDragOver, onDragLeave, onDragStart, onDragEnd, isDraggingOver, isDragging } = useDrag({ const { onDrop, dropPosition, onDragOver, onDragLeave, onDragStart, onDragEnd, isDraggingOver, isDragging } = useDrag({
@ -34,20 +50,20 @@ function NestedPage({ pageId }: { pageId: string }) {
}); });
const className = useMemo(() => { const className = useMemo(() => {
const defaultClassName = 'relative flex-1 flex flex-col w-full'; const defaultClassName = 'relative flex-1 select-none flex flex-col w-full';
if (isDragging) { if (isDragging) {
return `${defaultClassName} opacity-40`; return `${defaultClassName} opacity-40`;
} }
if (isDraggingOver && dropPosition === 'inside') { if (isDraggingOver && dropPosition === 'inside' && page?.layout === ViewLayoutPB.Document) {
if (dropPosition === 'inside') { if (dropPosition === 'inside') {
return `${defaultClassName} bg-content-blue-100`; return `${defaultClassName} bg-content-blue-100`;
} }
} else { } else {
return defaultClassName; return defaultClassName;
} }
}, [dropPosition, isDragging, isDraggingOver]); }, [dropPosition, isDragging, isDraggingOver, page?.layout]);
return ( return (
<div <div
@ -58,15 +74,17 @@ function NestedPage({ pageId }: { pageId: string }) {
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
onDrop={onDrop} onDrop={onDrop}
draggable={true} draggable={true}
data-drop-enabled={page?.layout === ViewLayoutPB.Document}
data-dragging={isDragging}
data-page-id={pageId} data-page-id={pageId}
> >
<div <div
style={{ style={{
height: dropPosition === 'before' || dropPosition === 'after' ? '4px' : '0px', height: dropPosition === 'before' || dropPosition === 'after' ? '2px' : '0px',
top: dropPosition === 'before' ? '-4px' : 'auto', top: dropPosition === 'before' ? '-2px' : 'auto',
bottom: dropPosition === 'after' ? '-4px' : 'auto', bottom: dropPosition === 'after' ? '-2px' : 'auto',
}} }}
className={'pointer-events-none absolute left-0 z-10 w-full bg-content-blue-100'} className={'pointer-events-none absolute left-0 z-10 w-full bg-content-blue-300'}
/> />
<NestedPageTitle <NestedPageTitle
onClick={() => { onClick={() => {

View File

@ -1,11 +1,14 @@
import React, { useState } from 'react'; import React, { useMemo, useState } from 'react';
import { ArrowRightSvg } from '$app/components/_shared/svg/ArrowRightSvg';
import { useAppSelector } from '$app/stores/store'; import { useAppSelector } from '$app/stores/store';
import AddButton from './AddButton'; import AddButton from './AddButton';
import MoreButton from './MoreButton'; import MoreButton from './MoreButton';
import { ViewLayoutPB } from '@/services/backend'; import { ViewLayoutPB } from '@/services/backend';
import { useSelectedPage } from '$app/components/layout/nested_page/NestedPage.hooks'; import { useSelectedPage } from '$app/components/layout/nested_page/NestedPage.hooks';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ReactComponent as MoreIcon } from '$app/assets/more.svg';
import { IconButton } from '@mui/material';
import { Page } from '$app_reducers/pages/slice';
import { getPageIcon } from '$app/hooks/page.hooks';
function NestedPageTitle({ function NestedPageTitle({
pageId, pageId,
@ -28,51 +31,68 @@ function NestedPageTitle({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const page = useAppSelector((state) => { const page = useAppSelector((state) => {
return state.pages.pageMap[pageId]; return state.pages.pageMap[pageId] as Page | undefined;
}); });
const disableChildren = useAppSelector((state) => {
if (!page) return true;
const layout = state.pages.pageMap[page.parentId]?.layout;
return !(layout === undefined || layout === ViewLayoutPB.Document);
});
const [isHovering, setIsHovering] = useState(false); const [isHovering, setIsHovering] = useState(false);
const isSelected = useSelectedPage(pageId); const isSelected = useSelectedPage(pageId);
const pageIcon = useMemo(() => (page ? getPageIcon(page) : null), [page]);
return ( return (
<div <div
className={`m-1 cursor-pointer rounded-lg bg-opacity-40 px-2 py-1 ${isHovering ? 'bg-fill-list-hover' : ''} ${ className={`my-0.5 cursor-pointer rounded-lg bg-opacity-40 p-0.5 ${isHovering ? 'bg-fill-list-hover' : ''} ${
isSelected ? 'bg-fill-list-hover' : '' isSelected ? 'bg-fill-list-active' : ''
}`} }`}
onClick={onClick} onClick={onClick}
onMouseEnter={() => setIsHovering(true)} onMouseMove={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)} onMouseLeave={() => setIsHovering(false)}
> >
<div className={'flex h-6 w-[100%] items-center justify-between'}> <div className={'flex h-6 w-[100%] items-center justify-between'}>
<div className={'flex flex-1 items-center justify-start overflow-hidden'}> <div className={'flex flex-1 items-center justify-start gap-1 overflow-hidden'}>
<button {disableChildren ? (
onClick={(e) => { <div className={'mx-2 h-1 w-1 rounded-full bg-text-title'} />
e.stopPropagation(); ) : (
toggleCollapsed(); <IconButton
}} size={'small'}
style={{ onClick={(e) => {
transform: collapsed ? 'rotate(0deg)' : 'rotate(90deg)', e.stopPropagation();
}} toggleCollapsed();
className={'flex h-[100%] w-8 items-center justify-center p-2'} }}
> style={{
<div className={'h-5 w-5'}> transform: collapsed ? 'rotate(0deg)' : 'rotate(90deg)',
<ArrowRightSvg /> }}
</div> >
</button> <MoreIcon className={'h-4 w-4 text-text-title'} />
{page.icon ? <div className={'mr-1 h-5 w-5'}>{page.icon.value}</div> : null} </IconButton>
)}
{pageIcon}
<div className={'flex-1 overflow-hidden text-ellipsis whitespace-nowrap'}> <div className={'flex-1 overflow-hidden text-ellipsis whitespace-nowrap'}>
{page.name || t('menuAppHeader.defaultNewPageName')} {page?.name || t('menuAppHeader.defaultNewPageName')}
</div> </div>
</div> </div>
<div onClick={(e) => e.stopPropagation()} className={'min:w-14 flex items-center justify-end'}> <div onClick={(e) => e.stopPropagation()} className={'min:w-14 flex items-center justify-end px-2'}>
<AddButton isVisible={isHovering} onAddPage={onAddPage} /> {page?.layout === ViewLayoutPB.Document && (
<MoreButton <AddButton setHovering={setIsHovering} isHovering={isHovering} onAddPage={onAddPage} />
page={page} )}
isVisible={isHovering} {page && (
onDelete={onDelete} <MoreButton
onDuplicate={onDuplicate} setHovering={setIsHovering}
onRename={onRename} isHovering={isHovering}
/> page={page}
onDelete={onDelete}
onDuplicate={onDuplicate}
onRename={onRename}
/>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,103 @@
import React, { ReactNode, useCallback, useMemo, useState } from 'react';
import { IconButton } from '@mui/material';
import Popover from '@mui/material/Popover';
import KeyboardNavigation from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
import Tooltip from '@mui/material/Tooltip';
function OperationMenu({
options,
onConfirm,
isHovering,
setHovering,
children,
tooltip,
onKeyDown,
}: {
isHovering: boolean;
setHovering: (hovering: boolean) => void;
options: {
key: string;
title: string;
icon: React.ReactNode;
caption?: string;
}[];
children: React.ReactNode;
onConfirm: (key: string) => void;
tooltip: string;
onKeyDown?: (e: KeyboardEvent) => void;
}) {
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const renderItem = useCallback((title: string, icon: ReactNode, caption?: string) => {
return (
<div className={'flex w-full items-center justify-between gap-2 px-1 font-medium'}>
{icon}
<div className={'flex-1'}>{title}</div>
<div className={'text-right text-text-caption'}>{caption || ''}</div>
</div>
);
}, []);
const handleClose = useCallback(() => {
setAnchorEl(null);
setHovering(false);
}, [setHovering]);
const optionList = useMemo(() => {
return options.map((option) => {
return {
key: option.key,
content: renderItem(option.title, option.icon, option.caption),
};
});
}, [options, renderItem]);
const open = Boolean(anchorEl);
const handleConfirm = useCallback(
(key: string) => {
onConfirm(key);
handleClose();
},
[handleClose, onConfirm]
);
return (
<>
<Tooltip disableInteractive={true} title={tooltip}>
<IconButton
size={'small'}
onClick={(e) => {
setAnchorEl(e.currentTarget);
}}
className={`${!isHovering ? 'invisible' : ''} text-icon-primary`}
>
{children}
</IconButton>
</Tooltip>
<Popover
onClose={handleClose}
open={open}
anchorEl={anchorEl}
disableRestoreFocus={true}
keepMounted={false}
PaperProps={{
className: 'py-2',
}}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
>
<KeyboardNavigation
onKeyDown={onKeyDown}
onEscape={handleClose}
options={optionList}
onConfirm={handleConfirm}
/>
</Popover>
</>
);
}
export default OperationMenu;

View File

@ -10,6 +10,7 @@ function Resizer() {
const startX = useRef(0); const startX = useRef(0);
const onResize = useCallback( const onResize = useCallback(
(e: MouseEvent) => { (e: MouseEvent) => {
e.preventDefault();
const diff = e.clientX - startX.current; const diff = e.clientX - startX.current;
const newWidth = width + diff; const newWidth = width + diff;

View File

@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import { useAppSelector } from '$app/stores/store'; import { useAppSelector } from '$app/stores/store';
import { ThemeMode } from '$app/stores/reducers/current-user/slice';
import { AppflowyLogoDark } from '$app/components/_shared/svg/AppflowyLogoDark'; import { AppflowyLogoDark } from '$app/components/_shared/svg/AppflowyLogoDark';
import { AppflowyLogoLight } from '$app/components/_shared/svg/AppflowyLogoLight'; import { AppflowyLogoLight } from '$app/components/_shared/svg/AppflowyLogoLight';
import CollapseMenuButton from '$app/components/layout/collapse_menu_button/CollapseMenuButton'; import CollapseMenuButton from '$app/components/layout/collapse_menu_button/CollapseMenuButton';
@ -10,19 +9,19 @@ import WorkspaceManager from '$app/components/layout/workspace_manager/Workspace
function SideBar() { function SideBar() {
const { isCollapsed, width, isResizing } = useAppSelector((state) => state.sidebar); const { isCollapsed, width, isResizing } = useAppSelector((state) => state.sidebar);
const isDark = useAppSelector((state) => state.currentUser?.userSetting?.themeMode === ThemeMode.Dark); const isDark = useAppSelector((state) => state.currentUser?.userSetting?.isDark);
return ( return (
<> <>
<div <div
style={{ style={{
width: isCollapsed ? 0 : width, width: isCollapsed ? 0 : width,
transition: isResizing ? 'none' : 'width 150ms cubic-bezier(0.4, 0, 0.2, 1)', transition: isResizing ? 'none' : 'width 250ms cubic-bezier(0.4, 0, 0.2, 1)',
}} }}
className={'relative h-screen overflow-hidden'} className={'relative h-screen overflow-hidden'}
> >
<div className={'flex h-[100vh] flex-col overflow-hidden border-r border-line-divider bg-bg-base'}> <div className={'flex h-[100vh] flex-col overflow-hidden border-r border-line-divider bg-bg-base'}>
<div className={'flex h-[64px] justify-between px-6 py-5'}> <div className={'flex h-[64px] justify-between px-4 py-5'}>
{isDark ? <AppflowyLogoDark /> : <AppflowyLogoLight />} {isDark ? <AppflowyLogoDark /> : <AppflowyLogoLight />}
<CollapseMenuButton /> <CollapseMenuButton />
</div> </div>

View File

@ -1,37 +1,46 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useAppSelector } from '$app/stores/store'; import { useAppSelector } from '$app/stores/store';
import { Avatar } from '@mui/material'; import { Avatar, IconButton } from '@mui/material';
import PersonOutline from '@mui/icons-material/PersonOutline'; import PersonOutline from '@mui/icons-material/PersonOutline';
import ArrowDropDown from '@mui/icons-material/ArrowDropDown';
import UserSetting from '../user_setting/UserSetting'; import UserSetting from '../user_setting/UserSetting';
import { ReactComponent as SettingIcon } from '$app/assets/settings.svg';
import Tooltip from '@mui/material/Tooltip';
import { useTranslation } from 'react-i18next';
function UserInfo() { function UserInfo() {
const currentUser = useAppSelector((state) => state.currentUser); const currentUser = useAppSelector((state) => state.currentUser);
const [showUserSetting, setShowUserSetting] = useState(false); const [showUserSetting, setShowUserSetting] = useState(false);
const { t } = useTranslation();
return ( return (
<> <>
<div <div className={'flex w-full cursor-pointer select-none items-center justify-between px-4 text-text-title'}>
onClick={(e) => { <div className={'flex w-full flex-1 items-center gap-1'}>
e.stopPropagation(); <Avatar
setShowUserSetting(!showUserSetting); sx={{
}} width: 26,
className={'flex cursor-pointer items-center px-6 text-text-title'} height: 26,
> backgroundColor: 'var(--fill-list-active)',
<Avatar }}
sx={{ className={'text-xs font-bold text-text-title'}
width: 23, variant={'circular'}
height: 23, >
}} {currentUser.displayName ? currentUser.displayName[0] : <PersonOutline />}
className={'text-text-title'} </Avatar>
variant={'rounded'} <span className={'ml-2 flex-1 text-xs'}>{currentUser.displayName}</span>
> </div>
<PersonOutline />
</Avatar> <Tooltip disableInteractive={true} title={t('settings.menu.open')}>
<span className={'ml-2 text-sm'}>{currentUser.displayName}</span> <IconButton
<button className={'ml-2 rounded hover:bg-fill-list-hover'}> size={'small'}
<ArrowDropDown /> onClick={() => {
</button> setShowUserSetting(!showUserSetting);
}}
>
<SettingIcon className={'text-text-title'} />
</IconButton>
</Tooltip>
</div> </div>
<UserSetting open={showUserSetting} onClose={() => setShowUserSetting(false)} /> <UserSetting open={showUserSetting} onClose={() => setShowUserSetting(false)} />

View File

@ -2,8 +2,6 @@ import React from 'react';
import CollapseMenuButton from '$app/components/layout/collapse_menu_button/CollapseMenuButton'; import CollapseMenuButton from '$app/components/layout/collapse_menu_button/CollapseMenuButton';
import { useAppSelector } from '$app/stores/store'; import { useAppSelector } from '$app/stores/store';
import Breadcrumb from '$app/components/layout/bread_crumb/BreadCrumb'; import Breadcrumb from '$app/components/layout/bread_crumb/BreadCrumb';
import ShareButton from '$app/components/layout/share/Share';
import MoreButton from '$app/components/layout/top_bar/MoreButton';
function TopBar() { function TopBar() {
const sidebarIsCollapsed = useAppSelector((state) => state.sidebar.isCollapsed); const sidebarIsCollapsed = useAppSelector((state) => state.sidebar.isCollapsed);
@ -19,13 +17,6 @@ function TopBar() {
<div className={'flex-1'}> <div className={'flex-1'}>
<Breadcrumb /> <Breadcrumb />
</div> </div>
<div className={'flex items-center justify-end'}>
<div className={'mr-2'}>
<ShareButton />
</div>
<MoreButton />
</div>
</div> </div>
</div> </div>
); );

View File

@ -1,12 +1,11 @@
import React, { useCallback, useEffect, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import Select from '@mui/material/Select'; import Select from '@mui/material/Select';
import { Theme, ThemeMode, UserSetting } from '$app/stores/reducers/current-user/slice'; import { Theme, ThemeMode, UserSetting } from '$app/stores/reducers/current-user/slice';
import MenuItem from '@mui/material/MenuItem'; import MenuItem from '@mui/material/MenuItem';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
function AppearanceSetting({ function AppearanceSetting({
theme = Theme.Default, themeMode = ThemeMode.System,
themeMode = ThemeMode.Light,
onChange, onChange,
}: { }: {
theme?: Theme; theme?: Theme;
@ -15,13 +14,6 @@ function AppearanceSetting({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
useEffect(() => {
const html = document.documentElement;
html?.setAttribute('data-dark-mode', String(themeMode === ThemeMode.Dark));
html?.setAttribute('data-theme', theme);
}, [theme, themeMode]);
const themeModeOptions = useMemo( const themeModeOptions = useMemo(
() => [ () => [
{ {
@ -32,6 +24,10 @@ function AppearanceSetting({
value: ThemeMode.Dark, value: ThemeMode.Dark,
content: t('settings.appearance.themeMode.dark'), content: t('settings.appearance.themeMode.dark'),
}, },
{
value: ThemeMode.System,
content: t('settings.appearance.themeMode.system'),
},
], ],
[t] [t]
); );
@ -63,7 +59,7 @@ function AppearanceSetting({
}} }}
> >
{options.map((option) => ( {options.map((option) => (
<MenuItem key={option.value} value={option.value}> <MenuItem key={option.value} className={'my-1 rounded-none px-2 py-1 text-xs'} value={option.value}>
{option.content} {option.content}
</MenuItem> </MenuItem>
))} ))}
@ -86,6 +82,9 @@ function AppearanceSetting({
onChange: (newValue) => { onChange: (newValue) => {
onChange({ onChange({
themeMode: newValue as ThemeMode, themeMode: newValue as ThemeMode,
isDark:
newValue === ThemeMode.Dark ||
(newValue === ThemeMode.System && window.matchMedia('(prefers-color-scheme: dark)').matches),
}); });
}, },
}, },

View File

@ -61,7 +61,7 @@ function LanguageSetting({
}} }}
> >
{languages.map((option) => ( {languages.map((option) => (
<MenuItem key={option.key} value={option.key}> <MenuItem key={option.key} className={'my-1 w-full rounded-none px-2 py-1 text-xs'} value={option.key}>
{option.title} {option.title}
</MenuItem> </MenuItem>
))} ))}

View File

@ -27,7 +27,7 @@ function UserSettingMenu({ selected, onSelect }: { onSelect: (selected: MenuItem
}, [t]); }, [t]);
return ( return (
<div className={'h-[300px] w-[200px] border-r border-solid border-r-line-border pr-4 text-sm'}> <div className={'h-[300px] w-[200px] border-r border-solid border-r-line-border pr-4 text-xs'}>
{options.map((option) => { {options.map((option) => {
return ( return (
<div <div
@ -35,7 +35,7 @@ function UserSettingMenu({ selected, onSelect }: { onSelect: (selected: MenuItem
onClick={() => { onClick={() => {
onSelect(option.value); onSelect(option.value);
}} }}
className={`my-1 flex h-10 w-full cursor-pointer items-center justify-start rounded-md px-4 py-2 text-text-title ${ className={`my-1 flex w-full cursor-pointer items-center justify-start rounded-md p-2 text-xs text-text-title ${
selected === option.value ? 'bg-fill-list-hover' : 'hover:text-content-blue-300' selected === option.value ? 'bg-fill-list-hover' : 'hover:text-content-blue-300'
}`} }`}
> >

View File

@ -5,10 +5,9 @@ import DialogTitle from '@mui/material/DialogTitle';
import Slide, { SlideProps } from '@mui/material/Slide'; import Slide, { SlideProps } from '@mui/material/Slide';
import UserSettingMenu, { MenuItem } from './Menu'; import UserSettingMenu, { MenuItem } from './Menu';
import UserSettingPanel from './SettingPanel'; import UserSettingPanel from './SettingPanel';
import { Theme, UserSetting } from '$app/stores/reducers/current-user/slice'; import { UserSetting } from '$app/stores/reducers/current-user/slice';
import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { currentUserActions } from '$app_reducers/current-user/slice'; import { currentUserActions } from '$app_reducers/current-user/slice';
import { ThemeModePB } from '@/services/backend';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { UserService } from '$app/application/user/user.service'; import { UserService } from '$app/application/user/user.service';
@ -29,8 +28,8 @@ function UserSettings({ open, onClose }: { open: boolean; onClose: () => void })
const language = newSetting.language || 'en'; const language = newSetting.language || 'en';
void UserService.setAppearanceSetting({ void UserService.setAppearanceSetting({
theme: newSetting.theme || Theme.Default, theme: newSetting.theme,
theme_mode: newSetting.themeMode || ThemeModePB.Light, theme_mode: newSetting.themeMode,
locale: { locale: {
language_code: language.split('-')[0], language_code: language.split('-')[0],
country_code: language.split('-')[1], country_code: language.split('-')[1],
@ -48,7 +47,7 @@ function UserSettings({ open, onClose }: { open: boolean; onClose: () => void })
keepMounted={false} keepMounted={false}
onClose={onClose} onClose={onClose}
> >
<DialogTitle>{t('settings.title')}</DialogTitle> <DialogTitle className={'text-sm'}>{t('settings.title')}</DialogTitle>
<DialogContent className={'flex w-[540px]'}> <DialogContent className={'flex w-[540px]'}>
<UserSettingMenu <UserSettingMenu
onSelect={(selected) => { onSelect={(selected) => {

View File

@ -1,64 +0,0 @@
import React, { useMemo } from 'react';
import { WorkspaceItem } from '$app_reducers/workspace/slice';
import { IconButton } from '@mui/material';
import MoreIcon from '@mui/icons-material/MoreHoriz';
import SettingsIcon from '@mui/icons-material/Settings';
import { useTranslation } from 'react-i18next';
import { DeleteOutline } from '@mui/icons-material';
import ButtonPopoverList from '$app/components/_shared/button_menu/ButtonMenu';
function MoreButton({
workspace,
isHovered,
onDelete,
}: {
isHovered: boolean;
workspace: WorkspaceItem;
onDelete: (id: string) => void;
}) {
const { t } = useTranslation();
const options = useMemo(() => {
return [
{
key: 'settings',
icon: <SettingsIcon />,
label: t('settings.title'),
onClick: () => {
//
},
},
{
key: 'delete',
icon: <DeleteOutline />,
label: t('button.delete'),
onClick: () => onDelete(workspace.id),
},
];
}, [onDelete, t, workspace.id]);
return (
<>
<ButtonPopoverList
isVisible={isHovered}
popoverOrigin={{
anchorOrigin: {
vertical: 'bottom',
horizontal: 'left',
},
transformOrigin: {
vertical: 'top',
horizontal: 'left',
},
}}
popoverOptions={options}
>
<IconButton>
<MoreIcon />
</IconButton>
</ButtonPopoverList>
</>
);
}
export default MoreButton;

View File

@ -8,7 +8,7 @@ function WorkspaceNestedPages({ workspaceId }: { workspaceId: string }) {
}); });
return ( return (
<div className={'h-full'}> <div className={'h-full w-full overflow-x-hidden p-4 text-xs'}>
{pageIds?.map((pageId) => ( {pageIds?.map((pageId) => (
<NestedPage key={pageId} pageId={pageId} /> <NestedPage key={pageId} pageId={pageId} />
))} ))}

View File

@ -1,35 +1,29 @@
import React from 'react'; import React from 'react';
import AddSvg from '$app/components/_shared/svg/AddSvg';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ViewLayoutPB } from '@/services/backend'; import { useWorkspaceActions } from '$app/components/layout/workspace_manager/Workspace.hooks';
import { useNavigate } from 'react-router-dom'; import Button from '@mui/material/Button';
import { createCurrentWorkspaceChildView } from '$app/application/folder/workspace.service'; import { ReactComponent as AddSvg } from '$app/assets/add.svg';
function NewPageButton({ workspaceId }: { workspaceId: string }) { function NewPageButton({ workspaceId }: { workspaceId: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const { newPage } = useWorkspaceActions(workspaceId);
return ( return (
<div className={'flex h-[60px] w-full items-center border-t border-line-divider px-6 py-5'}> <div className={'flex h-[60px] w-full items-center border-t border-line-divider px-5 py-5'}>
<button <Button
onClick={async () => { color={'inherit'}
const { id } = await createCurrentWorkspaceChildView({ onClick={newPage}
name: '', startIcon={
layout: ViewLayoutPB.Document, <div className={'rounded-full bg-fill-default'}>
parent_view_id: workspaceId, <div className={'flex h-[18px] w-[18px] items-center justify-center px-0 text-lg text-content-on-fill'}>
}); <AddSvg />
</div>
navigate(`/page/document/${id}`);
}}
className={'flex items-center hover:text-fill-default'}
>
<div className={'mr-2 rounded-full bg-fill-default'}>
<div className={'h-[24px] w-[24px] text-content-on-fill'}>
<AddSvg />
</div> </div>
</div> }
className={'flex w-full items-center justify-start text-xs hover:bg-transparent hover:text-fill-default'}
>
{t('newPageText')} {t('newPageText')}
</button> </Button>
</div> </div>
); );
} }

View File

@ -30,14 +30,14 @@ function TrashButton() {
onDragLeave={onDragLeave} onDragLeave={onDragLeave}
data-page-id={'trash'} data-page-id={'trash'}
onClick={navigateToTrash} onClick={navigateToTrash}
className={`mx-1 my-3 flex h-[32px] w-[100%] items-center rounded-lg p-2 hover:bg-fill-list-hover ${ className={`my-3 flex h-[32px] w-[100%] cursor-pointer items-center gap-2 rounded-lg p-3.5 text-xs font-medium hover:bg-fill-list-hover ${
selected ? 'bg-fill-list-active' : '' selected ? 'bg-fill-list-active' : ''
} ${isDraggingOver ? 'bg-fill-list-hover' : ''}`} } ${isDraggingOver ? 'bg-fill-list-hover' : ''}`}
> >
<div className='h-6 w-6'> <div className='h-5 w-5'>
<TrashSvg /> <TrashSvg />
</div> </div>
<span className={'ml-2'}>{t('trash.text')}</span> <span>{t('trash.text')}</span>
</div> </div>
); );
} }

View File

@ -3,8 +3,10 @@ import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { workspaceActions, WorkspaceItem } from '$app_reducers/workspace/slice'; import { workspaceActions, WorkspaceItem } from '$app_reducers/workspace/slice';
import { Page, pagesActions, parserViewPBToPage } from '$app_reducers/pages/slice'; import { Page, pagesActions, parserViewPBToPage } from '$app_reducers/pages/slice';
import { subscribeNotifications } from '$app/application/notification'; import { subscribeNotifications } from '$app/application/notification';
import { FolderNotification } from '@/services/backend'; import { FolderNotification, ViewLayoutPB } from '@/services/backend';
import * as workspaceService from '$app/application/folder/workspace.service'; import * as workspaceService from '$app/application/folder/workspace.service';
import { createCurrentWorkspaceChildView } from '$app/application/folder/workspace.service';
import { useNavigate } from 'react-router-dom';
export function useLoadWorkspaces() { export function useLoadWorkspaces() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -95,3 +97,21 @@ export function useLoadWorkspace(workspace: WorkspaceItem) {
deleteWorkspace, deleteWorkspace,
}; };
} }
export function useWorkspaceActions(workspaceId: string) {
const navigate = useNavigate();
const newPage = useCallback(async () => {
const { id } = await createCurrentWorkspaceChildView({
name: '',
layout: ViewLayoutPB.Document,
parent_view_id: workspaceId,
});
navigate(`/page/document/${id}`);
}, [navigate, workspaceId]);
return {
newPage,
};
}

View File

@ -1,20 +1,65 @@
import React from 'react'; import React, { useState } from 'react';
import { WorkspaceItem } from '$app_reducers/workspace/slice'; import { WorkspaceItem } from '$app_reducers/workspace/slice';
import NestedViews from '$app/components/layout/workspace_manager/NestedPages'; import NestedViews from '$app/components/layout/workspace_manager/NestedPages';
import { useLoadWorkspace } from '$app/components/layout/workspace_manager/Workspace.hooks'; import { useLoadWorkspace, useWorkspaceActions } from '$app/components/layout/workspace_manager/Workspace.hooks';
import Typography from '@mui/material/Typography';
import { useTranslation } from 'react-i18next';
import { ReactComponent as AddIcon } from '$app/assets/add.svg';
import { IconButton } from '@mui/material';
import Tooltip from '@mui/material/Tooltip';
function Workspace({ workspace, opened }: { workspace: WorkspaceItem; opened: boolean }) { function Workspace({ workspace, opened }: { workspace: WorkspaceItem; opened: boolean }) {
useLoadWorkspace(workspace); useLoadWorkspace(workspace);
const { t } = useTranslation();
const { newPage } = useWorkspaceActions(workspace.id);
const [showPages, setShowPages] = useState(true);
const [showAdd, setShowAdd] = useState(false);
return ( return (
<> <>
<div <div
className={'w-full'}
style={{ style={{
height: opened ? '100%' : 0, height: opened ? '100%' : 0,
overflow: 'hidden', overflow: 'hidden',
transition: 'height 0.2s ease-in-out', transition: 'height 0.2s ease-in-out',
}} }}
> >
<NestedViews workspaceId={workspace.id} /> <div
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setShowPages(!showPages);
}}
onMouseEnter={() => {
setShowAdd(true);
}}
onMouseLeave={() => {
setShowAdd(false);
}}
className={'mt-2 flex h-[22px] w-full cursor-pointer select-none items-center justify-between px-4'}
>
<Tooltip disableInteractive={true} placement={'top-start'} title={t('sideBar.clickToHidePersonal')}>
<Typography className={'rounded px-2 py-1 text-xs font-medium hover:bg-fill-list-active'}>
{t('sideBar.personal')}
</Typography>
</Tooltip>
{showAdd && (
<Tooltip disableInteractive={true} title={t('sideBar.addAPage')}>
<IconButton
onClick={(e) => {
e.stopPropagation();
void newPage();
}}
size={'small'}
>
<AddIcon />
</IconButton>
</Tooltip>
)}
</div>
{showPages && <NestedViews workspaceId={workspace.id} />}
</div> </div>
</> </>
); );

View File

@ -8,18 +8,17 @@ function WorkspaceManager() {
const { workspaces, currentWorkspace } = useLoadWorkspaces(); const { workspaces, currentWorkspace } = useLoadWorkspaces();
return ( return (
<div className={'flex h-full flex-col justify-between'}> <div className={'workspaces flex h-full select-none flex-col justify-between'}>
<div className={'flex w-full flex-1 flex-col overflow-y-auto overflow-x-hidden'}> <div className={'mt-4 flex w-full flex-1 select-none flex-col overflow-y-auto overflow-x-hidden'}>
<div className={'flex-1'}> <div className={'flex-1'}>
{workspaces.map((workspace) => ( {workspaces.map((workspace) => (
<Workspace opened={currentWorkspace?.id === workspace.id} key={workspace.id} workspace={workspace} /> <Workspace opened={currentWorkspace?.id === workspace.id} key={workspace.id} workspace={workspace} />
))} ))}
</div> </div>
<div className={'flex w-[100%] items-center px-2'}>
<TrashButton />
</div>
</div> </div>
<div className={'flex w-[100%] items-center px-2'}>
<TrashButton />
</div>
{currentWorkspace && <NewPageButton workspaceId={currentWorkspace.id} />} {currentWorkspace && <NewPageButton workspaceId={currentWorkspace.id} />}
</div> </div>
); );

View File

@ -1,38 +0,0 @@
import React, { useState } from 'react';
import MoreButton from '$app/components/layout/workspace_manager/MoreButton';
import MenuItem from '@mui/material/MenuItem';
import { WorkspaceItem } from '$app_reducers/workspace/slice';
function WorkspaceTitle({
workspace,
openWorkspace,
onDelete,
}: {
openWorkspace: () => void;
onDelete: (id: string) => void;
workspace: WorkspaceItem;
}) {
const [isHovered, setIsHovered] = useState(false);
return (
<MenuItem
onClick={() => openWorkspace()}
onMouseEnter={() => {
setIsHovered(true);
}}
onMouseLeave={() => {
setIsHovered(false);
}}
className={'hover:bg-fill-list-active'}
>
<div className={'flex w-[100%] items-center justify-between'}>
<div className={'flex-1 font-bold text-text-caption'}>{workspace.name}</div>
<div className='flex h-[23px] w-auto items-center justify-end'>
<MoreButton workspace={workspace} isHovered={isHovered} onDelete={onDelete} />
</div>
</div>
</MenuItem>
);
}
export default WorkspaceTitle;

View File

@ -3,9 +3,9 @@ import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import { DeleteOutline, RestoreOutlined } from '@mui/icons-material'; import { DeleteOutline, RestoreOutlined } from '@mui/icons-material';
import { useLoadTrash, useTrashActions } from '$app/components/trash/Trash.hooks'; import { useLoadTrash, useTrashActions } from '$app/components/trash/Trash.hooks';
import { Divider, List } from '@mui/material'; import { List } from '@mui/material';
import TrashItem from '$app/components/trash/TrashItem'; import TrashItem from '$app/components/trash/TrashItem';
import DeleteConfirmDialog from '$app/components/_shared/delete_confirm_dialog/DeleteConfirmDialog'; import DeleteConfirmDialog from '$app/components/_shared/confirm_dialog/DeleteConfirmDialog';
function Trash() { function Trash() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -26,7 +26,7 @@ function Trash() {
return ( return (
<div className={'flex flex-col'}> <div className={'flex flex-col'}>
<div className={'flex items-center justify-between'}> <div className={'flex items-center justify-between'}>
<div className={'text-2xl font-bold'}>{t('trash.text')}</div> <div className={'px-2 text-lg font-bold'}>{t('trash.text')}</div>
<div className={'flex items-center justify-end'}> <div className={'flex items-center justify-end'}>
<Button color={'inherit'} onClick={() => onClickRestoreAll()}> <Button color={'inherit'} onClick={() => onClickRestoreAll()}>
<RestoreOutlined /> <RestoreOutlined />
@ -38,13 +38,12 @@ function Trash() {
</Button> </Button>
</div> </div>
</div> </div>
<div className={'flex justify-around p-6 px-2 text-text-caption'}> <div className={'flex justify-around gap-2 p-6 px-2 text-xs text-text-caption'}>
<div className={'w-[40%]'}>{t('trash.pageHeader.fileName')}</div> <div className={'w-[40%]'}>{t('trash.pageHeader.fileName')}</div>
<div className={'flex-1'}>{t('trash.pageHeader.lastModified')}</div> <div className={'flex-1'}>{t('trash.pageHeader.lastModified')}</div>
<div className={'flex-1'}>{t('trash.pageHeader.created')}</div> <div className={'flex-1'}>{t('trash.pageHeader.created')}</div>
<div className={'w-[64px]'}></div> <div className={'w-[64px]'}></div>
</div> </div>
<Divider />
<List> <List>
{trash.map((item) => ( {trash.map((item) => (
<TrashItem <TrashItem

View File

@ -34,23 +34,23 @@ function TrashItem({
paddingInline: 0, paddingInline: 0,
}} }}
> >
<div className={'flex w-[100%] items-center justify-around rounded-lg px-2 py-3 hover:bg-fill-list-hover'}> <div className={'flex w-[100%] items-center justify-around gap-2 rounded-lg p-2 text-xs hover:bg-fill-list-hover'}>
<div className={'w-[40%] text-left'}>{item.name}</div> <div className={'w-[40%] whitespace-break-spaces text-left'}>{item.name || t('document.title.placeholder')}</div>
<div className={'flex-1'}>{dayjs.unix(item.modifiedTime).format('MM/DD/YYYY hh:mm A')}</div> <div className={'flex-1'}>{dayjs.unix(item.modifiedTime).format('MM/DD/YYYY hh:mm A')}</div>
<div className={'flex-1'}>{dayjs.unix(item.createTime).format('MM/DD/YYYY hh:mm A')}</div> <div className={'flex-1'}>{dayjs.unix(item.createTime).format('MM/DD/YYYY hh:mm A')}</div>
<div <div
style={{ style={{
visibility: hoverId === item.id ? 'visible' : 'hidden', visibility: hoverId === item.id ? 'visible' : 'hidden',
}} }}
className={'w-[64px]'} className={'whitespace-nowrap'}
> >
<Tooltip placement={'top-start'} title={t('button.putback')}> <Tooltip placement={'top-start'} title={t('button.putback')}>
<IconButton onClick={(_) => onPutback(item.id)} className={'mr-2'}> <IconButton size={'small'} onClick={(_) => onPutback(item.id)} className={'mr-2'}>
<RestoreOutlined /> <RestoreOutlined />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip placement={'top-start'} title={t('button.delete')}> <Tooltip placement={'top-start'} title={t('button.delete')}>
<IconButton color={'error'} onClick={(_) => onDelete([item.id])}> <IconButton size={'small'} color={'error'} onClick={(_) => onDelete([item.id])}>
<DeleteOutline /> <DeleteOutline />
</IconButton> </IconButton>
</Tooltip> </Tooltip>

View File

@ -0,0 +1,26 @@
import { ViewLayoutPB } from '@/services/backend';
import React from 'react';
import { Page } from '$app_reducers/pages/slice';
import { ReactComponent as DocumentIcon } from '$app/assets/document.svg';
import { ReactComponent as GridIcon } from '$app/assets/grid.svg';
import { ReactComponent as BoardIcon } from '$app/assets/board.svg';
import { ReactComponent as CalendarIcon } from '$app/assets/date.svg';
export function getPageIcon(page: Page) {
if (page.icon) {
return page.icon.value;
}
switch (page.layout) {
case ViewLayoutPB.Document:
return <DocumentIcon className={'h-4 w-4'} />;
case ViewLayoutPB.Grid:
return <GridIcon className={'h-4 w-4'} />;
case ViewLayoutPB.Board:
return <BoardIcon className={'h-4 w-4'} />;
case ViewLayoutPB.Calendar:
return <CalendarIcon className={'h-4 w-4'} />;
default:
return null;
}
}

View File

@ -8,6 +8,7 @@ export interface UserSetting {
theme?: Theme; theme?: Theme;
themeMode?: ThemeMode; themeMode?: ThemeMode;
language?: string; language?: string;
isDark?: boolean;
} }
export enum Theme { export enum Theme {

View File

@ -14,7 +14,7 @@ export const movePageThunk = createAsyncThunk(
thunkAPI thunkAPI
) => { ) => {
const { sourceId, targetId, insertType } = payload; const { sourceId, targetId, insertType } = payload;
const { getState } = thunkAPI; const { getState, dispatch } = thunkAPI;
const { pageMap, relationMap } = (getState() as RootState).pages; const { pageMap, relationMap } = (getState() as RootState).pages;
const sourcePage = pageMap[sourceId]; const sourcePage = pageMap[sourceId];
const targetPage = pageMap[targetId]; const targetPage = pageMap[targetId];
@ -51,6 +51,8 @@ export const movePageThunk = createAsyncThunk(
} }
} }
dispatch(pagesActions.movePage({ id: sourceId, newParentId: parentId, prevId }));
await movePage({ await movePage({
view_id: sourceId, view_id: sourceId,
new_parent_id: parentId, new_parent_id: parentId,

View File

@ -89,10 +89,85 @@ export const pagesSlice = createSlice({
} }
}, },
removeChildPages(state, action: PayloadAction<string>) { addPage(
const parentId = action.payload; state,
action: PayloadAction<{
page: Page;
isLast?: boolean;
prevId?: string;
}>
) {
const { page, prevId, isLast } = action.payload;
delete state.relationMap[parentId]; state.pageMap[page.id] = page;
state.relationMap[page.id] = [];
const parentId = page.parentId;
if (isLast) {
state.relationMap[parentId]?.push(page.id);
} else {
const index = prevId ? state.relationMap[parentId]?.indexOf(prevId) ?? -1 : -1;
state.relationMap[parentId]?.splice(index + 1, 0, page.id);
}
},
deletePages(state, action: PayloadAction<string[]>) {
const ids = action.payload;
ids.forEach((id) => {
const parentId = state.pageMap[id].parentId;
const parentChildren = state.relationMap[parentId];
state.relationMap[parentId] = parentChildren && parentChildren.filter((childId) => childId !== id);
delete state.relationMap[id];
delete state.expandedIdMap[id];
delete state.pageMap[id];
});
},
duplicatePage(
state,
action: PayloadAction<{
id: string;
newId: string;
}>
) {
const { id, newId } = action.payload;
const page = state.pageMap[id];
const newPage = { ...page, id: newId };
state.pageMap[newPage.id] = newPage;
const index = state.relationMap[page.parentId]?.indexOf(id);
state.relationMap[page.parentId]?.splice(index ?? 0, 0, newId);
},
movePage(
state,
action: PayloadAction<{
id: string;
newParentId: string;
prevId?: string;
}>
) {
const { id, newParentId, prevId } = action.payload;
const parentId = state.pageMap[id].parentId;
const parentChildren = state.relationMap[parentId];
const index = parentChildren?.indexOf(id) ?? -1;
if (index > -1) {
state.relationMap[parentId]?.splice(index, 1);
}
state.pageMap[id].parentId = newParentId;
const newParentChildren = state.relationMap[newParentId] || [];
const prevIndex = prevId ? newParentChildren.indexOf(prevId) : -1;
state.relationMap[newParentId]?.splice(prevIndex + 1, 0, id);
}, },
expandPage(state, action: PayloadAction<string>) { expandPage(state, action: PayloadAction<string>) {

View File

@ -0,0 +1,12 @@
export const isMac = () => {
return navigator.userAgent.includes('Mac OS X');
};
const MODIFIERS = {
control: 'Ctrl',
meta: '⌘',
};
export const getModifier = () => {
return isMac() ? MODIFIERS.meta : MODIFIERS.control;
};

View File

@ -1,11 +1,8 @@
import { ThemeMode } from '$app/stores/reducers/current-user/slice';
import { ThemeOptions } from '@mui/material'; import { ThemeOptions } from '@mui/material';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
export const getDesignTokens = (mode: ThemeMode): ThemeOptions => { export const getDesignTokens = (isDark: boolean): ThemeOptions => {
const isDark = mode === ThemeMode.Dark;
return { return {
typography: { typography: {
fontFamily: ['Poppins'].join(','), fontFamily: ['Poppins'].join(','),

View File

@ -24,10 +24,6 @@ body {
width: 8px; width: 8px;
} }
.MuiPopover-root::-webkit-scrollbar {
width: 0;
height: 0;
}
:root[data-dark-mode=true] body { :root[data-dark-mode=true] body {

View File

@ -879,7 +879,9 @@
"page": { "page": {
"label": "Link to page", "label": "Link to page",
"tooltip": "Click to open page" "tooltip": "Click to open page"
} },
"deleted": "Deleted",
"deletedContent": "This content does not exist or has been deleted"
}, },
"toolbar": { "toolbar": {
"resetToDefaultFont": "Reset to default" "resetToDefaultFont": "Reset to default"