) is block-level so an inline chevron
+ would wrap to a new line; absolute positioning takes it out of flow. */
+ position: absolute;
+ top: 50%;
+ right: 6px;
+ transform: translateY(-50%);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ border-radius: 4px;
+ background: light-dark(
+ rgba(55, 53, 47, 0.08),
+ rgba(255, 255, 255, 0.08)
+ );
+ color: light-dark(
+ rgba(55, 53, 47, 0.55),
+ rgba(255, 255, 255, 0.55)
+ );
+ user-select: none;
+ cursor: pointer;
+ z-index: 1;
+ /* Hidden by default; revealed on header-cell hover or when this column is
+ the active sort (see selectors below). */
+ opacity: 0;
+ transition: opacity 120ms ease, background-color 120ms ease, color 120ms ease;
+}
+
+.ProseMirror table th:hover .tableReadonlySortChevron,
+.tableReadonlySortChevron[data-sort] {
+ opacity: 1;
+}
+
+.ProseMirror table th:has(.tableReadonlySortChevron) {
+ padding-right: 30px;
+}
+
+.tableReadonlySortChevron:hover {
+ background: light-dark(
+ rgba(55, 53, 47, 0.16),
+ rgba(255, 255, 255, 0.16)
+ );
+}
+
+/* Immediate tooltip on the chevron — same style language as the rest of the
+ app (small, dark, rounded), unlike the native `title` tooltip which only
+ appears after a long delay. */
+.tableReadonlySortChevron::after {
+ content: attr(data-tooltip);
+ position: absolute;
+ /* Below the chevron — placing it above the cell hits the table's
+ overflow clipping (the wrapper has `overflow-x: auto` which forces
+ `overflow-y: auto` per spec). */
+ top: calc(100% + 6px);
+ right: 0;
+ padding: 4px 8px;
+ border-radius: 4px;
+ background: var(--mantine-color-dark-7);
+ color: var(--mantine-color-white);
+ font-size: 12px;
+ font-weight: 400;
+ line-height: 1.4;
+ white-space: nowrap;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 120ms ease;
+ z-index: 10;
+}
+
+.tableReadonlySortChevron:hover::after {
+ opacity: 1;
+}
+
+.tableReadonlySortChevron svg {
+ display: block;
+}
+
+.tableReadonlySortChevron[data-sort="asc"],
+.tableReadonlySortChevron[data-sort="desc"] {
+ background: light-dark(
+ var(--mantine-color-blue-1),
+ var(--mantine-color-blue-9)
+ );
+ color: light-dark(
+ var(--mantine-color-blue-7),
+ var(--mantine-color-blue-2)
+ );
+}
+
+.tableReadonlySortChevron[data-sort="asc"] svg {
+ transform: rotate(180deg);
+}
+
.editor-container:has(.table-dnd-drop-indicator[data-dragging="true"]) {
.prosemirror-dropcursor-block {
display: none;
diff --git a/apps/client/src/features/editor/title-editor.tsx b/apps/client/src/features/editor/title-editor.tsx
index e61d8c042..3ff2d7614 100644
--- a/apps/client/src/features/editor/title-editor.tsx
+++ b/apps/client/src/features/editor/title-editor.tsx
@@ -7,6 +7,7 @@ import { Text } from "@tiptap/extension-text";
import { Placeholder } from "@tiptap/extension-placeholder";
import { useAtomValue } from "jotai";
import {
+ currentPageEditModeAtom,
pageEditorAtom,
titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms";
@@ -24,7 +25,6 @@ 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";
import { searchSpotlight } from "@/features/search/constants.ts";
import { platformModifierKey } from "@/lib";
@@ -52,9 +52,7 @@ 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 currentPageEditMode = useAtomValue(currentPageEditModeAtom);
const titleEditor = useEditor({
extensions: [
@@ -172,18 +170,9 @@ export function TitleEditor({
}, [pageId]);
useEffect(() => {
- if (titleEditor) {
- if (userPageEditMode && editable) {
- if (userPageEditMode === PageEditMode.Edit) {
- titleEditor.setEditable(true);
- } else if (userPageEditMode === PageEditMode.Read) {
- titleEditor.setEditable(false);
- }
- } else {
- titleEditor.setEditable(false);
- }
- }
- }, [userPageEditMode, titleEditor, editable]);
+ if (!titleEditor) return;
+ titleEditor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
+ }, [currentPageEditMode, titleEditor, editable]);
const openSearchDialog = () => {
const event = new CustomEvent("openFindDialogFromEditor", {});
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 81c25e825..75b113eaa 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
@@ -29,7 +29,7 @@ import { buildPageUrl } from "@/features/page/page.utils.ts";
import { notifications } from "@mantine/notifications";
import { getAppUrl } from "@/lib/config.ts";
import { extractPageSlugId } from "@/lib";
-import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
+import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
import { Trans, useTranslation } from "react-i18next";
@@ -40,7 +40,7 @@ import {
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { formattedDate } from "@/lib/time.ts";
-import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
+import { PageEditModeToggle } 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 { PageShareModal } from "@/ee/page-permission";
@@ -65,6 +65,11 @@ interface PageHeaderMenuProps {
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const { t } = useTranslation();
const toggleAside = useToggleAside();
+ const { pageSlug } = useParams();
+ const { data: page } = usePageQuery({
+ pageId: extractPageSlugId(pageSlug),
+ });
+ const isDeleted = !!page?.deletedAt;
useHotkeys(
[
@@ -87,11 +92,15 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
[],
);
+ if (isDeleted) {
+ return null;
+ }
+
return (
<>