diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 905fde70..a5b5a3e3 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -983,5 +983,21 @@ "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}}" + "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" } diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index a75afc22..789b4860 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -45,6 +45,7 @@ import TemplateEditor from "@/ee/template/pages/template-editor"; import FavoritesPage from "@/pages/favorites/favorites-page"; import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx"; import VerifyEmail from "@/ee/pages/verify-email.tsx"; +import LabelPage from "@/pages/label/label-page"; export default function App() { const { t } = useTranslation(); @@ -92,6 +93,7 @@ export default function App() { } /> } /> } /> + } /> } /> ; + onRemove?: () => void; + asLink?: boolean; +}; + +export function LabelChip({ label, onRemove, asLink }: LabelChipProps) { + const { t } = useTranslation(); + const scheme = useComputedColorScheme("light"); + const c = getLabelColor(label.name, scheme); + + const nameNode = asLink ? ( + e.stopPropagation()} + > + {label.name} + + ) : ( + {label.name} + ); + + return ( + + {nameNode} + {onRemove && ( + + )} + + ); +} diff --git a/apps/client/src/features/label/components/label-page-row-skeleton.tsx b/apps/client/src/features/label/components/label-page-row-skeleton.tsx new file mode 100644 index 00000000..bc4a6991 --- /dev/null +++ b/apps/client/src/features/label/components/label-page-row-skeleton.tsx @@ -0,0 +1,29 @@ +import { Skeleton } from "@mantine/core"; +import classes from "@/features/label/label.module.css"; + +type LabelPageRowSkeletonProps = { + titleWidth?: number; + metaWidth?: number; +}; + +export function LabelPageRowSkeleton({ + titleWidth = 220, + metaWidth = 180, +}: LabelPageRowSkeletonProps) { + return ( + + ); +} diff --git a/apps/client/src/features/label/components/label-page-row.tsx b/apps/client/src/features/label/components/label-page-row.tsx new file mode 100644 index 00000000..7ca54c32 --- /dev/null +++ b/apps/client/src/features/label/components/label-page-row.tsx @@ -0,0 +1,91 @@ +import { Link } from "react-router-dom"; +import { ThemeIcon, Tooltip } from "@mantine/core"; +import { IconFileDescription } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { ILabelPageItem } from "@/features/label/types/label.types.ts"; +import { LabelChip } from "@/features/label/components/label-chip.tsx"; +import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; +import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; +import { buildPageUrl } from "@/features/page/page.utils"; +import { formatLabelListDate } from "@/features/label/utils/format-label-date.ts"; +import classes from "@/features/label/label.module.css"; + +type LabelPageRowProps = { + page: ILabelPageItem; + currentLabelName: string; +}; + +const MAX_VISIBLE_CHIPS = 3; + +export function LabelPageRow({ page, currentLabelName }: LabelPageRowProps) { + const { t } = useTranslation(); + + const otherLabels = page.labels.filter((l) => l.name !== currentLabelName); + const visibleLabels = otherLabels.slice(0, MAX_VISIBLE_CHIPS); + const hiddenLabels = otherLabels.slice(MAX_VISIBLE_CHIPS); + + return ( + +
+
+ {page.icon ? ( + {page.icon} + ) : ( + + + + )} +
+
+
+ {page.title || t("Untitled")} +
+
+ {page.space && ( + <> + + {page.space.name} + + + )} + + {t("Updated {{date}}", { + date: formatLabelListDate(new Date(page.updatedAt)), + })} + +
+ {/* {otherLabels.length > 0 && ( +
+ {visibleLabels.map((label) => ( + + ))} + {hiddenLabels.length > 0 && ( + l.name).join(", ")} + withArrow + openDelay={200} + > + + +{hiddenLabels.length} + + + )} +
+ )} */} +
+
+ + ); +} diff --git a/apps/client/src/features/label/components/label-picker.tsx b/apps/client/src/features/label/components/label-picker.tsx new file mode 100644 index 00000000..0bd5e6ad --- /dev/null +++ b/apps/client/src/features/label/components/label-picker.tsx @@ -0,0 +1,160 @@ +import { useMemo, useRef, useState, KeyboardEvent } from "react"; +import clsx from "clsx"; +import { IconPlus } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { useComputedColorScheme } from "@mantine/core"; +import { ILabel } from "@/features/label/types/label.types.ts"; +import { useWorkspaceLabelsQuery } from "@/features/label/queries/label-query.ts"; +import { getLabelColor } from "@/features/label/utils/label-colors.ts"; +import { normalizeLabelName } from "@/features/label/utils/normalize-label.ts"; +import classes from "@/features/label/label.module.css"; + +type LabelPickerProps = { + applied: ILabel[]; + enabled: boolean; + onAdd: (name: string) => void; + onClose: () => void; +}; + +const NAME_PATTERN = /^[a-z0-9_-][a-z0-9_~-]*$/; +const MAX_LABEL_NAME_LENGTH = 100; + +function isValidLabelName(name: string): boolean { + return ( + name.length > 0 && + name.length <= MAX_LABEL_NAME_LENGTH && + NAME_PATTERN.test(name) + ); +} + +export function LabelPicker({ + applied, + enabled, + onAdd, + onClose, +}: LabelPickerProps) { + const { t } = useTranslation(); + const scheme = useComputedColorScheme("light"); + const [query, setQuery] = useState(""); + const [hover, setHover] = useState(0); + const inputRef = useRef(null); + + const normalized = normalizeLabelName(query); + const { data } = useWorkspaceLabelsQuery(normalized, enabled); + + const appliedNames = useMemo( + () => new Set(applied.map((l) => l.name.toLowerCase())), + [applied], + ); + + const suggestions = useMemo(() => { + const items = data?.items ?? []; + return items.filter((l) => !appliedNames.has(l.name.toLowerCase())); + }, [data, appliedNames]); + + const exact = suggestions.find((l) => l.name === normalized); + const canCreate = + !exact && !appliedNames.has(normalized) && isValidLabelName(normalized); + + const total = suggestions.length + (canCreate ? 1 : 0); + + const select = (idx: number) => { + if (idx < suggestions.length) { + onAdd(suggestions[idx].name); + } else if (canCreate) { + onAdd(normalized); + } + setQuery(""); + setHover(0); + inputRef.current?.focus(); + }; + + const onKey = (e: KeyboardEvent) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + setHover((h) => Math.min(Math.max(total - 1, 0), h + 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setHover((h) => Math.max(0, h - 1)); + } else if (e.key === "Enter") { + e.preventDefault(); + if (total === 0) return; + select(hover); + } else if (e.key === "Escape") { + e.preventDefault(); + onClose(); + } + }; + + return ( +
+
+ { + setQuery(e.target.value); + setHover(0); + }} + onKeyDown={onKey} + /> +
+
+ {total === 0 && ( +
+ {normalized.length === 0 + ? t("No labels yet") + : appliedNames.has(normalized) + ? t("Already added") + : !isValidLabelName(normalized) + ? t("Invalid label name") + : t("No matches")} +
+ )} + {suggestions.map((s, i) => { + const c = getLabelColor(s.name, scheme); + return ( + + ); + })} + {canCreate && ( + + )} +
+
+ ); +} diff --git a/apps/client/src/features/label/components/labels-section.tsx b/apps/client/src/features/label/components/labels-section.tsx new file mode 100644 index 00000000..f44472b0 --- /dev/null +++ b/apps/client/src/features/label/components/labels-section.tsx @@ -0,0 +1,93 @@ +import { useState } from "react"; +import clsx from "clsx"; +import { Divider, Popover, Stack, Text } from "@mantine/core"; +import { IconPlus } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { LabelChip } from "@/features/label/components/label-chip.tsx"; +import { LabelPicker } from "@/features/label/components/label-picker.tsx"; +import { + useAddLabelsMutation, + usePageLabelsQuery, + useRemoveLabelMutation, +} from "@/features/label/queries/label-query.ts"; +import classes from "@/features/label/label.module.css"; + +type LabelsSectionProps = { + pageId: string; + canEdit: boolean; +}; + +export function LabelsSection({ pageId, canEdit }: LabelsSectionProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + const { data } = usePageLabelsQuery(pageId); + const addMutation = useAddLabelsMutation(pageId); + const removeMutation = useRemoveLabelMutation(pageId); + + const labels = data?.items ?? []; + + if (!canEdit && labels.length === 0) { + return null; + } + + const handleAdd = (name: string) => { + addMutation.mutate({ pageId, names: [name] }); + }; + + const handleRemove = (labelId: string) => { + removeMutation.mutate({ pageId, labelId }); + }; + + return ( + <> + + + + {t("Labels")} + +
+ {labels.map((label) => ( + handleRemove(label.id) : undefined} + /> + ))} + {canEdit && ( + + + + + + handleAdd(name)} + onClose={() => setOpen(false)} + /> + + + )} +
+
+ + ); +} diff --git a/apps/client/src/features/label/label.module.css b/apps/client/src/features/label/label.module.css new file mode 100644 index 00000000..266a9c26 --- /dev/null +++ b/apps/client/src/features/label/label.module.css @@ -0,0 +1,325 @@ +.labelsWrap { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + margin-top: 4px; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + height: 24px; + padding: 0 8px; + border-radius: 4px; + font-size: 12.5px; + font-weight: 500; + line-height: 1; + user-select: none; + white-space: nowrap; +} + +.chipName { + letter-spacing: 0.005em; +} + +.chipX { + appearance: none; + border: 0; + background: transparent; + color: currentColor; + width: 18px; + height: 18px; + border-radius: 4px; + margin-right: -4px; + margin-left: 0; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + padding: 0; + opacity: 0.6; +} + +.chipX:hover { + opacity: 1; + background: light-dark(rgba(0, 0, 0, 0.08), rgba(255, 255, 255, 0.12)); +} + +.addBtn { + appearance: none; + border: 1px dashed + light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3)); + background: transparent; + color: var(--mantine-color-dimmed); + height: 24px; + padding: 0 8px; + border-radius: 4px; + font: inherit; + font-size: 12.5px; + font-weight: 500; + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; + transition: + background 100ms ease, + border-color 100ms ease, + color 100ms ease; +} + +.addBtn:hover { + background: light-dark(rgba(0, 0, 0, 0.03), rgba(255, 255, 255, 0.04)); + color: var(--mantine-color-text); + border-color: light-dark( + var(--mantine-color-gray-5), + var(--mantine-color-dark-2) + ); +} + +.addBtnOpen { + background: var(--mantine-color-body); + border-style: solid; + border-color: light-dark( + var(--mantine-color-gray-5), + var(--mantine-color-dark-2) + ); + color: var(--mantine-color-text); +} + +.popover { + width: 240px; + padding: 0; + overflow: hidden; +} + +.popoverSearch { + padding: 8px 8px 4px; + border-bottom: 1px solid + light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); +} + +.popoverSearch input { + width: 100%; + border: 0; + background: transparent; + font: inherit; + font-size: 13px; + padding: 4px 4px; + color: var(--mantine-color-text); + outline: none; +} + +.popoverSearch input::placeholder { + color: var(--mantine-color-placeholder); +} + +.popoverList { + max-height: 240px; + overflow-y: auto; + padding: 4px; +} + +.popoverEmpty { + padding: 12px 8px; + color: var(--mantine-color-dimmed); + font-size: 12.5px; + text-align: center; +} + +.popoverItem { + appearance: none; + width: 100%; + border: 0; + background: transparent; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: 4px; + font: inherit; + font-size: 13px; + color: var(--mantine-color-text); + cursor: pointer; + text-align: left; +} + +.popoverItemHover { + background: light-dark( + var(--mantine-color-gray-1), + var(--mantine-color-dark-5) + ); +} + +.popoverItemDot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.popoverItemName { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.popoverCreatePlus { + width: 14px; + height: 14px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--mantine-color-dimmed); +} + +.headerChip { + display: inline-flex; + align-items: center; + gap: 8px; + height: 36px; + padding: 0 14px; + border-radius: 8px; + font-size: 22px; + font-weight: 600; + line-height: 1; + letter-spacing: -0.005em; + text-decoration: none; + user-select: none; + transition: filter 100ms ease; +} + +.headerChip:hover { + filter: brightness(0.97); +} + +.headerDot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 14px 12px; + margin: 0 -12px; + border-radius: 8px; + text-decoration: none; + color: inherit; + cursor: pointer; + transition: background-color 80ms ease; +} + +.row + .row { + border-top: 1px solid + light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); +} + +.row:hover { + background-color: light-dark( + var(--mantine-color-gray-0), + var(--mantine-color-dark-6) + ); +} + +.row:hover + .row, +.row:has(+ .row:hover) { + border-top-color: transparent; +} + +.rowMain { + display: flex; + gap: 12px; + min-width: 0; + flex: 1; +} + +.rowIcon { + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--mantine-color-dimmed); + margin-top: 2px; +} + +.rowBody { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +} + +.rowTitle { + font-size: 15px; + font-weight: 500; + color: var(--mantine-color-text); + line-height: 1.3; + word-break: break-word; +} + +.rowChips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.chipMore { + display: inline-flex; + align-items: center; + height: 24px; + padding: 0 8px; + border-radius: 4px; + font-size: 12.5px; + font-weight: 500; + line-height: 1; + color: var(--mantine-color-dimmed); + background: light-dark( + var(--mantine-color-gray-1), + var(--mantine-color-dark-5) + ); + user-select: none; + white-space: nowrap; +} + +.rowMeta { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + color: var(--mantine-color-dimmed); + font-size: 13px; +} + +.rowDate { + color: var(--mantine-color-dimmed); + font-size: 13px; + white-space: nowrap; + flex-shrink: 0; +} + +.metaDot { + font-size: 14px; + line-height: 1; + color: var(--mantine-color-dimmed); +} + +.chipLink { + text-decoration: none; + color: inherit; + display: inline-flex; +} + +.chipLink:hover { + filter: brightness(0.97); +} diff --git a/apps/client/src/features/label/queries/label-query.ts b/apps/client/src/features/label/queries/label-query.ts new file mode 100644 index 00000000..c7b312fe --- /dev/null +++ b/apps/client/src/features/label/queries/label-query.ts @@ -0,0 +1,157 @@ +import { + keepPreviousData, + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { + addLabelsToPage, + 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({ + mutationFn: (data) => addLabelsToPage(data), + onSuccess: (added) => { + queryClient.setQueryData>( + PAGE_LABELS_KEY(pageId ?? ""), + (cache) => { + if (!cache) return cache; + const existing = new Set(cache.items.map((l) => l.id)); + const additions = added.filter((l) => !existing.has(l.id)); + if (additions.length === 0) return cache; + return { ...cache, items: [...cache.items, ...additions] }; + }, + ); + + queryClient.setQueriesData>( + { queryKey: ["workspace-labels"] }, + (cache) => { + if (!cache) return cache; + const existing = new Set(cache.items.map((l) => l.id)); + const additions = added.filter((l) => !existing.has(l.id)); + if (additions.length === 0) return cache; + return { + ...cache, + items: [...cache.items, ...additions].sort((a, b) => + a.name.localeCompare(b.name), + ), + }; + }, + ); + + queryClient.invalidateQueries({ queryKey: ["label-pages"] }); + queryClient.invalidateQueries({ queryKey: ["label-info"] }); + }, + onError: (error: any) => { + notifications.show({ + message: error?.response?.data?.message ?? t("Failed to add label"), + color: "red", + }); + }, + }); +} + +export function useRemoveLabelMutation(pageId: string | undefined) { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: (data) => removeLabelFromPage(data), + onSuccess: (_data, variables) => { + const cache = queryClient.getQueryData>( + PAGE_LABELS_KEY(pageId ?? ""), + ); + if (cache) { + queryClient.setQueryData>( + PAGE_LABELS_KEY(pageId ?? ""), + { + ...cache, + items: cache.items.filter((l) => l.id !== variables.labelId), + }, + ); + } + queryClient.invalidateQueries({ queryKey: ["workspace-labels"] }); + queryClient.invalidateQueries({ queryKey: ["label-pages"] }); + queryClient.invalidateQueries({ queryKey: ["label-info"] }); + }, + onError: () => { + notifications.show({ + message: t("Failed to remove label"), + color: "red", + }); + }, + }); +} + +export function useLabelInfoQuery(name: string, spaceId?: string) { + return useQuery({ + queryKey: ["label-info", name, spaceId ?? ""], + queryFn: () => getLabelInfo({ name, type: "page", spaceId }), + enabled: !!name, + placeholderData: keepPreviousData, + }); +} + +const LABEL_PAGES_LIMIT = 25; + +export function useLabelPagesQuery( + name: string, + query: string, + spaceId?: string, +) { + return useInfiniteQuery({ + queryKey: ["label-pages", name, query, spaceId ?? ""], + queryFn: ({ pageParam }) => + 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, + }); +} diff --git a/apps/client/src/features/label/services/label-service.ts b/apps/client/src/features/label/services/label-service.ts new file mode 100644 index 00000000..57c4d195 --- /dev/null +++ b/apps/client/src/features/label/services/label-service.ts @@ -0,0 +1,55 @@ +import api from "@/lib/api-client"; +import { IPagination } from "@/lib/types.ts"; +import { + IAddLabels, + ILabel, + ILabelInfo, + ILabelInfoParams, + ILabelPageItem, + IListLabelsParams, + IPageLabelsParams, + IRemoveLabel, + ISearchPagesByLabelParams, +} from "@/features/label/types/label.types.ts"; + +export async function getPageLabels( + params: IPageLabelsParams, +): Promise> { + const req = await api.post>("/pages/labels", params); + return req.data; +} + +export async function getWorkspaceLabels( + params: IListLabelsParams, +): Promise> { + const req = await api.post>("/labels", params); + return req.data; +} + +export async function addLabelsToPage( + data: IAddLabels, +): Promise { + const req = await api.post("/pages/labels/add", data); + return req.data; +} + +export async function removeLabelFromPage(data: IRemoveLabel): Promise { + await api.post("/pages/labels/remove", data); +} + +export async function getLabelInfo( + params: ILabelInfoParams, +): Promise { + const req = await api.post("/labels/info", params); + return req.data; +} + +export async function searchPagesByLabel( + params: ISearchPagesByLabelParams, +): Promise> { + const req = await api.post>( + "/labels/pages", + params, + ); + return req.data; +} diff --git a/apps/client/src/features/label/types/label.types.ts b/apps/client/src/features/label/types/label.types.ts new file mode 100644 index 00000000..3530c51e --- /dev/null +++ b/apps/client/src/features/label/types/label.types.ts @@ -0,0 +1,71 @@ +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; +} diff --git a/apps/client/src/features/label/utils/format-label-date.ts b/apps/client/src/features/label/utils/format-label-date.ts new file mode 100644 index 00000000..1221c8ad --- /dev/null +++ b/apps/client/src/features/label/utils/format-label-date.ts @@ -0,0 +1,15 @@ +import { format, isThisYear, isToday, isYesterday } from "date-fns"; +import i18n from "@/i18n.ts"; + +export function formatLabelListDate(date: Date): string { + if (isToday(date)) { + return i18n.t("Today, {{time}}", { time: format(date, "h:mma") }); + } + if (isYesterday(date)) { + return i18n.t("Yesterday, {{time}}", { time: format(date, "h:mma") }); + } + if (isThisYear(date)) { + return format(date, "MMM dd"); + } + return format(date, "MMM dd, yyyy"); +} diff --git a/apps/client/src/features/label/utils/label-colors.ts b/apps/client/src/features/label/utils/label-colors.ts new file mode 100644 index 00000000..b9da2858 --- /dev/null +++ b/apps/client/src/features/label/utils/label-colors.ts @@ -0,0 +1,55 @@ +type LabelColor = { + bg: string; + fg: string; + dot: string; +}; + +const LABEL_PALETTE: Record = { + slate: { bg: "#eef1f5", fg: "#3b475a", dot: "#6b7a90" }, + blue: { bg: "#e6f0ff", fg: "#1e4fbf", dot: "#3b82f6" }, + green: { bg: "#e3f5ea", fg: "#1f7a47", dot: "#22a05a" }, + amber: { bg: "#fbf0d9", fg: "#8a5a00", dot: "#d99c1f" }, + red: { bg: "#fde6e6", fg: "#a02b2b", dot: "#dc4a4a" }, + purple: { bg: "#efe9fb", fg: "#5a3aa8", dot: "#8b6bd9" }, + pink: { bg: "#fce6ee", fg: "#a8336d", dot: "#dc6699" }, + teal: { bg: "#daf1ee", fg: "#1f6f6a", dot: "#2fa39a" }, +}; + +const PALETTE_KEYS = Object.keys(LABEL_PALETTE); + +const DARK_PALETTE: Record = { + slate: { bg: "#2a3140", fg: "#c8d3e3", dot: "#7e8da8" }, + blue: { bg: "#152a52", fg: "#a9c4ff", dot: "#5b9aff" }, + green: { bg: "#143b27", fg: "#9ce3b8", dot: "#3ec97c" }, + amber: { bg: "#3d2c0e", fg: "#f5cf85", dot: "#e6b34a" }, + red: { bg: "#401a1a", fg: "#f1a8a8", dot: "#e26565" }, + purple: { bg: "#2a1f4d", fg: "#c8b4f4", dot: "#a48ce6" }, + pink: { bg: "#3c1a2a", fg: "#f3a9c9", dot: "#e07ab0" }, + teal: { bg: "#103633", fg: "#92d5cf", dot: "#48b8af" }, +}; + +function hashName(name: string): number { + // Per-char accumulation with 31. Note: 31 ≡ -1 (mod 8), so the low bits of + // this hash are highly correlated across short strings — `% 8` would cluster. + let h = 0; + for (let i = 0; i < name.length; i++) { + h = (Math.imul(h, 31) + name.charCodeAt(i)) | 0; + } + // Murmur3 fmix32 finalizer — avalanches high bits into low bits so the + // subsequent `% palette.length` (small power of two) is well-distributed. + h ^= h >>> 16; + h = Math.imul(h, 0x85ebca6b); + h ^= h >>> 13; + h = Math.imul(h, 0xc2b2ae35); + h ^= h >>> 16; + return h >>> 0; +} + +export function getLabelColor( + name: string, + scheme: "light" | "dark" = "light", +): LabelColor { + const key = PALETTE_KEYS[hashName(name) % PALETTE_KEYS.length]; + const palette = scheme === "dark" ? DARK_PALETTE : LABEL_PALETTE; + return palette[key]; +} diff --git a/apps/client/src/features/label/utils/normalize-label.ts b/apps/client/src/features/label/utils/normalize-label.ts new file mode 100644 index 00000000..cb170d93 --- /dev/null +++ b/apps/client/src/features/label/utils/normalize-label.ts @@ -0,0 +1,3 @@ +export function normalizeLabelName(name: string): string { + return name.trim().replace(/\s+/g, "-").toLowerCase(); +} diff --git a/apps/client/src/features/page-details/components/page-details-aside.tsx b/apps/client/src/features/page-details/components/page-details-aside.tsx index 7435f9e6..84209d7a 100644 --- a/apps/client/src/features/page-details/components/page-details-aside.tsx +++ b/apps/client/src/features/page-details/components/page-details-aside.tsx @@ -18,6 +18,7 @@ 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(); @@ -61,6 +62,11 @@ export function PageDetailsAside() { isLoading={countsLoading} onClick={openModal} /> + + ( spaceId || null, ); - const [spaceSearchQuery, setSpaceSearchQuery] = useState(""); - const [debouncedSpaceQuery] = useDebouncedValue(spaceSearchQuery, 300); const [contentType, setContentType] = useState("page"); const [workspace] = useAtom(workspaceAtom); - const { data: spacesData } = useGetSpacesQuery({ - limit: 100, - query: debouncedSpaceQuery, - }); - - const selectedSpaceData = useMemo(() => { - if (!spacesData?.items || !selectedSpaceId) return null; - return spacesData.items.find((space) => space.id === selectedSpaceId); - }, [spacesData?.items, selectedSpaceId]); - - const availableSpaces = useMemo(() => { - const spaces = spacesData?.items || []; - if (!selectedSpaceId) return spaces; - - // Sort to put selected space first - return [...spaces].sort((a, b) => { - if (a.id === selectedSpaceId) return -1; - if (b.id === selectedSpaceId) return 1; - return 0; - }); - }, [spacesData?.items, selectedSpaceId]); + const { data: spacesData } = useGetSpacesQuery({ limit: 100 }); + const selectedSpaceData = selectedSpaceId + ? spacesData?.items.find((space) => space.id === selectedSpaceId) + : null; useEffect(() => { if (onFiltersChange) { @@ -152,86 +128,27 @@ export function SearchSpotlightFilters({ )} - - - - - - } - value={spaceSearchQuery} - onChange={(e) => setSpaceSearchQuery(e.target.value)} - size="sm" - variant="filled" - radius="sm" - styles={{ input: { marginBottom: 8 } }} - /> - - - handleSpaceSelect(null)}> - - -
- - {t("All spaces")} - - - {t("Search in all your spaces")} - -
- {!selectedSpaceId && } -
-
- - - - {availableSpaces.map((space) => ( - handleSpaceSelect(space.id)} - > - - - - {space.name} - - {selectedSpaceId === space.id && } - - - ))} -
-
-
+ + 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 ( + + {children} + + } + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + size="sm" + variant="filled" + radius="sm" + styles={{ input: { marginBottom: 8 } }} + /> + + + onChange(null)}> + + +
+ + {t("All spaces")} + + + {t("Search in all your spaces")} + +
+ {!value && } +
+
+ + + + {orderedSpaces.map((space) => ( + onChange(space.id)}> + + + + {space.name} + + {value === space.id && } + + + ))} +
+
+
+ ); +} + +export const SPACE_FILTER_MENU_MAX_Z = getDefaultZIndex("max"); diff --git a/apps/client/src/pages/label/label-page.tsx b/apps/client/src/pages/label/label-page.tsx new file mode 100644 index 00000000..03a2eaf8 --- /dev/null +++ b/apps/client/src/pages/label/label-page.tsx @@ -0,0 +1,200 @@ +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(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(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 ( + <> + + + {labelName} - {getAppName()} + + + + + + + + {t("Labels")} + {" / "} + + {labelName} + + + + + + + {labelName} + + {infoLoading ? ( + + ) : info ? ( + + {t("{{count}} page", { + count: info.usageCount, + defaultValue_one: "{{count}} page", + defaultValue_other: "{{count}} pages", + })} + + ) : null} + + + + + } + value={search} + onChange={(e) => setSearch(e.target.value)} + size="sm" + style={{ flex: 1 }} + /> + + + + + + {pagesLoading && pages.length === 0 ? ( +
+ + + + + +
+ ) : pages.length > 0 ? ( +
+ {pages.map((page) => ( + + ))} +
+ {isFetchingNextPage && ( +
+ +
+ )} +
+ ) : ( + + )} + + + + ); +} diff --git a/apps/server/src/core/label/dto/label.dto.ts b/apps/server/src/core/label/dto/label.dto.ts index 9b3540e0..36c2a2bd 100644 --- a/apps/server/src/core/label/dto/label.dto.ts +++ b/apps/server/src/core/label/dto/label.dto.ts @@ -2,6 +2,7 @@ import { ArrayMaxSize, ArrayMinSize, IsArray, + IsIn, IsNotEmpty, IsOptional, IsString, @@ -10,52 +11,76 @@ import { 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'; -function normalizeLabel(name: string): string { - return name.trim().replace(/\s+/g, '-').toLowerCase(); -} - -export class AddLabelsDto { - @IsString() - @IsNotEmpty() - pageId: string; +// 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(normalizeLabel) : value, + Array.isArray(value) ? value.map(normalizeLabelName) : value, ) @MaxLength(100, { each: true }) - @Matches(/^[a-z0-9_~-]+$/, { + @Matches(/^[a-z0-9_-][a-z0-9_~-]*$/, { each: true, - message: 'Label names can only contain letters, numbers, hyphens, underscores, and tildes', + message: + 'Label names can only contain letters, numbers, hyphens, underscores, and tildes, and cannot start with a tilde', }) names: string[]; } -export class RemoveLabelDto { - @IsString() - @IsNotEmpty() - pageId: string; - +export class RemoveLabelDto extends PageIdDto { @IsUUID() labelId: string; } -export class PageLabelsDto { - @IsString() - @IsNotEmpty() - pageId: string; -} - -export class SearchPagesByLabelDto { +export class FindPagesByLabelDto { + @IsOptional() @IsUUID() - labelId: string; + 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; +} diff --git a/apps/server/src/core/label/label.controller.ts b/apps/server/src/core/label/label.controller.ts index ee0f196b..087c9b01 100644 --- a/apps/server/src/core/label/label.controller.ts +++ b/apps/server/src/core/label/label.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, ForbiddenException, @@ -10,23 +11,22 @@ import { } from '@nestjs/common'; import { LabelService } from './label.service'; import { - AddLabelsDto, - PageLabelsDto, - RemoveLabelDto, - SearchPagesByLabelDto, + 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'; -import { PageRepo } from '@docmost/db/repos/page/page.repo'; -import { LabelRepo } from '@docmost/db/repos/label/label.repo'; -import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; @UseGuards(JwtAuthGuard) @Controller('labels') @@ -34,102 +34,89 @@ export class LabelController { constructor( private readonly labelService: LabelService, private readonly labelRepo: LabelRepo, - private readonly pageRepo: PageRepo, private readonly spaceAbility: SpaceAbilityFactory, ) {} @HttpCode(HttpStatus.OK) @Post('/') async getLabels( + @Body() dto: ListLabelsDto, @Body() pagination: PaginationOptions, - @AuthWorkspace() workspace: Workspace, - ) { - return this.labelService.getLabels(workspace.id, pagination); - } - - @HttpCode(HttpStatus.OK) - @Post('add') - async addLabels( - @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'); - } - - const ability = await this.spaceAbility.createForUser(user, page.spaceId); - if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) { - throw new ForbiddenException(); - } - - return this.labelService.addLabelsToPage( - page.id, - dto.names, + return this.labelService.getLabels( workspace.id, + user.id, + dto.type, + pagination, ); } @HttpCode(HttpStatus.OK) - @Post('remove') - async removeLabel( - @Body() dto: RemoveLabelDto, - @AuthUser() user: User, - ) { - const page = await this.pageRepo.findById(dto.pageId); - if (!page || page.deletedAt) { - throw new NotFoundException('Page not found'); - } - - const ability = await this.spaceAbility.createForUser(user, page.spaceId); - if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) { - throw new ForbiddenException(); - } - - await this.labelService.removeLabelFromPage(page.id, dto.labelId); - } - - @HttpCode(HttpStatus.OK) - @Post('page') - async getPageLabels( - @Body() dto: PageLabelsDto, + @Post('pages') + async findPagesByLabel( + @Body() dto: FindPagesByLabelDto, @Body() pagination: PaginationOptions, @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, ) { - const page = await this.pageRepo.findById(dto.pageId); - if (!page) { - throw new NotFoundException('Page not found'); - } - - const ability = await this.spaceAbility.createForUser(user, page.spaceId); - if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { - throw new ForbiddenException(); - } - - return this.labelService.getPageLabels(page.id, pagination); - } - - @HttpCode(HttpStatus.OK) - @Post('search-pages') - async searchPagesByLabel( - @Body() dto: SearchPagesByLabelDto, - @AuthUser() user: User, - ) { - const label = await this.labelRepo.findById(dto.labelId); - if (!label) { - throw new NotFoundException('Label not found'); - } - if (dto.spaceId) { - const ability = await this.spaceAbility.createForUser(user, dto.spaceId); - if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { - throw new ForbiddenException(); + 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.searchPagesByLabel(label.id, user.id, { + 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(); + } + } } diff --git a/apps/server/src/core/label/label.service.ts b/apps/server/src/core/label/label.service.ts index 912cca54..97739c7d 100644 --- a/apps/server/src/core/label/label.service.ts +++ b/apps/server/src/core/label/label.service.ts @@ -1,16 +1,18 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +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'; - -const MAX_LABELS_PER_PAGE = 25; +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, ) {} @@ -18,15 +20,9 @@ export class LabelService { pageId: string, names: string[], workspaceId: string, - ) { + ): Promise { + const attached: Label[] = []; await executeTx(this.db, async (trx) => { - const currentCount = await this.labelRepo.getPageLabelCount(pageId, trx); - if (currentCount + names.length > MAX_LABELS_PER_PAGE) { - throw new BadRequestException( - `A page can have a maximum of ${MAX_LABELS_PER_PAGE} labels`, - ); - } - for (const name of names) { const label = await this.labelRepo.findOrCreate( name.trim(), @@ -35,22 +31,37 @@ export class LabelService { trx, ); await this.labelRepo.addLabelToPage(pageId, label.id, trx); + attached.push(label); } }); - - return this.labelRepo.findLabelsByPageId(pageId, { limit: 100 } as PaginationOptions); + return attached; } async removeLabelFromPage( pageId: string, labelId: string, + workspaceId: string, ): Promise { await executeTx(this.db, async (trx) => { - await this.labelRepo.removeLabelFromPage(pageId, labelId, trx); + const label = await this.labelRepo.findById(labelId, trx); + if (!label || label.workspaceId !== workspaceId) { + throw new NotFoundException('Label not found'); + } - const count = await this.labelRepo.getLabelPageCount(labelId, trx); + 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, trx); + await this.labelRepo.deleteLabel(labelId, workspaceId, trx); } }); } @@ -61,22 +72,70 @@ export class LabelService { async getLabels( workspaceId: string, + userId: string, + type: LabelType, pagination: PaginationOptions, ) { - return this.labelRepo.findLabels(workspaceId, LabelType.PAGE, pagination); + return this.labelRepo.findLabels( + workspaceId, + userId, + type, + pagination, + ); } - async searchPagesByLabel( + async findPagesByLabel( labelId: string, userId: string, - opts?: { spaceId?: string }, + opts: { + spaceId?: string; + query?: string; + pagination: PaginationOptions; + }, ) { - return this.labelRepo.findPagesByLabelId(labelId, userId, opts); + 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 cleanupOrphanedLabels(pageIds: string[]): Promise { - const labelIds = await this.labelRepo.findLabelIdsByPageIds(pageIds); - if (labelIds.length === 0) return; - await this.labelRepo.deleteOrphanedLabels(labelIds); + 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, + }; } } diff --git a/apps/server/src/core/label/utils.ts b/apps/server/src/core/label/utils.ts new file mode 100644 index 00000000..f417f4a7 --- /dev/null +++ b/apps/server/src/core/label/utils.ts @@ -0,0 +1,6 @@ +// 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(); +} diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts index 67e005a0..5ce07750 100644 --- a/apps/server/src/core/page/page.controller.ts +++ b/apps/server/src/core/page/page.controller.ts @@ -40,6 +40,8 @@ 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, @@ -61,6 +63,7 @@ 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, ) {} @@ -99,6 +102,64 @@ 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( diff --git a/apps/server/src/core/page/page.module.ts b/apps/server/src/core/page/page.module.ts index b54b3646..56360941 100644 --- a/apps/server/src/core/page/page.module.ts +++ b/apps/server/src/core/page/page.module.ts @@ -8,6 +8,7 @@ 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], @@ -23,6 +24,7 @@ import { TransclusionModule } from './transclusion/transclusion.module'; CollaborationModule, WatcherModule, TransclusionModule, + LabelModule, ], }) export class PageModule {} diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index 65d1a71c..4c149cea 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -53,7 +53,6 @@ import { } from '../../../integrations/export/utils'; import { markdownToHtml } from '@docmost/editor-ext'; import { WatcherService } from '../../watcher/watcher.service'; -import { LabelRepo } from '@docmost/db/repos/label/label.repo'; import { sql } from 'kysely'; import { TransclusionService } from '../transclusion/transclusion.service'; @@ -73,7 +72,6 @@ export class PageService { private eventEmitter: EventEmitter2, private collaborationGateway: CollaborationGateway, private readonly watcherService: WatcherService, - private readonly labelRepo: LabelRepo, private readonly transclusionService: TransclusionService, ) {} @@ -427,11 +425,7 @@ export class PageService { if (pageIdsToMove.length > 1) { // Update sub pages (all accessible pages except root) - await this.pageRepo.updatePages( - { spaceId }, - childPageIds, - trx, - ); + await this.pageRepo.updatePages({ spaceId }, childPageIds, trx); } if (pageIdsToMove.length > 0) { @@ -478,9 +472,13 @@ export class PageService { ); // Update watchers and remove those without access to new space - await this.watcherService.movePageWatchersToSpace(pageIdsToMove, spaceId, { - trx, - }); + await this.watcherService.movePageWatchersToSpace( + pageIdsToMove, + spaceId, + { + trx, + }, + ); await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, { pageId: pageIdsToMove, @@ -860,13 +858,15 @@ export class PageService { .selectFrom('page_ancestors') .selectAll('page_ancestors') .select((eb) => - eb.exists( - eb - .selectFrom('pages as child') - .select(sql`1`.as('one')) - .whereRef('child.parentPageId', '=', 'page_ancestors.id') - .where('child.deletedAt', 'is', null), - ).as('hasChildren'), + eb + .exists( + eb + .selectFrom('pages as child') + .select(sql`1`.as('one')) + .whereRef('child.parentPageId', '=', 'page_ancestors.id') + .where('child.deletedAt', 'is', null), + ) + .as('hasChildren'), ) .execute(); @@ -1010,18 +1010,11 @@ export class PageService { } if (pageIds.length > 0) { - const affectedLabelIds = - await this.labelRepo.findLabelIdsByPageIds(pageIds); - await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute(); this.eventEmitter.emit(EventName.PAGE_DELETED, { pageIds: pageIds, workspaceId, }); - - if (affectedLabelIds.length > 0) { - await this.labelRepo.deleteOrphanedLabels(affectedLabelIds); - } } } diff --git a/apps/server/src/core/page/services/trash-cleanup.service.ts b/apps/server/src/core/page/services/trash-cleanup.service.ts index 644ea54b..42a4a11a 100644 --- a/apps/server/src/core/page/services/trash-cleanup.service.ts +++ b/apps/server/src/core/page/services/trash-cleanup.service.ts @@ -5,7 +5,6 @@ import { KyselyDB } from '@docmost/db/types/kysely.types'; import { InjectQueue } from '@nestjs/bullmq'; import { Queue } from 'bullmq'; import { QueueJob, QueueName } from '../../../integrations/queue/constants'; -import { LabelRepo } from '@docmost/db/repos/label/label.repo'; const DEFAULT_RETENTION_DAYS = 30; @@ -16,7 +15,6 @@ export class TrashCleanupService { constructor( @InjectKysely() private readonly db: KyselyDB, @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue, - private readonly labelRepo: LabelRepo, ) {} @Interval('trash-cleanup', 24 * 60 * 60 * 1000) // every 24 hours @@ -117,14 +115,7 @@ export class TrashCleanupService { try { if (pageIds.length > 0) { - const affectedLabelIds = - await this.labelRepo.findLabelIdsByPageIds(pageIds); - await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute(); - - if (affectedLabelIds.length > 0) { - await this.labelRepo.deleteOrphanedLabels(affectedLabelIds); - } } } catch (error) { // Log but don't throw - pages might have been deleted by another node diff --git a/apps/server/src/database/migrations/20260509T121236-labels.ts b/apps/server/src/database/migrations/20260509T121236-labels.ts index 5d2c5812..c1810c5d 100644 --- a/apps/server/src/database/migrations/20260509T121236-labels.ts +++ b/apps/server/src/database/migrations/20260509T121236-labels.ts @@ -20,9 +20,9 @@ export async function up(db: Kysely): Promise { .execute(); await db.schema - .createIndex('labels_workspace_id_name_unique') + .createIndex('labels_workspace_id_type_name_unique') .on('labels') - .columns(['workspace_id', 'name', 'type']) + .columns(['workspace_id', 'type', 'name']) .unique() .execute(); diff --git a/apps/server/src/database/repos/label/label.repo.ts b/apps/server/src/database/repos/label/label.repo.ts index 62abd678..bac199ed 100644 --- a/apps/server/src/database/repos/label/label.repo.ts +++ b/apps/server/src/database/repos/label/label.repo.ts @@ -6,6 +6,8 @@ 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', @@ -43,7 +45,7 @@ export class LabelRepo { return db .selectFrom('labels') .selectAll() - .where('name', '=', name.toLowerCase()) + .where('name', '=', normalizeLabelName(name)) .where('type', '=', type) .where('workspaceId', '=', workspaceId) .executeTakeFirst(); @@ -56,22 +58,21 @@ export class LabelRepo { trx?: KyselyTransaction, ): Promise