mirror of
https://github.com/docmost/docmost.git
synced 2026-05-11 00:44:07 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a3bd65ee3d | |||
| b92829c3a6 | |||
| 468c96bb4f | |||
| a92fa2334b |
@@ -970,34 +970,5 @@
|
|||||||
"Experimental": "Experimental",
|
"Experimental": "Experimental",
|
||||||
"Strikethrough": "Strikethrough",
|
"Strikethrough": "Strikethrough",
|
||||||
"Undo": "Undo",
|
"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}}",
|
|
||||||
"{{count}} page_one": "{{count}} page",
|
|
||||||
"{{count}} page_other": "{{count}} pages"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ import TemplateEditor from "@/ee/template/pages/template-editor";
|
|||||||
import FavoritesPage from "@/pages/favorites/favorites-page";
|
import FavoritesPage from "@/pages/favorites/favorites-page";
|
||||||
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
|
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
|
||||||
import VerifyEmail from "@/ee/pages/verify-email.tsx";
|
import VerifyEmail from "@/ee/pages/verify-email.tsx";
|
||||||
import LabelPage from "@/pages/label/label-page";
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -93,7 +92,6 @@ export default function App() {
|
|||||||
<Route path={"/ai/chat/:chatId"} element={<AiChat />} />
|
<Route path={"/ai/chat/:chatId"} element={<AiChat />} />
|
||||||
<Route path={"/spaces"} element={<SpacesPage />} />
|
<Route path={"/spaces"} element={<SpacesPage />} />
|
||||||
<Route path={"/favorites"} element={<FavoritesPage />} />
|
<Route path={"/favorites"} element={<FavoritesPage />} />
|
||||||
<Route path={"/labels/:labelName"} element={<LabelPage />} />
|
|
||||||
<Route path={"/templates"} element={<TemplateList />} />
|
<Route path={"/templates"} element={<TemplateList />} />
|
||||||
<Route
|
<Route
|
||||||
path={"/templates/:templateId"}
|
path={"/templates/:templateId"}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { TableOfContents } from "@/features/editor/components/table-of-contents/
|
|||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
|
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() {
|
export default function Aside() {
|
||||||
const [{ tab }] = useAtom(asideStateAtom);
|
const [{ tab }] = useAtom(asideStateAtom);
|
||||||
@@ -31,10 +30,6 @@ export default function Aside() {
|
|||||||
component = <AsideChatPanel />;
|
component = <AsideChatPanel />;
|
||||||
title = "AI Chat";
|
title = "AI Chat";
|
||||||
break;
|
break;
|
||||||
case "details":
|
|
||||||
component = <PageDetailsAside />;
|
|
||||||
title = "Details";
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
component = null;
|
component = null;
|
||||||
title = null;
|
title = null;
|
||||||
|
|||||||
@@ -147,9 +147,7 @@ export default function GlobalAppShell({
|
|||||||
? t("Table of contents")
|
? t("Table of contents")
|
||||||
: asideTab === "chat"
|
: asideTab === "chat"
|
||||||
? t("AI Chat")
|
? t("AI Chat")
|
||||||
: asideTab === "details"
|
: undefined
|
||||||
? t("Details")
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Aside />
|
<Aside />
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export const desktopSidebarAtom = atomWithWebStorage<boolean>(
|
|||||||
|
|
||||||
export const desktopAsideAtom = atom<boolean>(false);
|
export const desktopAsideAtom = atom<boolean>(false);
|
||||||
|
|
||||||
// Valid `tab` values: "" | "comments" | "toc" | "chat" | "details"
|
|
||||||
type AsideStateType = {
|
type AsideStateType = {
|
||||||
tab: string;
|
tab: string;
|
||||||
isAsideOpen: boolean;
|
isAsideOpen: boolean;
|
||||||
|
|||||||
@@ -118,20 +118,10 @@ export function PageVerificationBadge({
|
|||||||
|
|
||||||
if (status === "none" && readOnly) return null;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{status !== "none" ? (
|
{status !== "none" ? (
|
||||||
<Tooltip label={tooltipLabel} withArrow openDelay={250}>
|
<Tooltip label={getStatusLabel(status, t)} withArrow openDelay={250}>
|
||||||
<Group
|
<Group
|
||||||
gap={4}
|
gap={4}
|
||||||
onClick={open}
|
onClick={open}
|
||||||
|
|||||||
@@ -3,17 +3,14 @@ import React from "react";
|
|||||||
import { TitleEditor } from "@/features/editor/title-editor";
|
import { TitleEditor } from "@/features/editor/title-editor";
|
||||||
import PageEditor from "@/features/editor/page-editor";
|
import PageEditor from "@/features/editor/page-editor";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
|
||||||
Container,
|
Container,
|
||||||
Divider,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
Popover,
|
Popover,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Tooltip,
|
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconInfoCircle } from "@tabler/icons-react";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
@@ -22,8 +19,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { IContributor } from "@/features/page/types/page.types.ts";
|
import { IContributor } from "@/features/page/types/page.types.ts";
|
||||||
import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-toolbar";
|
import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-toolbar";
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
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 MemoizedTitleEditor = React.memo(TitleEditor);
|
||||||
const MemoizedPageEditor = React.memo(PageEditor);
|
const MemoizedPageEditor = React.memo(PageEditor);
|
||||||
@@ -106,7 +101,6 @@ function PageByline({
|
|||||||
readOnly,
|
readOnly,
|
||||||
}: PageBylineProps) {
|
}: PageBylineProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const toggleAside = useToggleAside();
|
|
||||||
|
|
||||||
const otherContributors = (contributors ?? []).filter(
|
const otherContributors = (contributors ?? []).filter(
|
||||||
(c) => c.id !== creator?.id,
|
(c) => c.id !== creator?.id,
|
||||||
@@ -116,8 +110,8 @@ function PageByline({
|
|||||||
<Group
|
<Group
|
||||||
gap="sm"
|
gap="sm"
|
||||||
mb="md"
|
mb="md"
|
||||||
className={clsx("print-hide", classes.byline)}
|
className="print-hide"
|
||||||
style={{ marginTop: "-0.5em" }}
|
style={{ marginTop: "-0.5em", paddingLeft: "3rem" }}
|
||||||
>
|
>
|
||||||
{creator && (
|
{creator && (
|
||||||
<Popover position="bottom-start" shadow="md" width={280} withArrow>
|
<Popover position="bottom-start" shadow="md" width={280} withArrow>
|
||||||
@@ -179,17 +173,6 @@ function PageByline({
|
|||||||
</Popover.Dropdown>
|
</Popover.Dropdown>
|
||||||
</Popover>
|
</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} />
|
<PageVerificationBadge readOnly={readOnly} />
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,15 +9,3 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.byline {
|
|
||||||
padding-left: 3rem;
|
|
||||||
|
|
||||||
@media (max-width: $mantine-breakpoint-sm) {
|
|
||||||
padding-left: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media print {
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
.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);
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import {
|
|
||||||
keepPreviousData,
|
|
||||||
useInfiniteQuery,
|
|
||||||
useMutation,
|
|
||||||
useQuery,
|
|
||||||
useQueryClient,
|
|
||||||
} from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
addLabelsToPage,
|
|
||||||
getLabelInfo,
|
|
||||||
getPageLabels,
|
|
||||||
getWorkspaceLabels,
|
|
||||||
removeLabelFromPage,
|
|
||||||
searchPagesByLabel,
|
|
||||||
} 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 }) =>
|
|
||||||
searchPagesByLabel({
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import api from "@/lib/api-client";
|
|
||||||
import { IPagination } from "@/lib/types.ts";
|
|
||||||
import {
|
|
||||||
IAddLabels,
|
|
||||||
ILabel,
|
|
||||||
ILabelInfo,
|
|
||||||
ILabelInfoParams,
|
|
||||||
ILabelPageItem,
|
|
||||||
IListLabelsParams,
|
|
||||||
IPageLabelsParams,
|
|
||||||
IRemoveLabel,
|
|
||||||
ISearchPagesByLabelParams,
|
|
||||||
} 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 searchPagesByLabel(
|
|
||||||
params: ISearchPagesByLabelParams,
|
|
||||||
): Promise<IPagination<ILabelPageItem>> {
|
|
||||||
const req = await api.post<IPagination<ILabelPageItem>>(
|
|
||||||
"/labels/pages",
|
|
||||||
params,
|
|
||||||
);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
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 ISearchPagesByLabelParams {
|
|
||||||
labelId?: string;
|
|
||||||
name?: string;
|
|
||||||
spaceId?: string;
|
|
||||||
query?: string;
|
|
||||||
cursor?: string;
|
|
||||||
limit?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ILabelInfoParams {
|
|
||||||
name: string;
|
|
||||||
type: LabelType;
|
|
||||||
spaceId?: string;
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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");
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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];
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export function normalizeLabelName(name: string): string {
|
|
||||||
return name.trim().replace(/\s+/g, "-").toLowerCase();
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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,9 +1,13 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useMemo, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Menu,
|
Menu,
|
||||||
Text,
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Divider,
|
||||||
Badge,
|
Badge,
|
||||||
|
ScrollArea,
|
||||||
|
Avatar,
|
||||||
Group,
|
Group,
|
||||||
Switch,
|
Switch,
|
||||||
getDefaultZIndex,
|
getDefaultZIndex,
|
||||||
@@ -12,11 +16,12 @@ import {
|
|||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconBuilding,
|
IconBuilding,
|
||||||
IconFileDescription,
|
IconFileDescription,
|
||||||
|
IconSearch,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
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 { useHasFeature } from "@/ee/hooks/use-feature";
|
||||||
import { Feature } from "@/ee/features";
|
import { Feature } from "@/ee/features";
|
||||||
import classes from "./search-spotlight-filters.module.css";
|
import classes from "./search-spotlight-filters.module.css";
|
||||||
@@ -41,13 +46,32 @@ export function SearchSpotlightFilters({
|
|||||||
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(
|
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(
|
||||||
spaceId || null,
|
spaceId || null,
|
||||||
);
|
);
|
||||||
|
const [spaceSearchQuery, setSpaceSearchQuery] = useState("");
|
||||||
|
const [debouncedSpaceQuery] = useDebouncedValue(spaceSearchQuery, 300);
|
||||||
const [contentType, setContentType] = useState<string | null>("page");
|
const [contentType, setContentType] = useState<string | null>("page");
|
||||||
const [workspace] = useAtom(workspaceAtom);
|
const [workspace] = useAtom(workspaceAtom);
|
||||||
|
|
||||||
const { data: spacesData } = useGetSpacesQuery({ limit: 100 });
|
const { data: spacesData } = useGetSpacesQuery({
|
||||||
const selectedSpaceData = selectedSpaceId
|
limit: 100,
|
||||||
? spacesData?.items.find((space) => space.id === selectedSpaceId)
|
query: debouncedSpaceQuery,
|
||||||
: null;
|
});
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (onFiltersChange) {
|
if (onFiltersChange) {
|
||||||
@@ -128,27 +152,86 @@ export function SearchSpotlightFilters({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SpaceFilterMenu
|
<Menu
|
||||||
value={selectedSpaceId}
|
shadow="md"
|
||||||
onChange={handleSpaceSelect}
|
|
||||||
position="bottom-start"
|
|
||||||
width={250}
|
width={250}
|
||||||
|
position="bottom-start"
|
||||||
zIndex={getDefaultZIndex("max")}
|
zIndex={getDefaultZIndex("max")}
|
||||||
>
|
>
|
||||||
<Button
|
<Menu.Target>
|
||||||
variant="subtle"
|
<Button
|
||||||
color="gray"
|
variant="subtle"
|
||||||
size="sm"
|
color="gray"
|
||||||
rightSection={<IconChevronDown size={14} />}
|
size="sm"
|
||||||
leftSection={<IconBuilding size={16} />}
|
rightSection={<IconChevronDown size={14} />}
|
||||||
className={classes.filterButton}
|
leftSection={<IconBuilding size={16} />}
|
||||||
fw={500}
|
className={classes.filterButton}
|
||||||
>
|
fw={500}
|
||||||
{selectedSpaceId
|
>
|
||||||
? `${t("Space")}: ${selectedSpaceData?.name || t("Unknown")}`
|
{selectedSpaceId
|
||||||
: `${t("Space")}: ${t("All spaces")}`}
|
? `${t("Space")}: ${selectedSpaceData?.name || t("Unknown")}`
|
||||||
</Button>
|
: `${t("Space")}: ${t("All spaces")}`}
|
||||||
</SpaceFilterMenu>
|
</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>
|
||||||
|
|
||||||
<Menu
|
<Menu
|
||||||
shadow="md"
|
shadow="md"
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
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");
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Center,
|
|
||||||
Container,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Skeleton,
|
|
||||||
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 {
|
|
||||||
useLabelInfoQuery,
|
|
||||||
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: info, isLoading: infoLoading } = useLabelInfoQuery(
|
|
||||||
labelName,
|
|
||||||
activeSpaceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
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>
|
|
||||||
{infoLoading ? (
|
|
||||||
<Skeleton height={18} width={70} />
|
|
||||||
) : info ? (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("{{count}} page", {
|
|
||||||
count: info.usageCount,
|
|
||||||
defaultValue_one: "{{count}} page",
|
|
||||||
defaultValue_other: "{{count}} pages",
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -18,7 +18,6 @@ import { PageAccessModule } from './page/page-access/page-access.module';
|
|||||||
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
|
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
|
||||||
import { AuditContextMiddleware } from '../common/middlewares/audit-context.middleware';
|
import { AuditContextMiddleware } from '../common/middlewares/audit-context.middleware';
|
||||||
import { ShareModule } from './share/share.module';
|
import { ShareModule } from './share/share.module';
|
||||||
import { LabelModule } from './label/label.module';
|
|
||||||
import { NotificationModule } from './notification/notification.module';
|
import { NotificationModule } from './notification/notification.module';
|
||||||
import { WatcherModule } from './watcher/watcher.module';
|
import { WatcherModule } from './watcher/watcher.module';
|
||||||
import { FavoriteModule } from './favorite/favorite.module';
|
import { FavoriteModule } from './favorite/favorite.module';
|
||||||
@@ -40,7 +39,6 @@ import { ClsMiddleware } from 'nestjs-cls';
|
|||||||
CaslModule,
|
CaslModule,
|
||||||
PageAccessModule,
|
PageAccessModule,
|
||||||
ShareModule,
|
ShareModule,
|
||||||
LabelModule,
|
|
||||||
NotificationModule,
|
NotificationModule,
|
||||||
WatcherModule,
|
WatcherModule,
|
||||||
SessionModule,
|
SessionModule,
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
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';
|
|
||||||
|
|
||||||
// Only PAGE is implemented today. SPACE/TEMPLATE will be added when their
|
|
||||||
// junction tables and access rules ship; reject them at the boundary now so
|
|
||||||
// requests don't silently get an empty result via the page-only query path.
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
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 — never expose whether the label row actually
|
|
||||||
// exists. Real labels with no accessible pages and nonexistent labels
|
|
||||||
// both look like { name, usageCount: 0 } from the outside.
|
|
||||||
const usageCount = label
|
|
||||||
? await this.labelRepo.getLabelPageCountForUser(
|
|
||||||
label.id,
|
|
||||||
userId,
|
|
||||||
spaceId,
|
|
||||||
)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: normalized,
|
|
||||||
usageCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// Canonical form for label names: trimmed, whitespace collapsed to hyphens,
|
|
||||||
// lowercase. Applied at storage AND every lookup path so callers don't have
|
|
||||||
// to know the rule.
|
|
||||||
export function normalizeLabelName(name: string): string {
|
|
||||||
return name.trim().replace(/\s+/g, '-').toLowerCase();
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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,7 +11,6 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PageService } from './services/page.service';
|
import { PageService } from './services/page.service';
|
||||||
import { BacklinkService } from './services/backlink.service';
|
|
||||||
import { PageAccessService } from './page-access/page-access.service';
|
import { PageAccessService } from './page-access/page-access.service';
|
||||||
import { CreatePageDto } from './dto/create-page.dto';
|
import { CreatePageDto } from './dto/create-page.dto';
|
||||||
import { UpdatePageDto } from './dto/update-page.dto';
|
import { UpdatePageDto } from './dto/update-page.dto';
|
||||||
@@ -39,9 +38,6 @@ import { RecentPageDto } from './dto/recent-page.dto';
|
|||||||
import { CreatedByUserDto } from './dto/created-by-user.dto';
|
import { CreatedByUserDto } from './dto/created-by-user.dto';
|
||||||
import { DuplicatePageDto } from './dto/duplicate-page.dto';
|
import { DuplicatePageDto } from './dto/duplicate-page.dto';
|
||||||
import { DeletedPageDto } from './dto/deleted-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 {
|
import {
|
||||||
jsonToHtml,
|
jsonToHtml,
|
||||||
jsonToMarkdown,
|
jsonToMarkdown,
|
||||||
@@ -62,8 +58,6 @@ export class PageController {
|
|||||||
private readonly pageHistoryService: PageHistoryService,
|
private readonly pageHistoryService: PageHistoryService,
|
||||||
private readonly spaceAbility: SpaceAbilityFactory,
|
private readonly spaceAbility: SpaceAbilityFactory,
|
||||||
private readonly pageAccessService: PageAccessService,
|
private readonly pageAccessService: PageAccessService,
|
||||||
private readonly backlinkService: BacklinkService,
|
|
||||||
private readonly labelService: LabelService,
|
|
||||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -102,100 +96,6 @@ export class PageController {
|
|||||||
return { ...page, permissions };
|
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)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('create')
|
@Post('create')
|
||||||
async create(
|
async create(
|
||||||
|
|||||||
@@ -3,28 +3,15 @@ import { PageService } from './services/page.service';
|
|||||||
import { PageController } from './page.controller';
|
import { PageController } from './page.controller';
|
||||||
import { PageHistoryService } from './services/page-history.service';
|
import { PageHistoryService } from './services/page-history.service';
|
||||||
import { TrashCleanupService } from './services/trash-cleanup.service';
|
import { TrashCleanupService } from './services/trash-cleanup.service';
|
||||||
import { BacklinkService } from './services/backlink.service';
|
|
||||||
import { StorageModule } from '../../integrations/storage/storage.module';
|
import { StorageModule } from '../../integrations/storage/storage.module';
|
||||||
import { CollaborationModule } from '../../collaboration/collaboration.module';
|
import { CollaborationModule } from '../../collaboration/collaboration.module';
|
||||||
import { WatcherModule } from '../watcher/watcher.module';
|
import { WatcherModule } from '../watcher/watcher.module';
|
||||||
import { TransclusionModule } from './transclusion/transclusion.module';
|
import { TransclusionModule } from './transclusion/transclusion.module';
|
||||||
import { LabelModule } from '../label/label.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [PageController],
|
controllers: [PageController],
|
||||||
providers: [
|
providers: [PageService, PageHistoryService, TrashCleanupService],
|
||||||
PageService,
|
|
||||||
PageHistoryService,
|
|
||||||
TrashCleanupService,
|
|
||||||
BacklinkService,
|
|
||||||
],
|
|
||||||
exports: [PageService, PageHistoryService],
|
exports: [PageService, PageHistoryService],
|
||||||
imports: [
|
imports: [StorageModule, CollaborationModule, WatcherModule, TransclusionModule],
|
||||||
StorageModule,
|
|
||||||
CollaborationModule,
|
|
||||||
WatcherModule,
|
|
||||||
TransclusionModule,
|
|
||||||
LabelModule,
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
export class PageModule {}
|
export class PageModule {}
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
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,7 +425,11 @@ export class PageService {
|
|||||||
|
|
||||||
if (pageIdsToMove.length > 1) {
|
if (pageIdsToMove.length > 1) {
|
||||||
// Update sub pages (all accessible pages except root)
|
// 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) {
|
if (pageIdsToMove.length > 0) {
|
||||||
@@ -472,13 +476,9 @@ export class PageService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Update watchers and remove those without access to new space
|
// Update watchers and remove those without access to new space
|
||||||
await this.watcherService.movePageWatchersToSpace(
|
await this.watcherService.movePageWatchersToSpace(pageIdsToMove, spaceId, {
|
||||||
pageIdsToMove,
|
trx,
|
||||||
spaceId,
|
});
|
||||||
{
|
|
||||||
trx,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
|
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
|
||||||
pageId: pageIdsToMove,
|
pageId: pageIdsToMove,
|
||||||
@@ -858,15 +858,13 @@ export class PageService {
|
|||||||
.selectFrom('page_ancestors')
|
.selectFrom('page_ancestors')
|
||||||
.selectAll('page_ancestors')
|
.selectAll('page_ancestors')
|
||||||
.select((eb) =>
|
.select((eb) =>
|
||||||
eb
|
eb.exists(
|
||||||
.exists(
|
eb
|
||||||
eb
|
.selectFrom('pages as child')
|
||||||
.selectFrom('pages as child')
|
.select(sql`1`.as('one'))
|
||||||
.select(sql`1`.as('one'))
|
.whereRef('child.parentPageId', '=', 'page_ancestors.id')
|
||||||
.whereRef('child.parentPageId', '=', 'page_ancestors.id')
|
.where('child.deletedAt', 'is', null),
|
||||||
.where('child.deletedAt', 'is', null),
|
).as('hasChildren'),
|
||||||
)
|
|
||||||
.as('hasChildren'),
|
|
||||||
)
|
)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
|||||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||||
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
|
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
|
||||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.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 { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
|
||||||
import { TemplateRepo } from '@docmost/db/repos/template/template.repo';
|
import { TemplateRepo } from '@docmost/db/repos/template/template.repo';
|
||||||
import { PageListener } from '@docmost/db/listeners/page.listener';
|
import { PageListener } from '@docmost/db/listeners/page.listener';
|
||||||
@@ -90,7 +89,6 @@ import { normalizePostgresUrl } from '../common/helpers';
|
|||||||
ShareRepo,
|
ShareRepo,
|
||||||
NotificationRepo,
|
NotificationRepo,
|
||||||
WatcherRepo,
|
WatcherRepo,
|
||||||
LabelRepo,
|
|
||||||
TemplateRepo,
|
TemplateRepo,
|
||||||
PageListener,
|
PageListener,
|
||||||
],
|
],
|
||||||
@@ -115,7 +113,6 @@ import { normalizePostgresUrl } from '../common/helpers';
|
|||||||
ShareRepo,
|
ShareRepo,
|
||||||
NotificationRepo,
|
NotificationRepo,
|
||||||
WatcherRepo,
|
WatcherRepo,
|
||||||
LabelRepo,
|
|
||||||
TemplateRepo,
|
TemplateRepo,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
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,20 +7,10 @@ import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
|||||||
import { dbOrTx } from '@docmost/db/utils';
|
import { dbOrTx } from '@docmost/db/utils';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
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()
|
@Injectable()
|
||||||
export class BacklinkRepo {
|
export class BacklinkRepo {
|
||||||
constructor(
|
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
|
||||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async findById(
|
async findById(
|
||||||
backlinkId: string,
|
backlinkId: string,
|
||||||
@@ -79,84 +69,4 @@ export class BacklinkRepo {
|
|||||||
const db = dbOrTx(this.db, trx);
|
const db = dbOrTx(this.db, trx);
|
||||||
await db.deleteFrom('backlinks').where('id', '=', backlinkId).execute();
|
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,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,345 +0,0 @@
|
|||||||
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
@@ -459,15 +459,6 @@ export interface Watchers {
|
|||||||
createdAt: Generated<Timestamp>;
|
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 {
|
export interface PageAccess {
|
||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -479,13 +470,6 @@ export interface PageAccess {
|
|||||||
updatedAt: Generated<Timestamp>;
|
updatedAt: Generated<Timestamp>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PageLabels {
|
|
||||||
id: Generated<string>;
|
|
||||||
pageId: string;
|
|
||||||
labelId: string;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PagePermissions {
|
export interface PagePermissions {
|
||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
pageAccessId: string;
|
pageAccessId: string;
|
||||||
@@ -604,14 +588,12 @@ export interface DB {
|
|||||||
fileTasks: FileTasks;
|
fileTasks: FileTasks;
|
||||||
groups: Groups;
|
groups: Groups;
|
||||||
groupUsers: GroupUsers;
|
groupUsers: GroupUsers;
|
||||||
labels: Labels;
|
|
||||||
notifications: Notifications;
|
notifications: Notifications;
|
||||||
pageAccess: PageAccess;
|
pageAccess: PageAccess;
|
||||||
pageTransclusionReferences: PageTransclusionReferences;
|
pageTransclusionReferences: PageTransclusionReferences;
|
||||||
pageTransclusions: PageTransclusions;
|
pageTransclusions: PageTransclusions;
|
||||||
pagePermissions: PagePermissions;
|
pagePermissions: PagePermissions;
|
||||||
pageHistory: PageHistory;
|
pageHistory: PageHistory;
|
||||||
pageLabels: PageLabels;
|
|
||||||
pageVerifications: PageVerifications;
|
pageVerifications: PageVerifications;
|
||||||
pageVerifiers: PageVerifiers;
|
pageVerifiers: PageVerifiers;
|
||||||
pages: Pages;
|
pages: Pages;
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ import {
|
|||||||
Attachments,
|
Attachments,
|
||||||
Comments,
|
Comments,
|
||||||
Groups,
|
Groups,
|
||||||
Labels,
|
|
||||||
Notifications,
|
Notifications,
|
||||||
PageLabels,
|
|
||||||
PageAccess as _PageAccess,
|
PageAccess as _PageAccess,
|
||||||
PageTransclusions,
|
PageTransclusions,
|
||||||
PageTransclusionReferences,
|
PageTransclusionReferences,
|
||||||
@@ -196,15 +194,6 @@ export type Watcher = Selectable<Watchers>;
|
|||||||
export type InsertableWatcher = Insertable<Watchers>;
|
export type InsertableWatcher = Insertable<Watchers>;
|
||||||
export type UpdatableWatcher = Updateable<Omit<Watchers, 'id'>>;
|
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
|
// Page Access
|
||||||
export type PageAccess = Selectable<_PageAccess>;
|
export type PageAccess = Selectable<_PageAccess>;
|
||||||
export type InsertablePageAccess = Insertable<_PageAccess>;
|
export type InsertablePageAccess = Insertable<_PageAccess>;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { TableCell as TiptapTableCell } from "@tiptap/extension-table";
|
|||||||
export const TableCell = TiptapTableCell.extend({
|
export const TableCell = TiptapTableCell.extend({
|
||||||
name: "tableCell",
|
name: "tableCell",
|
||||||
content:
|
content:
|
||||||
"(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | audio | subpages | attachment | mathBlock | details | codeBlock)+",
|
"(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | attachment | mathBlock | details | codeBlock)+",
|
||||||
|
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { TableHeader as TiptapTableHeader } from "@tiptap/extension-table";
|
|||||||
export const TableHeader = TiptapTableHeader.extend({
|
export const TableHeader = TiptapTableHeader.extend({
|
||||||
name: "tableHeader",
|
name: "tableHeader",
|
||||||
content:
|
content:
|
||||||
"(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | audio | subpages | attachment | mathBlock | details | codeBlock)+",
|
"(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | attachment | mathBlock | details | codeBlock)+",
|
||||||
|
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user