mirror of
https://github.com/docmost/docmost.git
synced 2026-05-20 08:34:04 +08:00
feat: page labels/tags (#2188)
* feat: labels (WIP) * full implementation
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useComputedColorScheme } from "@mantine/core";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ILabel } from "@/features/label/types/label.types.ts";
|
||||
import { getLabelColor } from "@/features/label/utils/label-colors.ts";
|
||||
import classes from "@/features/label/label.module.css";
|
||||
|
||||
type LabelChipProps = {
|
||||
label: Pick<ILabel, "id" | "name">;
|
||||
onRemove?: () => void;
|
||||
asLink?: boolean;
|
||||
};
|
||||
|
||||
export function LabelChip({ label, onRemove, asLink }: LabelChipProps) {
|
||||
const { t } = useTranslation();
|
||||
const scheme = useComputedColorScheme("light");
|
||||
const c = getLabelColor(label.name, scheme);
|
||||
|
||||
const nameNode = asLink ? (
|
||||
<Link
|
||||
to={`/labels/${encodeURIComponent(label.name)}`}
|
||||
className={classes.chipLink}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className={classes.chipName}>{label.name}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className={classes.chipName}>{label.name}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<span className={classes.chip} style={{ background: c.bg, color: c.fg }}>
|
||||
{nameNode}
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
className={classes.chipX}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
aria-label={t("Remove label {{name}}", { name: label.name })}
|
||||
>
|
||||
<IconX size={12} stroke={2} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Skeleton } from "@mantine/core";
|
||||
import classes from "@/features/label/label.module.css";
|
||||
|
||||
type LabelPageRowSkeletonProps = {
|
||||
titleWidth?: number;
|
||||
metaWidth?: number;
|
||||
};
|
||||
|
||||
export function LabelPageRowSkeleton({
|
||||
titleWidth = 220,
|
||||
metaWidth = 180,
|
||||
}: LabelPageRowSkeletonProps) {
|
||||
return (
|
||||
<div className={classes.row} aria-hidden="true">
|
||||
<div className={classes.rowMain}>
|
||||
<div className={classes.rowIcon}>
|
||||
<Skeleton height={18} width={18} radius="sm" />
|
||||
</div>
|
||||
<div className={classes.rowBody}>
|
||||
<Skeleton height={15} width={titleWidth} radius="xs" />
|
||||
<div className={classes.rowMeta}>
|
||||
<Skeleton height={18} width={18} radius="sm" />
|
||||
<Skeleton height={12} width={metaWidth} radius="xs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { ThemeIcon, Tooltip } from "@mantine/core";
|
||||
import { IconFileDescription } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ILabelPageItem } from "@/features/label/types/label.types.ts";
|
||||
import { LabelChip } from "@/features/label/components/label-chip.tsx";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils";
|
||||
import { formatLabelListDate } from "@/features/label/utils/format-label-date.ts";
|
||||
import classes from "@/features/label/label.module.css";
|
||||
|
||||
type LabelPageRowProps = {
|
||||
page: ILabelPageItem;
|
||||
currentLabelName: string;
|
||||
};
|
||||
|
||||
const MAX_VISIBLE_CHIPS = 3;
|
||||
|
||||
export function LabelPageRow({ page, currentLabelName }: LabelPageRowProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const otherLabels = page.labels.filter((l) => l.name !== currentLabelName);
|
||||
const visibleLabels = otherLabels.slice(0, MAX_VISIBLE_CHIPS);
|
||||
const hiddenLabels = otherLabels.slice(MAX_VISIBLE_CHIPS);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={buildPageUrl(page.space?.slug, page.slugId, page.title ?? undefined)}
|
||||
className={classes.row}
|
||||
>
|
||||
<div className={classes.rowMain}>
|
||||
<div className={classes.rowIcon}>
|
||||
{page.icon ? (
|
||||
<span style={{ fontSize: 16, lineHeight: 1 }}>{page.icon}</span>
|
||||
) : (
|
||||
<ThemeIcon variant="transparent" color="gray" size={18}>
|
||||
<IconFileDescription size={18} />
|
||||
</ThemeIcon>
|
||||
)}
|
||||
</div>
|
||||
<div className={classes.rowBody}>
|
||||
<div className={classes.rowTitle}>
|
||||
{page.title || t("Untitled")}
|
||||
</div>
|
||||
<div className={classes.rowMeta}>
|
||||
{page.space && (
|
||||
<>
|
||||
<CustomAvatar
|
||||
name={page.space.name}
|
||||
avatarUrl={page.space.logo ?? undefined}
|
||||
type={AvatarIconType.SPACE_ICON}
|
||||
color="initials"
|
||||
variant="filled"
|
||||
size={18}
|
||||
/>
|
||||
<span>{page.space.name}</span>
|
||||
<span className={classes.metaDot} aria-hidden="true">
|
||||
•
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className={classes.rowDate}>
|
||||
{t("Updated {{date}}", {
|
||||
date: formatLabelListDate(new Date(page.updatedAt)),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{/* {otherLabels.length > 0 && (
|
||||
<div className={classes.rowChips}>
|
||||
{visibleLabels.map((label) => (
|
||||
<LabelChip key={label.id} label={label} asLink />
|
||||
))}
|
||||
{hiddenLabels.length > 0 && (
|
||||
<Tooltip
|
||||
label={hiddenLabels.map((l) => l.name).join(", ")}
|
||||
withArrow
|
||||
openDelay={200}
|
||||
>
|
||||
<span className={classes.chipMore}>
|
||||
+{hiddenLabels.length}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useMemo, useRef, useState, KeyboardEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useComputedColorScheme } from "@mantine/core";
|
||||
import { ILabel } from "@/features/label/types/label.types.ts";
|
||||
import { useWorkspaceLabelsQuery } from "@/features/label/queries/label-query.ts";
|
||||
import { getLabelColor } from "@/features/label/utils/label-colors.ts";
|
||||
import { normalizeLabelName } from "@/features/label/utils/normalize-label.ts";
|
||||
import classes from "@/features/label/label.module.css";
|
||||
|
||||
type LabelPickerProps = {
|
||||
applied: ILabel[];
|
||||
enabled: boolean;
|
||||
onAdd: (name: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const NAME_PATTERN = /^[a-z0-9_-][a-z0-9_~-]*$/;
|
||||
const MAX_LABEL_NAME_LENGTH = 100;
|
||||
|
||||
function isValidLabelName(name: string): boolean {
|
||||
return (
|
||||
name.length > 0 &&
|
||||
name.length <= MAX_LABEL_NAME_LENGTH &&
|
||||
NAME_PATTERN.test(name)
|
||||
);
|
||||
}
|
||||
|
||||
export function LabelPicker({
|
||||
applied,
|
||||
enabled,
|
||||
onAdd,
|
||||
onClose,
|
||||
}: LabelPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const scheme = useComputedColorScheme("light");
|
||||
const [query, setQuery] = useState("");
|
||||
const [hover, setHover] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const normalized = normalizeLabelName(query);
|
||||
const { data } = useWorkspaceLabelsQuery(normalized, enabled);
|
||||
|
||||
const appliedNames = useMemo(
|
||||
() => new Set(applied.map((l) => l.name.toLowerCase())),
|
||||
[applied],
|
||||
);
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
const items = data?.items ?? [];
|
||||
return items.filter((l) => !appliedNames.has(l.name.toLowerCase()));
|
||||
}, [data, appliedNames]);
|
||||
|
||||
const exact = suggestions.find((l) => l.name === normalized);
|
||||
const canCreate =
|
||||
!exact && !appliedNames.has(normalized) && isValidLabelName(normalized);
|
||||
|
||||
const total = suggestions.length + (canCreate ? 1 : 0);
|
||||
|
||||
const select = (idx: number) => {
|
||||
if (idx < suggestions.length) {
|
||||
onAdd(suggestions[idx].name);
|
||||
} else if (canCreate) {
|
||||
onAdd(normalized);
|
||||
}
|
||||
setQuery("");
|
||||
setHover(0);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHover((h) => Math.min(Math.max(total - 1, 0), h + 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHover((h) => Math.max(0, h - 1));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (total === 0) return;
|
||||
select(hover);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.popover}>
|
||||
<div className={classes.popoverSearch}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
autoFocus
|
||||
maxLength={MAX_LABEL_NAME_LENGTH}
|
||||
placeholder={t("Search or create…")}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setHover(0);
|
||||
}}
|
||||
onKeyDown={onKey}
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.popoverList}>
|
||||
{total === 0 && (
|
||||
<div className={classes.popoverEmpty}>
|
||||
{normalized.length === 0
|
||||
? t("No labels yet")
|
||||
: appliedNames.has(normalized)
|
||||
? t("Already added")
|
||||
: !isValidLabelName(normalized)
|
||||
? t("Invalid label name")
|
||||
: t("No matches")}
|
||||
</div>
|
||||
)}
|
||||
{suggestions.map((s, i) => {
|
||||
const c = getLabelColor(s.name, scheme);
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className={clsx(
|
||||
classes.popoverItem,
|
||||
hover === i && classes.popoverItemHover,
|
||||
)}
|
||||
onMouseEnter={() => setHover(i)}
|
||||
onClick={() => select(i)}
|
||||
>
|
||||
<span
|
||||
className={classes.popoverItemDot}
|
||||
style={{ background: c.dot }}
|
||||
/>
|
||||
<span className={classes.popoverItemName}>{s.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{canCreate && (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
classes.popoverItem,
|
||||
hover === suggestions.length && classes.popoverItemHover,
|
||||
)}
|
||||
onMouseEnter={() => setHover(suggestions.length)}
|
||||
onClick={() => select(suggestions.length)}
|
||||
>
|
||||
<span className={classes.popoverCreatePlus}>
|
||||
<IconPlus size={12} stroke={2} />
|
||||
</span>
|
||||
<span className={classes.popoverItemName}>
|
||||
{t("Create")} <b>"{normalized}"</b>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Divider, Popover, Stack, Text } from "@mantine/core";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LabelChip } from "@/features/label/components/label-chip.tsx";
|
||||
import { LabelPicker } from "@/features/label/components/label-picker.tsx";
|
||||
import {
|
||||
useAddLabelsMutation,
|
||||
usePageLabelsQuery,
|
||||
useRemoveLabelMutation,
|
||||
} from "@/features/label/queries/label-query.ts";
|
||||
import classes from "@/features/label/label.module.css";
|
||||
|
||||
type LabelsSectionProps = {
|
||||
pageId: string;
|
||||
canEdit: boolean;
|
||||
};
|
||||
|
||||
export function LabelsSection({ pageId, canEdit }: LabelsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { data } = usePageLabelsQuery(pageId);
|
||||
const addMutation = useAddLabelsMutation(pageId);
|
||||
const removeMutation = useRemoveLabelMutation(pageId);
|
||||
|
||||
const labels = data?.items ?? [];
|
||||
|
||||
if (!canEdit && labels.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleAdd = (name: string) => {
|
||||
addMutation.mutate({ pageId, names: [name] });
|
||||
};
|
||||
|
||||
const handleRemove = (labelId: string) => {
|
||||
removeMutation.mutate({ pageId, labelId });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider />
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={500} c="dimmed">
|
||||
{t("Labels")}
|
||||
</Text>
|
||||
<div className={classes.labelsWrap}>
|
||||
{labels.map((label) => (
|
||||
<LabelChip
|
||||
key={label.id}
|
||||
label={label}
|
||||
asLink
|
||||
onRemove={canEdit ? () => handleRemove(label.id) : undefined}
|
||||
/>
|
||||
))}
|
||||
{canEdit && (
|
||||
<Popover
|
||||
opened={open}
|
||||
onChange={setOpen}
|
||||
position="bottom-end"
|
||||
shadow="lg"
|
||||
withinPortal
|
||||
offset={6}
|
||||
>
|
||||
<Popover.Target>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(classes.addBtn, open && classes.addBtnOpen)}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<IconPlus size={12} stroke={2} />
|
||||
<span>
|
||||
{labels.length === 0 ? t("Add label") : t("Add")}
|
||||
</span>
|
||||
</button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={0} className={classes.popover}>
|
||||
<LabelPicker
|
||||
applied={labels}
|
||||
enabled={open}
|
||||
onAdd={(name) => handleAdd(name)}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user