diff --git a/src/@types/translations/en.json b/src/@types/translations/en.json index 5bdbef06..3d011128 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!", @@ -507,6 +508,8 @@ "namespaceLengthAtLeast2Characters": "The namespace must be at least 2 characters long", "onlyWorkspaceOwnerCanUpdateNamespace": "Only workspace owner can update the namespace", "onlyWorkspaceOwnerCanRemoveHomepage": "Only workspace owner can remove the homepage", + "onlyWorkspaceOwnerCanChangeHomepage": "Only workspace owner can change the homepage", + "onlyProCanSetHomepage": "Only Pro Plan workspace owner can set the homepage", "setHomepageFailed": "Failed to set homepage", "namespaceTooLong": "The namespace is too long, please try another one", "namespaceTooShort": "The namespace is too short, please try another one", @@ -3039,5 +3042,10 @@ "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", + "namespace": "Namespace", + "manageNamespaceDescription": "Manage your namespace and homepage", + "homepage": "Homepage" } diff --git a/src/application/services/js-services/http/http_api.ts b/src/application/services/js-services/http/http_api.ts index 6d5f76cd..069211fa 100644 --- a/src/application/services/js-services/http/http_api.ts +++ b/src/application/services/js-services/http/http_api.ts @@ -21,7 +21,13 @@ import { CreateSpacePayload, UpdateSpacePayload, Role, - WorkspaceMember, QuickNote, QuickNoteEditorData, CreateWorkspacePayload, UpdateWorkspacePayload, + WorkspaceMember, + QuickNote, + QuickNoteEditorData, + CreateWorkspacePayload, + UpdateWorkspacePayload, + PublishViewPayload, + UploadPublishNamespacePayload, } from '@/application/types'; import { GlobalComment, Reaction } from '@/application/comment.type'; import { initGrantService, refreshToken } from '@/application/services/js-services/http/gotrue'; @@ -328,6 +334,48 @@ export async function getUserWorkspaceInfo (): Promise<{ return Promise.reject(data); } +export async function publishView (workspaceId: string, viewId: string, payload?: PublishViewPayload) { + const url = `/api/workspace/${workspaceId}/page-view/${viewId}/publish`; + const response = await axiosInstance?.post<{ + code: number; + message: string; + }>(url, payload); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data); +} + +export async function unpublishView (workspaceId: string, viewId: string) { + const url = `/api/workspace/${workspaceId}/page-view/${viewId}/unpublish`; + const response = await axiosInstance?.post<{ + code: number; + message: string; + }>(url); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data); +} + +export async function updatePublishNamespace (workspaceId: string, payload: UploadPublishNamespacePayload) { + const url = `/api/workspace/${workspaceId}/publish-namespace`; + const response = await axiosInstance?.put<{ + code: number; + message: string; + }>(url, payload); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data); +} + export async function getPublishViewMeta (namespace: string, publishName: string) { const url = `/api/workspace/v1/published/${namespace}/${publishName}`; const response = await axiosInstance?.get<{ @@ -491,6 +539,9 @@ export async function getPublishInfoWithViewId (viewId: string) { data?: { namespace: string; publish_name: string; + publisher_email: string; + view_id: string; + publish_timestamp: string; }; message: string; }>(url); @@ -596,6 +647,75 @@ export async function getView (workspaceId: string, viewId: string, depth: numbe return Promise.reject(data); } +export async function getPublishNamespace (workspaceId: string) { + const url = `/api/workspace/${workspaceId}/publish-namespace`; + const response = await axiosInstance?.get<{ + code: number; + data?: string; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data; + } + + return Promise.reject(data); +} + +export async function getPublishHomepage (workspaceId: string) { + const url = `/api/workspace/${workspaceId}/publish-default`; + const response = await axiosInstance?.get<{ + code: number; + data?: { + namespace: string; + publish_name: string; + publisher_email: string; + view_id: string; + }; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data; + } + + return Promise.reject(data); +} + +export async function updatePublishHomepage (workspaceId: string, viewId: string) { + const url = `/api/workspace/${workspaceId}/publish-default`; + const response = await axiosInstance?.put<{ + code: number; + message: string; + }>(url, { + view_id: viewId, + }); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data); +} + +export async function removePublishHomepage (workspaceId: string) { + const url = `/api/workspace/${workspaceId}/publish-default`; + const response = await axiosInstance?.delete<{ + code: number; + message: string; + }>(url); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data); +} + export async function getPublishOutline (publishNamespace: string) { const url = `/api/workspace/published-outline/${publishNamespace}`; const response = await axiosInstance?.get<{ @@ -1679,5 +1799,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 81dbe739..a2560aa9 100644 --- a/src/application/services/js-services/index.ts +++ b/src/application/services/js-services/index.ts @@ -31,9 +31,9 @@ import { import { CreatePagePayload, CreateSpacePayload, CreateWorkspacePayload, DatabaseRelations, - DuplicatePublishView, QuickNoteEditorData, + DuplicatePublishView, PublishViewPayload, QuickNoteEditorData, SubscriptionInterval, SubscriptionPlan, - Types, UpdatePagePayload, UpdateSpacePayload, UpdateWorkspacePayload, WorkspaceMember, + Types, UpdatePagePayload, UpdateSpacePayload, UpdateWorkspacePayload, UploadPublishNamespacePayload, WorkspaceMember, YjsEditorKey, } from '@/application/types'; import { applyYDoc } from '@/application/ydoc/apply'; @@ -65,6 +65,43 @@ export class AFClientService implements AFService { return this.clientId; } + async publishView (workspaceId: string, viewId: string, payload?: PublishViewPayload) { + if (this.publishViewInfo.has(viewId)) { + this.publishViewInfo.delete(viewId); + } + + return APIService.publishView(workspaceId, viewId, payload); + } + + async unpublishView (workspaceId: string, viewId: string) { + if (this.publishViewInfo.has(viewId)) { + this.publishViewInfo.delete(viewId); + } + + return APIService.unpublishView(workspaceId, viewId); + } + + async updatePublishNamespace (workspaceId: string, payload: UploadPublishNamespacePayload) { + this.publishViewInfo.clear(); + return APIService.updatePublishNamespace(workspaceId, payload); + } + + async getPublishNamespace (workspaceId: string) { + return APIService.getPublishNamespace(workspaceId); + } + + async getPublishHomepage (workspaceId: string) { + return APIService.getPublishHomepage(workspaceId); + } + + async updatePublishHomepage (workspaceId: string, viewId: string) { + return APIService.updatePublishHomepage(workspaceId, viewId); + } + + async removePublishHomepage (workspaceId: string) { + return APIService.removePublishHomepage(workspaceId); + } + async getPublishViewMeta (namespace: string, publishName: string) { const name = `${namespace}_${publishName}`; @@ -165,6 +202,9 @@ export class AFClientService implements AFService { return this.publishViewInfo.get(viewId) as { namespace: string; publishName: string; + publisherEmail: string; + viewId: string; + publishedAt: string; }; } @@ -179,6 +219,9 @@ export class AFClientService implements AFService { const data = { namespace, publishName: info.publish_name, + publisherEmail: info.publisher_email, + viewId: info.view_id, + publishedAt: info.publish_timestamp, }; this.publishViewInfo.set(viewId, data); @@ -568,4 +611,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 231d5605..62492524 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 2cd6f593..5d910182 100644 --- a/src/application/services/services.type.ts +++ b/src/application/services/services.type.ts @@ -19,7 +19,12 @@ import { UpdateSpacePayload, WorkspaceMember, QuickNoteEditorData, - QuickNote, Subscription, CreateWorkspacePayload, UpdateWorkspacePayload, + QuickNote, + Subscription, + CreateWorkspacePayload, + UpdateWorkspacePayload, + PublishViewPayload, + UploadPublishNamespacePayload, } from '@/application/types'; import { GlobalComment, Reaction } from '@/application/comment.type'; import { ViewMeta } from '@/application/db/tables/view_metas'; @@ -53,6 +58,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 { @@ -137,11 +143,22 @@ export interface TemplateService { } export interface PublishService { - + publishView: (workspaceId: string, viewId: string, payload?: PublishViewPayload) => Promise; + unpublishView: (workspaceId: string, viewId: string) => Promise; + updatePublishNamespace: (workspaceId: string, payload: UploadPublishNamespacePayload) => Promise; getPublishViewMeta: (namespace: string, publishName: string) => Promise; getPublishView: (namespace: string, publishName: string) => Promise; getPublishRowDocument: (viewId: string) => Promise; - getPublishInfo: (viewId: string) => Promise<{ namespace: string; publishName: string }>; + getPublishInfo: (viewId: string) => Promise<{ + namespace: string; + publishName: string, + publisherEmail: string, + publishedAt: string + }>; + getPublishNamespace: (namespace: string) => Promise; + getPublishHomepage: (workspaceId: string) => Promise<{ view_id: string }>; + updatePublishHomepage: (workspaceId: string, viewId: string) => Promise; + removePublishHomepage: (workspaceId: string) => Promise; getPublishOutline (namespace: string): Promise; diff --git a/src/application/types.ts b/src/application/types.ts index 629b0c06..65e97a22 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -684,6 +684,16 @@ export enum CollabOrigin { } +export interface PublishViewPayload { + publish_name?: string; + visible_database_view_ids?: string[]; +} + +export interface UploadPublishNamespacePayload { + old_namespace: string; + new_namespace: string; +} + export const layoutMap = { [ViewLayout.Document]: 'document', [ViewLayout.Grid]: 'grid', @@ -1020,4 +1030,11 @@ export interface CreateWorkspacePayload { export interface UpdateWorkspacePayload { workspace_name: string; +} + +export enum SettingMenuItem { + ACCOUNT = 'ACCOUNT', + WORKSPACE = 'WORKSPACE', + MEMBERS = 'MEMBERS', + SITES = 'SITES', } \ No newline at end of file diff --git a/src/application/ydoc/apply/index.ts b/src/application/ydoc/apply/index.ts index d4d7188e..fb492590 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/modal/NormalModal.tsx b/src/components/_shared/modal/NormalModal.tsx index 3fbcd629..40eb67a9 100644 --- a/src/components/_shared/modal/NormalModal.tsx +++ b/src/components/_shared/modal/NormalModal.tsx @@ -7,7 +7,7 @@ import { ReactComponent as CloseIcon } from '@/assets/close.svg'; // @ts-ignore export interface NormalModalProps extends DialogProps { okText?: string; - cancelText?: string; + cancelText?: string | React.ReactNode; onOk?: () => void; onCancel?: () => void; danger?: boolean; @@ -17,6 +17,7 @@ export interface NormalModalProps extends DialogProps { cancelButtonProps?: ButtonProps; okLoading?: boolean; closable?: boolean; + overflowHidden?: boolean; } export function NormalModal ({ @@ -32,6 +33,7 @@ export function NormalModal ({ cancelButtonProps, okLoading, closable = true, + overflowHidden = false, ...dialogProps }: NormalModalProps) { const { t } = useTranslation(); @@ -51,7 +53,12 @@ export function NormalModal ({ }} {...dialogProps} > -
+
{title}
{closable &&
@@ -67,7 +74,12 @@ export function NormalModal ({
-
{children}
+
{children}
+ ; + } + + return ( +
+ + + + {homePage && + + { + e.stopPropagation(); + if (!isOwner) return; + setRemoveLoading(true); + try { + await onRemoveHomePage(); + } finally { + setRemoveLoading(false); + } + }} + size={'small'} + className={'ml-1'} + > + {removeLoading ? : + } + + } + setAnchorEl(null)} + classes={{ + paper: 'max-h-[500px] w-[320px] appflowy-scroller overflow-y-auto overflow-x-hidden', + }} + > +
+ setSearchText(e.target.value)} + size={'small'} + autoFocus={true} + startAdornment={} + inputProps={{ + className: 'px-2 py-1.5 text-sm', + }} + /> +
+
+ {views.map(view => ( + + ))} +
+
+
+ ); +} + +export default HomePageSetting; \ No newline at end of file diff --git a/src/components/app/publish-manage/PublishManage.tsx b/src/components/app/publish-manage/PublishManage.tsx new file mode 100644 index 00000000..6e1a131b --- /dev/null +++ b/src/components/app/publish-manage/PublishManage.tsx @@ -0,0 +1,296 @@ +import { SubscriptionPlan, View, ViewLayout } from '@/application/types'; +import { notify } from '@/components/_shared/notify'; +import { flattenViews } from '@/components/_shared/outline/utils'; +import { useAppHandlers, useUserWorkspaceInfo } from '@/components/app/app.hooks'; +import HomePageSetting from '@/components/app/publish-manage/HomePageSetting'; +import PublishedPages from '@/components/app/publish-manage/PublishedPages'; +import UpdateNamespace from '@/components/app/publish-manage/UpdateNamespace'; +import { useCurrentUser, useService } from '@/components/main/app.hooks'; +import { openUrl } from '@/utils/url'; +import { Button, Divider, IconButton, Tooltip } from '@mui/material'; +import React, { useCallback, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as EditIcon } from '@/assets/edit.svg'; + +export function PublishManage ({ + onClose, +}: { + onClose?: () => void; +}) { + const { t } = useTranslation(); + const userWorkspaceInfo = useUserWorkspaceInfo(); + const currentUser = useCurrentUser(); + const isOwner = userWorkspaceInfo?.selectedWorkspace?.owner?.uid.toString() === currentUser?.uid.toString(); + const [loading, setLoading] = React.useState(false); + const service = useService(); + const workspaceId = userWorkspaceInfo?.selectedWorkspace?.id; + const [namespace, setNamespace] = React.useState(''); + const [homePageId, setHomePageId] = React.useState(''); + const [publishViews, setPublishViews] = React.useState([]); + const [updateOpen, setUpdateOpen] = React.useState(false); + const homePage = useMemo(() => { + return publishViews.find(view => view.view_id === homePageId); + }, [homePageId, publishViews]); + const loadPublishNamespace = useCallback(async () => { + if (!service || !workspaceId) return; + try { + const namespace = await service.getPublishNamespace(workspaceId); + + setNamespace(namespace); + + // eslint-disable-next-line + } catch (e: any) { + console.error(e); + } + }, [service, workspaceId]); + + const loadHomePageId = useCallback(async () => { + if (!service || !workspaceId) return; + try { + const { + view_id, + } = await service.getPublishHomepage(workspaceId); + + setHomePageId(view_id); + + // eslint-disable-next-line + } catch (e: any) { + console.error(e); + } + }, [service, workspaceId]); + + const loadPublishPages = useCallback(async () => { + if (!service || !namespace) return; + setLoading(true); + try { + const outline = await service.getPublishOutline(namespace); + + setPublishViews(flattenViews(outline).filter(item => item.is_published)); + // eslint-disable-next-line + } catch (e: any) { + console.error(e); + } + + setLoading(false); + }, [namespace, service]); + + const handleUpdateNamespace = useCallback(async (newNamespace: string) => { + if (!service || !workspaceId) return; + try { + await service.updatePublishNamespace(workspaceId, { + old_namespace: namespace, + new_namespace: newNamespace, + }); + + setNamespace(newNamespace); + notify.success(t('settings.sites.success.namespaceUpdated')); + // eslint-disable-next-line + } catch (e: any) { + notify.error(e.message); + throw e; + } + }, [namespace, service, t, workspaceId]); + + const handleUpdateHomePage = useCallback(async (newHomePageId: string) => { + if (!service || !workspaceId) return; + if (!isOwner) { + return; + } + + try { + await service.updatePublishHomepage(workspaceId, newHomePageId); + + setHomePageId(newHomePageId); + notify.success(t('settings.sites.success.setHomepageSuccess')); + // eslint-disable-next-line + } catch (e: any) { + notify.error(e.message); + throw e; + } + }, [isOwner, service, t, workspaceId]); + + const handleRemoveHomePage = useCallback(async () => { + if (!service || !workspaceId) return; + if (!isOwner) { + notify.error(t('settings.sites.error.onlyWorkspaceOwnerCanRemoveHomepage')); + return; + } + + try { + await service.removePublishHomepage(workspaceId); + + setHomePageId(''); + notify.success(t('settings.sites.success.removeHomePageSuccess')); + // eslint-disable-next-line + } catch (e: any) { + notify.error(e.message); + throw e; + } + + }, [isOwner, service, t, workspaceId]); + + const currentWorkspaceId = userWorkspaceInfo?.selectedWorkspace?.id; + const handlePublish = useCallback(async (view: View, publishName: string) => { + if (!service || !currentWorkspaceId) return; + const isDatabase = [ViewLayout.Board, ViewLayout.Grid, ViewLayout.Calendar].includes(view.layout); + const viewId = view.view_id; + + try { + await service.publishView(currentWorkspaceId, viewId, { + publish_name: publishName, + visible_database_view_ids: isDatabase ? view.children?.map((v) => v.view_id) : undefined, + }); + notify.success(t('publish.publishSuccessfully')); + // eslint-disable-next-line + } catch (e: any) { + notify.error(e.message); + } + }, [currentWorkspaceId, service, t]); + + const handleUnpublish = useCallback(async (viewId: string) => { + if (!service || !currentWorkspaceId) return; + + try { + await service.unpublishView(currentWorkspaceId, viewId); + void loadPublishPages(); + notify.success(t('publish.unpublishSuccessfully')); + // eslint-disable-next-line + } catch (e: any) { + notify.error(e.message); + } + }, [currentWorkspaceId, loadPublishPages, service, t]); + + const { + getSubscriptions, + } = useAppHandlers(); + const [activeSubscription, setActiveSubscription] = React.useState(null); + const loadSubscription = useCallback(async () => { + try { + const subscriptions = await getSubscriptions?.(); + + if (!subscriptions || subscriptions.length === 0) { + setActiveSubscription(SubscriptionPlan.Free); + return; + } + + const subscription = subscriptions[0]; + + setActiveSubscription(subscription?.plan || SubscriptionPlan.Free); + } catch (e) { + console.error(e); + } + + }, [getSubscriptions]); + + useEffect(() => { + void loadSubscription(); + }, [loadSubscription]); + + useEffect(() => { + void loadPublishNamespace(); + }, [loadPublishNamespace]); + + useEffect(() => { + void loadHomePageId(); + }, [loadHomePageId]); + + useEffect(() => { + void loadPublishPages(); + }, [loadPublishPages]); + const url = `${window.location.origin}/${namespace}`; + + return ( +
+
{t('namespace')}
+
{t('manageNamespaceDescription')}
+ +
+
{t('namespace')}
+
{t('homepage')}
+
+ + +
+
+ +
{t('shareAction.visitSite')}
+
{url}
+
+ } + > + + +
+
+ + + { + if (!isOwner || activeSubscription === SubscriptionPlan.Free) { + return; + } + + e.currentTarget.blur(); + setUpdateOpen(true); + }} + > + + + +
+
+ +
{t('settings.sites.publishedPage.title')}
+
{t('settings.sites.publishedPage.description')}
+ +
+
{t('settings.sites.publishedPage.page')}
+
+ {t('settings.sites.publishedPage.pathName')} +
+
{t('settings.sites.publishedPage.date')}
+
+ + + + + {updateOpen && setUpdateOpen(false)} + onUpdateNamespace={handleUpdateNamespace} + />} +
+ ); +} + +export default PublishManage; \ No newline at end of file diff --git a/src/components/app/publish-manage/PublishNameSetting.tsx b/src/components/app/publish-manage/PublishNameSetting.tsx new file mode 100644 index 00000000..c7fc922b --- /dev/null +++ b/src/components/app/publish-manage/PublishNameSetting.tsx @@ -0,0 +1,117 @@ +import { NormalModal } from '@/components/_shared/modal'; +import { notify } from '@/components/_shared/notify'; +import { openUrl } from '@/utils/url'; +import { Button, CircularProgress, InputBase } from '@mui/material'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +export function PublishNameSetting ({ defaultName, onClose, open, onUnPublish, onPublish, url }: { + onClose: () => void; + open: boolean; + defaultName: string; + onUnPublish: () => Promise; + onPublish: (publishName: string) => Promise; + url: string; +}) { + const [value, setValue] = React.useState(defaultName); + const { t } = useTranslation(); + const [publishLoading, setPublishLoading] = React.useState(false); + const [unPublishLoading, setUnPublishLoading] = React.useState(false); + + const handlePublish = async () => { + if (!value) { + notify.error(t('settings.sites.error.publishNameCannotBeEmpty')); + return; + } + + if (value.length > 100) { + notify.error(t('settings.sites.error.publishNameTooLong')); + return; + } + + if (value.includes(' ') || value.includes('/')) { + notify.error(t('settings.sites.error.publishNameContainsInvalidCharacters')); + return; + } + + if (value === defaultName) { + notify.error(t('settings.sites.error.publishNameAlreadyInUse')); + return; + } + + setPublishLoading(true); + + try { + await onPublish(value); + } finally { + setPublishLoading(false); + } + }; + + const handleUnPublish = async () => { + setUnPublishLoading(true); + try { + await onUnPublish(); + } finally { + setUnPublishLoading(false); + } + }; + + return { + if (e.key === 'Escape') { + onClose?.(); + } + + if (e.key === 'Enter') { + void handlePublish(); + } + }} + cancelText={ + unPublishLoading ? : t('shareAction.unPublish') + } + okText={t( + 'shareAction.visitSite', + )} + cancelButtonProps={{ + disabled: unPublishLoading, + }} + onCancel={handleUnPublish} + onOk={() => { + void openUrl(url, '_blank'); + }} + title={
{t('settings.sites.publishedPage.settings')}
} + open={open} + onClose={onClose} + > +
+
+ {t('settings.sites.publishedPage.pathName')} +
+
+
+ setValue(e.target.value)} + /> +
+ + +
+ +
+
; +} \ No newline at end of file diff --git a/src/components/app/publish-manage/PublishedPageItem.tsx b/src/components/app/publish-manage/PublishedPageItem.tsx new file mode 100644 index 00000000..38177a45 --- /dev/null +++ b/src/components/app/publish-manage/PublishedPageItem.tsx @@ -0,0 +1,232 @@ +import { View } from '@/application/types'; +import { notify } from '@/components/_shared/notify'; +import { Popover } from '@/components/_shared/popover'; +import PageIcon from '@/components/_shared/view-icon/PageIcon'; +import { useAppHandlers, useUserWorkspaceInfo } from '@/components/app/app.hooks'; +import { PublishNameSetting } from '@/components/app/publish-manage/PublishNameSetting'; +import { useCurrentUser, useService } from '@/components/main/app.hooks'; +import { copyTextToClipboard } from '@/utils/copy'; +import { openUrl } from '@/utils/url'; +import { Button, CircularProgress, IconButton, Tooltip } from '@mui/material'; +import dayjs from 'dayjs'; +import React, { useCallback, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as MoreIcon } from '@/assets/more.svg'; +import { ReactComponent as GlobalIcon } from '@/assets/publish.svg'; +import { ReactComponent as CopyIcon } from '@/assets/copy.svg'; +import { ReactComponent as TrashIcon } from '@/assets/trash.svg'; +import { ReactComponent as SettingIcon } from '@/assets/settings.svg'; + +function PublishedPageItem ({ onClose, view, onUnPublish, onPublish }: { + view: View, + onClose?: () => void; + onUnPublish: (viewId: string) => Promise; + onPublish: (view: View, publishName: string) => Promise +}) { + const { t } = useTranslation(); + const [openSetting, setOpenSetting] = React.useState(false); + const [anchorEl, setAnchorEl] = React.useState(null); + const [publishInfo, setPublishInfo] = React.useState<{ + namespace: string, + publishName: string, + publisherEmail: string + publishedAt: string + } | undefined>(undefined); + const toView = useAppHandlers().toView; + const service = useService(); + const [unPublishLoading, setUnPublishLoading] = React.useState(false); + const userWorkspaceInfo = useUserWorkspaceInfo(); + const currentUser = useCurrentUser(); + const isOwner = userWorkspaceInfo?.selectedWorkspace?.owner?.uid.toString() === currentUser?.uid.toString(); + const isPublisher = publishInfo?.publisherEmail === currentUser?.email; + + const getPublishInfo = useCallback(async (viewId: string) => { + if (!service) return; + + try { + const res = await service?.getPublishInfo(viewId); + + setPublishInfo(res); + return res; + } catch (e) { + console.error(e); + } + }, [service]); + + const url = useMemo(() => { + return `${window.origin}/${publishInfo?.namespace}/${publishInfo?.publishName}`; + }, [publishInfo]); + + useEffect(() => { + void getPublishInfo(view.view_id); + }, [getPublishInfo, view.view_id]); + + const actions = useMemo(() => { + return [ + { + value: 'visit', + label: t('shareAction.visitSite'), + IconComponent: GlobalIcon, + onClick: () => { + void openUrl(url, '_blank'); + }, + }, + { + value: 'copy', + label: t('shareAction.copyLink'), + IconComponent: CopyIcon, + onClick: () => { + void copyTextToClipboard(url); + notify.success(t('shareAction.copyLinkSuccess')); + }, + }, + { + value: 'unpublish', + disabled: unPublishLoading, + label: t('shareAction.unPublish'), + tooltip: !(isOwner || isPublisher) ? t('settings.sites.error.publishPermissionDenied') : undefined, + IconComponent: unPublishLoading ? CircularProgress : TrashIcon, + onClick: async () => { + if (!(isOwner || isPublisher)) { + return; + } + + setUnPublishLoading(true); + try { + await onUnPublish(view.view_id); + } catch (e) { + console.error(e); + } finally { + setUnPublishLoading(false); + } + }, + }, + { + value: 'setting', + label: t('settings.title'), + tooltip: !(isOwner || isPublisher) ? t('settings.sites.error.publishPermissionDenied') : undefined, + + IconComponent: SettingIcon, + onClick: () => { + if (!(isOwner || isPublisher)) return; + setOpenSetting(true); + }, + }, + + ]; + }, [t, isOwner, isPublisher, unPublishLoading, url, onUnPublish, view.view_id]); + + return ( +
+
+ + Open {view.name || t('menuAppHeader.defaultNewPageName')} +
+ } + > + + +
+
+ + {`Open Page in New Tab \n${publishInfo?.publishName || ''}`} +
} + > + + +
+
+ {dayjs(publishInfo?.publishedAt).format('MMM D, YYYY')} + { + setAnchorEl(e.currentTarget); + }} + size={'small'} + > + + +
+ { + setAnchorEl(null); + }} + > +
+ {actions.map((action) => { + return + + ; + })} +
+
+ {openSetting && publishInfo && { + return onUnPublish(view.view_id); + }} + onPublish={async (publishName: string) => { + await onPublish(view, publishName); + void getPublishInfo(view.view_id); + }} + onClose={() => { + setOpenSetting(false); + }} + url={url} + open={openSetting} + defaultName={publishInfo.publishName} + />} +
+ ); +} + +export default PublishedPageItem; \ No newline at end of file diff --git a/src/components/app/publish-manage/PublishedPages.tsx b/src/components/app/publish-manage/PublishedPages.tsx new file mode 100644 index 00000000..bf05b977 --- /dev/null +++ b/src/components/app/publish-manage/PublishedPages.tsx @@ -0,0 +1,44 @@ +import { View } from '@/application/types'; + +import PublishedPageItem from '@/components/app/publish-manage/PublishedPageItem'; +import { CircularProgress, Divider } from '@mui/material'; + +import React from 'react'; + +function PublishedPages ({ + publishViews, + onUnPublish, + onPublish, + loading, + onClose, +}: { + publishViews: View[]; + loading: boolean; + onUnPublish: (viewId: string) => Promise; + onPublish: (view: View, publishName: string) => Promise; + onClose?: () => void +}) { + + return ( +
+ + {loading ?
+
+ : publishViews.map((view, index) => { + return + + {index !== publishViews.length - 1 && } + + ; + })} + +
+ ); +} + +export default PublishedPages; \ No newline at end of file diff --git a/src/components/app/publish-manage/UpdateNamespace.tsx b/src/components/app/publish-manage/UpdateNamespace.tsx new file mode 100644 index 00000000..c1d70361 --- /dev/null +++ b/src/components/app/publish-manage/UpdateNamespace.tsx @@ -0,0 +1,73 @@ +import { NormalModal } from '@/components/_shared/modal'; +import { IconButton, OutlinedInput, Tooltip } from '@mui/material'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as TipIcon } from '@/assets/warning.svg'; + +function UpdateNamespace ({ + namespace, + open, + onClose, + onUpdateNamespace, +}: { + namespace: string; + open: boolean; + onClose: () => void; + onUpdateNamespace: (namespace: string) => Promise; +}) { + const { t } = useTranslation(); + const [value, setValue] = React.useState(namespace); + const [loading, setLoading] = React.useState(false); + + const handleOk = async () => { + setLoading(true); + try { + await onUpdateNamespace(value); + onClose(); + } finally { + setLoading(false); + } + }; + + return ( + +
{t('settings.sites.namespace.updateExistingNamespace')}
+ + + + + +
} + > +
{t('settings.sites.namespace.description')}
+ setValue(e.target.value)} + size={'small'} + autoFocus={true} + inputProps={{ + className: 'px-2 py-1.5 text-sm', + }} + /> +
{window.location.host}/{value}
+ + ); +} + +export default UpdateNamespace; \ No newline at end of file diff --git a/src/components/app/publish-manage/index.tsx b/src/components/app/publish-manage/index.tsx new file mode 100644 index 00000000..2f673f0e --- /dev/null +++ b/src/components/app/publish-manage/index.tsx @@ -0,0 +1 @@ +export * from './PublishManage'; \ No newline at end of file diff --git a/src/components/app/search/BestMatch.tsx b/src/components/app/search/BestMatch.tsx new file mode 100644 index 00000000..ac2722f2 --- /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 00000000..74bbb4db --- /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 00000000..c42462cf --- /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 00000000..c733580c --- /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 00000000..31d6bce1 --- /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 00000000..0215c2ad --- /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/settings/SettingMenu.tsx b/src/components/app/settings/SettingMenu.tsx new file mode 100644 index 00000000..63242e71 --- /dev/null +++ b/src/components/app/settings/SettingMenu.tsx @@ -0,0 +1,58 @@ +import { SettingMenuItem } from '@/application/types'; +import { ReactComponent as PersonIcon } from '@/assets/person.svg'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +interface SettingMenuProps { + selectedItem: SettingMenuItem; + onSelectItem: (item: SettingMenuItem) => void; +} + +function SettingMenu ({ + selectedItem, + onSelectItem, +}: SettingMenuProps) { + const { t } = useTranslation(); + + const options = useMemo(() => { + return [ + { + value: SettingMenuItem.ACCOUNT, + label: t('settings.accountPage.menuLabel'), + IconComponent: PersonIcon, + }, + { + value: SettingMenuItem.WORKSPACE, + label: t('settings.workspacePage.menuLabel'), + IconComponent: PersonIcon, + }, + { + value: SettingMenuItem.MEMBERS, + label: t('settings.appearance.members.label'), + IconComponent: PersonIcon, + }, + { + value: SettingMenuItem.SITES, + label: t('settings.sites.title'), + IconComponent: PersonIcon, + }, + ]; + }, [t]); + + return ( +
+ {options.map(option => ( +
onSelectItem(option.value)} + className={`flex items-center gap-3 p-2 rounded-[8px] hover:bg-fill-list-hover cursor-pointer ${option.value === selectedItem ? 'bg-fill-list-hover' : ''}`} + > + + {option.label} +
+ ))} +
+ ); +} + +export default SettingMenu; \ No newline at end of file diff --git a/src/components/app/settings/Settings.tsx b/src/components/app/settings/Settings.tsx new file mode 100644 index 00000000..d9bc88d0 --- /dev/null +++ b/src/components/app/settings/Settings.tsx @@ -0,0 +1,57 @@ +import { SettingMenuItem } from '@/application/types'; +import { ReactComponent as SettingsIcon } from '@/assets/settings.svg'; +import SettingMenu from '@/components/app/settings/SettingMenu'; +import { Button, Dialog } from '@mui/material'; +import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; + +export function Settings () { + const { t } = useTranslation(); + const [open, setOpen] = React.useState(false); + const [search, setSearch] = useSearchParams(); + + const [selectedItem, setSelectedItem] = React.useState(SettingMenuItem.ACCOUNT); + + useEffect(() => { + const item = search.get('setting') as SettingMenuItem; + + if (item) { + setOpen(true); + setSelectedItem(item); + setSearch(prev => { + prev.delete('setting'); + return prev; + }); + } + }, [search, setSearch]); + + return ( + <> + + setOpen(false)} + > + + + + ); +} + +export default Settings; \ No newline at end of file diff --git a/src/components/app/settings/index.ts b/src/components/app/settings/index.ts new file mode 100644 index 00000000..2cca89bf --- /dev/null +++ b/src/components/app/settings/index.ts @@ -0,0 +1 @@ +export * from './Settings'; \ No newline at end of file diff --git a/src/components/app/share/PublishLinkPreview.tsx b/src/components/app/share/PublishLinkPreview.tsx new file mode 100644 index 00000000..b6b416c8 --- /dev/null +++ b/src/components/app/share/PublishLinkPreview.tsx @@ -0,0 +1,178 @@ +import { NormalModal } from '@/components/_shared/modal'; +import { PublishManage } from '@/components/app/publish-manage'; +import { PublishNameSetting } from '@/components/app/publish-manage/PublishNameSetting'; +import { CircularProgress, IconButton, InputBase, Tooltip } from '@mui/material'; +import { ReactComponent as LinkIcon } from '@/assets/link.svg'; +import { ReactComponent as DownIcon } from '@/assets/chevron_down.svg'; +import { ReactComponent as CheckIcon } from '@/assets/check.svg'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +function PublishLinkPreview ({ + publishInfo, + onUnPublish, + onPublish, + url, + isOwner, + isPublisher, +}: { + publishInfo: { namespace: string, publishName: string }; + onUnPublish: () => Promise; + onPublish: (publishName?: string) => Promise; + url: string; + isOwner: boolean; + isPublisher: boolean; +}) { + const [siteOpen, setSiteOpen] = React.useState(false); + const [renameOpen, setRenameOpen] = React.useState(false); + const { t } = useTranslation(); + const [publishName, setPublishName] = React.useState(publishInfo.publishName); + const [focused, setFocused] = React.useState(false); + const [loading, setLoading] = React.useState(false); + + const handlePublish = async () => { + if (loading) return; + if (publishName === publishInfo.publishName) return; + setLoading(true); + try { + await onPublish(publishName); + } finally { + setLoading(false); + } + }; + + return ( + <> +
+
+
{window.location.origin}
+ {'/'} +
+ + {publishInfo.namespace} + + + { + setSiteOpen(true); + }} + > + + + + +
+ {'/'} +
+ { + setFocused(true); + }} + onBlur={() => { + setFocused(false); + }} + onKeyDown={async (e) => { + if (e.key === 'Enter') { + void handlePublish(); + } + }} + size={'small'} + value={publishName} + onChange={e => { + setPublishName(e.target.value); + }} + className={'flex-1 truncate'} + /> + {(isOwner || isPublisher) && + e.preventDefault()} + onClick={(e) => { + e.stopPropagation(); + if (focused) { + void handlePublish(); + return; + } + + setRenameOpen(true); + }} + > + {loading ? : + focused ? : + } + + } + +
+
+
+ + + + + +
+ {renameOpen && { setRenameOpen(false); }} + open={renameOpen} + onUnPublish={onUnPublish} + onPublish={onPublish} + url={url} + />} + {siteOpen && { + setSiteOpen(false); + }} + scroll={'paper'} + open={siteOpen} + title={
{t('settings.sites.title')}
} + > +
+ { + setSiteOpen(false); + }} + /> +
+ +
} +
+ + + ); +} + +export default PublishLinkPreview; \ No newline at end of file diff --git a/src/components/app/share/PublishPanel.tsx b/src/components/app/share/PublishPanel.tsx index 26062de8..38a288fb 100644 --- a/src/components/app/share/PublishPanel.tsx +++ b/src/components/app/share/PublishPanel.tsx @@ -1,25 +1,82 @@ -import { useAppView, useCurrentWorkspaceId } from '@/components/app/app.hooks'; -import LinkPreview from '@/components/app/share/LinkPreview'; +import { ViewLayout } from '@/application/types'; +import { notify } from '@/components/_shared/notify'; +import { useCurrentWorkspaceId } from '@/components/app/app.hooks'; import { useLoadPublishInfo } from '@/components/app/share/publish.hooks'; -import { openOrDownload } from '@/utils/open_schema'; -import { openAppFlowySchema } from '@/utils/url'; -import { Button, Typography } from '@mui/material'; +import PublishLinkPreview from '@/components/app/share/PublishLinkPreview'; +import { useService } from '@/components/main/app.hooks'; +import { Button, CircularProgress, Typography } from '@mui/material'; import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { ReactComponent as PublishIcon } from '@/assets/publish.svg'; function PublishPanel ({ viewId }: { viewId: string }) { - const view = useAppView(viewId); const currentWorkspaceId = useCurrentWorkspaceId(); const { t } = useTranslation(); const { url, + loadPublishInfo, + view, + publishInfo, + loading, + isOwner, + isPublisher, } = useLoadPublishInfo(viewId); + const service = useService(); + const handlePublish = useCallback(async (publishName?: string) => { + if (!service || !currentWorkspaceId || !view) return; + const isDatabase = [ViewLayout.Board, ViewLayout.Grid, ViewLayout.Calendar].includes(view.layout); + + try { + await service.publishView(currentWorkspaceId, viewId, { + publish_name: publishName, + visible_database_view_ids: isDatabase ? view.children?.map((v) => v.view_id) : undefined, + }); + await loadPublishInfo(); + notify.success(t('publish.publishSuccessfully')); + // eslint-disable-next-line + } catch (e: any) { + notify.error(e.message); + } + }, [currentWorkspaceId, loadPublishInfo, service, t, view, viewId]); + + const handleUnpublish = useCallback(async () => { + if (!service || !currentWorkspaceId || !view) return; + if (!isOwner && !isPublisher) { + notify.error(t('settings.sites.error.publishPermissionDenied')); + return; + } + + try { + await service.unpublishView(currentWorkspaceId, viewId); + await loadPublishInfo(); + notify.success(t('publish.unpublishSuccessfully')); + // eslint-disable-next-line + } catch (e: any) { + notify.error(e.message); + } + }, [currentWorkspaceId, isOwner, isPublisher, loadPublishInfo, service, t, view, viewId]); + const renderPublished = useCallback(() => { - return
- -
+ if (!publishInfo || !view) return null; + return
+ +
+
; - }, [t, url]); + }, [handlePublish, handleUnpublish, isOwner, isPublisher, publishInfo, t, url, view]); const renderUnpublished = useCallback(() => { return ; - }, [currentWorkspaceId, t, view?.view_id]); + >{t('shareAction.publish')}; + }, [handlePublish, t]); return ( -
+
{t('shareAction.publishToTheWebHint')} - {view?.is_published ? renderPublished() : renderUnpublished()} + {loading &&
} +
+ {view?.is_published ? renderPublished() : renderUnpublished()} +
+
); } diff --git a/src/components/app/share/publish.hooks.ts b/src/components/app/share/publish.hooks.ts index 7f7a96cd..7f2e9ead 100644 --- a/src/components/app/share/publish.hooks.ts +++ b/src/components/app/share/publish.hooks.ts @@ -1,24 +1,56 @@ -import { useAppView } from '@/components/app/app.hooks'; -import { useService } from '@/components/main/app.hooks'; +import { View } from '@/application/types'; +import { useCurrentWorkspaceId, useUserWorkspaceInfo } from '@/components/app/app.hooks'; +import { useCurrentUser, useService } from '@/components/main/app.hooks'; import React, { useCallback, useEffect, useMemo } from 'react'; export function useLoadPublishInfo (viewId: string) { - const view = useAppView(viewId); - const [publishInfo, setPublishInfo] = React.useState<{ namespace: string, publishName: string }>(); - + const [view, setView] = React.useState(); + const currentWorkspaceId = useCurrentWorkspaceId(); + const [publishInfo, setPublishInfo] = React.useState<{ + namespace: string, + publishName: string, + publisherEmail: string + }>(); + const [loading, setLoading] = React.useState(false); const service = useService(); + const userWorkspaceInfo = useUserWorkspaceInfo(); + const currentUser = useCurrentUser(); + const isOwner = userWorkspaceInfo?.selectedWorkspace?.owner?.uid.toString() === currentUser?.uid.toString(); + const isPublisher = publishInfo?.publisherEmail === currentUser?.email; + const loadView = useCallback(async () => { + if (!viewId || !service || !currentWorkspaceId) return; + try { + + const view = await service.getAppView(currentWorkspaceId, viewId); + + setView(view); + return view; + // eslint-disable-next-line + } catch (e: any) { + // do nothing + console.error(e); + } + }, [currentWorkspaceId, service, viewId]); const loadPublishInfo = useCallback(async () => { - if (!service || !view?.view_id) return; + if (!service) return; + setLoading(true); try { + const view = await loadView(); + + if (!view) return; + const res = await service.getPublishInfo(view?.view_id); setPublishInfo(res); + // eslint-disable-next-line } catch (e: any) { // do nothing } - }, [service, view?.view_id]); + + setLoading(false); + }, [loadView, service]); useEffect(() => { void loadPublishInfo(); @@ -28,5 +60,5 @@ export function useLoadPublishInfo (viewId: string) { return `${window.origin}/${publishInfo?.namespace}/${publishInfo?.publishName}`; }, [publishInfo]); - return { publishInfo, url }; + return { publishInfo, url, loadPublishInfo, view, loading, isPublisher, isOwner }; } \ 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 c52035ee..1e18d2f7 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/components/app/workspaces/InviteMember.tsx b/src/components/app/workspaces/InviteMember.tsx index 8f00ed90..4efc2c9f 100644 --- a/src/components/app/workspaces/InviteMember.tsx +++ b/src/components/app/workspaces/InviteMember.tsx @@ -5,12 +5,12 @@ import { useTranslation } from 'react-i18next'; import { NormalModal } from '@/components/_shared/modal'; import { notify } from '@/components/_shared/notify'; import { useCurrentUser, useService } from '@/components/main/app.hooks'; -import { Subscription, SubscriptionPlan, Workspace, WorkspaceMember } from '@/application/types'; +import { SubscriptionPlan, Workspace, WorkspaceMember } from '@/application/types'; import { useAppHandlers } from '@/components/app/app.hooks'; import { ReactComponent as TipIcon } from '@/assets/warning.svg'; import { useSearchParams } from 'react-router-dom'; -function InviteMember({ workspace, onClick }: { +function InviteMember ({ workspace, onClick }: { workspace: Workspace; onClick?: () => void; }) { @@ -40,33 +40,40 @@ function InviteMember({ workspace, onClick }: { } }, [currentWorkspaceId, service]); - const [activeSubscription, setActiveSubscription] = React.useState(null); + const [activeSubscriptionPlan, setActiveSubscriptionPaln] = React.useState(null); const loadSubscription = useCallback(async () => { try { const subscriptions = await getSubscriptions?.(); - if (!subscriptions || subscriptions.length === 0) return; + if (!subscriptions || subscriptions.length === 0) { + setActiveSubscriptionPaln(SubscriptionPlan.Free); + + return; + } + const subscription = subscriptions[0]; - setActiveSubscription(subscription); + setActiveSubscriptionPaln(subscription?.plan || SubscriptionPlan.Free); } catch (e) { + setActiveSubscriptionPaln(SubscriptionPlan.Free); console.error(e); } }, [getSubscriptions]); const isExceed = useMemo(() => { - if (!activeSubscription || activeSubscription.plan === SubscriptionPlan.Free) { + if (activeSubscriptionPlan === null) return false; + if (activeSubscriptionPlan === SubscriptionPlan.Free) { return memberCount >= 2; } - if (activeSubscription.plan === SubscriptionPlan.Pro) { + if (activeSubscriptionPlan === SubscriptionPlan.Pro) { return memberCount >= 10; } return false; - }, [activeSubscription, memberCount]); + }, [activeSubscriptionPlan, memberCount]); const handleOk = async () => { if (!service || !currentWorkspaceId) return; @@ -121,11 +128,11 @@ function InviteMember({ workspace, onClick }: { setOpen(true); onClick?.(); }} - startIcon={} + startIcon={} >{t('settings.appearance.members.inviteMembers')} } okText={t('inviteMember.requestInvites')} - onOk={handleOk}> - {isExceed &&
- + onOk={handleOk} + > +
+ {t('inviteMember.inviteFailedMemberLimit')} {t('inviteMember.upgrade')} -
} + className={'hover:underline cursor-pointer text-fill-default'} + >{t('inviteMember.upgrade')} +
{t('inviteMember.emails')}
setValue(e.target.value)} + fullWidth={true} + size={'small'} + value={value} + onChange={e => setValue(e.target.value)} placeholder={t('inviteMember.addEmail')} />
diff --git a/src/components/app/workspaces/Workspaces.tsx b/src/components/app/workspaces/Workspaces.tsx index 1390e5b7..5c35358a 100644 --- a/src/components/app/workspaces/Workspaces.tsx +++ b/src/components/app/workspaces/Workspaces.tsx @@ -125,7 +125,6 @@ export function Workspaces () { }} workspace={currentWorkspace} />} -