Compare commits

..

2 Commits

Author SHA1 Message Date
Philipinho faf4a29cb9 refactor(backlinks): nest space details its own object 2026-05-09 17:01:12 +01:00
Philipinho 3eb29e2a46 feat: page details section and backlinks 2026-05-09 16:48:13 +01:00
32 changed files with 123 additions and 2358 deletions
@@ -983,21 +983,5 @@
"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"
"Verified until {{date}}": "Verified until {{date}}"
}
-2
View File
@@ -45,7 +45,6 @@ import TemplateEditor from "@/ee/template/pages/template-editor";
import FavoritesPage from "@/pages/favorites/favorites-page";
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
import VerifyEmail from "@/ee/pages/verify-email.tsx";
import LabelPage from "@/pages/label/label-page";
export default function App() {
const { t } = useTranslation();
@@ -93,7 +92,6 @@ export default function App() {
<Route path={"/ai/chat/:chatId"} element={<AiChat />} />
<Route path={"/spaces"} element={<SpacesPage />} />
<Route path={"/favorites"} element={<FavoritesPage />} />
<Route path={"/labels/:labelName"} element={<LabelPage />} />
<Route path={"/templates"} element={<TemplateList />} />
<Route
path={"/templates/:templateId"}
@@ -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();
}
@@ -18,7 +18,6 @@ import { useBacklinksCountQuery } from "@/features/page-details/queries/backlink
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();
@@ -62,11 +61,6 @@ export function PageDetailsAside() {
isLoading={countsLoading}
onClick={openModal}
/>
<LabelsSection
pageId={page.id}
canEdit={page.permissions?.canEdit ?? false}
/>
</Stack>
<BacklinksModal
@@ -1,9 +1,13 @@
import React, { useState, useEffect } from "react";
import React, { useState, useMemo, useEffect } from "react";
import {
Button,
Menu,
Text,
TextInput,
Divider,
Badge,
ScrollArea,
Avatar,
Group,
Switch,
getDefaultZIndex,
@@ -12,11 +16,12 @@ import {
IconChevronDown,
IconBuilding,
IconFileDescription,
IconSearch,
IconCheck,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useDebouncedValue } from "@mantine/hooks";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { SpaceFilterMenu } from "@/features/space/components/space-filter-menu";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import classes from "./search-spotlight-filters.module.css";
@@ -41,13 +46,32 @@ export function SearchSpotlightFilters({
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(
spaceId || null,
);
const [spaceSearchQuery, setSpaceSearchQuery] = useState("");
const [debouncedSpaceQuery] = useDebouncedValue(spaceSearchQuery, 300);
const [contentType, setContentType] = useState<string | null>("page");
const [workspace] = useAtom(workspaceAtom);
const { data: spacesData } = useGetSpacesQuery({ limit: 100 });
const selectedSpaceData = selectedSpaceId
? spacesData?.items.find((space) => space.id === selectedSpaceId)
: null;
const { data: spacesData } = useGetSpacesQuery({
limit: 100,
query: debouncedSpaceQuery,
});
const selectedSpaceData = useMemo(() => {
if (!spacesData?.items || !selectedSpaceId) return null;
return spacesData.items.find((space) => space.id === selectedSpaceId);
}, [spacesData?.items, selectedSpaceId]);
const availableSpaces = useMemo(() => {
const spaces = spacesData?.items || [];
if (!selectedSpaceId) return spaces;
// Sort to put selected space first
return [...spaces].sort((a, b) => {
if (a.id === selectedSpaceId) return -1;
if (b.id === selectedSpaceId) return 1;
return 0;
});
}, [spacesData?.items, selectedSpaceId]);
useEffect(() => {
if (onFiltersChange) {
@@ -128,27 +152,86 @@ export function SearchSpotlightFilters({
</div>
)}
<SpaceFilterMenu
value={selectedSpaceId}
onChange={handleSpaceSelect}
position="bottom-start"
<Menu
shadow="md"
width={250}
position="bottom-start"
zIndex={getDefaultZIndex("max")}
>
<Button
variant="subtle"
color="gray"
size="sm"
rightSection={<IconChevronDown size={14} />}
leftSection={<IconBuilding size={16} />}
className={classes.filterButton}
fw={500}
>
{selectedSpaceId
? `${t("Space")}: ${selectedSpaceData?.name || t("Unknown")}`
: `${t("Space")}: ${t("All spaces")}`}
</Button>
</SpaceFilterMenu>
<Menu.Target>
<Button
variant="subtle"
color="gray"
size="sm"
rightSection={<IconChevronDown size={14} />}
leftSection={<IconBuilding size={16} />}
className={classes.filterButton}
fw={500}
>
{selectedSpaceId
? `${t("Space")}: ${selectedSpaceData?.name || t("Unknown")}`
: `${t("Space")}: ${t("All spaces")}`}
</Button>
</Menu.Target>
<Menu.Dropdown>
<TextInput
placeholder={t("Find a space")}
data-autofocus
autoFocus
leftSection={<IconSearch size={16} />}
value={spaceSearchQuery}
onChange={(e) => setSpaceSearchQuery(e.target.value)}
size="sm"
variant="filled"
radius="sm"
styles={{ input: { marginBottom: 8 } }}
/>
<ScrollArea.Autosize mah={280}>
<Menu.Item onClick={() => handleSpaceSelect(null)}>
<Group flex="1" gap="xs">
<Avatar
color="initials"
variant="filled"
name={t("All spaces")}
size={20}
/>
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{t("All spaces")}
</Text>
<Text size="xs" c="dimmed">
{t("Search in all your spaces")}
</Text>
</div>
{!selectedSpaceId && <IconCheck size={20} />}
</Group>
</Menu.Item>
<Divider my="xs" />
{availableSpaces.map((space) => (
<Menu.Item
key={space.id}
onClick={() => handleSpaceSelect(space.id)}
>
<Group flex="1" gap="xs">
<Avatar
color="initials"
variant="filled"
name={space.name}
size={20}
/>
<Text size="sm" fw={500} style={{ flex: 1 }} truncate>
{space.name}
</Text>
{selectedSpaceId === space.id && <IconCheck size={20} />}
</Group>
</Menu.Item>
))}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
<Menu
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");
-200
View File
@@ -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>
</>
);
}
-2
View File
@@ -18,7 +18,6 @@ import { PageAccessModule } from './page/page-access/page-access.module';
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
import { AuditContextMiddleware } from '../common/middlewares/audit-context.middleware';
import { ShareModule } from './share/share.module';
import { LabelModule } from './label/label.module';
import { NotificationModule } from './notification/notification.module';
import { WatcherModule } from './watcher/watcher.module';
import { FavoriteModule } from './favorite/favorite.module';
@@ -40,7 +39,6 @@ import { ClsMiddleware } from 'nestjs-cls';
CaslModule,
PageAccessModule,
ShareModule,
LabelModule,
NotificationModule,
WatcherModule,
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 {}
-141
View File
@@ -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,
};
}
}
-6
View File
@@ -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();
}
@@ -40,8 +40,6 @@ import { CreatedByUserDto } from './dto/created-by-user.dto';
import { DuplicatePageDto } from './dto/duplicate-page.dto';
import { DeletedPageDto } from './dto/deleted-page.dto';
import { BacklinksListDto } from './dto/backlink.dto';
import { LabelService } from '../label/label.service';
import { AddLabelsDto, RemoveLabelDto } from '../label/dto/label.dto';
import {
jsonToHtml,
jsonToMarkdown,
@@ -63,7 +61,6 @@ export class PageController {
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageAccessService: PageAccessService,
private readonly backlinkService: BacklinkService,
private readonly labelService: LabelService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@@ -102,64 +99,6 @@ export class PageController {
return { ...page, permissions };
}
@HttpCode(HttpStatus.OK)
@Post('labels')
async getPageLabels(
@Body() dto: PageIdDto,
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
return this.labelService.getPageLabels(page.id, pagination);
}
@HttpCode(HttpStatus.OK)
@Post('labels/add')
async addPageLabels(
@Body() dto: AddLabelsDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page || page.deletedAt) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanEdit(page, user);
return this.labelService.addLabelsToPage(
page.id,
dto.names,
workspace.id,
);
}
@HttpCode(HttpStatus.OK)
@Post('labels/remove')
async removePageLabel(
@Body() dto: RemoveLabelDto,
@AuthUser() user: User,
) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page || page.deletedAt) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanEdit(page, user);
await this.labelService.removeLabelFromPage(
page.id,
dto.labelId,
page.workspaceId,
);
}
@HttpCode(HttpStatus.OK)
@Post('backlinks-count')
async getBacklinksCount(
-2
View File
@@ -8,7 +8,6 @@ import { StorageModule } from '../../integrations/storage/storage.module';
import { CollaborationModule } from '../../collaboration/collaboration.module';
import { WatcherModule } from '../watcher/watcher.module';
import { TransclusionModule } from './transclusion/transclusion.module';
import { LabelModule } from '../label/label.module';
@Module({
controllers: [PageController],
@@ -24,7 +23,6 @@ import { LabelModule } from '../label/label.module';
CollaborationModule,
WatcherModule,
TransclusionModule,
LabelModule,
],
})
export class PageModule {}
@@ -425,7 +425,11 @@ export class PageService {
if (pageIdsToMove.length > 1) {
// Update sub pages (all accessible pages except root)
await this.pageRepo.updatePages({ spaceId }, childPageIds, trx);
await this.pageRepo.updatePages(
{ spaceId },
childPageIds,
trx,
);
}
if (pageIdsToMove.length > 0) {
@@ -472,13 +476,9 @@ export class PageService {
);
// Update watchers and remove those without access to new space
await this.watcherService.movePageWatchersToSpace(
pageIdsToMove,
spaceId,
{
trx,
},
);
await this.watcherService.movePageWatchersToSpace(pageIdsToMove, spaceId, {
trx,
});
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
pageId: pageIdsToMove,
@@ -858,15 +858,13 @@ export class PageService {
.selectFrom('page_ancestors')
.selectAll('page_ancestors')
.select((eb) =>
eb
.exists(
eb
.selectFrom('pages as child')
.select(sql`1`.as('one'))
.whereRef('child.parentPageId', '=', 'page_ancestors.id')
.where('child.deletedAt', 'is', null),
)
.as('hasChildren'),
eb.exists(
eb
.selectFrom('pages as child')
.select(sql`1`.as('one'))
.whereRef('child.parentPageId', '=', 'page_ancestors.id')
.where('child.deletedAt', 'is', null),
).as('hasChildren'),
)
.execute();
@@ -24,7 +24,6 @@ import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { LabelRepo } from '@docmost/db/repos/label/label.repo';
import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
import { TemplateRepo } from '@docmost/db/repos/template/template.repo';
import { PageListener } from '@docmost/db/listeners/page.listener';
@@ -90,7 +89,6 @@ import { normalizePostgresUrl } from '../common/helpers';
ShareRepo,
NotificationRepo,
WatcherRepo,
LabelRepo,
TemplateRepo,
PageListener,
],
@@ -115,7 +113,6 @@ import { normalizePostgresUrl } from '../common/helpers';
ShareRepo,
NotificationRepo,
WatcherRepo,
LabelRepo,
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();
}
@@ -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
View File
@@ -459,15 +459,6 @@ export interface Watchers {
createdAt: Generated<Timestamp>;
}
export interface Labels {
id: Generated<string>;
name: string;
type: Generated<string>;
workspaceId: string;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
}
export interface PageAccess {
id: Generated<string>;
pageId: string;
@@ -479,13 +470,6 @@ export interface PageAccess {
updatedAt: Generated<Timestamp>;
}
export interface PageLabels {
id: Generated<string>;
pageId: string;
labelId: string;
createdAt: Generated<Timestamp>;
}
export interface PagePermissions {
id: Generated<string>;
pageAccessId: string;
@@ -604,14 +588,12 @@ export interface DB {
fileTasks: FileTasks;
groups: Groups;
groupUsers: GroupUsers;
labels: Labels;
notifications: Notifications;
pageAccess: PageAccess;
pageTransclusionReferences: PageTransclusionReferences;
pageTransclusions: PageTransclusions;
pagePermissions: PagePermissions;
pageHistory: PageHistory;
pageLabels: PageLabels;
pageVerifications: PageVerifications;
pageVerifiers: PageVerifiers;
pages: Pages;
@@ -5,9 +5,7 @@ import {
Attachments,
Comments,
Groups,
Labels,
Notifications,
PageLabels,
PageAccess as _PageAccess,
PageTransclusions,
PageTransclusionReferences,
@@ -196,15 +194,6 @@ export type Watcher = Selectable<Watchers>;
export type InsertableWatcher = Insertable<Watchers>;
export type UpdatableWatcher = Updateable<Omit<Watchers, 'id'>>;
// Label
export type Label = Selectable<Labels>;
export type InsertableLabel = Insertable<Labels>;
export type UpdatableLabel = Updateable<Omit<Labels, 'id'>>;
// PageLabel
export type PageLabel = Selectable<PageLabels>;
export type InsertablePageLabel = Insertable<PageLabels>;
// Page Access
export type PageAccess = Selectable<_PageAccess>;
export type InsertablePageAccess = Insertable<_PageAccess>;