diff --git a/src/@types/translations/en.json b/src/@types/translations/en.json index 5bdbef0..38a8b99 100644 --- a/src/@types/translations/en.json +++ b/src/@types/translations/en.json @@ -436,7 +436,8 @@ "gotIt": "Got it", "retry": "Retry", "uploadFailed": "Upload failed.", - "copyLinkOriginal": "Copy link to original" + "copyLinkOriginal": "Copy link to original", + "search": "Search" }, "label": { "welcome": "Welcome!", @@ -3039,5 +3040,7 @@ "memberCount_zero": "{{count}} members", "memberCount_one": "{{count}} member", "memberCount_many": "{{count}} members", - "memberCount_other": "{{count}} members" + "memberCount_other": "{{count}} members", + "aiMatch": "AI match", + "titleMatch": "Title match" } diff --git a/src/application/services/js-services/http/http_api.ts b/src/application/services/js-services/http/http_api.ts index 6d5f76c..bc03fa5 100644 --- a/src/application/services/js-services/http/http_api.ts +++ b/src/application/services/js-services/http/http_api.ts @@ -1679,5 +1679,26 @@ export async function cancelSubscription (workspaceId: string, plan: Subscriptio return; } + return Promise.reject(res?.data); +} + +export async function searchWorkspace (workspaceId: string, query: string) { + const url = `/api/search/${workspaceId}`; + const res = await axiosInstance?.get<{ + code: number; + data: { + object_id: string + }[]; + message: string; + }>(url, { + params: { + query, + }, + }); + + if (res?.data.code === 0) { + return res?.data.data.map(item => item.object_id); + } + return Promise.reject(res?.data); } \ No newline at end of file diff --git a/src/application/services/js-services/index.ts b/src/application/services/js-services/index.ts index 81dbe73..ffe1de5 100644 --- a/src/application/services/js-services/index.ts +++ b/src/application/services/js-services/index.ts @@ -568,4 +568,8 @@ export class AFClientService implements AFService { deleteQuickNote (workspaceId: string, id: string) { return APIService.deleteQuickNote(workspaceId, id); } + + searchWorkspace(workspaceId: string, query: string) { + return APIService.searchWorkspace(workspaceId, query); + } } diff --git a/src/application/services/js-services/sync.ts b/src/application/services/js-services/sync.ts index 231d560..6249252 100644 --- a/src/application/services/js-services/sync.ts +++ b/src/application/services/js-services/sync.ts @@ -29,7 +29,7 @@ export class SyncManager { private setupListener () { this.doc.on('update', (_update: Uint8Array, origin: CollabOrigin) => { if (origin === CollabOrigin.Remote) return; - + console.log('Local changes detected. Sending update...', origin); this.debouncedSendUpdate(); }); } @@ -118,6 +118,7 @@ export class SyncManager { public initialize () { if (this.hasUnsyncedChanges) { + console.log('Unsynced changes found. Sending update...'); // Send an update if there are unsynced changes this.debouncedSendUpdate(); } diff --git a/src/application/services/services.type.ts b/src/application/services/services.type.ts index 2cd6f59..bfb168a 100644 --- a/src/application/services/services.type.ts +++ b/src/application/services/services.type.ts @@ -53,6 +53,7 @@ export interface WorkspaceService { deleteWorkspace: (workspaceId: string) => Promise; getWorkspaceMembers: (workspaceId: string) => Promise; inviteMembers: (workspaceId: string, emails: string[]) => Promise; + searchWorkspace: (workspaceId: string, searchTerm: string) => Promise; } export interface AppService { diff --git a/src/application/ydoc/apply/index.ts b/src/application/ydoc/apply/index.ts index d4d7188..fb49259 100644 --- a/src/application/ydoc/apply/index.ts +++ b/src/application/ydoc/apply/index.ts @@ -12,7 +12,7 @@ export function applyYDoc(doc: Y.Doc, state: Uint8Array) { doc, () => { try { - Y.applyUpdate(doc, state); + Y.applyUpdate(doc, state, CollabOrigin.Remote); } catch (e) { console.error('Error applying', doc, e); throw e; diff --git a/src/components/_shared/view-icon/PageIcon.tsx b/src/components/_shared/view-icon/PageIcon.tsx index 38af645..6f98f31 100644 --- a/src/components/_shared/view-icon/PageIcon.tsx +++ b/src/components/_shared/view-icon/PageIcon.tsx @@ -21,7 +21,7 @@ function PageIcon({ }) { const emoji = useMemo(() => { - if (view.icon && view.icon.ty === ViewIconType.Emoji) { + if (view.icon && view.icon.ty === ViewIconType.Emoji && view.icon.value) { return view.icon.value; } @@ -33,7 +33,7 @@ function PageIcon({ }, [emoji]); const icon = useMemo(() => { - if (view.icon && view.icon.ty === ViewIconType.Icon) { + if (view.icon && view.icon.ty === ViewIconType.Icon && view.icon.value) { const json = JSON.parse(view.icon.value); const cleanSvg = DOMPurify.sanitize(json.iconContent.replaceAll('black', renderColor(json.color)).replace(' import('@/components/app/SideBarBottom')); @@ -41,12 +42,16 @@ function SideBar({ className={'flex w-full gap-1 flex-1 flex-col'} >
-
10 ? 'var(--line-divider)' : undefined, - }} className={'flex border-b pb-2 w-full border-transparent'}> - + +
10 ? 'var(--line-divider)' : undefined, + }} + className={'flex border-b pb-2 w-full border-transparent'} + > +
@@ -55,7 +60,7 @@ function SideBar({ width={drawerWidth} /> - +
); diff --git a/src/components/app/search/BestMatch.tsx b/src/components/app/search/BestMatch.tsx new file mode 100644 index 0000000..ac2722f --- /dev/null +++ b/src/components/app/search/BestMatch.tsx @@ -0,0 +1,62 @@ +import { View } from '@/application/types'; +import { notify } from '@/components/_shared/notify'; +import { findView } from '@/components/_shared/outline/utils'; +import { useAppOutline, useCurrentWorkspaceId } from '@/components/app/app.hooks'; +import ViewList from '@/components/app/search/ViewList'; +import { useService } from '@/components/main/app.hooks'; +import { debounce } from 'lodash-es'; +import React, { useCallback, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +function BestMatch ({ + onClose, + searchValue +}: { + onClose: () => void; + searchValue: string; +}) { + const [views, setViews] = React.useState([]); + const { t } = useTranslation(); + const outline = useAppOutline(); + const [loading, setLoading] = React.useState(false); + const service = useService(); + const currentWorkspaceId = useCurrentWorkspaceId() + const handleSearch = useCallback(async (searchTerm: string) => { + if (!outline) return; + if (!currentWorkspaceId || !service) return; + if (!searchTerm) { + setViews([]); + return; + } + + setLoading(true) + + try { + const res = await service.searchWorkspace(currentWorkspaceId, searchTerm); + const views = res.map(id => { + return findView(outline, id); + }); + + setViews(views.filter(Boolean) as View[]); + // eslint-disable-next-line + } catch (e: any) { + notify.error(e.message); + } + + setLoading(false); + + }, [currentWorkspaceId, outline, service]); + + const debounceSearch = useMemo(() => { + return debounce(handleSearch, 300); + }, [handleSearch]); + + useEffect(() => { + void debounceSearch(searchValue); + }, [searchValue, debounceSearch]); + + + return +} + +export default BestMatch; \ No newline at end of file diff --git a/src/components/app/search/RecentViews.tsx b/src/components/app/search/RecentViews.tsx new file mode 100644 index 0000000..74bbb4d --- /dev/null +++ b/src/components/app/search/RecentViews.tsx @@ -0,0 +1,32 @@ +import { useAppRecent } from '@/components/app/app.hooks'; +import ViewList from '@/components/app/search/ViewList'; +import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +function RecentViews ({ + onClose +}: { + onClose: () => void; +}) { + const { + recentViews, + loadRecentViews + } = useAppRecent(); + const { t } = useTranslation(); + const [loading, setLoading] = React.useState(false); + + useEffect(() => { + void (async () => { + setLoading(true); + await loadRecentViews?.(); + setLoading(false); + })(); + }, [loadRecentViews]); + + + return ( + + ); +} + +export default RecentViews; \ No newline at end of file diff --git a/src/components/app/search/Search.tsx b/src/components/app/search/Search.tsx new file mode 100644 index 0000000..c42462c --- /dev/null +++ b/src/components/app/search/Search.tsx @@ -0,0 +1,147 @@ +import { Popover } from '@/components/_shared/popover'; +import BestMatch from '@/components/app/search/BestMatch'; +import RecentViews from '@/components/app/search/RecentViews'; +import TitleMatch from '@/components/app/search/TitleMatch'; +import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys'; +import { Button, Dialog, InputBase, Tooltip } from '@mui/material'; +import React, { useCallback, useEffect } from 'react'; +import { ReactComponent as SearchIcon } from '@/assets/search.svg'; +import { ReactComponent as CheckIcon } from '@/assets/check.svg'; +import { ReactComponent as DownIcon } from '@/assets/chevron_down.svg'; + +import { useTranslation } from 'react-i18next'; +import { ReactComponent as CloseIcon } from '@/assets/close.svg'; + +enum SEARCH_TYPE { + AI_SUGGESTION = 'AI_SUGGESTION', + TITLE_MATCH = 'TITLE_MATCH', +} + +export function Search () { + const [open, setOpen] = React.useState(false); + const { t } = useTranslation(); + const [searchValue, setSearchValue] = React.useState(''); + const [searchType, setSearchType] = React.useState(SEARCH_TYPE.TITLE_MATCH); + const [searchTypeAnchorEl, setSearchTypeAnchorEl] = React.useState(null); + + const handleClose = () => { + setOpen(false); + setSearchValue(''); + }; + + const onKeyDown = useCallback((e: KeyboardEvent) => { + switch (true) { + + case createHotkey(HOT_KEY_NAME.SEARCH)(e): + e.preventDefault(); + setOpen(true); + break; + default: + break; + } + }, []); + + useEffect(() => { + + document.addEventListener('keydown', onKeyDown, true); + return () => { + document.removeEventListener('keydown', onKeyDown, true); + }; + }, [onKeyDown]); + + return ( + <> + + +
+
+ + setSearchValue(e.target.value)} + autoFocus={true} + className={'flex-1'} + fullWidth={true} + placeholder={t('commandPalette.placeholder')} + /> + e.preventDefault()} + onClick={(e) => { + e.preventDefault(); + setSearchValue(''); + }} + > + +
{ + setSearchTypeAnchorEl(e.currentTarget); + }} + className={'cursor-pointer flex items-center p-1 px-2 text-xs rounded bg-fill-list-hover'} + > + { + searchType === SEARCH_TYPE.TITLE_MATCH ? + t('titleMatch') : + t('aiMatch') + } + +
+
+
+
+ {!searchValue ? : searchType === SEARCH_TYPE.AI_SUGGESTION ? : } +
+ setSearchTypeAnchorEl(null)} + slotProps={{ + paper: { + className: 'p-2 w-fit my-2', + }, + }} + > + {[SEARCH_TYPE.TITLE_MATCH, SEARCH_TYPE.AI_SUGGESTION].map(type => ( +
{ + setSearchType(type); + setSearchTypeAnchorEl(null); + }} + > + {type === SEARCH_TYPE.TITLE_MATCH ? t('titleMatch') : t('aiMatch')} + {type === searchType && } +
+ ))} +
+ + ); +} + +export default Search; \ No newline at end of file diff --git a/src/components/app/search/TitleMatch.tsx b/src/components/app/search/TitleMatch.tsx new file mode 100644 index 0000000..c733580 --- /dev/null +++ b/src/components/app/search/TitleMatch.tsx @@ -0,0 +1,32 @@ +import { filterViews } from '@/components/_shared/outline/utils'; +import { useAppOutline } from '@/components/app/app.hooks'; +import ViewList from '@/components/app/search/ViewList'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +function TitleMatch ({ + onClose, + searchValue, +}: { + onClose: () => void; + searchValue: string; +}) { + const outline = useAppOutline(); + const { t } = useTranslation(); + + const views = useMemo(() => { + if (!outline) return []; + return filterViews(outline, searchValue); + }, [outline, searchValue]); + + return ( + + ); +} + +export default TitleMatch; \ No newline at end of file diff --git a/src/components/app/search/ViewList.tsx b/src/components/app/search/ViewList.tsx new file mode 100644 index 0000000..31d6bce --- /dev/null +++ b/src/components/app/search/ViewList.tsx @@ -0,0 +1,115 @@ +import { View } from '@/application/types'; +import PageIcon from '@/components/_shared/view-icon/PageIcon'; +import { useAppHandlers } from '@/components/app/app.hooks'; +import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys'; +import CircularProgress from '@mui/material/CircularProgress'; +import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +function ViewList ({ + title, + views, + onClose, + loading +}: { + title: string; + views?: View[]; + onClose: () => void; + loading: boolean; +}) { + const { t } = useTranslation(); + const [selectedView, setSelectedView] = React.useState(''); + const { toView: navigateToView } = useAppHandlers(); + const ref = React.useRef(null); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!views) return; + if (createHotkey(HOT_KEY_NAME.ENTER)(e) && selectedView) { + e.preventDefault(); + e.stopPropagation(); + void navigateToView(selectedView); + onClose(); + } else if (createHotkey(HOT_KEY_NAME.DOWN)(e) || createHotkey(HOT_KEY_NAME.UP)(e) || createHotkey(HOT_KEY_NAME.TAB)(e)) { + e.preventDefault(); + const currentIndex = views.findIndex(view => view.view_id === selectedView); + let nextViewId = ''; + + if (currentIndex === -1) { + nextViewId = views[0].view_id; + } else { + if (createHotkey(HOT_KEY_NAME.DOWN)(e) || createHotkey(HOT_KEY_NAME.TAB)(e)) { + nextViewId = views[(currentIndex + 1) % views.length].view_id; + } else { + nextViewId = views[(currentIndex - 1 + views.length) % views.length].view_id; + } + } + + setSelectedView(nextViewId); + const el = ref.current?.querySelector(`[data-item-id="${nextViewId}"]`); + + if (el) { + el.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'nearest' + }); + } + + } + } + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + } + + }, [navigateToView, onClose, views, selectedView]); + return ( +
+
+ {title} +
+
+ {views?.length ? views.map(view => ( +
{ + void navigateToView(view.view_id); + onClose(); + }} + key={view.view_id} + className={'flex items-center border-t border-line-default w-full p-4 cursor-pointer hover:bg-fill-list-active gap-2'} + > + +
+ {view.name.trim() || t('menuAppHeader.defaultNewPageName')} +
+
+ )) :
+ {t('findAndReplace.noResult')} +
} + {loading && +
+ +
+ } +
+
+ TAB + to navigate +
+
+ ); +} + +export default ViewList; \ No newline at end of file diff --git a/src/components/app/search/index.ts b/src/components/app/search/index.ts new file mode 100644 index 0000000..0215c2a --- /dev/null +++ b/src/components/app/search/index.ts @@ -0,0 +1 @@ +export * from './Search' \ No newline at end of file diff --git a/src/components/app/view-actions/NewPage.tsx b/src/components/app/view-actions/NewPage.tsx index c52035e..1e18d2f 100644 --- a/src/components/app/view-actions/NewPage.tsx +++ b/src/components/app/view-actions/NewPage.tsx @@ -65,7 +65,7 @@ function NewPage() { onClick={() => setOpen(true)} startIcon={} size={'small'} - className={'text-sm font-normal py-1 justify-start w-full hover:bg-fill-list-hover'} + className={'text-sm font-normal py-1.5 justify-start w-full hover:bg-fill-list-hover'} color={'inherit'} > {t('newPageText')} diff --git a/src/styles/variables/dark.variables.css b/src/styles/variables/dark.variables.css index 907cf60..a631bac 100644 --- a/src/styles/variables/dark.variables.css +++ b/src/styles/variables/dark.variables.css @@ -13,6 +13,7 @@ --icon-on-toolbar: white; --line-border: #59647a; --line-divider: #384967; + --line-default: #2e2e2e; --line-on-toolbar: #99a6b8; --line-card: #384967; --fill-default: #00bcf0; diff --git a/src/styles/variables/light.variables.css b/src/styles/variables/light.variables.css index 8ff71b0..884fdd2 100644 --- a/src/styles/variables/light.variables.css +++ b/src/styles/variables/light.variables.css @@ -14,6 +14,7 @@ --icon-on-toolbar: #ffffff; --line-border: #bdbdbd; --line-divider: #1F232923; + --line-default: #f3f4f6; --line-on-toolbar: #4f4f4f; --line-card: #1F23291E; --fill-toolbar: #333333; diff --git a/src/utils/hotkeys.ts b/src/utils/hotkeys.ts index 23c0f3e..cd73a2d 100644 --- a/src/utils/hotkeys.ts +++ b/src/utils/hotkeys.ts @@ -15,6 +15,7 @@ export const getModifier = () => { export enum HOT_KEY_NAME { ENTER = 'enter', + TAB = 'tab', CLEAR_CACHE = 'clear-cache', UP = 'up', DOWN = 'down', @@ -62,6 +63,7 @@ export enum HOT_KEY_NAME { TOGGLE_THEME = 'toggle-theme', TOGGLE_SIDEBAR = 'toggle-sidebar', QUICK_NOTE = 'quick-note', + SEARCH = 'search', } const defaultHotKeys = { @@ -110,6 +112,8 @@ const defaultHotKeys = { [HOT_KEY_NAME.MOVE_CURSOR_TO_TOP]: ['mod+up'], [HOT_KEY_NAME.ENTER]: ['enter'], [HOT_KEY_NAME.QUICK_NOTE]: ['mod+/'], + [HOT_KEY_NAME.SEARCH]: ['mod+p'], + [HOT_KEY_NAME.TAB]: ['tab'], }; const replaceModifier = (hotkey: string) => { diff --git a/tailwind/box-shadow.cjs b/tailwind/box-shadow.cjs index 510e8f3..7e3990a 100644 --- a/tailwind/box-shadow.cjs +++ b/tailwind/box-shadow.cjs @@ -1,7 +1,7 @@ /** * Do not edit directly -* Generated on Tue, 24 Dec 2024 08:57:39 GMT +* Generated on Fri, 03 Jan 2025 08:02:02 GMT * Generated from $pnpm css:variables */ diff --git a/tailwind/colors.cjs b/tailwind/colors.cjs index 71c1759..e9ca222 100644 --- a/tailwind/colors.cjs +++ b/tailwind/colors.cjs @@ -1,7 +1,7 @@ /** * Do not edit directly -* Generated on Tue, 24 Dec 2024 08:57:39 GMT +* Generated on Fri, 03 Jan 2025 08:02:02 GMT * Generated from $pnpm css:variables */ @@ -26,6 +26,7 @@ module.exports = { "line": { "border": "var(--line-border)", "divider": "var(--line-divider)", + "default": "var(--line-default)", "on-toolbar": "var(--line-on-toolbar)", "card": "var(--line-card)" },