mirror of
https://github.com/docmost/docmost.git
synced 2025-11-10 08:02:04 +10:00
feat: edit mode preference (#666)
* lock/unlock pages * remove using isLocked column - add default page edit state preference * * Move state management to editors (avoids flickers on edit mode switch) * Rename variables * Add strings to translation file * Memoize components in page component * Fix title editor sending update request on editable state change * fixed errors merging main * Fix embed view in read-only mode * remove unused line * sync * fix responsiveness on mobile --------- Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
This commit is contained in:
@ -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.",
|
||||
|
||||
@ -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) {
|
||||
</AspectRatio>
|
||||
</>
|
||||
) : (
|
||||
<Popover width={300} position="bottom" withArrow shadow="md">
|
||||
<Popover
|
||||
width={300}
|
||||
position="bottom"
|
||||
withArrow
|
||||
shadow="md"
|
||||
disabled={!editor.isEditable}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Card
|
||||
radius="md"
|
||||
|
||||
@ -42,7 +42,11 @@ export function FullEditor({
|
||||
spaceSlug={spaceSlug}
|
||||
editable={editable}
|
||||
/>
|
||||
<MemoizedPageEditor pageId={pageId} editable={editable} content={content} />
|
||||
<MemoizedPageEditor
|
||||
pageId={pageId}
|
||||
editable={editable}
|
||||
content={content}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 ? (
|
||||
<div>
|
||||
<div ref={menuContainerRef}>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -22,3 +22,9 @@
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.breadcrumbDiv {
|
||||
overflow: hidden;
|
||||
@media (max-width: $mantine-breakpoint-sm) {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@ -161,7 +161,7 @@ export default function Breadcrumb() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ overflow: "hidden" }}>
|
||||
<div className={classes.breadcrumbDiv}>
|
||||
{breadcrumbNodes && (
|
||||
<Breadcrumbs className={classes.breadcrumbs}>
|
||||
{isMobile ? getMobileBreadcrumbItems() : getBreadcrumbItems()}
|
||||
|
||||
@ -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) {
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{!readOnly && <PageStateSegmentedControl size="xs" />}
|
||||
|
||||
<ShareModal readOnly={readOnly} />
|
||||
|
||||
<Tooltip label={t("Comments")} openDelay={250} withArrow>
|
||||
|
||||
@ -9,7 +9,19 @@
|
||||
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
|
||||
inset-inline-end: var(--app-shell-aside-offset, 0rem);
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,10 +9,10 @@ interface Props {
|
||||
export default function PageHeader({ readOnly }: Props) {
|
||||
return (
|
||||
<div className={classes.header}>
|
||||
<Group justify="space-between" h="100%" px="md" wrap="nowrap">
|
||||
<Group justify="space-between" h="100%" px="md" wrap="nowrap" className={classes.group}>
|
||||
<Breadcrumb />
|
||||
|
||||
<Group justify="flex-end" h="100%" px="md" wrap="nowrap">
|
||||
<Group justify="flex-end" h="100%" px="md" wrap="nowrap" gap="var(--mantine-spacing-xs)">
|
||||
<PageHeaderMenu readOnly={readOnly} />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@ -65,6 +65,7 @@ export interface IPageInput {
|
||||
icon: string;
|
||||
coverPhoto: string;
|
||||
position: string;
|
||||
isLocked: boolean;
|
||||
}
|
||||
|
||||
export interface IExportPageParams {
|
||||
|
||||
65
apps/client/src/features/user/components/page-state-pref.tsx
Normal file
65
apps/client/src/features/user/components/page-state-pref.tsx
Normal file
@ -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 (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Text size="md">{t("Default page edit mode")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Choose your preferred page edit mode. Avoid accidental edits.")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<PageStateSegmentedControl />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<SegmentedControl
|
||||
size={size}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
data={[
|
||||
{ label: t("Edit"), value: PageEditMode.Edit },
|
||||
{ label: t("Read"), value: PageEditMode.Read },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
export enum PageEditMode {
|
||||
Read = "read",
|
||||
Edit = "edit",
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
<title>{`${page?.icon || ""} ${page?.title || t("untitled")}`}</title>
|
||||
</Helmet>
|
||||
|
||||
<PageHeader
|
||||
<MemoizedPageHeader
|
||||
readOnly={spaceAbility.cannot(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Page,
|
||||
)}
|
||||
/>
|
||||
|
||||
<FullEditor
|
||||
<MemoizedFullEditor
|
||||
key={page.id}
|
||||
pageId={page.id}
|
||||
title={page.title}
|
||||
@ -68,7 +73,7 @@ export default function Page() {
|
||||
SpaceCaslSubject.Page,
|
||||
)}
|
||||
/>
|
||||
<HistoryModal pageId={page.id} />
|
||||
<MemoizedHistoryModal pageId={page.id} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
@ -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() {
|
||||
<Divider my={"md"} />
|
||||
|
||||
<PageWidthPref />
|
||||
|
||||
<Divider my={"md"} />
|
||||
|
||||
<PageEditPref />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user