diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 6b500cf0..b9287dc9 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -354,6 +354,9 @@ "Character count: {{characterCount}}": "Character count: {{characterCount}}", "New update": "New update", "{{latestVersion}} is available": "{{latestVersion}} is available", + "Default page edit mode": "Default page edit mode", + "Choose your preferred page edit mode. Avoid accidental edits.": "Choose your preferred page edit mode. Avoid accidental edits.", + "Reading": "Reading" "Delete member": "Delete member", "Member deleted successfully": "Member deleted successfully", "Are you sure you want to delete this workspace member? This action is irreversible.": "Are you sure you want to delete this workspace member? This action is irreversible.", diff --git a/apps/client/src/features/editor/components/embed/embed-view.tsx b/apps/client/src/features/editor/components/embed/embed-view.tsx index 0eaa820b..dfc6a5da 100644 --- a/apps/client/src/features/editor/components/embed/embed-view.tsx +++ b/apps/client/src/features/editor/components/embed/embed-view.tsx @@ -32,7 +32,7 @@ const schema = z.object({ export default function EmbedView(props: NodeViewProps) { const { t } = useTranslation(); - const { node, selected, updateAttributes } = props; + const { node, selected, updateAttributes, editor } = props; const { src, provider } = node.attrs; const embedUrl = useMemo(() => { @@ -50,6 +50,10 @@ export default function EmbedView(props: NodeViewProps) { }); async function onSubmit(data: { url: string }) { + if (!editor.isEditable) { + return; + } + if (provider) { const embedProvider = getEmbedProviderById(provider); if (embedProvider.id === "iframe") { @@ -85,7 +89,13 @@ export default function EmbedView(props: NodeViewProps) { ) : ( - + - + ); } diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 07d9da74..37b28d7f 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -52,6 +52,7 @@ import { IPage } from "@/features/page/types/page.types.ts"; import { useParams } from "react-router-dom"; import { extractPageSlugId } from "@/lib"; import { FIVE_MINUTES } from "@/lib/constants.ts"; +import { PageEditMode } from "@/features/user/types/user.types.ts"; import { jwtDecode } from "jwt-decode"; interface PageEditorProps { @@ -85,6 +86,8 @@ export default function PageEditor({ const [isCollabReady, setIsCollabReady] = useState(false); const { pageSlug } = useParams(); const slugId = extractPageSlugId(pageSlug); + const userPageEditMode = + currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; const localProvider = useMemo(() => { const provider = new IndexeddbPersistence(documentName, ydoc); @@ -290,6 +293,17 @@ export default function PageEditor({ return () => clearTimeout(collabReadyTimeout); }, [isRemoteSynced, isLocalSynced, remoteProvider?.status]); + useEffect(() => { + // honor user default page edit mode preference + if (userPageEditMode && editor && editable && isSynced) { + if (userPageEditMode === PageEditMode.Edit) { + editor.setEditable(true); + } else if (userPageEditMode === PageEditMode.Read) { + editor.setEditable(false); + } + } + }, [userPageEditMode, editor, editable, isSynced]); + return isCollabReady ? (
diff --git a/apps/client/src/features/editor/title-editor.tsx b/apps/client/src/features/editor/title-editor.tsx index 869cb1eb..33b71b0c 100644 --- a/apps/client/src/features/editor/title-editor.tsx +++ b/apps/client/src/features/editor/title-editor.tsx @@ -21,6 +21,8 @@ import { useTranslation } from "react-i18next"; import EmojiCommand from "@/features/editor/extensions/emoji-command.ts"; import { UpdateEvent } from "@/features/websocket/types"; import localEmitter from "@/lib/local-emitter.ts"; +import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { PageEditMode } from "@/features/user/types/user.types.ts"; export interface TitleEditorProps { pageId: string; @@ -44,6 +46,9 @@ export function TitleEditor({ const emit = useQueryEmit(); const navigate = useNavigate(); const [activePageId, setActivePageId] = useState(pageId); + const [currentUser] = useAtom(currentUserAtom); + const userPageEditMode = + currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; const titleEditor = useEditor({ extensions: [ @@ -136,7 +141,18 @@ export function TitleEditor({ }; }, [pageId]); - function handleTitleKeyDown(event) { + useEffect(() => { + // honor user default page edit mode preference + if (userPageEditMode && titleEditor && editable) { + if (userPageEditMode === PageEditMode.Edit) { + titleEditor.setEditable(true); + } else if (userPageEditMode === PageEditMode.Read) { + titleEditor.setEditable(false); + } + } + }, [userPageEditMode, titleEditor, editable]); + + function handleTitleKeyDown(event: any) { if (!titleEditor || !pageEditor || event.shiftKey) return; const { key } = event; diff --git a/apps/client/src/features/page/components/breadcrumbs/breadcrumb.module.css b/apps/client/src/features/page/components/breadcrumbs/breadcrumb.module.css index cebee031..e4a0ccd6 100644 --- a/apps/client/src/features/page/components/breadcrumbs/breadcrumb.module.css +++ b/apps/client/src/features/page/components/breadcrumbs/breadcrumb.module.css @@ -1,24 +1,30 @@ .breadcrumbs { - display: flex; - align-items: center; + display: flex; + align-items: center; + overflow: hidden; + flex-wrap: nowrap; + + a { + color: var(--mantine-color-default-color); + line-height: inherit; + } + + .mantine-Breadcrumbs-breadcrumb { + min-width: 1px; overflow: hidden; - flex-wrap: nowrap; - - a { - color: var(--mantine-color-default-color); - line-height: inherit; - } - - .mantine-Breadcrumbs-breadcrumb { - min-width: 1px; - overflow: hidden; - } + } } .truncatedText { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 200px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; } +.breadcrumbDiv { + overflow: hidden; + @media (max-width: $mantine-breakpoint-sm) { + overflow: visible; + } +} diff --git a/apps/client/src/features/page/components/breadcrumbs/breadcrumb.tsx b/apps/client/src/features/page/components/breadcrumbs/breadcrumb.tsx index 9d78f38c..11507e40 100644 --- a/apps/client/src/features/page/components/breadcrumbs/breadcrumb.tsx +++ b/apps/client/src/features/page/components/breadcrumbs/breadcrumb.tsx @@ -161,7 +161,7 @@ export default function Breadcrumb() { }; return ( -
+
{breadcrumbNodes && ( {isMobile ? getMobileBreadcrumbItems() : getBreadcrumbItems()} diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index 09305491..816cc502 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -33,6 +33,7 @@ import { yjsConnectionStatusAtom, } from "@/features/editor/atoms/editor-atoms.ts"; import { formattedDate, timeAgo } from "@/lib/time.ts"; +import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx"; import MovePageModal from "@/features/page/components/move-page-modal.tsx"; import { useTimeAgo } from "@/hooks/use-time-ago.tsx"; import ShareModal from "@/features/share/components/share-modal.tsx"; @@ -59,6 +60,8 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { )} + {!readOnly && } + diff --git a/apps/client/src/features/page/components/header/page-header.module.css b/apps/client/src/features/page/components/header/page-header.module.css index f3e5f7a3..61f97084 100644 --- a/apps/client/src/features/page/components/header/page-header.module.css +++ b/apps/client/src/features/page/components/header/page-header.module.css @@ -1,15 +1,27 @@ .header { - height: 45px; - background-color: var(--mantine-color-body); - padding-left: var(--mantine-spacing-md); - padding-right: var(--mantine-spacing-md); - position: fixed; - z-index: 99; - top: var(--app-shell-header-offset, 0rem); - inset-inline-start: var(--app-shell-navbar-offset, 0rem); - inset-inline-end: var(--app-shell-aside-offset, 0rem); + height: 45px; + background-color: var(--mantine-color-body); + padding-left: var(--mantine-spacing-md); + padding-right: var(--mantine-spacing-md); + position: fixed; + z-index: 99; + top: var(--app-shell-header-offset, 0rem); + inset-inline-start: var(--app-shell-navbar-offset, 0rem); + inset-inline-end: var(--app-shell-aside-offset, 0rem); - @media print { - display: none; - } + @media (max-width: $mantine-breakpoint-sm) { + padding-left: var(--mantine-spacing-xs); + padding-right: var(--mantine-spacing-xs); + } + + @media print { + display: none; + } +} + +.group { + @media (max-width: $mantine-breakpoint-sm) { + gap: var(--mantine-spacing-sm); + padding-inline: 0 !important; + } } diff --git a/apps/client/src/features/page/components/header/page-header.tsx b/apps/client/src/features/page/components/header/page-header.tsx index b0f380e6..12f131b8 100644 --- a/apps/client/src/features/page/components/header/page-header.tsx +++ b/apps/client/src/features/page/components/header/page-header.tsx @@ -9,10 +9,10 @@ interface Props { export default function PageHeader({ readOnly }: Props) { return (
- + - + diff --git a/apps/client/src/features/page/types/page.types.ts b/apps/client/src/features/page/types/page.types.ts index 9156c98c..19dc18fd 100644 --- a/apps/client/src/features/page/types/page.types.ts +++ b/apps/client/src/features/page/types/page.types.ts @@ -65,6 +65,7 @@ export interface IPageInput { icon: string; coverPhoto: string; position: string; + isLocked: boolean; } export interface IExportPageParams { diff --git a/apps/client/src/features/user/components/page-state-pref.tsx b/apps/client/src/features/user/components/page-state-pref.tsx new file mode 100644 index 00000000..12ba2d6f --- /dev/null +++ b/apps/client/src/features/user/components/page-state-pref.tsx @@ -0,0 +1,65 @@ +import { Group, Text, MantineSize, SegmentedControl } from "@mantine/core"; +import { useAtom } from "jotai"; +import { userAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { updateUser } from "@/features/user/services/user-service.ts"; +import React, { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { PageEditMode } from "@/features/user/types/user.types.ts"; + +export default function PageStatePref() { + const { t } = useTranslation(); + + return ( + +
+ {t("Default page edit mode")} + + {t("Choose your preferred page edit mode. Avoid accidental edits.")} + +
+ + +
+ ); +} + +interface PageStateSegmentedControlProps { + size?: MantineSize; +} + +export function PageStateSegmentedControl({ + size, +}: PageStateSegmentedControlProps) { + const { t } = useTranslation(); + const [user, setUser] = useAtom(userAtom); + const pageEditMode = + user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; + const [value, setValue] = useState(pageEditMode); + + const handleChange = useCallback( + async (value: string) => { + const updatedUser = await updateUser({ pageEditMode: value }); + setValue(value); + setUser(updatedUser); + }, + [user, setUser], + ); + + useEffect(() => { + if (pageEditMode !== value) { + setValue(pageEditMode); + } + }, [pageEditMode, value]); + + return ( + + ); +} diff --git a/apps/client/src/features/user/types/user.types.ts b/apps/client/src/features/user/types/user.types.ts index 5439580f..95060358 100644 --- a/apps/client/src/features/user/types/user.types.ts +++ b/apps/client/src/features/user/types/user.types.ts @@ -19,6 +19,7 @@ export interface IUser { deactivatedAt: Date; deletedAt: Date; fullPageWidth: boolean; // used for update + pageEditMode: string; // used for update } export interface ICurrentUser { @@ -29,5 +30,11 @@ export interface ICurrentUser { export interface IUserSettings { preferences: { fullPageWidth: boolean; + pageEditMode: string; }; -} \ No newline at end of file +} + +export enum PageEditMode { + Read = "read", + Edit = "edit", +} diff --git a/apps/client/src/pages/page/page.tsx b/apps/client/src/pages/page/page.tsx index 43d0fe59..65a4f64f 100644 --- a/apps/client/src/pages/page/page.tsx +++ b/apps/client/src/pages/page/page.tsx @@ -12,6 +12,11 @@ import { SpaceCaslSubject, } from "@/features/space/permissions/permissions.type.ts"; import { useTranslation } from "react-i18next"; +import React from "react"; + +const MemoizedFullEditor = React.memo(FullEditor); +const MemoizedPageHeader = React.memo(PageHeader); +const MemoizedHistoryModal = React.memo(HistoryModal); export default function Page() { const { t } = useTranslation(); @@ -49,14 +54,14 @@ export default function Page() { {`${page?.icon || ""} ${page?.title || t("untitled")}`} - - - +
) ); diff --git a/apps/client/src/pages/settings/account/account-preferences.tsx b/apps/client/src/pages/settings/account/account-preferences.tsx index 26daa488..f082ea1b 100644 --- a/apps/client/src/pages/settings/account/account-preferences.tsx +++ b/apps/client/src/pages/settings/account/account-preferences.tsx @@ -2,6 +2,7 @@ import SettingsTitle from "@/components/settings/settings-title.tsx"; import AccountLanguage from "@/features/user/components/account-language.tsx"; import AccountTheme from "@/features/user/components/account-theme.tsx"; import PageWidthPref from "@/features/user/components/page-width-pref.tsx"; +import PageEditPref from "@/features/user/components/page-state-pref"; import { getAppName } from "@/lib/config.ts"; import { Divider } from "@mantine/core"; import { Helmet } from "react-helmet-async"; @@ -28,6 +29,10 @@ export default function AccountPreferences() { + + + + ); } diff --git a/apps/server/src/core/user/dto/update-user.dto.ts b/apps/server/src/core/user/dto/update-user.dto.ts index cdf085bb..6b55a3db 100644 --- a/apps/server/src/core/user/dto/update-user.dto.ts +++ b/apps/server/src/core/user/dto/update-user.dto.ts @@ -1,5 +1,5 @@ import { OmitType, PartialType } from '@nestjs/mapped-types'; -import { IsBoolean, IsOptional, IsString } from 'class-validator'; +import { IsBoolean, IsIn, IsOptional, IsString } from 'class-validator'; import { CreateUserDto } from '../../auth/dto/create-user.dto'; export class UpdateUserDto extends PartialType( @@ -13,6 +13,11 @@ export class UpdateUserDto extends PartialType( @IsBoolean() fullPageWidth: boolean; + @IsOptional() + @IsString() + @IsIn(['read', 'edit']) + pageEditMode: string; + @IsOptional() @IsString() locale: string; diff --git a/apps/server/src/core/user/user.service.ts b/apps/server/src/core/user/user.service.ts index 434f4cac..d7c59320 100644 --- a/apps/server/src/core/user/user.service.ts +++ b/apps/server/src/core/user/user.service.ts @@ -34,6 +34,14 @@ export class UserService { ); } + if (typeof updateUserDto.pageEditMode !== 'undefined') { + return this.userRepo.updatePreference( + userId, + 'pageEditMode', + updateUserDto.pageEditMode.toLowerCase(), + ); + } + if (updateUserDto.name) { user.name = updateUserDto.name; }