- >
+
);
}
diff --git a/apps/client/src/features/editor/styles/core.css b/apps/client/src/features/editor/styles/core.css
index 34ddaca3c..077570fb5 100644
--- a/apps/client/src/features/editor/styles/core.css
+++ b/apps/client/src/features/editor/styles/core.css
@@ -203,7 +203,8 @@
}
}
- .resize-cursor {
+ &.resize-cursor,
+ &.resize-cursor * {
cursor: ew-resize;
cursor: col-resize;
}
diff --git a/apps/client/src/features/editor/styles/editor.module.css b/apps/client/src/features/editor/styles/editor.module.css
index dfe7393f8..ed5f86432 100644
--- a/apps/client/src/features/editor/styles/editor.module.css
+++ b/apps/client/src/features/editor/styles/editor.module.css
@@ -9,3 +9,15 @@
}
}
+.byline {
+ padding-left: 3rem;
+
+ @media (max-width: $mantine-breakpoint-sm) {
+ padding-left: 1rem;
+ }
+
+ @media print {
+ padding-left: 0;
+ }
+}
+
diff --git a/apps/client/src/features/editor/styles/indent.css b/apps/client/src/features/editor/styles/indent.css
new file mode 100644
index 000000000..cd2bd5857
--- /dev/null
+++ b/apps/client/src/features/editor/styles/indent.css
@@ -0,0 +1,14 @@
+.ProseMirror {
+ --indent-step: 2rem;
+}
+
+.ProseMirror [data-indent="1"] { padding-inline-start: calc(var(--indent-step) * 1); }
+.ProseMirror [data-indent="2"] { padding-inline-start: calc(var(--indent-step) * 2); }
+.ProseMirror [data-indent="3"] { padding-inline-start: calc(var(--indent-step) * 3); }
+.ProseMirror [data-indent="4"] { padding-inline-start: calc(var(--indent-step) * 4); }
+.ProseMirror [data-indent="5"] { padding-inline-start: calc(var(--indent-step) * 5); }
+.ProseMirror [data-indent="6"] { padding-inline-start: calc(var(--indent-step) * 6); }
+.ProseMirror [data-indent="7"] { padding-inline-start: calc(var(--indent-step) * 7); }
+.ProseMirror [data-indent="8"] { padding-inline-start: calc(var(--indent-step) * 8); }
+.ProseMirror [data-indent="9"] { padding-inline-start: calc(var(--indent-step) * 9); }
+.ProseMirror [data-indent="10"] { padding-inline-start: calc(var(--indent-step) * 10); }
diff --git a/apps/client/src/features/editor/styles/index.css b/apps/client/src/features/editor/styles/index.css
index 7ec4be9b6..52d9268e1 100644
--- a/apps/client/src/features/editor/styles/index.css
+++ b/apps/client/src/features/editor/styles/index.css
@@ -9,9 +9,11 @@
@import "./media.css";
@import "./code.css";
@import "./print.css";
+@import "./page-break.css";
@import "./find.css";
@import "./mention.css";
@import "./ordered-list.css";
@import "./highlight.css";
+@import "./indent.css";
@import "./columns.css";
@import "./status.css";
diff --git a/apps/client/src/features/editor/styles/page-break.css b/apps/client/src/features/editor/styles/page-break.css
new file mode 100644
index 000000000..6dc97c738
--- /dev/null
+++ b/apps/client/src/features/editor/styles/page-break.css
@@ -0,0 +1,50 @@
+.ProseMirror .page-break {
+ position: relative;
+ margin: 1.5rem 0;
+ border-top: 1px dashed var(--mantine-color-default-border);
+ height: 0;
+ user-select: none;
+}
+
+.ProseMirror[contenteditable="false"] .page-break {
+ margin: 0;
+ border: none;
+ height: 0;
+}
+
+.ProseMirror[contenteditable="false"] .page-break::after {
+ content: none;
+}
+
+.ProseMirror .page-break::after {
+ content: "Page break";
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ padding: 0 0.5rem;
+ background: var(--mantine-color-body);
+ color: var(--mantine-color-dimmed);
+ font-size: 0.75rem;
+ line-height: 1;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+}
+
+.ProseMirror .page-break.ProseMirror-selectednode {
+ border-top-color: var(--mantine-primary-color-filled);
+}
+
+@media print {
+ .ProseMirror .page-break {
+ break-before: always;
+ page-break-before: always;
+ visibility: hidden;
+ border: none;
+ margin: 0;
+ }
+
+ .ProseMirror .page-break::after {
+ content: none;
+ }
+}
diff --git a/apps/client/src/features/editor/styles/table.css b/apps/client/src/features/editor/styles/table.css
index 9926d0bc0..5d802e4ab 100644
--- a/apps/client/src/features/editor/styles/table.css
+++ b/apps/client/src/features/editor/styles/table.css
@@ -15,7 +15,8 @@
}
.table-dnd-drop-indicator {
- background-color: #adf;
+ background-color: var(--mantine-color-blue-5);
+ z-index: 3;
}
.ProseMirror {
@@ -57,13 +58,14 @@
}
.column-resize-handle {
- background-color: #adf;
+ background-color: var(--mantine-color-blue-5);
bottom: -1px;
position: absolute;
- right: -2px;
+ right: -1px;
pointer-events: none;
top: 0;
- width: 4px;
+ width: 2px;
+ z-index: 3;
}
.selectedCell:after {
@@ -129,6 +131,139 @@
}
}
+
+/* Header-row pinning. Two CSS paths, picked by `header-pin/controller.ts`:
+ - native sticky (preferred): wrapper drops its overflow constraint so
+ `position: sticky` on the row can resolve against the document scroll.
+ - transform fallback: wrapper keeps `overflow-x: auto` for horizontal
+ scrolling; the row is positioned imperatively per scroll frame.
+
+ `--editor-pin-offset` is published to :root by `pinOffsetWatcher` in
+ `header-pin/offset.ts`, measured against the lowest fixed surface above
+ the editor (app shell header, page header, fixed toolbar). */
+
+.tableWrapper.tableWrapperNoOverflow,
+.tableWrapper.tableWrapperNoOverflow table {
+ overflow: visible;
+}
+
+.tableWrapper.tableHeaderPinned table tr:first-child {
+ z-index: 2;
+}
+
+.tableWrapper.tableWrapperNoOverflow.tableHeaderPinned table tr:first-child {
+ position: sticky;
+ top: var(--editor-pin-offset, 90px);
+}
+
+.tableWrapper.tableHeaderPinned:not(.tableWrapperNoOverflow) table tr:first-child {
+ position: relative;
+ transform: translateY(var(--table-pin-offset, 0px));
+}
+
+@media print {
+ .tableWrapper.tableHeaderPinned table tr:first-child {
+ position: static;
+ transform: none;
+ }
+}
+
+.tableReadonlySortChevron {
+ /* Anchor to the cell's right edge, vertically centered with the cell
+ content. The cell content (a
) 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 15c3ff028..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,9 +25,9 @@ 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";
export interface TitleEditorProps {
pageId: string;
@@ -51,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: [
@@ -90,11 +89,11 @@ export function TitleEditor({
editorProps: {
handleDOMEvents: {
keydown: (_view, event) => {
- if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") {
+ if (platformModifierKey(event) && event.code === "KeyS") {
event.preventDefault();
return true;
}
- if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") {
+ if (platformModifierKey(event) && event.code === "KeyK") {
searchSpotlight.open();
return true;
}
@@ -171,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/favorite/components/star-button.tsx b/apps/client/src/features/favorite/components/star-button.tsx
index 6a99e01e9..7ff8ff77d 100644
--- a/apps/client/src/features/favorite/components/star-button.tsx
+++ b/apps/client/src/features/favorite/components/star-button.tsx
@@ -53,15 +53,17 @@ export default function StarButton(props: StarButtonProps) {
}
};
+ const label = isFavorited
+ ? t("Remove from favorites")
+ : t("Add to favorites");
+
return (
-
+
diff --git a/apps/client/src/features/group/components/group-action-menu.tsx b/apps/client/src/features/group/components/group-action-menu.tsx
index 331ea3dea..8c3dccb05 100644
--- a/apps/client/src/features/group/components/group-action-menu.tsx
+++ b/apps/client/src/features/group/components/group-action-menu.tsx
@@ -53,7 +53,7 @@ export default function GroupActionMenu() {
arrowPosition="center"
>
-
+
diff --git a/apps/client/src/features/group/components/group-members.tsx b/apps/client/src/features/group/components/group-members.tsx
index 9b5bbd7b2..56807bf8d 100644
--- a/apps/client/src/features/group/components/group-members.tsx
+++ b/apps/client/src/features/group/components/group-members.tsx
@@ -54,7 +54,7 @@ export default function GroupMembersList() {
{t("User")}
{t("Status")}
-
+
diff --git a/apps/client/src/features/home/components/created-by-me.tsx b/apps/client/src/features/home/components/created-by-me.tsx
index 70137b105..99051357e 100644
--- a/apps/client/src/features/home/components/created-by-me.tsx
+++ b/apps/client/src/features/home/components/created-by-me.tsx
@@ -4,7 +4,7 @@ import {
UnstyledButton,
Badge,
Table,
- ActionIcon,
+ ThemeIcon,
Button,
} from "@mantine/core";
import { Link } from "react-router-dom";
@@ -61,13 +61,13 @@ export default function CreatedByMe({ spaceId }: Props) {
>
{page.icon || (
-
-
+
)}
{page.title || t("Untitled")}
diff --git a/apps/client/src/features/home/components/favorites-pages.tsx b/apps/client/src/features/home/components/favorites-pages.tsx
index eb87216e0..aed8e653a 100644
--- a/apps/client/src/features/home/components/favorites-pages.tsx
+++ b/apps/client/src/features/home/components/favorites-pages.tsx
@@ -4,7 +4,7 @@ import {
UnstyledButton,
Badge,
Table,
- ActionIcon,
+ ThemeIcon,
Button,
} from "@mantine/core";
import { Link } from "react-router-dom";
@@ -62,13 +62,13 @@ export default function FavoritesPages({ spaceId }: Props) {
>
{fav.page.icon || (
-
-
+
)}
{fav.page.title || t("Untitled")}
diff --git a/apps/client/src/features/home/components/home-ai-prompt.module.css b/apps/client/src/features/home/components/home-ai-prompt.module.css
index e6d816067..8a6d57e11 100644
--- a/apps/client/src/features/home/components/home-ai-prompt.module.css
+++ b/apps/client/src/features/home/components/home-ai-prompt.module.css
@@ -16,7 +16,7 @@
.subtitle {
font-size: var(--mantine-font-size-sm);
- color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
+ color: var(--mantine-color-dimmed);
text-align: center;
margin-top: 6px;
margin-bottom: var(--mantine-spacing-lg);
diff --git a/apps/client/src/features/label/components/label-chip.tsx b/apps/client/src/features/label/components/label-chip.tsx
new file mode 100644
index 000000000..ad15ff4a3
--- /dev/null
+++ b/apps/client/src/features/label/components/label-chip.tsx
@@ -0,0 +1,51 @@
+import { Link } from "react-router-dom";
+import { useComputedColorScheme } from "@mantine/core";
+import { IconX } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import { ILabel } from "@/features/label/types/label.types.ts";
+import { getLabelColor } from "@/features/label/utils/label-colors.ts";
+import classes from "@/features/label/label.module.css";
+
+type LabelChipProps = {
+ label: Pick;
+ onRemove?: () => void;
+ asLink?: boolean;
+};
+
+export function LabelChip({ label, onRemove, asLink }: LabelChipProps) {
+ const { t } = useTranslation();
+ const scheme = useComputedColorScheme("light");
+ const c = getLabelColor(label.name, scheme);
+
+ const nameNode = asLink ? (
+ e.stopPropagation()}
+ >
+ {label.name}
+
+ ) : (
+ {label.name}
+ );
+
+ return (
+
+ {nameNode}
+ {onRemove && (
+
+ )}
+
+ );
+}
diff --git a/apps/client/src/features/label/components/label-page-row-skeleton.tsx b/apps/client/src/features/label/components/label-page-row-skeleton.tsx
new file mode 100644
index 000000000..bc4a69915
--- /dev/null
+++ b/apps/client/src/features/label/components/label-page-row-skeleton.tsx
@@ -0,0 +1,29 @@
+import { Skeleton } from "@mantine/core";
+import classes from "@/features/label/label.module.css";
+
+type LabelPageRowSkeletonProps = {
+ titleWidth?: number;
+ metaWidth?: number;
+};
+
+export function LabelPageRowSkeleton({
+ titleWidth = 220,
+ metaWidth = 180,
+}: LabelPageRowSkeletonProps) {
+ return (
+
+ );
+}
diff --git a/apps/client/src/features/label/components/label-page-row.tsx b/apps/client/src/features/label/components/label-page-row.tsx
new file mode 100644
index 000000000..7ca54c324
--- /dev/null
+++ b/apps/client/src/features/label/components/label-page-row.tsx
@@ -0,0 +1,91 @@
+import { Link } from "react-router-dom";
+import { ThemeIcon, Tooltip } from "@mantine/core";
+import { IconFileDescription } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import { ILabelPageItem } from "@/features/label/types/label.types.ts";
+import { LabelChip } from "@/features/label/components/label-chip.tsx";
+import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
+import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
+import { buildPageUrl } from "@/features/page/page.utils";
+import { formatLabelListDate } from "@/features/label/utils/format-label-date.ts";
+import classes from "@/features/label/label.module.css";
+
+type LabelPageRowProps = {
+ page: ILabelPageItem;
+ currentLabelName: string;
+};
+
+const MAX_VISIBLE_CHIPS = 3;
+
+export function LabelPageRow({ page, currentLabelName }: LabelPageRowProps) {
+ const { t } = useTranslation();
+
+ const otherLabels = page.labels.filter((l) => l.name !== currentLabelName);
+ const visibleLabels = otherLabels.slice(0, MAX_VISIBLE_CHIPS);
+ const hiddenLabels = otherLabels.slice(MAX_VISIBLE_CHIPS);
+
+ return (
+
+
+
+ {page.icon ? (
+ {page.icon}
+ ) : (
+
+
+
+ )}
+
+
+
+ {page.title || t("Untitled")}
+
+
+ {page.space && (
+ <>
+
+ {page.space.name}
+
+ •
+
+ >
+ )}
+
+ {t("Updated {{date}}", {
+ date: formatLabelListDate(new Date(page.updatedAt)),
+ })}
+
+
+ {/* {otherLabels.length > 0 && (
+
+ {visibleLabels.map((label) => (
+
+ ))}
+ {hiddenLabels.length > 0 && (
+ l.name).join(", ")}
+ withArrow
+ openDelay={200}
+ >
+
+ +{hiddenLabels.length}
+
+
+ )}
+
+ )} */}
+
+
+
+ );
+}
diff --git a/apps/client/src/features/label/components/label-picker.tsx b/apps/client/src/features/label/components/label-picker.tsx
new file mode 100644
index 000000000..0bd5e6ada
--- /dev/null
+++ b/apps/client/src/features/label/components/label-picker.tsx
@@ -0,0 +1,160 @@
+import { useMemo, useRef, useState, KeyboardEvent } from "react";
+import clsx from "clsx";
+import { IconPlus } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import { useComputedColorScheme } from "@mantine/core";
+import { ILabel } from "@/features/label/types/label.types.ts";
+import { useWorkspaceLabelsQuery } from "@/features/label/queries/label-query.ts";
+import { getLabelColor } from "@/features/label/utils/label-colors.ts";
+import { normalizeLabelName } from "@/features/label/utils/normalize-label.ts";
+import classes from "@/features/label/label.module.css";
+
+type LabelPickerProps = {
+ applied: ILabel[];
+ enabled: boolean;
+ onAdd: (name: string) => void;
+ onClose: () => void;
+};
+
+const NAME_PATTERN = /^[a-z0-9_-][a-z0-9_~-]*$/;
+const MAX_LABEL_NAME_LENGTH = 100;
+
+function isValidLabelName(name: string): boolean {
+ return (
+ name.length > 0 &&
+ name.length <= MAX_LABEL_NAME_LENGTH &&
+ NAME_PATTERN.test(name)
+ );
+}
+
+export function LabelPicker({
+ applied,
+ enabled,
+ onAdd,
+ onClose,
+}: LabelPickerProps) {
+ const { t } = useTranslation();
+ const scheme = useComputedColorScheme("light");
+ const [query, setQuery] = useState("");
+ const [hover, setHover] = useState(0);
+ const inputRef = useRef(null);
+
+ const normalized = normalizeLabelName(query);
+ const { data } = useWorkspaceLabelsQuery(normalized, enabled);
+
+ const appliedNames = useMemo(
+ () => new Set(applied.map((l) => l.name.toLowerCase())),
+ [applied],
+ );
+
+ const suggestions = useMemo(() => {
+ const items = data?.items ?? [];
+ return items.filter((l) => !appliedNames.has(l.name.toLowerCase()));
+ }, [data, appliedNames]);
+
+ const exact = suggestions.find((l) => l.name === normalized);
+ const canCreate =
+ !exact && !appliedNames.has(normalized) && isValidLabelName(normalized);
+
+ const total = suggestions.length + (canCreate ? 1 : 0);
+
+ const select = (idx: number) => {
+ if (idx < suggestions.length) {
+ onAdd(suggestions[idx].name);
+ } else if (canCreate) {
+ onAdd(normalized);
+ }
+ setQuery("");
+ setHover(0);
+ inputRef.current?.focus();
+ };
+
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setHover((h) => Math.min(Math.max(total - 1, 0), h + 1));
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setHover((h) => Math.max(0, h - 1));
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ if (total === 0) return;
+ select(hover);
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ onClose();
+ }
+ };
+
+ return (
+
+
+ {
+ setQuery(e.target.value);
+ setHover(0);
+ }}
+ onKeyDown={onKey}
+ />
+
+
+ {total === 0 && (
+
+ {normalized.length === 0
+ ? t("No labels yet")
+ : appliedNames.has(normalized)
+ ? t("Already added")
+ : !isValidLabelName(normalized)
+ ? t("Invalid label name")
+ : t("No matches")}
+
+ )}
+ {suggestions.map((s, i) => {
+ const c = getLabelColor(s.name, scheme);
+ return (
+
+ );
+ })}
+ {canCreate && (
+
+ )}
+
+
+ );
+}
diff --git a/apps/client/src/features/label/components/labels-section.tsx b/apps/client/src/features/label/components/labels-section.tsx
new file mode 100644
index 000000000..f44472b03
--- /dev/null
+++ b/apps/client/src/features/label/components/labels-section.tsx
@@ -0,0 +1,93 @@
+import { useState } from "react";
+import clsx from "clsx";
+import { Divider, Popover, Stack, Text } from "@mantine/core";
+import { IconPlus } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import { LabelChip } from "@/features/label/components/label-chip.tsx";
+import { LabelPicker } from "@/features/label/components/label-picker.tsx";
+import {
+ useAddLabelsMutation,
+ usePageLabelsQuery,
+ useRemoveLabelMutation,
+} from "@/features/label/queries/label-query.ts";
+import classes from "@/features/label/label.module.css";
+
+type LabelsSectionProps = {
+ pageId: string;
+ canEdit: boolean;
+};
+
+export function LabelsSection({ pageId, canEdit }: LabelsSectionProps) {
+ const { t } = useTranslation();
+ const [open, setOpen] = useState(false);
+
+ const { data } = usePageLabelsQuery(pageId);
+ const addMutation = useAddLabelsMutation(pageId);
+ const removeMutation = useRemoveLabelMutation(pageId);
+
+ const labels = data?.items ?? [];
+
+ if (!canEdit && labels.length === 0) {
+ return null;
+ }
+
+ const handleAdd = (name: string) => {
+ addMutation.mutate({ pageId, names: [name] });
+ };
+
+ const handleRemove = (labelId: string) => {
+ removeMutation.mutate({ pageId, labelId });
+ };
+
+ return (
+ <>
+
+
+
+ {t("Labels")}
+
+
+ {labels.map((label) => (
+
handleRemove(label.id) : undefined}
+ />
+ ))}
+ {canEdit && (
+
+
+
+
+
+ handleAdd(name)}
+ onClose={() => setOpen(false)}
+ />
+
+
+ )}
+
+
+ >
+ );
+}
diff --git a/apps/client/src/features/label/label.module.css b/apps/client/src/features/label/label.module.css
new file mode 100644
index 000000000..266a9c26f
--- /dev/null
+++ b/apps/client/src/features/label/label.module.css
@@ -0,0 +1,325 @@
+.labelsWrap {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+ margin-top: 4px;
+}
+
+.chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 24px;
+ padding: 0 8px;
+ border-radius: 4px;
+ font-size: 12.5px;
+ font-weight: 500;
+ line-height: 1;
+ user-select: none;
+ white-space: nowrap;
+}
+
+.chipName {
+ letter-spacing: 0.005em;
+}
+
+.chipX {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ color: currentColor;
+ width: 18px;
+ height: 18px;
+ border-radius: 4px;
+ margin-right: -4px;
+ margin-left: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ padding: 0;
+ opacity: 0.6;
+}
+
+.chipX:hover {
+ opacity: 1;
+ background: light-dark(rgba(0, 0, 0, 0.08), rgba(255, 255, 255, 0.12));
+}
+
+.addBtn {
+ appearance: none;
+ border: 1px dashed
+ light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
+ background: transparent;
+ color: var(--mantine-color-dimmed);
+ height: 24px;
+ padding: 0 8px;
+ border-radius: 4px;
+ font: inherit;
+ font-size: 12.5px;
+ font-weight: 500;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ cursor: pointer;
+ transition:
+ background 100ms ease,
+ border-color 100ms ease,
+ color 100ms ease;
+}
+
+.addBtn:hover {
+ background: light-dark(rgba(0, 0, 0, 0.03), rgba(255, 255, 255, 0.04));
+ color: var(--mantine-color-text);
+ border-color: light-dark(
+ var(--mantine-color-gray-5),
+ var(--mantine-color-dark-2)
+ );
+}
+
+.addBtnOpen {
+ background: var(--mantine-color-body);
+ border-style: solid;
+ border-color: light-dark(
+ var(--mantine-color-gray-5),
+ var(--mantine-color-dark-2)
+ );
+ color: var(--mantine-color-text);
+}
+
+.popover {
+ width: 240px;
+ padding: 0;
+ overflow: hidden;
+}
+
+.popoverSearch {
+ padding: 8px 8px 4px;
+ border-bottom: 1px solid
+ light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
+}
+
+.popoverSearch input {
+ width: 100%;
+ border: 0;
+ background: transparent;
+ font: inherit;
+ font-size: 13px;
+ padding: 4px 4px;
+ color: var(--mantine-color-text);
+ outline: none;
+}
+
+.popoverSearch input::placeholder {
+ color: var(--mantine-color-placeholder);
+}
+
+.popoverList {
+ max-height: 240px;
+ overflow-y: auto;
+ padding: 4px;
+}
+
+.popoverEmpty {
+ padding: 12px 8px;
+ color: var(--mantine-color-dimmed);
+ font-size: 12.5px;
+ text-align: center;
+}
+
+.popoverItem {
+ appearance: none;
+ width: 100%;
+ border: 0;
+ background: transparent;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 8px;
+ border-radius: 4px;
+ font: inherit;
+ font-size: 13px;
+ color: var(--mantine-color-text);
+ cursor: pointer;
+ text-align: left;
+}
+
+.popoverItemHover {
+ background: light-dark(
+ var(--mantine-color-gray-1),
+ var(--mantine-color-dark-5)
+ );
+}
+
+.popoverItemDot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.popoverItemName {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.popoverCreatePlus {
+ width: 14px;
+ height: 14px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--mantine-color-dimmed);
+}
+
+.headerChip {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ height: 36px;
+ padding: 0 14px;
+ border-radius: 8px;
+ font-size: 22px;
+ font-weight: 600;
+ line-height: 1;
+ letter-spacing: -0.005em;
+ text-decoration: none;
+ user-select: none;
+ transition: filter 100ms ease;
+}
+
+.headerChip:hover {
+ filter: brightness(0.97);
+}
+
+.headerDot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.row {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 14px 12px;
+ margin: 0 -12px;
+ border-radius: 8px;
+ text-decoration: none;
+ color: inherit;
+ cursor: pointer;
+ transition: background-color 80ms ease;
+}
+
+.row + .row {
+ border-top: 1px solid
+ light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
+}
+
+.row:hover {
+ background-color: light-dark(
+ var(--mantine-color-gray-0),
+ var(--mantine-color-dark-6)
+ );
+}
+
+.row:hover + .row,
+.row:has(+ .row:hover) {
+ border-top-color: transparent;
+}
+
+.rowMain {
+ display: flex;
+ gap: 12px;
+ min-width: 0;
+ flex: 1;
+}
+
+.rowIcon {
+ width: 20px;
+ height: 20px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ color: var(--mantine-color-dimmed);
+ margin-top: 2px;
+}
+
+.rowBody {
+ min-width: 0;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.rowTitle {
+ font-size: 15px;
+ font-weight: 500;
+ color: var(--mantine-color-text);
+ line-height: 1.3;
+ word-break: break-word;
+}
+
+.rowChips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.chipMore {
+ display: inline-flex;
+ align-items: center;
+ height: 24px;
+ padding: 0 8px;
+ border-radius: 4px;
+ font-size: 12.5px;
+ font-weight: 500;
+ line-height: 1;
+ color: var(--mantine-color-dimmed);
+ background: light-dark(
+ var(--mantine-color-gray-1),
+ var(--mantine-color-dark-5)
+ );
+ user-select: none;
+ white-space: nowrap;
+}
+
+.rowMeta {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+ color: var(--mantine-color-dimmed);
+ font-size: 13px;
+}
+
+.rowDate {
+ color: var(--mantine-color-dimmed);
+ font-size: 13px;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.metaDot {
+ font-size: 14px;
+ line-height: 1;
+ color: var(--mantine-color-dimmed);
+}
+
+.chipLink {
+ text-decoration: none;
+ color: inherit;
+ display: inline-flex;
+}
+
+.chipLink:hover {
+ filter: brightness(0.97);
+}
diff --git a/apps/client/src/features/label/queries/label-query.ts b/apps/client/src/features/label/queries/label-query.ts
new file mode 100644
index 000000000..6b06c4e30
--- /dev/null
+++ b/apps/client/src/features/label/queries/label-query.ts
@@ -0,0 +1,157 @@
+import {
+ keepPreviousData,
+ useInfiniteQuery,
+ useMutation,
+ useQuery,
+ useQueryClient,
+} from "@tanstack/react-query";
+import {
+ addLabelsToPage,
+ findPagesByLabel,
+ getLabelInfo,
+ getPageLabels,
+ getWorkspaceLabels,
+ removeLabelFromPage,
+} from "@/features/label/services/label-service.ts";
+import {
+ IAddLabels,
+ ILabel,
+ IRemoveLabel,
+} from "@/features/label/types/label.types.ts";
+import { IPagination } from "@/lib/types.ts";
+import { notifications } from "@mantine/notifications";
+import { useTranslation } from "react-i18next";
+
+const PAGE_LABELS_KEY = (pageId: string) => ["page-labels", pageId];
+const WORKSPACE_LABELS_KEY = (query?: string) => ["workspace-labels", query ?? ""];
+
+export function usePageLabelsQuery(pageId: string | undefined) {
+ return useQuery({
+ queryKey: PAGE_LABELS_KEY(pageId ?? ""),
+ queryFn: () => getPageLabels({ pageId: pageId as string, limit: 100 }),
+ enabled: !!pageId,
+ });
+}
+
+export function useWorkspaceLabelsQuery(query: string, enabled: boolean) {
+ return useQuery({
+ queryKey: WORKSPACE_LABELS_KEY(query),
+ queryFn: () => getWorkspaceLabels({ type: "page", query, limit: 50 }),
+ enabled,
+ staleTime: 30 * 1000,
+ });
+}
+
+export function useAddLabelsMutation(pageId: string | undefined) {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+
+ return useMutation({
+ mutationFn: (data) => addLabelsToPage(data),
+ onSuccess: (added) => {
+ queryClient.setQueryData>(
+ PAGE_LABELS_KEY(pageId ?? ""),
+ (cache) => {
+ if (!cache) return cache;
+ const existing = new Set(cache.items.map((l) => l.id));
+ const additions = added.filter((l) => !existing.has(l.id));
+ if (additions.length === 0) return cache;
+ return { ...cache, items: [...cache.items, ...additions] };
+ },
+ );
+
+ queryClient.setQueriesData>(
+ { queryKey: ["workspace-labels"] },
+ (cache) => {
+ if (!cache) return cache;
+ const existing = new Set(cache.items.map((l) => l.id));
+ const additions = added.filter((l) => !existing.has(l.id));
+ if (additions.length === 0) return cache;
+ return {
+ ...cache,
+ items: [...cache.items, ...additions].sort((a, b) =>
+ a.name.localeCompare(b.name),
+ ),
+ };
+ },
+ );
+
+ queryClient.invalidateQueries({ queryKey: ["label-pages"] });
+ queryClient.invalidateQueries({ queryKey: ["label-info"] });
+ },
+ onError: (error: any) => {
+ notifications.show({
+ message: error?.response?.data?.message ?? t("Failed to add label"),
+ color: "red",
+ });
+ },
+ });
+}
+
+export function useRemoveLabelMutation(pageId: string | undefined) {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+
+ return useMutation({
+ mutationFn: (data) => removeLabelFromPage(data),
+ onSuccess: (_data, variables) => {
+ const cache = queryClient.getQueryData>(
+ PAGE_LABELS_KEY(pageId ?? ""),
+ );
+ if (cache) {
+ queryClient.setQueryData>(
+ PAGE_LABELS_KEY(pageId ?? ""),
+ {
+ ...cache,
+ items: cache.items.filter((l) => l.id !== variables.labelId),
+ },
+ );
+ }
+ queryClient.invalidateQueries({ queryKey: ["workspace-labels"] });
+ queryClient.invalidateQueries({ queryKey: ["label-pages"] });
+ queryClient.invalidateQueries({ queryKey: ["label-info"] });
+ },
+ onError: () => {
+ notifications.show({
+ message: t("Failed to remove label"),
+ color: "red",
+ });
+ },
+ });
+}
+
+export function useLabelInfoQuery(name: string, spaceId?: string) {
+ return useQuery({
+ queryKey: ["label-info", name, spaceId ?? ""],
+ queryFn: () => getLabelInfo({ name, type: "page", spaceId }),
+ enabled: !!name,
+ placeholderData: keepPreviousData,
+ });
+}
+
+const LABEL_PAGES_LIMIT = 25;
+
+export function useLabelPagesQuery(
+ name: string,
+ query: string,
+ spaceId?: string,
+) {
+ return useInfiniteQuery({
+ queryKey: ["label-pages", name, query, spaceId ?? ""],
+ queryFn: ({ pageParam }) =>
+ findPagesByLabel({
+ name,
+ query,
+ spaceId,
+ cursor: pageParam,
+ limit: LABEL_PAGES_LIMIT,
+ }),
+ enabled: !!name,
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: (lastPage) =>
+ lastPage.meta.hasNextPage
+ ? (lastPage.meta.nextCursor ?? undefined)
+ : undefined,
+ placeholderData: keepPreviousData,
+ });
+}
diff --git a/apps/client/src/features/label/services/label-service.ts b/apps/client/src/features/label/services/label-service.ts
new file mode 100644
index 000000000..b7180c1ad
--- /dev/null
+++ b/apps/client/src/features/label/services/label-service.ts
@@ -0,0 +1,55 @@
+import api from "@/lib/api-client";
+import { IPagination } from "@/lib/types.ts";
+import {
+ IAddLabels,
+ IFindPagesByLabelParams,
+ ILabel,
+ ILabelInfo,
+ ILabelInfoParams,
+ ILabelPageItem,
+ IListLabelsParams,
+ IPageLabelsParams,
+ IRemoveLabel,
+} from "@/features/label/types/label.types.ts";
+
+export async function getPageLabels(
+ params: IPageLabelsParams,
+): Promise> {
+ const req = await api.post>("/pages/labels", params);
+ return req.data;
+}
+
+export async function getWorkspaceLabels(
+ params: IListLabelsParams,
+): Promise> {
+ const req = await api.post>("/labels", params);
+ return req.data;
+}
+
+export async function addLabelsToPage(
+ data: IAddLabels,
+): Promise {
+ const req = await api.post("/pages/labels/add", data);
+ return req.data;
+}
+
+export async function removeLabelFromPage(data: IRemoveLabel): Promise {
+ await api.post("/pages/labels/remove", data);
+}
+
+export async function getLabelInfo(
+ params: ILabelInfoParams,
+): Promise {
+ const req = await api.post("/labels/info", params);
+ return req.data;
+}
+
+export async function findPagesByLabel(
+ params: IFindPagesByLabelParams,
+): Promise> {
+ const req = await api.post>(
+ "/labels/pages",
+ params,
+ );
+ return req.data;
+}
diff --git a/apps/client/src/features/label/types/label.types.ts b/apps/client/src/features/label/types/label.types.ts
new file mode 100644
index 000000000..a1152d5d4
--- /dev/null
+++ b/apps/client/src/features/label/types/label.types.ts
@@ -0,0 +1,70 @@
+import { QueryParams } from "@/lib/types.ts";
+
+export type LabelType = "page" | "space";
+
+export interface ILabel {
+ id: string;
+ name: string;
+ type: LabelType;
+ workspaceId: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface IAddLabels {
+ pageId: string;
+ names: string[];
+}
+
+export interface IRemoveLabel {
+ pageId: string;
+ labelId: string;
+}
+
+export interface IPageLabelsParams {
+ pageId: string;
+ cursor?: string;
+ limit?: number;
+}
+
+export interface IListLabelsParams {
+ type: LabelType;
+ query?: string;
+ cursor?: string;
+ limit?: number;
+}
+
+export interface ILabelInfo {
+ name: string;
+ usageCount: number;
+}
+
+export interface ILabelPageItem {
+ id: string;
+ slugId: string;
+ title: string | null;
+ icon: string | null;
+ spaceId: string;
+ createdAt: string;
+ updatedAt: string;
+ space: {
+ id: string;
+ name: string;
+ slug: string;
+ logo: string | null;
+ } | null;
+ creator: { id: string; name: string; avatarUrl: string | null } | null;
+ labels: { id: string; name: string }[];
+}
+
+export interface IFindPagesByLabelParams extends QueryParams {
+ labelId?: string;
+ name?: string;
+ spaceId?: string;
+}
+
+export interface ILabelInfoParams {
+ name: string;
+ type: LabelType;
+ spaceId?: string;
+}
diff --git a/apps/client/src/features/label/utils/format-label-date.ts b/apps/client/src/features/label/utils/format-label-date.ts
new file mode 100644
index 000000000..1221c8ad8
--- /dev/null
+++ b/apps/client/src/features/label/utils/format-label-date.ts
@@ -0,0 +1,15 @@
+import { format, isThisYear, isToday, isYesterday } from "date-fns";
+import i18n from "@/i18n.ts";
+
+export function formatLabelListDate(date: Date): string {
+ if (isToday(date)) {
+ return i18n.t("Today, {{time}}", { time: format(date, "h:mma") });
+ }
+ if (isYesterday(date)) {
+ return i18n.t("Yesterday, {{time}}", { time: format(date, "h:mma") });
+ }
+ if (isThisYear(date)) {
+ return format(date, "MMM dd");
+ }
+ return format(date, "MMM dd, yyyy");
+}
diff --git a/apps/client/src/features/label/utils/label-colors.ts b/apps/client/src/features/label/utils/label-colors.ts
new file mode 100644
index 000000000..b9da2858f
--- /dev/null
+++ b/apps/client/src/features/label/utils/label-colors.ts
@@ -0,0 +1,55 @@
+type LabelColor = {
+ bg: string;
+ fg: string;
+ dot: string;
+};
+
+const LABEL_PALETTE: Record = {
+ slate: { bg: "#eef1f5", fg: "#3b475a", dot: "#6b7a90" },
+ blue: { bg: "#e6f0ff", fg: "#1e4fbf", dot: "#3b82f6" },
+ green: { bg: "#e3f5ea", fg: "#1f7a47", dot: "#22a05a" },
+ amber: { bg: "#fbf0d9", fg: "#8a5a00", dot: "#d99c1f" },
+ red: { bg: "#fde6e6", fg: "#a02b2b", dot: "#dc4a4a" },
+ purple: { bg: "#efe9fb", fg: "#5a3aa8", dot: "#8b6bd9" },
+ pink: { bg: "#fce6ee", fg: "#a8336d", dot: "#dc6699" },
+ teal: { bg: "#daf1ee", fg: "#1f6f6a", dot: "#2fa39a" },
+};
+
+const PALETTE_KEYS = Object.keys(LABEL_PALETTE);
+
+const DARK_PALETTE: Record = {
+ slate: { bg: "#2a3140", fg: "#c8d3e3", dot: "#7e8da8" },
+ blue: { bg: "#152a52", fg: "#a9c4ff", dot: "#5b9aff" },
+ green: { bg: "#143b27", fg: "#9ce3b8", dot: "#3ec97c" },
+ amber: { bg: "#3d2c0e", fg: "#f5cf85", dot: "#e6b34a" },
+ red: { bg: "#401a1a", fg: "#f1a8a8", dot: "#e26565" },
+ purple: { bg: "#2a1f4d", fg: "#c8b4f4", dot: "#a48ce6" },
+ pink: { bg: "#3c1a2a", fg: "#f3a9c9", dot: "#e07ab0" },
+ teal: { bg: "#103633", fg: "#92d5cf", dot: "#48b8af" },
+};
+
+function hashName(name: string): number {
+ // Per-char accumulation with 31. Note: 31 ≡ -1 (mod 8), so the low bits of
+ // this hash are highly correlated across short strings — `% 8` would cluster.
+ let h = 0;
+ for (let i = 0; i < name.length; i++) {
+ h = (Math.imul(h, 31) + name.charCodeAt(i)) | 0;
+ }
+ // Murmur3 fmix32 finalizer — avalanches high bits into low bits so the
+ // subsequent `% palette.length` (small power of two) is well-distributed.
+ h ^= h >>> 16;
+ h = Math.imul(h, 0x85ebca6b);
+ h ^= h >>> 13;
+ h = Math.imul(h, 0xc2b2ae35);
+ h ^= h >>> 16;
+ return h >>> 0;
+}
+
+export function getLabelColor(
+ name: string,
+ scheme: "light" | "dark" = "light",
+): LabelColor {
+ const key = PALETTE_KEYS[hashName(name) % PALETTE_KEYS.length];
+ const palette = scheme === "dark" ? DARK_PALETTE : LABEL_PALETTE;
+ return palette[key];
+}
diff --git a/apps/client/src/features/label/utils/normalize-label.ts b/apps/client/src/features/label/utils/normalize-label.ts
new file mode 100644
index 000000000..cb170d93b
--- /dev/null
+++ b/apps/client/src/features/label/utils/normalize-label.ts
@@ -0,0 +1,3 @@
+export function normalizeLabelName(name: string): string {
+ return name.trim().replace(/\s+/g, "-").toLowerCase();
+}
diff --git a/apps/client/src/features/notification/components/notification-popover.tsx b/apps/client/src/features/notification/components/notification-popover.tsx
index 161ac1e6c..751b9edf1 100644
--- a/apps/client/src/features/notification/components/notification-popover.tsx
+++ b/apps/client/src/features/notification/components/notification-popover.tsx
@@ -58,6 +58,9 @@ export function NotificationPopover() {
variant="subtle"
color="dark"
size="sm"
+ aria-label={t("Notifications")}
+ aria-haspopup="dialog"
+ aria-expanded={opened}
onClick={() => setOpened((o) => !o)}
>
void;
+}
+
+export function BacklinksList({
+ pageId,
+ direction,
+ enabled,
+ onItemClick,
+}: BacklinksListProps) {
+ const { t } = useTranslation();
+ const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
+ useBacklinksQuery(pageId, direction, enabled);
+
+ if (!enabled) return null;
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ const items: IBacklinkPageItem[] =
+ data?.pages.flatMap((page) => page.items) ?? [];
+
+ if (items.length === 0) {
+ return (
+
+ {direction === "incoming"
+ ? t("No pages link here yet.")
+ : t("This page doesn't link to other pages yet.")}
+
+ );
+ }
+
+ const handleClick = (e: React.MouseEvent) => {
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
+ return;
+ }
+ onItemClick();
+ };
+
+ return (
+
+ {items.map((item) => (
+
+
+ {getPageIcon(item.icon ?? "")}
+
+
+ {item.title || t("Untitled")}
+
+ {item.space?.name && (
+
+ {item.space.name}
+
+ )}
+
+
+
+ ))}
+ {hasNextPage && (
+
+ )}
+
+ );
+}
diff --git a/apps/client/src/features/page-details/components/backlinks-modal.tsx b/apps/client/src/features/page-details/components/backlinks-modal.tsx
new file mode 100644
index 000000000..83fc31147
--- /dev/null
+++ b/apps/client/src/features/page-details/components/backlinks-modal.tsx
@@ -0,0 +1,62 @@
+import { Modal, Stack, Text } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+import { useBacklinksCountQuery } from "@/features/page-details/queries/backlinks-query.ts";
+import { BacklinksList } from "./backlinks-list";
+
+interface BacklinksModalProps {
+ pageId: string;
+ opened: boolean;
+ onClose: () => void;
+}
+
+export function BacklinksModal({
+ pageId,
+ opened,
+ onClose,
+}: BacklinksModalProps) {
+ const { t } = useTranslation();
+ const { data: counts } = useBacklinksCountQuery(pageId);
+
+ return (
+
+
+
+
+ {t("Backlinks")}
+
+
+
+
+
+
+ {t("Incoming links ({{count}})", {
+ count: counts?.incoming ?? 0,
+ })}
+
+
+
+
+
+
+ {t("Outgoing links ({{count}})", {
+ count: counts?.outgoing ?? 0,
+ })}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/client/src/features/page-details/components/page-details-aside.tsx b/apps/client/src/features/page-details/components/page-details-aside.tsx
new file mode 100644
index 000000000..84209d7a6
--- /dev/null
+++ b/apps/client/src/features/page-details/components/page-details-aside.tsx
@@ -0,0 +1,239 @@
+import {
+ Divider,
+ Group,
+ Skeleton,
+ Stack,
+ Text,
+ UnstyledButton,
+} from "@mantine/core";
+import { IconChevronRight } from "@tabler/icons-react";
+import { useDisclosure } from "@mantine/hooks";
+import { useAtomValue } from "jotai";
+import { useParams } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { extractPageSlugId } from "@/lib";
+import { usePageQuery } from "@/features/page/queries/page-query.ts";
+import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
+import { useBacklinksCountQuery } from "@/features/page-details/queries/backlinks-query.ts";
+import { BacklinksModal } from "./backlinks-modal";
+import { formattedDate, timeAgo } from "@/lib/time.ts";
+import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
+import { LabelsSection } from "@/features/label/components/labels-section.tsx";
+
+export function PageDetailsAside() {
+ const { pageSlug } = useParams();
+ const { data: page } = usePageQuery({
+ pageId: extractPageSlugId(pageSlug),
+ });
+ const pageEditor = useAtomValue(pageEditorAtom);
+ const { data: counts, isLoading: countsLoading } = useBacklinksCountQuery(page?.id);
+ const [modalOpened, { open: openModal, close: closeModal }] =
+ useDisclosure(false);
+
+ if (!page) return null;
+
+ const wordCount: number =
+ pageEditor?.storage?.characterCount?.words?.() ?? 0;
+ const characterCount: number =
+ pageEditor?.storage?.characterCount?.characters?.() ?? 0;
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+function PeopleSection({
+ creator,
+ lastUpdatedBy,
+}: {
+ creator: { id: string; name: string; avatarUrl: string } | null;
+ lastUpdatedBy: { id: string; name: string; avatarUrl: string } | null;
+}) {
+ const { t } = useTranslation();
+ return (
+
+
+
+
+ );
+}
+
+function PersonRow({
+ label,
+ person,
+}: {
+ label: string;
+ person: { id: string; name: string; avatarUrl: string } | null;
+}) {
+ return (
+
+
+ {label}
+
+ {person ? (
+
+
+
+ {person.name}
+
+
+ ) : (
+
+ —
+
+ )}
+
+ );
+}
+
+function StatsSection({
+ wordCount,
+ characterCount,
+ createdAt,
+ updatedAt,
+}: {
+ wordCount: number;
+ characterCount: number;
+ createdAt: Date | string;
+ updatedAt: Date | string;
+}) {
+ const { t } = useTranslation();
+ return (
+
+
+ {t("Stats")}
+
+
+
+
+
+
+ );
+}
+
+function StatRow({ label, value }: { label: string; value: string }) {
+ return (
+
+
+ {label}
+
+ {value}
+
+ );
+}
+
+function BacklinksSection({
+ incomingCount,
+ outgoingCount,
+ isLoading,
+ onClick,
+}: {
+ incomingCount: number;
+ outgoingCount: number;
+ isLoading: boolean;
+ onClick: () => void;
+}) {
+ const { t } = useTranslation();
+ return (
+
+
+ {t("Backlinks")}
+
+
+
+
+ );
+}
+
+function BacklinksRow({
+ label,
+ count,
+ isLoading,
+ onClick,
+}: {
+ label: string;
+ count: number;
+ isLoading: boolean;
+ onClick: () => void;
+}) {
+ return (
+
+
+
+ {label}
+
+
+ {isLoading ? (
+
+ ) : (
+ {count}
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/client/src/features/page-details/queries/backlinks-query.ts b/apps/client/src/features/page-details/queries/backlinks-query.ts
new file mode 100644
index 000000000..a5e4619ee
--- /dev/null
+++ b/apps/client/src/features/page-details/queries/backlinks-query.ts
@@ -0,0 +1,45 @@
+import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
+import {
+ getBacklinks,
+ getBacklinksCount,
+} from "@/features/page-details/services/backlinks-service.ts";
+import {
+ BacklinkDirection,
+ IBacklinkCount,
+} from "@/features/page-details/types/backlink.types.ts";
+
+const BACKLINKS_STALE_TIME = 30 * 1000;
+const BACKLINKS_PAGE_LIMIT = 100;
+
+export function useBacklinksCountQuery(pageId: string | undefined) {
+ return useQuery({
+ queryKey: ["backlinks-count", pageId],
+ queryFn: () => getBacklinksCount(pageId as string),
+ enabled: !!pageId,
+ staleTime: BACKLINKS_STALE_TIME,
+ });
+}
+
+export function useBacklinksQuery(
+ pageId: string | undefined,
+ direction: BacklinkDirection,
+ enabled: boolean,
+) {
+ return useInfiniteQuery({
+ queryKey: ["backlinks", pageId, direction],
+ queryFn: ({ pageParam }) =>
+ getBacklinks({
+ pageId: pageId as string,
+ direction,
+ cursor: pageParam,
+ limit: BACKLINKS_PAGE_LIMIT,
+ }),
+ enabled: enabled && !!pageId,
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: (lastPage) =>
+ lastPage.meta.hasNextPage
+ ? (lastPage.meta.nextCursor ?? undefined)
+ : undefined,
+ staleTime: BACKLINKS_STALE_TIME,
+ });
+}
diff --git a/apps/client/src/features/page-details/services/backlinks-service.ts b/apps/client/src/features/page-details/services/backlinks-service.ts
new file mode 100644
index 000000000..779911eec
--- /dev/null
+++ b/apps/client/src/features/page-details/services/backlinks-service.ts
@@ -0,0 +1,26 @@
+import api from "@/lib/api-client";
+import { IPagination } from "@/lib/types.ts";
+import {
+ IBacklinkCount,
+ IBacklinkPageItem,
+ IBacklinksListParams,
+} from "@/features/page-details/types/backlink.types.ts";
+
+export async function getBacklinksCount(
+ pageId: string,
+): Promise {
+ const req = await api.post("/pages/backlinks-count", {
+ pageId,
+ });
+ return req.data;
+}
+
+export async function getBacklinks(
+ params: IBacklinksListParams,
+): Promise> {
+ const req = await api.post>(
+ "/pages/backlinks",
+ params,
+ );
+ return req.data;
+}
diff --git a/apps/client/src/features/page-details/types/backlink.types.ts b/apps/client/src/features/page-details/types/backlink.types.ts
new file mode 100644
index 000000000..f45874e5d
--- /dev/null
+++ b/apps/client/src/features/page-details/types/backlink.types.ts
@@ -0,0 +1,23 @@
+export type BacklinkDirection = "incoming" | "outgoing";
+
+export interface IBacklinkCount {
+ incoming: number;
+ outgoing: number;
+}
+
+export interface IBacklinkPageItem {
+ id: string;
+ slugId: string;
+ title: string | null;
+ icon: string | null;
+ spaceId: string;
+ space: { id: string; slug: string; name: string } | null;
+ updatedAt: string;
+}
+
+export interface IBacklinksListParams {
+ pageId: string;
+ direction: BacklinkDirection;
+ cursor?: string;
+ limit?: number;
+}
diff --git a/apps/client/src/features/page-history/components/history-modal.tsx b/apps/client/src/features/page-history/components/history-modal.tsx
index dfd43cf1f..08f05c9e9 100644
--- a/apps/client/src/features/page-history/components/history-modal.tsx
+++ b/apps/client/src/features/page-history/components/history-modal.tsx
@@ -22,6 +22,7 @@ export default function HistoryModal({ pageId, pageTitle }: Props) {
opened={isModalOpen}
onClose={() => setModalOpen(false)}
fullScreen
+ aria-label={t("Page history")}
>
@@ -49,6 +50,7 @@ export default function HistoryModal({ pageId, pageTitle }: Props) {
size={1400}
opened={isModalOpen}
onClose={() => setModalOpen(false)}
+ aria-label={t("Page history")}
>
diff --git a/apps/client/src/features/page/components/breadcrumbs/breadcrumb.tsx b/apps/client/src/features/page/components/breadcrumbs/breadcrumb.tsx
index 11507e404..d02ba6e91 100644
--- a/apps/client/src/features/page/components/breadcrumbs/breadcrumb.tsx
+++ b/apps/client/src/features/page/components/breadcrumbs/breadcrumb.tsx
@@ -19,6 +19,7 @@ import { buildPageUrl } from "@/features/page/page.utils.ts";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { extractPageSlugId } from "@/lib";
import { useMediaQuery } from "@mantine/hooks";
+import { useTranslation } from "react-i18next";
function getTitle(name: string, icon: string) {
if (icon) {
@@ -28,6 +29,7 @@ function getTitle(name: string, icon: string) {
}
export default function Breadcrumb() {
+ const { t } = useTranslation();
const treeData = useAtomValue(treeDataAtom);
const [breadcrumbNodes, setBreadcrumbNodes] = useState<
SpaceTreeNode[] | null
@@ -80,7 +82,7 @@ export default function Breadcrumb() {
));
const renderAnchor = useCallback(
- (node: SpaceTreeNode) => (
+ (node: SpaceTreeNode, isCurrent = false) => (
{getTitle(node.name, node.icon)}
@@ -115,7 +118,11 @@ export default function Breadcrumb() {
key="hidden-nodes"
>
-
+
@@ -124,11 +131,13 @@ export default function Breadcrumb() {
,
//renderAnchor(secondLastNode),
- renderAnchor(lastNode),
+ renderAnchor(lastNode, true),
];
}
- return breadcrumbNodes.map(renderAnchor);
+ return breadcrumbNodes.map((node, i) =>
+ renderAnchor(node, i === breadcrumbNodes.length - 1),
+ );
};
const getMobileBreadcrumbItems = () => {
@@ -144,8 +153,12 @@ export default function Breadcrumb() {
key="mobile-hidden-nodes"
>
-
-
+
+
@@ -157,16 +170,18 @@ export default function Breadcrumb() {
];
}
- return breadcrumbNodes.map(renderAnchor);
+ return breadcrumbNodes.map((node, i) =>
+ renderAnchor(node, i === breadcrumbNodes.length - 1),
+ );
};
return (
-
+
+
);
}
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 9e4b72096..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
@@ -1,4 +1,4 @@
-import { ActionIcon, Group, Menu, Text, Tooltip } from "@mantine/core";
+import { ActionIcon, Group, Menu, Text, ThemeIcon, Tooltip } from "@mantine/core";
import {
IconArrowRight,
IconArrowsHorizontal,
@@ -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 (
<>
- {!readOnly && }
+ {!readOnly && }
@@ -99,6 +108,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
toggleAside("comments")}
>
@@ -109,6 +119,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
toggleAside("toc")}
>
@@ -132,7 +143,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
pageId: extractPageSlugId(pageSlug),
});
const { openDeleteModal } = useDeletePageModal();
- const [tree] = useAtom(treeApiAtom);
+ const { handleDelete } = useTreeMutation(page?.spaceId ?? "");
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
const [
@@ -181,7 +192,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
};
const handleDeletePage = () => {
- openDeleteModal({ onConfirm: () => tree?.delete(page.id) });
+ openDeleteModal({ onConfirm: () => handleDelete(page.id) });
};
const handleToggleFavorite = () => {
@@ -205,7 +216,11 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
arrowPosition="center"
>
-
+
@@ -416,9 +431,15 @@ function ConnectionWarning() {
openDelay={250}
withArrow
>
-
+
-
+
);
}
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 12f131b8d..0614cf0bd 100644
--- a/apps/client/src/features/page/components/header/page-header.tsx
+++ b/apps/client/src/features/page/components/header/page-header.tsx
@@ -8,7 +8,7 @@ interface Props {
}
export default function PageHeader({ readOnly }: Props) {
return (
-