Compare commits

..

4 Commits

Author SHA1 Message Date
Philipinho 62f0a2278d fix(editor): prevent stuck list after pasting plain text
marked.parse() emits a trailing newline that became a whitespace text
node at the body level, which parseSlice converted into a spurious
paragraph at the end of the target — inside a list item this blocked
the "Enter exits list" behavior since splitListItem's empty-last-block
check never fired.
Strip whitespace-only text nodes between block elements before parsing
the slice, and place the cursor at the end of the inserted content.
Also extend transformPasted to drop trailing hardBreaks and whitespace
text nodes for the HTML-clipboard path.
2026-05-12 22:32:44 +01:00
Philip Okugbe a689cca7a0 feat: page labels/tags (#2188)
* feat: labels (WIP)
* full implementation
2026-05-10 18:14:15 +01:00
Philip Okugbe 537e45bc11 feat: page details section and backlinks (#2186)
* feat: page details section and backlinks
2026-05-09 17:03:08 +01:00
Philip Okugbe bdc369fce0 feat(editor): fixed toolbar preference (#2185)
* feat(editor): fixed toolbar preference

* remove key

* cleanup translation strings

* update axios
2026-05-09 13:27:03 +01:00
50 changed files with 3308 additions and 147 deletions
@@ -970,5 +970,32 @@
"Experimental": "Experimental",
"Strikethrough": "Strikethrough",
"Undo": "Undo",
"Redo": "Redo"
"Redo": "Redo",
"Backlinks": "Backlinks",
"Last updated by": "Last updated by",
"Last updated": "Last updated",
"Stats": "Stats",
"Word count": "Word count",
"Characters": "Characters",
"Incoming links": "Incoming links",
"Outgoing links": "Outgoing links",
"Incoming links ({{count}})": "Incoming links ({{count}})",
"Outgoing links ({{count}})": "Outgoing links ({{count}})",
"No pages link here yet.": "No pages link here yet.",
"This page doesn't link to other pages yet.": "This page doesn't link to other pages yet.",
"Verified until {{date}}": "Verified until {{date}}",
"Labels": "Labels",
"Add label": "Add label",
"No labels yet": "No labels yet",
"Already added": "Already added",
"Invalid label name": "Invalid label name",
"No matches": "No matches",
"Search or create…": "Search or create…",
"Remove label {{name}}": "Remove label {{name}}",
"Failed to add label": "Failed to add label",
"Failed to remove label": "Failed to remove label",
"No pages with this label": "No pages with this label",
"Pages tagged with this label will appear here.": "Pages tagged with this label will appear here.",
"No pages match your search.": "No pages match your search.",
"Updated {{date}}": "Updated {{date}}"
}
+2
View File
@@ -45,6 +45,7 @@ import TemplateEditor from "@/ee/template/pages/template-editor";
import FavoritesPage from "@/pages/favorites/favorites-page";
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
import VerifyEmail from "@/ee/pages/verify-email.tsx";
import LabelPage from "@/pages/label/label-page";
export default function App() {
const { t } = useTranslation();
@@ -92,6 +93,7 @@ export default function App() {
<Route path={"/ai/chat/:chatId"} element={<AiChat />} />
<Route path={"/spaces"} element={<SpacesPage />} />
<Route path={"/favorites"} element={<FavoritesPage />} />
<Route path={"/labels/:labelName"} element={<LabelPage />} />
<Route path={"/templates"} element={<TemplateList />} />
<Route
path={"/templates/:templateId"}
@@ -8,6 +8,7 @@ import { TableOfContents } from "@/features/editor/components/table-of-contents/
import { useAtomValue } from "jotai";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
export default function Aside() {
const [{ tab }] = useAtom(asideStateAtom);
@@ -30,6 +31,10 @@ export default function Aside() {
component = <AsideChatPanel />;
title = "AI Chat";
break;
case "details":
component = <PageDetailsAside />;
title = "Details";
break;
default:
component = null;
title = null;
@@ -147,7 +147,9 @@ export default function GlobalAppShell({
? t("Table of contents")
: asideTab === "chat"
? t("AI Chat")
: undefined
: asideTab === "details"
? t("Details")
: undefined
}
>
<Aside />
@@ -10,6 +10,7 @@ export const desktopSidebarAtom = atomWithWebStorage<boolean>(
export const desktopAsideAtom = atom<boolean>(false);
// Valid `tab` values: "" | "comments" | "toc" | "chat" | "details"
type AsideStateType = {
tab: string;
isAsideOpen: boolean;
@@ -118,10 +118,20 @@ export function PageVerificationBadge({
if (status === "none" && readOnly) return null;
const tooltipLabel =
status === "verified" && verificationInfo?.expiresAt
? t("Verified until {{date}}", {
date: new Date(verificationInfo.expiresAt).toLocaleDateString(
undefined,
{ month: "long", day: "numeric", year: "numeric" },
),
})
: getStatusLabel(status, t);
return (
<>
{status !== "none" ? (
<Tooltip label={getStatusLabel(status, t)} withArrow openDelay={250}>
<Tooltip label={tooltipLabel} withArrow openDelay={250}>
<Group
gap={4}
onClick={open}
@@ -81,6 +81,7 @@ export const MarkdownClipboard = Extension.create({
const parsed = markdownToHtml(text.replace(/\n+$/, ""));
const body = elementFromString(parsed);
stripBlockLevelWhitespaceNodes(body);
normalizeTableColumnWidths(body);
const contentNodes = DOMParser.fromSchema(
@@ -91,7 +92,7 @@ export const MarkdownClipboard = Extension.create({
tr.replaceRange(from, to, contentNodes);
const insertEnd = tr.mapping.map(from, 1);
tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(from, insertEnd - 2)), -1));
tr.setSelection(TextSelection.near(tr.doc.resolve(insertEnd), -1));
tr.setMeta('paste', true)
view.dispatch(tr);
return true;
@@ -104,21 +105,28 @@ export const MarkdownClipboard = Extension.create({
transformPasted: (slice) => {
let { content, openStart, openEnd } = slice;
// Remove trailing paragraphs that contain only whitespace
while (content.childCount > 1) {
const lastChild = content.lastChild;
if (
lastChild?.type.name === "paragraph" &&
lastChild.textContent.trim() === ""
) {
const children = [];
for (let i = 0; i < content.childCount - 1; i++) {
children.push(content.child(i));
}
content = Fragment.from(children);
} else {
break;
const isTrailingNoise = (node: any) => {
if (!node) return false;
if (node.type.name === "hardBreak") return true;
if (node.isText && (node.text ?? "").trim() === "") return true;
if (node.type.name === "paragraph") {
let onlyNoise = true;
node.content.forEach((c: any) => {
if (c.type.name === "hardBreak") return;
if (c.isText && (c.text ?? "").trim() === "") return;
onlyNoise = false;
});
return onlyNoise;
}
return false;
};
while (content.childCount > 1 && isTrailingNoise(content.lastChild)) {
const children = [];
for (let i = 0; i < content.childCount - 1; i++) {
children.push(content.child(i));
}
content = Fragment.from(children);
}
if (content !== slice.content) {
@@ -140,6 +148,21 @@ function elementFromString(value) {
return new window.DOMParser().parseFromString(wrappedValue, "text/html").body;
}
// marked.parse() emits "<p>...</p>\n<p>...</p>\n" — those literal newlines
// become whitespace text nodes that parseSlice (preserveWhitespace: true)
// converts into spurious empty paragraphs at the insertion site. Inside a
// list item the trailing one prevents Enter from exiting the list.
function stripBlockLevelWhitespaceNodes(body: HTMLElement): void {
Array.from(body.childNodes).forEach((node) => {
if (
node.nodeType === 3 /* TEXT_NODE */ &&
(node.textContent ?? "").trim() === ""
) {
body.removeChild(node);
}
});
}
const DEFAULT_PASTE_COL_WIDTH_PX = 150;
function parsePixelWidth(el: Element): number | null {
@@ -3,14 +3,17 @@ import React from "react";
import { TitleEditor } from "@/features/editor/title-editor";
import PageEditor from "@/features/editor/page-editor";
import {
ActionIcon,
Container,
Divider,
Group,
Popover,
Stack,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { useAtom } from "jotai";
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
@@ -19,6 +22,8 @@ import { useTranslation } from "react-i18next";
import { IContributor } from "@/features/page/types/page.types.ts";
import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-toolbar";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import clsx from "clsx";
const MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageEditor = React.memo(PageEditor);
@@ -101,6 +106,7 @@ function PageByline({
readOnly,
}: PageBylineProps) {
const { t } = useTranslation();
const toggleAside = useToggleAside();
const otherContributors = (contributors ?? []).filter(
(c) => c.id !== creator?.id,
@@ -110,8 +116,8 @@ function PageByline({
<Group
gap="sm"
mb="md"
className="print-hide"
style={{ marginTop: "-0.5em", paddingLeft: "3rem" }}
className={clsx("print-hide", classes.byline)}
style={{ marginTop: "-0.5em" }}
>
{creator && (
<Popover position="bottom-start" shadow="md" width={280} withArrow>
@@ -173,6 +179,17 @@ function PageByline({
</Popover.Dropdown>
</Popover>
)}
<Tooltip label={t("Details")} withArrow openDelay={250}>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t("Details")}
onClick={() => toggleAside("details")}
>
<IconInfoCircle size={20} stroke={1.5} />
</ActionIcon>
</Tooltip>
<PageVerificationBadge readOnly={readOnly} />
</Group>
);
@@ -9,3 +9,15 @@
}
}
.byline {
padding-left: 3rem;
@media (max-width: $mantine-breakpoint-sm) {
padding-left: 1rem;
}
@media print {
padding-left: 0;
}
}
@@ -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<ILabel, "id" | "name">;
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 ? (
<Link
to={`/labels/${encodeURIComponent(label.name)}`}
className={classes.chipLink}
onClick={(e) => e.stopPropagation()}
>
<span className={classes.chipName}>{label.name}</span>
</Link>
) : (
<span className={classes.chipName}>{label.name}</span>
);
return (
<span className={classes.chip} style={{ background: c.bg, color: c.fg }}>
{nameNode}
{onRemove && (
<button
type="button"
className={classes.chipX}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemove();
}}
aria-label={t("Remove label {{name}}", { name: label.name })}
>
<IconX size={12} stroke={2} />
</button>
)}
</span>
);
}
@@ -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 (
<div className={classes.row} aria-hidden="true">
<div className={classes.rowMain}>
<div className={classes.rowIcon}>
<Skeleton height={18} width={18} radius="sm" />
</div>
<div className={classes.rowBody}>
<Skeleton height={15} width={titleWidth} radius="xs" />
<div className={classes.rowMeta}>
<Skeleton height={18} width={18} radius="sm" />
<Skeleton height={12} width={metaWidth} radius="xs" />
</div>
</div>
</div>
</div>
);
}
@@ -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 (
<Link
to={buildPageUrl(page.space?.slug, page.slugId, page.title ?? undefined)}
className={classes.row}
>
<div className={classes.rowMain}>
<div className={classes.rowIcon}>
{page.icon ? (
<span style={{ fontSize: 16, lineHeight: 1 }}>{page.icon}</span>
) : (
<ThemeIcon variant="transparent" color="gray" size={18}>
<IconFileDescription size={18} />
</ThemeIcon>
)}
</div>
<div className={classes.rowBody}>
<div className={classes.rowTitle}>
{page.title || t("Untitled")}
</div>
<div className={classes.rowMeta}>
{page.space && (
<>
<CustomAvatar
name={page.space.name}
avatarUrl={page.space.logo ?? undefined}
type={AvatarIconType.SPACE_ICON}
color="initials"
variant="filled"
size={18}
/>
<span>{page.space.name}</span>
<span className={classes.metaDot} aria-hidden="true">
</span>
</>
)}
<span className={classes.rowDate}>
{t("Updated {{date}}", {
date: formatLabelListDate(new Date(page.updatedAt)),
})}
</span>
</div>
{/* {otherLabels.length > 0 && (
<div className={classes.rowChips}>
{visibleLabels.map((label) => (
<LabelChip key={label.id} label={label} asLink />
))}
{hiddenLabels.length > 0 && (
<Tooltip
label={hiddenLabels.map((l) => l.name).join(", ")}
withArrow
openDelay={200}
>
<span className={classes.chipMore}>
+{hiddenLabels.length}
</span>
</Tooltip>
)}
</div>
)} */}
</div>
</div>
</Link>
);
}
@@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className={classes.popover}>
<div className={classes.popoverSearch}>
<input
ref={inputRef}
type="text"
autoFocus
maxLength={MAX_LABEL_NAME_LENGTH}
placeholder={t("Search or create…")}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setHover(0);
}}
onKeyDown={onKey}
/>
</div>
<div className={classes.popoverList}>
{total === 0 && (
<div className={classes.popoverEmpty}>
{normalized.length === 0
? t("No labels yet")
: appliedNames.has(normalized)
? t("Already added")
: !isValidLabelName(normalized)
? t("Invalid label name")
: t("No matches")}
</div>
)}
{suggestions.map((s, i) => {
const c = getLabelColor(s.name, scheme);
return (
<button
key={s.id}
type="button"
className={clsx(
classes.popoverItem,
hover === i && classes.popoverItemHover,
)}
onMouseEnter={() => setHover(i)}
onClick={() => select(i)}
>
<span
className={classes.popoverItemDot}
style={{ background: c.dot }}
/>
<span className={classes.popoverItemName}>{s.name}</span>
</button>
);
})}
{canCreate && (
<button
type="button"
className={clsx(
classes.popoverItem,
hover === suggestions.length && classes.popoverItemHover,
)}
onMouseEnter={() => setHover(suggestions.length)}
onClick={() => select(suggestions.length)}
>
<span className={classes.popoverCreatePlus}>
<IconPlus size={12} stroke={2} />
</span>
<span className={classes.popoverItemName}>
{t("Create")} <b>"{normalized}"</b>
</span>
</button>
)}
</div>
</div>
);
}
@@ -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 (
<>
<Divider />
<Stack gap="xs">
<Text size="xs" fw={500} c="dimmed">
{t("Labels")}
</Text>
<div className={classes.labelsWrap}>
{labels.map((label) => (
<LabelChip
key={label.id}
label={label}
asLink
onRemove={canEdit ? () => handleRemove(label.id) : undefined}
/>
))}
{canEdit && (
<Popover
opened={open}
onChange={setOpen}
position="bottom-end"
shadow="lg"
withinPortal
offset={6}
>
<Popover.Target>
<button
type="button"
className={clsx(classes.addBtn, open && classes.addBtnOpen)}
onClick={() => setOpen((v) => !v)}
>
<IconPlus size={12} stroke={2} />
<span>
{labels.length === 0 ? t("Add label") : t("Add")}
</span>
</button>
</Popover.Target>
<Popover.Dropdown p={0} className={classes.popover}>
<LabelPicker
applied={labels}
enabled={open}
onAdd={(name) => handleAdd(name)}
onClose={() => setOpen(false)}
/>
</Popover.Dropdown>
</Popover>
)}
</div>
</Stack>
</>
);
}
@@ -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);
}
@@ -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<ILabel[], Error, IAddLabels>({
mutationFn: (data) => addLabelsToPage(data),
onSuccess: (added) => {
queryClient.setQueryData<IPagination<ILabel>>(
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<IPagination<ILabel>>(
{ 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<void, Error, IRemoveLabel>({
mutationFn: (data) => removeLabelFromPage(data),
onSuccess: (_data, variables) => {
const cache = queryClient.getQueryData<IPagination<ILabel>>(
PAGE_LABELS_KEY(pageId ?? ""),
);
if (cache) {
queryClient.setQueryData<IPagination<ILabel>>(
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,
});
}
@@ -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<IPagination<ILabel>> {
const req = await api.post<IPagination<ILabel>>("/pages/labels", params);
return req.data;
}
export async function getWorkspaceLabels(
params: IListLabelsParams,
): Promise<IPagination<ILabel>> {
const req = await api.post<IPagination<ILabel>>("/labels", params);
return req.data;
}
export async function addLabelsToPage(
data: IAddLabels,
): Promise<ILabel[]> {
const req = await api.post<ILabel[]>("/pages/labels/add", data);
return req.data;
}
export async function removeLabelFromPage(data: IRemoveLabel): Promise<void> {
await api.post("/pages/labels/remove", data);
}
export async function getLabelInfo(
params: ILabelInfoParams,
): Promise<ILabelInfo> {
const req = await api.post<ILabelInfo>("/labels/info", params);
return req.data;
}
export async function findPagesByLabel(
params: IFindPagesByLabelParams,
): Promise<IPagination<ILabelPageItem>> {
const req = await api.post<IPagination<ILabelPageItem>>(
"/labels/pages",
params,
);
return req.data;
}
@@ -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;
}
@@ -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");
}
@@ -0,0 +1,55 @@
type LabelColor = {
bg: string;
fg: string;
dot: string;
};
const LABEL_PALETTE: Record<string, LabelColor> = {
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<string, LabelColor> = {
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];
}
@@ -0,0 +1,3 @@
export function normalizeLabelName(name: string): string {
return name.trim().replace(/\s+/g, "-").toLowerCase();
}
@@ -0,0 +1,113 @@
import {
Button,
Center,
Group,
Loader,
Stack,
Text,
UnstyledButton,
} from "@mantine/core";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useBacklinksQuery } from "@/features/page-details/queries/backlinks-query.ts";
import {
BacklinkDirection,
IBacklinkPageItem,
} from "@/features/page-details/types/backlink.types.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { getPageIcon } from "@/lib";
interface BacklinksListProps {
pageId: string;
direction: BacklinkDirection;
enabled: boolean;
onItemClick: () => 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 (
<Center py="sm">
<Loader size="sm" />
</Center>
);
}
const items: IBacklinkPageItem[] =
data?.pages.flatMap((page) => page.items) ?? [];
if (items.length === 0) {
return (
<Text c="dimmed" size="sm" py="md">
{direction === "incoming"
? t("No pages link here yet.")
: t("This page doesn't link to other pages yet.")}
</Text>
);
}
const handleClick = (e: React.MouseEvent) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
return;
}
onItemClick();
};
return (
<Stack gap={4}>
{items.map((item) => (
<UnstyledButton
key={item.id}
component={Link}
to={
item.space?.slug
? buildPageUrl(
item.space.slug,
item.slugId,
item.title ?? undefined,
)
: "#"
}
onClick={handleClick}
style={{ padding: "8px 4px", borderRadius: 4, userSelect: "none" }}
>
<Group gap="xs" wrap="nowrap">
{getPageIcon(item.icon ?? "")}
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} lineClamp={1}>
{item.title || t("Untitled")}
</Text>
{item.space?.name && (
<Text size="xs" c="dimmed" lineClamp={1}>
{item.space.name}
</Text>
)}
</Stack>
</Group>
</UnstyledButton>
))}
{hasNextPage && (
<Button
variant="subtle"
size="xs"
loading={isFetchingNextPage}
onClick={() => fetchNextPage()}
mt="xs"
>
{t("Load more")}
</Button>
)}
</Stack>
);
}
@@ -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 (
<Modal.Root opened={opened} onClose={onClose} size={640} yOffset="10vh">
<Modal.Overlay />
<Modal.Content>
<Modal.Header>
<Modal.Title fw={500}>{t("Backlinks")}</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<Stack gap="lg">
<Stack gap="xs">
<Text size="sm" fw={500} c="dimmed">
{t("Incoming links ({{count}})", {
count: counts?.incoming ?? 0,
})}
</Text>
<BacklinksList
pageId={pageId}
direction="incoming"
enabled={opened}
onItemClick={onClose}
/>
</Stack>
<Stack gap="xs">
<Text size="sm" fw={500} c="dimmed">
{t("Outgoing links ({{count}})", {
count: counts?.outgoing ?? 0,
})}
</Text>
<BacklinksList
pageId={pageId}
direction="outgoing"
enabled={opened}
onItemClick={onClose}
/>
</Stack>
</Stack>
</Modal.Body>
</Modal.Content>
</Modal.Root>
);
}
@@ -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 (
<>
<Stack gap="md">
<PeopleSection
creator={page.creator}
lastUpdatedBy={page.lastUpdatedBy}
/>
<Divider />
<StatsSection
wordCount={wordCount}
characterCount={characterCount}
createdAt={page.createdAt}
updatedAt={page.updatedAt}
/>
<Divider />
<BacklinksSection
incomingCount={counts?.incoming ?? 0}
outgoingCount={counts?.outgoing ?? 0}
isLoading={countsLoading}
onClick={openModal}
/>
<LabelsSection
pageId={page.id}
canEdit={page.permissions?.canEdit ?? false}
/>
</Stack>
<BacklinksModal
pageId={page.id}
opened={modalOpened}
onClose={closeModal}
/>
</>
);
}
function PeopleSection({
creator,
lastUpdatedBy,
}: {
creator: { id: string; name: string; avatarUrl: string } | null;
lastUpdatedBy: { id: string; name: string; avatarUrl: string } | null;
}) {
const { t } = useTranslation();
return (
<Stack gap="xs">
<PersonRow label={t("Created by")} person={creator} />
<PersonRow label={t("Last updated by")} person={lastUpdatedBy} />
</Stack>
);
}
function PersonRow({
label,
person,
}: {
label: string;
person: { id: string; name: string; avatarUrl: string } | null;
}) {
return (
<Group justify="space-between" wrap="nowrap">
<Text size="sm" c="dimmed">
{label}
</Text>
{person ? (
<Group gap={6} wrap="nowrap">
<CustomAvatar
avatarUrl={person.avatarUrl}
name={person.name}
size={20}
radius="xl"
/>
<Text size="sm" lineClamp={1}>
{person.name}
</Text>
</Group>
) : (
<Text size="sm" c="dimmed">
</Text>
)}
</Group>
);
}
function StatsSection({
wordCount,
characterCount,
createdAt,
updatedAt,
}: {
wordCount: number;
characterCount: number;
createdAt: Date | string;
updatedAt: Date | string;
}) {
const { t } = useTranslation();
return (
<Stack gap="xs">
<Text size="xs" fw={500} c="dimmed">
{t("Stats")}
</Text>
<StatRow label={t("Word count")} value={String(wordCount)} />
<StatRow label={t("Characters")} value={String(characterCount)} />
<StatRow
label={t("Created")}
value={formattedDate(new Date(createdAt))}
/>
<StatRow
label={t("Last updated")}
value={timeAgo(new Date(updatedAt))}
/>
</Stack>
);
}
function StatRow({ label, value }: { label: string; value: string }) {
return (
<Group justify="space-between" wrap="nowrap">
<Text size="sm" c="dimmed">
{label}
</Text>
<Text size="sm">{value}</Text>
</Group>
);
}
function BacklinksSection({
incomingCount,
outgoingCount,
isLoading,
onClick,
}: {
incomingCount: number;
outgoingCount: number;
isLoading: boolean;
onClick: () => void;
}) {
const { t } = useTranslation();
return (
<Stack gap="xs">
<Text size="xs" fw={500} c="dimmed">
{t("Backlinks")}
</Text>
<BacklinksRow
label={t("Incoming links")}
count={incomingCount}
isLoading={isLoading}
onClick={onClick}
/>
<BacklinksRow
label={t("Outgoing links")}
count={outgoingCount}
isLoading={isLoading}
onClick={onClick}
/>
</Stack>
);
}
function BacklinksRow({
label,
count,
isLoading,
onClick,
}: {
label: string;
count: number;
isLoading: boolean;
onClick: () => void;
}) {
return (
<UnstyledButton
onClick={onClick}
style={{
padding: "4px 4px",
borderRadius: 4,
}}
>
<Group justify="space-between" wrap="nowrap">
<Text size="sm" c="dimmed">
{label}
</Text>
<Group gap={6} wrap="nowrap">
{isLoading ? (
<Skeleton height={18} width={20} />
) : (
<Text size="sm">{count}</Text>
)}
<IconChevronRight size={16} stroke={2} color="var(--mantine-color-dimmed)" />
</Group>
</Group>
</UnstyledButton>
);
}
@@ -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<IBacklinkCount>({
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,
});
}
@@ -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<IBacklinkCount> {
const req = await api.post<IBacklinkCount>("/pages/backlinks-count", {
pageId,
});
return req.data;
}
export async function getBacklinks(
params: IBacklinksListParams,
): Promise<IPagination<IBacklinkPageItem>> {
const req = await api.post<IPagination<IBacklinkPageItem>>(
"/pages/backlinks",
params,
);
return req.data;
}
@@ -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;
}
@@ -1,13 +1,9 @@
import React, { useState, useMemo, useEffect } from "react";
import React, { useState, useEffect } from "react";
import {
Button,
Menu,
Text,
TextInput,
Divider,
Badge,
ScrollArea,
Avatar,
Group,
Switch,
getDefaultZIndex,
@@ -16,12 +12,11 @@ import {
IconChevronDown,
IconBuilding,
IconFileDescription,
IconSearch,
IconCheck,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useDebouncedValue } from "@mantine/hooks";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { SpaceFilterMenu } from "@/features/space/components/space-filter-menu";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import classes from "./search-spotlight-filters.module.css";
@@ -46,32 +41,13 @@ export function SearchSpotlightFilters({
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(
spaceId || null,
);
const [spaceSearchQuery, setSpaceSearchQuery] = useState("");
const [debouncedSpaceQuery] = useDebouncedValue(spaceSearchQuery, 300);
const [contentType, setContentType] = useState<string | null>("page");
const [workspace] = useAtom(workspaceAtom);
const { data: spacesData } = useGetSpacesQuery({
limit: 100,
query: debouncedSpaceQuery,
});
const selectedSpaceData = useMemo(() => {
if (!spacesData?.items || !selectedSpaceId) return null;
return spacesData.items.find((space) => space.id === selectedSpaceId);
}, [spacesData?.items, selectedSpaceId]);
const availableSpaces = useMemo(() => {
const spaces = spacesData?.items || [];
if (!selectedSpaceId) return spaces;
// Sort to put selected space first
return [...spaces].sort((a, b) => {
if (a.id === selectedSpaceId) return -1;
if (b.id === selectedSpaceId) return 1;
return 0;
});
}, [spacesData?.items, selectedSpaceId]);
const { data: spacesData } = useGetSpacesQuery({ limit: 100 });
const selectedSpaceData = selectedSpaceId
? spacesData?.items.find((space) => space.id === selectedSpaceId)
: null;
useEffect(() => {
if (onFiltersChange) {
@@ -152,86 +128,27 @@ export function SearchSpotlightFilters({
</div>
)}
<Menu
shadow="md"
width={250}
<SpaceFilterMenu
value={selectedSpaceId}
onChange={handleSpaceSelect}
position="bottom-start"
width={250}
zIndex={getDefaultZIndex("max")}
>
<Menu.Target>
<Button
variant="subtle"
color="gray"
size="sm"
rightSection={<IconChevronDown size={14} />}
leftSection={<IconBuilding size={16} />}
className={classes.filterButton}
fw={500}
>
{selectedSpaceId
? `${t("Space")}: ${selectedSpaceData?.name || t("Unknown")}`
: `${t("Space")}: ${t("All spaces")}`}
</Button>
</Menu.Target>
<Menu.Dropdown>
<TextInput
placeholder={t("Find a space")}
data-autofocus
autoFocus
leftSection={<IconSearch size={16} />}
value={spaceSearchQuery}
onChange={(e) => setSpaceSearchQuery(e.target.value)}
size="sm"
variant="filled"
radius="sm"
styles={{ input: { marginBottom: 8 } }}
/>
<ScrollArea.Autosize mah={280}>
<Menu.Item onClick={() => handleSpaceSelect(null)}>
<Group flex="1" gap="xs">
<Avatar
color="initials"
variant="filled"
name={t("All spaces")}
size={20}
/>
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{t("All spaces")}
</Text>
<Text size="xs" c="dimmed">
{t("Search in all your spaces")}
</Text>
</div>
{!selectedSpaceId && <IconCheck size={20} />}
</Group>
</Menu.Item>
<Divider my="xs" />
{availableSpaces.map((space) => (
<Menu.Item
key={space.id}
onClick={() => handleSpaceSelect(space.id)}
>
<Group flex="1" gap="xs">
<Avatar
color="initials"
variant="filled"
name={space.name}
size={20}
/>
<Text size="sm" fw={500} style={{ flex: 1 }} truncate>
{space.name}
</Text>
{selectedSpaceId === space.id && <IconCheck size={20} />}
</Group>
</Menu.Item>
))}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
<Button
variant="subtle"
color="gray"
size="sm"
rightSection={<IconChevronDown size={14} />}
leftSection={<IconBuilding size={16} />}
className={classes.filterButton}
fw={500}
>
{selectedSpaceId
? `${t("Space")}: ${selectedSpaceData?.name || t("Unknown")}`
: `${t("Space")}: ${t("All spaces")}`}
</Button>
</SpaceFilterMenu>
<Menu
shadow="md"
@@ -0,0 +1,121 @@
import { ReactNode, useMemo, useState } from "react";
import {
Avatar,
Divider,
Group,
Menu,
ScrollArea,
Text,
TextInput,
getDefaultZIndex,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconCheck, IconSearch } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
type SpaceFilterMenuProps = {
value: string | null;
onChange: (spaceId: string | null) => void;
children: ReactNode;
width?: number;
position?:
| "bottom-start"
| "bottom-end"
| "bottom"
| "top-start"
| "top-end"
| "top";
zIndex?: number;
};
export function SpaceFilterMenu({
value,
onChange,
children,
width = 280,
position = "bottom-end",
zIndex,
}: SpaceFilterMenuProps) {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
const { data: spacesData } = useGetSpacesQuery({
limit: 100,
query: debouncedQuery,
});
const spaces = spacesData?.items ?? [];
const orderedSpaces = useMemo(() => {
if (!value) return spaces;
return [...spaces].sort((a, b) => {
if (a.id === value) return -1;
if (b.id === value) return 1;
return 0;
});
}, [spaces, value]);
return (
<Menu shadow="md" width={width} position={position} zIndex={zIndex}>
<Menu.Target>{children}</Menu.Target>
<Menu.Dropdown>
<TextInput
placeholder={t("Find a space")}
data-autofocus
autoFocus
leftSection={<IconSearch size={16} />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
size="sm"
variant="filled"
radius="sm"
styles={{ input: { marginBottom: 8 } }}
/>
<ScrollArea.Autosize mah={280}>
<Menu.Item onClick={() => onChange(null)}>
<Group flex="1" gap="xs">
<Avatar
color="initials"
variant="filled"
name={t("All spaces")}
size={20}
/>
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{t("All spaces")}
</Text>
<Text size="xs" c="dimmed">
{t("Search in all your spaces")}
</Text>
</div>
{!value && <IconCheck size={20} />}
</Group>
</Menu.Item>
<Divider my="xs" />
{orderedSpaces.map((space) => (
<Menu.Item key={space.id} onClick={() => onChange(space.id)}>
<Group flex="1" gap="xs">
<Avatar
color="initials"
variant="filled"
name={space.name}
size={20}
/>
<Text size="sm" fw={500} style={{ flex: 1 }} truncate>
{space.name}
</Text>
{value === space.id && <IconCheck size={20} />}
</Group>
</Menu.Item>
))}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
);
}
export const SPACE_FILTER_MENU_MAX_Z = getDefaultZIndex("max");
+180
View File
@@ -0,0 +1,180 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
Button,
Center,
Container,
Group,
Loader,
Stack,
Text,
TextInput,
useComputedColorScheme,
} from "@mantine/core";
import {
IconChevronDown,
IconLabel,
IconSearch,
} from "@tabler/icons-react";
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Helmet } from "react-helmet-async";
import { useDebouncedValue } from "@mantine/hooks";
import { getAppName } from "@/lib/config";
import { useLabelPagesQuery } from "@/features/label/queries/label-query.ts";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import { getLabelColor } from "@/features/label/utils/label-colors.ts";
import { LabelPageRow } from "@/features/label/components/label-page-row.tsx";
import { LabelPageRowSkeleton } from "@/features/label/components/label-page-row-skeleton.tsx";
import { normalizeLabelName } from "@/features/label/utils/normalize-label.ts";
import { SpaceFilterMenu } from "@/features/space/components/space-filter-menu.tsx";
import { EmptyState } from "@/components/ui/empty-state";
import classes from "@/features/label/label.module.css";
export default function LabelPage() {
const { t } = useTranslation();
const { labelName: rawName } = useParams<{ labelName: string }>();
const labelName = normalizeLabelName(decodeURIComponent(rawName ?? ""));
const scheme = useComputedColorScheme("light");
const c = getLabelColor(labelName, scheme);
const [spaceId, setSpaceId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search.trim(), 200);
const activeSpaceId = spaceId ?? undefined;
const { data: spacesData } = useGetSpacesQuery({ limit: 100 });
const spaces = spacesData?.items ?? [];
const {
data: pagesData,
isLoading: pagesLoading,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useLabelPagesQuery(labelName, debouncedSearch, activeSpaceId);
const pages = useMemo(
() => pagesData?.pages.flatMap((p) => p.items) ?? [],
[pagesData],
);
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ rootMargin: "200px 0px" },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const selectedSpaceName = useMemo(() => {
if (!spaceId) return t("All spaces");
return spaces.find((s) => s.id === spaceId)?.name ?? t("All spaces");
}, [spaceId, spaces, t]);
return (
<>
<Helmet>
<title>
{labelName} - {getAppName()}
</title>
</Helmet>
<Container size={820} py="xl">
<Stack gap="lg">
<Stack gap="sm">
<Text size="sm" c="dimmed">
{t("Labels")}
{" / "}
<Text component="span" c="bright" fw={500}>
{labelName}
</Text>
</Text>
<Group gap="md" align="center" wrap="nowrap">
<Link
to={`/labels/${encodeURIComponent(labelName)}`}
className={classes.headerChip}
style={{ background: c.bg, color: c.fg }}
>
<span
className={classes.headerDot}
style={{ background: c.dot }}
/>
<span>{labelName}</span>
</Link>
</Group>
</Stack>
<Group gap="sm" wrap="nowrap" align="center">
<TextInput
placeholder={t("Search by title")}
leftSection={<IconSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.target.value)}
size="sm"
style={{ flex: 1 }}
/>
<SpaceFilterMenu value={spaceId} onChange={setSpaceId}>
<Button
variant="default"
size="sm"
rightSection={<IconChevronDown size={14} />}
>
{selectedSpaceName}
</Button>
</SpaceFilterMenu>
</Group>
{pagesLoading && pages.length === 0 ? (
<div>
<LabelPageRowSkeleton titleWidth={260} metaWidth={170} />
<LabelPageRowSkeleton titleWidth={180} metaWidth={150} />
<LabelPageRowSkeleton titleWidth={220} metaWidth={190} />
<LabelPageRowSkeleton titleWidth={140} metaWidth={140} />
<LabelPageRowSkeleton titleWidth={240} metaWidth={170} />
</div>
) : pages.length > 0 ? (
<div>
{pages.map((page) => (
<LabelPageRow
key={page.id}
page={page}
currentLabelName={labelName}
/>
))}
<div ref={sentinelRef} />
{isFetchingNextPage && (
<Center py="md">
<Loader size="sm" />
</Center>
)}
</div>
) : (
<EmptyState
icon={IconLabel}
title={
debouncedSearch
? t("No matches")
: t("No pages with this label")
}
description={
debouncedSearch
? t("No pages match your search.")
: t("Pages tagged with this label will appear here.")
}
/>
)}
</Stack>
</Container>
</>
);
}
+2
View File
@@ -18,6 +18,7 @@ import { PageAccessModule } from './page/page-access/page-access.module';
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
import { AuditContextMiddleware } from '../common/middlewares/audit-context.middleware';
import { ShareModule } from './share/share.module';
import { LabelModule } from './label/label.module';
import { NotificationModule } from './notification/notification.module';
import { WatcherModule } from './watcher/watcher.module';
import { FavoriteModule } from './favorite/favorite.module';
@@ -39,6 +40,7 @@ import { ClsMiddleware } from 'nestjs-cls';
CaslModule,
PageAccessModule,
ShareModule,
LabelModule,
NotificationModule,
WatcherModule,
SessionModule,
@@ -0,0 +1,84 @@
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
Matches,
MaxLength,
} from 'class-validator';
import { Transform } from 'class-transformer';
import { LabelType } from '@docmost/db/repos/label/label.repo';
import { PageIdDto } from '../../page/dto/page.dto';
import { normalizeLabelName } from '../utils';
//TODO: We may support SPACE/TEMPLATE labels in the future
const SUPPORTED_LABEL_TYPES: LabelType[] = [LabelType.PAGE];
export class AddLabelsDto extends PageIdDto {
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(25)
@IsString({ each: true })
@IsNotEmpty({ each: true })
@Transform(({ value }) =>
Array.isArray(value) ? value.map(normalizeLabelName) : value,
)
@MaxLength(100, { each: true })
@Matches(/^[a-z0-9_-][a-z0-9_~-]*$/, {
each: true,
message:
'Label names can only contain letters, numbers, hyphens, underscores, and tildes, and cannot start with a tilde',
})
names: string[];
}
export class RemoveLabelDto extends PageIdDto {
@IsUUID()
labelId: string;
}
export class FindPagesByLabelDto {
@IsOptional()
@IsUUID()
labelId?: string;
@IsOptional()
@IsString()
@Transform(({ value }) =>
typeof value === 'string' ? normalizeLabelName(value) : value,
)
@MaxLength(100)
name?: string;
@IsOptional()
@IsUUID()
spaceId?: string;
}
export class LabelInfoDto {
@IsString()
@IsNotEmpty()
@Transform(({ value }) =>
typeof value === 'string' ? normalizeLabelName(value) : value,
)
@MaxLength(100)
name: string;
@IsString()
@IsIn(SUPPORTED_LABEL_TYPES)
type: LabelType;
@IsOptional()
@IsUUID()
spaceId?: string;
}
export class ListLabelsDto {
@IsString()
@IsIn(SUPPORTED_LABEL_TYPES)
type: LabelType;
}
@@ -0,0 +1,122 @@
import {
BadRequestException,
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
NotFoundException,
Post,
UseGuards,
} from '@nestjs/common';
import { LabelService } from './label.service';
import {
FindPagesByLabelDto,
LabelInfoDto,
ListLabelsDto,
} from './dto/label.dto';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { LabelRepo, LabelType } from '@docmost/db/repos/label/label.repo';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { emptyCursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
@UseGuards(JwtAuthGuard)
@Controller('labels')
export class LabelController {
constructor(
private readonly labelService: LabelService,
private readonly labelRepo: LabelRepo,
private readonly spaceAbility: SpaceAbilityFactory,
) {}
@HttpCode(HttpStatus.OK)
@Post('/')
async getLabels(
@Body() dto: ListLabelsDto,
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
return this.labelService.getLabels(
workspace.id,
user.id,
dto.type,
pagination,
);
}
@HttpCode(HttpStatus.OK)
@Post('pages')
async findPagesByLabel(
@Body() dto: FindPagesByLabelDto,
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
if (dto.spaceId) {
await this.assertCanReadSpace(user, dto.spaceId);
}
let labelId = dto.labelId;
if (!labelId) {
if (!dto.name) {
throw new BadRequestException('labelId or name is required');
}
const label = await this.labelRepo.findByNameAndWorkspace(
dto.name,
workspace.id,
LabelType.PAGE,
);
if (!label) {
return emptyCursorPaginationResult(pagination.limit);
}
labelId = label.id;
} else {
const label = await this.labelRepo.findById(labelId);
if (!label) {
throw new NotFoundException('Label not found');
}
}
return this.labelService.findPagesByLabel(labelId, user.id, {
spaceId: dto.spaceId,
query: pagination.query,
pagination,
});
}
// @HttpCode(HttpStatus.OK)
// @Post('info')
// async getLabelInfo(
// @Body() dto: LabelInfoDto,
// @AuthUser() user: User,
// @AuthWorkspace() workspace: Workspace,
// ) {
// if (dto.spaceId) {
// await this.assertCanReadSpace(user, dto.spaceId);
// }
//
// return this.labelService.getLabelInfo(
// dto.name,
// dto.type,
// workspace.id,
// user.id,
// dto.spaceId,
// );
// }
private async assertCanReadSpace(user: User, spaceId: string) {
const ability = await this.spaceAbility.createForUser(user, spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { LabelController } from './label.controller';
import { LabelService } from './label.service';
@Module({
controllers: [LabelController],
providers: [LabelService],
exports: [LabelService],
})
export class LabelModule {}
+140
View File
@@ -0,0 +1,140 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Label } from '@docmost/db/types/entity.types';
import { LabelRepo, LabelType } from '@docmost/db/repos/label/label.repo';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { executeTx } from '@docmost/db/utils';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { normalizeLabelName } from './utils';
@Injectable()
export class LabelService {
constructor(
private readonly labelRepo: LabelRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
@InjectKysely() private readonly db: KyselyDB,
) {}
async addLabelsToPage(
pageId: string,
names: string[],
workspaceId: string,
): Promise<Label[]> {
const attached: Label[] = [];
await executeTx(this.db, async (trx) => {
for (const name of names) {
const label = await this.labelRepo.findOrCreate(
name.trim(),
workspaceId,
LabelType.PAGE,
trx,
);
await this.labelRepo.addLabelToPage(pageId, label.id, trx);
attached.push(label);
}
});
return attached;
}
async removeLabelFromPage(
pageId: string,
labelId: string,
workspaceId: string,
): Promise<void> {
await executeTx(this.db, async (trx) => {
const label = await this.labelRepo.findById(labelId, trx);
if (!label || label.workspaceId !== workspaceId) {
throw new NotFoundException('Label not found');
}
await this.labelRepo.removeLabelFromPage(
pageId,
labelId,
workspaceId,
trx,
);
const count = await this.labelRepo.getLabelPageCount(
labelId,
workspaceId,
trx,
);
if (count === 0) {
await this.labelRepo.deleteLabel(labelId, workspaceId, trx);
}
});
}
async getPageLabels(pageId: string, pagination: PaginationOptions) {
return this.labelRepo.findLabelsByPageId(pageId, pagination);
}
async getLabels(
workspaceId: string,
userId: string,
type: LabelType,
pagination: PaginationOptions,
) {
return this.labelRepo.findLabels(
workspaceId,
userId,
type,
pagination,
);
}
async findPagesByLabel(
labelId: string,
userId: string,
opts: {
spaceId?: string;
query?: string;
pagination: PaginationOptions;
},
) {
const result = await this.labelRepo.findPagesByLabelId(labelId, userId, opts);
if (result.items.length === 0) return result;
const accessibleIds = await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds: result.items.map((p) => p.id),
userId,
spaceId: opts.spaceId,
});
const accessible = new Set(accessibleIds);
return {
items: result.items.filter((p) => accessible.has(p.id)),
meta: result.meta,
};
}
async getLabelInfo(
name: string,
type: LabelType,
workspaceId: string,
userId: string,
spaceId?: string,
) {
const normalized = normalizeLabelName(name);
const label = await this.labelRepo.findByNameAndWorkspace(
normalized,
workspaceId,
type,
);
// Uniform response shape.
// We don't want to expose whether the label row exists
const usageCount = label
? await this.labelRepo.getLabelPageCountForUser(
label.id,
userId,
spaceId,
)
: 0;
return {
name: normalized,
usageCount,
};
}
}
+3
View File
@@ -0,0 +1,3 @@
export function normalizeLabelName(name: string): string {
return name.trim().replace(/\s+/g, '-').toLowerCase();
}
@@ -0,0 +1,11 @@
import { IsIn, IsNotEmpty, IsString } from 'class-validator';
import { PageIdDto } from './page.dto';
export type BacklinkDirection = 'incoming' | 'outgoing';
export class BacklinksListDto extends PageIdDto {
@IsString()
@IsNotEmpty()
@IsIn(['incoming', 'outgoing'])
direction: BacklinkDirection;
}
@@ -11,6 +11,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { PageService } from './services/page.service';
import { BacklinkService } from './services/backlink.service';
import { PageAccessService } from './page-access/page-access.service';
import { CreatePageDto } from './dto/create-page.dto';
import { UpdatePageDto } from './dto/update-page.dto';
@@ -38,6 +39,9 @@ import { RecentPageDto } from './dto/recent-page.dto';
import { CreatedByUserDto } from './dto/created-by-user.dto';
import { DuplicatePageDto } from './dto/duplicate-page.dto';
import { DeletedPageDto } from './dto/deleted-page.dto';
import { BacklinksListDto } from './dto/backlink.dto';
import { LabelService } from '../label/label.service';
import { AddLabelsDto, RemoveLabelDto } from '../label/dto/label.dto';
import {
jsonToHtml,
jsonToMarkdown,
@@ -58,6 +62,8 @@ export class PageController {
private readonly pageHistoryService: PageHistoryService,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageAccessService: PageAccessService,
private readonly backlinkService: BacklinkService,
private readonly labelService: LabelService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@@ -96,6 +102,100 @@ export class PageController {
return { ...page, permissions };
}
@HttpCode(HttpStatus.OK)
@Post('labels')
async getPageLabels(
@Body() dto: PageIdDto,
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
return this.labelService.getPageLabels(page.id, pagination);
}
@HttpCode(HttpStatus.OK)
@Post('labels/add')
async addPageLabels(
@Body() dto: AddLabelsDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page || page.deletedAt) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanEdit(page, user);
return this.labelService.addLabelsToPage(
page.id,
dto.names,
workspace.id,
);
}
@HttpCode(HttpStatus.OK)
@Post('labels/remove')
async removePageLabel(
@Body() dto: RemoveLabelDto,
@AuthUser() user: User,
) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page || page.deletedAt) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanEdit(page, user);
await this.labelService.removeLabelFromPage(
page.id,
dto.labelId,
page.workspaceId,
);
}
@HttpCode(HttpStatus.OK)
@Post('backlinks-count')
async getBacklinksCount(
@Body() dto: PageIdDto,
@AuthUser() user: User,
): Promise<{ incoming: number; outgoing: number }> {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
return this.backlinkService.countByPageId(page.id, user.id);
}
@HttpCode(HttpStatus.OK)
@Post('backlinks')
async getBacklinks(
@Body() dto: BacklinksListDto,
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
return this.backlinkService.findByPageId(
page.id,
dto.direction,
user.id,
pagination,
);
}
@HttpCode(HttpStatus.OK)
@Post('create')
async create(
+15 -2
View File
@@ -3,15 +3,28 @@ import { PageService } from './services/page.service';
import { PageController } from './page.controller';
import { PageHistoryService } from './services/page-history.service';
import { TrashCleanupService } from './services/trash-cleanup.service';
import { BacklinkService } from './services/backlink.service';
import { StorageModule } from '../../integrations/storage/storage.module';
import { CollaborationModule } from '../../collaboration/collaboration.module';
import { WatcherModule } from '../watcher/watcher.module';
import { TransclusionModule } from './transclusion/transclusion.module';
import { LabelModule } from '../label/label.module';
@Module({
controllers: [PageController],
providers: [PageService, PageHistoryService, TrashCleanupService],
providers: [
PageService,
PageHistoryService,
TrashCleanupService,
BacklinkService,
],
exports: [PageService, PageHistoryService],
imports: [StorageModule, CollaborationModule, WatcherModule, TransclusionModule],
imports: [
StorageModule,
CollaborationModule,
WatcherModule,
TransclusionModule,
LabelModule,
],
})
export class PageModule {}
@@ -0,0 +1,163 @@
import { Test } from '@nestjs/testing';
import { BacklinkService } from './backlink.service';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
describe('BacklinkService.countByPageId', () => {
let service: BacklinkService;
let backlinkRepo: jest.Mocked<BacklinkRepo>;
let permissionRepo: jest.Mocked<PagePermissionRepo>;
const pageId = '00000000-0000-0000-0000-000000000001';
const userId = '00000000-0000-0000-0000-000000000099';
beforeEach(async () => {
const backlinkRepoMock: jest.Mocked<Partial<BacklinkRepo>> = {
findRelatedPageIds: jest.fn(),
findPagesByIdsPaginated: jest.fn(),
};
const permissionRepoMock: jest.Mocked<Partial<PagePermissionRepo>> = {
filterAccessiblePageIds: jest.fn(),
};
const module = await Test.createTestingModule({
providers: [
BacklinkService,
{ provide: BacklinkRepo, useValue: backlinkRepoMock },
{ provide: PagePermissionRepo, useValue: permissionRepoMock },
],
}).compile();
service = module.get(BacklinkService);
backlinkRepo = module.get(BacklinkRepo) as jest.Mocked<BacklinkRepo>;
permissionRepo = module.get(
PagePermissionRepo,
) as jest.Mocked<PagePermissionRepo>;
});
it('returns post-filter counts for both directions', async () => {
backlinkRepo.findRelatedPageIds.mockImplementation(async (_id, dir) =>
dir === 'incoming' ? ['a', 'b', 'c'] : ['x', 'y'],
);
permissionRepo.filterAccessiblePageIds.mockImplementation(
async ({ pageIds }) =>
pageIds.filter((id) => id !== 'b' && id !== 'y'),
);
const result = await service.countByPageId(pageId, userId);
expect(result).toEqual({ incoming: 2, outgoing: 1 });
expect(permissionRepo.filterAccessiblePageIds).toHaveBeenCalledWith({
pageIds: ['a', 'b', 'c'],
userId,
});
expect(permissionRepo.filterAccessiblePageIds).toHaveBeenCalledWith({
pageIds: ['x', 'y'],
userId,
});
});
it('skips the permission filter when there are no candidates', async () => {
backlinkRepo.findRelatedPageIds.mockResolvedValue([]);
permissionRepo.filterAccessiblePageIds.mockResolvedValue([]);
const result = await service.countByPageId(pageId, userId);
expect(result).toEqual({ incoming: 0, outgoing: 0 });
expect(permissionRepo.filterAccessiblePageIds).not.toHaveBeenCalled();
});
it('passes the userId to findRelatedPageIds so the repo can apply space membership filtering', async () => {
backlinkRepo.findRelatedPageIds.mockResolvedValue([]);
await service.countByPageId(pageId, userId);
expect(backlinkRepo.findRelatedPageIds).toHaveBeenCalledWith(
pageId,
'incoming',
userId,
);
expect(backlinkRepo.findRelatedPageIds).toHaveBeenCalledWith(
pageId,
'outgoing',
userId,
);
});
});
describe('BacklinkService.findByPageId', () => {
let service: BacklinkService;
let backlinkRepo: jest.Mocked<BacklinkRepo>;
let permissionRepo: jest.Mocked<PagePermissionRepo>;
const pageId = '00000000-0000-0000-0000-000000000001';
const userId = '00000000-0000-0000-0000-000000000099';
beforeEach(async () => {
const backlinkRepoMock: jest.Mocked<Partial<BacklinkRepo>> = {
findRelatedPageIds: jest.fn(),
findPagesByIdsPaginated: jest.fn(),
};
const permissionRepoMock: jest.Mocked<Partial<PagePermissionRepo>> = {
filterAccessiblePageIds: jest.fn(),
};
const module = await Test.createTestingModule({
providers: [
BacklinkService,
{ provide: BacklinkRepo, useValue: backlinkRepoMock },
{ provide: PagePermissionRepo, useValue: permissionRepoMock },
],
}).compile();
service = module.get(BacklinkService);
backlinkRepo = module.get(BacklinkRepo) as jest.Mocked<BacklinkRepo>;
permissionRepo = module.get(
PagePermissionRepo,
) as jest.Mocked<PagePermissionRepo>;
});
it('passes filtered ids through to the paginated repo call', async () => {
backlinkRepo.findRelatedPageIds.mockResolvedValue(['a', 'b']);
permissionRepo.filterAccessiblePageIds.mockResolvedValue(['a']);
backlinkRepo.findPagesByIdsPaginated.mockResolvedValue({
items: [],
meta: {
limit: 20,
hasNextPage: false,
hasPrevPage: false,
nextCursor: null,
prevCursor: null,
},
} as any);
await service.findByPageId(pageId, 'incoming', userId, { limit: 20 } as any);
expect(backlinkRepo.findPagesByIdsPaginated).toHaveBeenCalledWith(
['a'],
expect.objectContaining({ limit: 20 }),
);
});
it('hands an empty list to the repo when there are no accessible ids', async () => {
backlinkRepo.findRelatedPageIds.mockResolvedValue([]);
backlinkRepo.findPagesByIdsPaginated.mockResolvedValue({
items: [],
meta: {
limit: 20,
hasNextPage: false,
hasPrevPage: false,
nextCursor: null,
prevCursor: null,
},
} as any);
await service.findByPageId(pageId, 'incoming', userId, { limit: 20 } as any);
expect(backlinkRepo.findPagesByIdsPaginated).toHaveBeenCalledWith(
[],
expect.objectContaining({ limit: 20 }),
);
expect(permissionRepo.filterAccessiblePageIds).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,56 @@
import { Injectable } from '@nestjs/common';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
export type BacklinkDirection = 'incoming' | 'outgoing';
@Injectable()
export class BacklinkService {
constructor(
private readonly backlinkRepo: BacklinkRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
) {}
async countByPageId(
pageId: string,
userId: string,
): Promise<{ incoming: number; outgoing: number }> {
const [incomingIds, outgoingIds] = await Promise.all([
this.accessibleRelatedIds(pageId, 'incoming', userId),
this.accessibleRelatedIds(pageId, 'outgoing', userId),
]);
return { incoming: incomingIds.length, outgoing: outgoingIds.length };
}
async findByPageId(
pageId: string,
direction: BacklinkDirection,
userId: string,
pagination: PaginationOptions,
) {
const accessibleIds = await this.accessibleRelatedIds(
pageId,
direction,
userId,
);
return this.backlinkRepo.findPagesByIdsPaginated(accessibleIds, pagination);
}
private async accessibleRelatedIds(
pageId: string,
direction: BacklinkDirection,
userId: string,
): Promise<string[]> {
const candidateIds = await this.backlinkRepo.findRelatedPageIds(
pageId,
direction,
userId,
);
if (candidateIds.length === 0) return [];
return this.pagePermissionRepo.filterAccessiblePageIds({
pageIds: candidateIds,
userId,
});
}
}
@@ -425,11 +425,7 @@ export class PageService {
if (pageIdsToMove.length > 1) {
// Update sub pages (all accessible pages except root)
await this.pageRepo.updatePages(
{ spaceId },
childPageIds,
trx,
);
await this.pageRepo.updatePages({ spaceId }, childPageIds, trx);
}
if (pageIdsToMove.length > 0) {
@@ -476,9 +472,13 @@ export class PageService {
);
// Update watchers and remove those without access to new space
await this.watcherService.movePageWatchersToSpace(pageIdsToMove, spaceId, {
trx,
});
await this.watcherService.movePageWatchersToSpace(
pageIdsToMove,
spaceId,
{
trx,
},
);
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
pageId: pageIdsToMove,
@@ -858,13 +858,15 @@ export class PageService {
.selectFrom('page_ancestors')
.selectAll('page_ancestors')
.select((eb) =>
eb.exists(
eb
.selectFrom('pages as child')
.select(sql`1`.as('one'))
.whereRef('child.parentPageId', '=', 'page_ancestors.id')
.where('child.deletedAt', 'is', null),
).as('hasChildren'),
eb
.exists(
eb
.selectFrom('pages as child')
.select(sql`1`.as('one'))
.whereRef('child.parentPageId', '=', 'page_ancestors.id')
.where('child.deletedAt', 'is', null),
)
.as('hasChildren'),
)
.execute();
@@ -24,6 +24,7 @@ import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { LabelRepo } from '@docmost/db/repos/label/label.repo';
import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
import { TemplateRepo } from '@docmost/db/repos/template/template.repo';
import { PageListener } from '@docmost/db/listeners/page.listener';
@@ -89,6 +90,7 @@ import { normalizePostgresUrl } from '../common/helpers';
ShareRepo,
NotificationRepo,
WatcherRepo,
LabelRepo,
TemplateRepo,
PageListener,
],
@@ -113,6 +115,7 @@ import { normalizePostgresUrl } from '../common/helpers';
ShareRepo,
NotificationRepo,
WatcherRepo,
LabelRepo,
TemplateRepo,
],
})
@@ -0,0 +1,59 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('labels')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('name', 'varchar', (col) => col.notNull())
.addColumn('type', 'varchar', (col) => col.notNull().defaultTo('page'))
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await db.schema
.createIndex('labels_workspace_id_type_name_unique')
.on('labels')
.columns(['workspace_id', 'type', 'name'])
.unique()
.execute();
await db.schema
.createTable('page_labels')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('page_id', 'uuid', (col) =>
col.references('pages.id').onDelete('cascade').notNull(),
)
.addColumn('label_id', 'uuid', (col) =>
col.references('labels.id').onDelete('cascade').notNull(),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addUniqueConstraint('page_labels_page_id_label_id_unique', [
'page_id',
'label_id',
])
.execute();
await db.schema
.createIndex('page_labels_label_id_idx')
.on('page_labels')
.column('label_id')
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('page_labels').execute();
await db.schema.dropTable('labels').execute();
}
@@ -7,10 +7,20 @@ import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils';
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import {
executeWithCursorPagination,
emptyCursorPaginationResult,
} from '@docmost/db/pagination/cursor-pagination';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
@Injectable()
export class BacklinkRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly spaceMemberRepo: SpaceMemberRepo,
) {}
async findById(
backlinkId: string,
@@ -69,4 +79,84 @@ export class BacklinkRepo {
const db = dbOrTx(this.db, trx);
await db.deleteFrom('backlinks').where('id', '=', backlinkId).execute();
}
async findRelatedPageIds(
pageId: string,
direction: 'incoming' | 'outgoing',
userId: string,
): Promise<string[]> {
const userSpaceIds = this.spaceMemberRepo.getUserSpaceIdsQuery(userId);
if (direction === 'incoming') {
const rows = await this.db
.selectFrom('backlinks')
.innerJoin('pages', 'pages.id', 'backlinks.sourcePageId')
.select('backlinks.sourcePageId as relatedId')
.where('backlinks.targetPageId', '=', pageId)
.where('pages.deletedAt', 'is', null)
.where('pages.spaceId', 'in', userSpaceIds)
.execute();
return rows.map((r) => r.relatedId);
}
const rows = await this.db
.selectFrom('backlinks')
.innerJoin('pages', 'pages.id', 'backlinks.targetPageId')
.select('backlinks.targetPageId as relatedId')
.where('backlinks.sourcePageId', '=', pageId)
.where('pages.deletedAt', 'is', null)
.where('pages.spaceId', 'in', userSpaceIds)
.execute();
return rows.map((r) => r.relatedId);
}
async findPagesByIdsPaginated(
pageIds: string[],
pagination: PaginationOptions,
) {
if (pageIds.length === 0) {
return emptyCursorPaginationResult<{
id: string;
slugId: string;
title: string | null;
icon: string | null;
spaceId: string;
updatedAt: Date;
space: { id: string; slug: string; name: string } | null;
}>(pagination.limit);
}
const query = this.db
.selectFrom('pages')
.select((eb) => [
'pages.id',
'pages.slugId',
'pages.title',
'pages.icon',
'pages.spaceId',
'pages.updatedAt',
jsonObjectFrom(
eb
.selectFrom('spaces')
.select(['spaces.id', 'spaces.slug', 'spaces.name'])
.whereRef('spaces.id', '=', 'pages.spaceId'),
).as('space'),
])
.where('pages.deletedAt', 'is', null)
.where('pages.id', 'in', pageIds);
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'pages.updatedAt', direction: 'desc', key: 'updatedAt' },
{ expression: 'pages.id', direction: 'desc', key: 'id' },
],
parseCursor: (cursor) => ({
updatedAt: new Date(cursor.updatedAt),
id: cursor.id,
}),
});
}
}
@@ -0,0 +1,345 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { Label } from '@docmost/db/types/entity.types';
import { dbOrTx } from '@docmost/db/utils';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { normalizeLabelName } from '../../../core/label/utils';
export const LabelType = {
PAGE: 'page',
SPACE: 'space',
} as const;
export type LabelType = (typeof LabelType)[keyof typeof LabelType];
@Injectable()
export class LabelRepo {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly spaceMemberRepo: SpaceMemberRepo,
) {}
async findById(
labelId: string,
trx?: KyselyTransaction,
): Promise<Label | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('labels')
.selectAll()
.where('id', '=', labelId)
.executeTakeFirst();
}
async findByNameAndWorkspace(
name: string,
workspaceId: string,
type: LabelType,
trx?: KyselyTransaction,
): Promise<Label | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('labels')
.selectAll()
.where('name', '=', normalizeLabelName(name))
.where('type', '=', type)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
}
async findOrCreate(
name: string,
workspaceId: string,
type: LabelType,
trx?: KyselyTransaction,
): Promise<Label> {
const db = dbOrTx(this.db, trx);
const normalizedName = normalizeLabelName(name);
// DO UPDATE (rather than DO NOTHING) so RETURNING always emits a row,
// even on conflict. Avoids a race where a follow-up SELECT could miss a
// row inserted by a concurrent transaction. The set is a no-op write.
return db
.insertInto('labels')
.values({ name: normalizedName, type, workspaceId })
.onConflict((oc) =>
oc
.columns(['name', 'type', 'workspaceId'])
.doUpdateSet({ name: normalizedName }),
)
.returningAll()
.executeTakeFirstOrThrow();
}
async findLabelsByPageId(pageId: string, pagination: PaginationOptions) {
const query = this.db
.selectFrom('labels')
.innerJoin('pageLabels', 'pageLabels.labelId', 'labels.id')
.select([
'labels.id',
'labels.name',
'labels.type',
'labels.createdAt',
'labels.updatedAt',
'labels.workspaceId',
'pageLabels.id as joinId',
])
.where('pageLabels.pageId', '=', pageId)
.where('labels.type', '=', LabelType.PAGE);
const result = await executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'pageLabels.id', direction: 'asc', key: 'joinId' },
],
parseCursor: (cursor) => ({
joinId: cursor.joinId,
}),
});
// joinId is an internal pagination cursor; don't leak it to callers.
return {
...result,
items: result.items.map(({ joinId: _joinId, ...rest }) => rest),
};
}
async findLabels(
workspaceId: string,
userId: string,
type: LabelType,
pagination: PaginationOptions,
) {
// Label visibility is scoped to space membership: a label surfaces if it
// is attached to any non-deleted page in a space the user belongs to.
// Per-page permission restrictions intentionally do not narrow this
// further — labels are a space-level concept, not a page-level one.
let query = this.db
.selectFrom('labels')
.select(['id', 'name', 'type', 'createdAt', 'updatedAt', 'workspaceId'])
.where('workspaceId', '=', workspaceId)
.where('type', '=', type)
.where(
'id',
'in',
this.db
.selectFrom('pageLabels')
.innerJoin('pages', 'pages.id', 'pageLabels.pageId')
.select('pageLabels.labelId')
.where('pages.deletedAt', 'is', null)
.where(
'pages.spaceId',
'in',
this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
),
);
if (pagination.query) {
query = query.where(
'name',
'like',
`%${pagination.query.toLowerCase()}%`,
);
}
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'name', direction: 'asc' },
{ expression: 'id', direction: 'asc' },
],
parseCursor: (cursor) => ({
name: cursor.name,
id: cursor.id,
}),
});
}
async addLabelToPage(
pageId: string,
labelId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.insertInto('pageLabels')
.values({ pageId, labelId })
.onConflict((oc) => oc.doNothing())
.execute();
}
async removeLabelFromPage(
pageId: string,
labelId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('pageLabels')
.where('pageId', '=', pageId)
.where('labelId', '=', labelId)
.where((eb) =>
eb.exists(
eb
.selectFrom('labels')
.select('id')
.whereRef('labels.id', '=', 'pageLabels.labelId')
.where('labels.workspaceId', '=', workspaceId),
),
)
.execute();
}
async getPageLabelCount(
pageId: string,
trx?: KyselyTransaction,
): Promise<number> {
const db = dbOrTx(this.db, trx);
const result = await db
.selectFrom('pageLabels')
.select((eb) => eb.fn.count('id').as('count'))
.where('pageId', '=', pageId)
.executeTakeFirst();
return Number(result?.count ?? 0);
}
async getLabelPageCount(
labelId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<number> {
const db = dbOrTx(this.db, trx);
const result = await db
.selectFrom('pageLabels')
.innerJoin('labels', 'labels.id', 'pageLabels.labelId')
.select((eb) => eb.fn.count('pageLabels.id').as('count'))
.where('pageLabels.labelId', '=', labelId)
.where('labels.workspaceId', '=', workspaceId)
.executeTakeFirst();
return Number(result?.count ?? 0);
}
async deleteLabel(
labelId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('labels')
.where('id', '=', labelId)
.where('workspaceId', '=', workspaceId)
.execute();
}
async findPagesByLabelId(
labelId: string,
userId: string,
opts: {
spaceId?: string;
query?: string;
pagination: PaginationOptions;
},
) {
let query = this.db
.selectFrom('pages')
.innerJoin('pageLabels', 'pageLabels.pageId', 'pages.id')
.select((eb) => [
'pages.id',
'pages.slugId',
'pages.title',
'pages.icon',
'pages.spaceId',
'pages.createdAt',
'pages.updatedAt',
jsonObjectFrom(
eb
.selectFrom('spaces')
.select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo'])
.whereRef('spaces.id', '=', 'pages.spaceId'),
).as('space'),
jsonObjectFrom(
eb
.selectFrom('users')
.select(['users.id', 'users.name', 'users.avatarUrl'])
.whereRef('users.id', '=', 'pages.creatorId'),
).as('creator'),
jsonArrayFrom(
eb
.selectFrom('labels')
.innerJoin('pageLabels as pl', 'pl.labelId', 'labels.id')
.select(['labels.id', 'labels.name'])
.whereRef('pl.pageId', '=', 'pages.id')
.where('labels.type', '=', LabelType.PAGE)
.orderBy('pl.id', 'asc'),
).as('labels'),
])
.where('pageLabels.labelId', '=', labelId)
.where('pages.deletedAt', 'is', null);
if (opts.spaceId) {
query = query.where('pages.spaceId', '=', opts.spaceId);
} else {
query = query.where(
'pages.spaceId',
'in',
this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
);
}
if (opts.query) {
query = query.where('pages.title', 'ilike', `%${opts.query}%`);
}
return executeWithCursorPagination(query, {
perPage: opts.pagination.limit,
cursor: opts.pagination.cursor,
beforeCursor: opts.pagination.beforeCursor,
fields: [
{ expression: 'pages.updatedAt', direction: 'desc', key: 'updatedAt' },
{ expression: 'pages.id', direction: 'desc', key: 'id' },
],
parseCursor: (cursor) => ({
updatedAt: new Date(cursor.updatedAt),
id: cursor.id,
}),
});
}
async getLabelPageCountForUser(
labelId: string,
userId: string,
spaceId?: string,
): Promise<number> {
let query = this.db
.selectFrom('pageLabels')
.innerJoin('pages', 'pages.id', 'pageLabels.pageId')
.select((eb) => eb.fn.count('pageLabels.id').as('count'))
.where('pageLabels.labelId', '=', labelId)
.where('pages.deletedAt', 'is', null);
if (spaceId) {
query = query.where('pages.spaceId', '=', spaceId);
} else {
query = query.where(
'pages.spaceId',
'in',
this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
);
}
const result = await query.executeTakeFirst();
return Number(result?.count ?? 0);
}
}
+18
View File
@@ -459,6 +459,15 @@ export interface Watchers {
createdAt: Generated<Timestamp>;
}
export interface Labels {
id: Generated<string>;
name: string;
type: Generated<string>;
workspaceId: string;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
}
export interface PageAccess {
id: Generated<string>;
pageId: string;
@@ -470,6 +479,13 @@ export interface PageAccess {
updatedAt: Generated<Timestamp>;
}
export interface PageLabels {
id: Generated<string>;
pageId: string;
labelId: string;
createdAt: Generated<Timestamp>;
}
export interface PagePermissions {
id: Generated<string>;
pageAccessId: string;
@@ -588,12 +604,14 @@ export interface DB {
fileTasks: FileTasks;
groups: Groups;
groupUsers: GroupUsers;
labels: Labels;
notifications: Notifications;
pageAccess: PageAccess;
pageTransclusionReferences: PageTransclusionReferences;
pageTransclusions: PageTransclusions;
pagePermissions: PagePermissions;
pageHistory: PageHistory;
pageLabels: PageLabels;
pageVerifications: PageVerifications;
pageVerifiers: PageVerifiers;
pages: Pages;
@@ -5,7 +5,9 @@ import {
Attachments,
Comments,
Groups,
Labels,
Notifications,
PageLabels,
PageAccess as _PageAccess,
PageTransclusions,
PageTransclusionReferences,
@@ -194,6 +196,15 @@ export type Watcher = Selectable<Watchers>;
export type InsertableWatcher = Insertable<Watchers>;
export type UpdatableWatcher = Updateable<Omit<Watchers, 'id'>>;
// Label
export type Label = Selectable<Labels>;
export type InsertableLabel = Insertable<Labels>;
export type UpdatableLabel = Updateable<Omit<Labels, 'id'>>;
// PageLabel
export type PageLabel = Selectable<PageLabels>;
export type InsertablePageLabel = Insertable<PageLabels>;
// Page Access
export type PageAccess = Selectable<_PageAccess>;
export type InsertablePageAccess = Insertable<_PageAccess>;
+1 -1
View File
@@ -3,7 +3,7 @@ import { TableCell as TiptapTableCell } from "@tiptap/extension-table";
export const TableCell = TiptapTableCell.extend({
name: "tableCell",
content:
"(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | attachment | mathBlock | details | codeBlock)+",
"(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | audio | subpages | attachment | mathBlock | details | codeBlock)+",
addAttributes() {
return {
+1 -1
View File
@@ -3,7 +3,7 @@ import { TableHeader as TiptapTableHeader } from "@tiptap/extension-table";
export const TableHeader = TiptapTableHeader.extend({
name: "tableHeader",
content:
"(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | attachment | mathBlock | details | codeBlock)+",
"(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | audio | subpages | attachment | mathBlock | details | codeBlock)+",
addAttributes() {
return {