mirror of
https://github.com/docmost/docmost.git
synced 2026-05-11 00:44:07 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d20c9e5256 | |||
| 56dc2e4eca |
@@ -25,7 +25,7 @@
|
||||
"@tabler/icons-react": "^3.40.0",
|
||||
"@tanstack/react-query": "5.90.17",
|
||||
"alfaaz": "^1.1.0",
|
||||
"axios": "1.16.0",
|
||||
"axios": "1.15.0",
|
||||
"blueimp-load-image": "^5.16.0",
|
||||
"clsx": "^2.1.1",
|
||||
"emoji-mart": "^5.6.0",
|
||||
|
||||
@@ -932,6 +932,7 @@
|
||||
"Settings navigation": "Settings navigation",
|
||||
"AI navigation": "AI navigation",
|
||||
"Breadcrumb": "Breadcrumb",
|
||||
"Skip to main content": "Skip to main content"
|
||||
"Synced block": "Synced block",
|
||||
"Create a block that stays in sync across pages.": "Create a block that stays in sync across pages.",
|
||||
"Editing original": "Editing original",
|
||||
@@ -945,59 +946,5 @@
|
||||
"No pages": "No pages",
|
||||
"The original synced block no longer exists": "The original synced block no longer exists",
|
||||
"You don't have access to this synced block": "You don't have access to this synced block",
|
||||
"Failed to load this synced block": "Failed to load this synced block",
|
||||
"Fixed editor toolbar": "Fixed editor toolbar",
|
||||
"Show a formatting toolbar above the editor with quick access to common actions.": "Show a formatting toolbar above the editor with quick access to common actions.",
|
||||
"Toggle fixed editor toolbar": "Toggle fixed editor toolbar",
|
||||
"Normal text": "Normal text",
|
||||
"More inline formatting": "More inline formatting",
|
||||
"Subscript": "Subscript",
|
||||
"Superscript": "Superscript",
|
||||
"Inline code": "Inline code",
|
||||
"Insert media": "Insert media",
|
||||
"Mention": "Mention",
|
||||
"Emoji": "Emoji",
|
||||
"Columns": "Columns",
|
||||
"More inserts": "More inserts",
|
||||
"Embeds": "Embeds",
|
||||
"Diagrams": "Diagrams",
|
||||
"Advanced": "Advanced",
|
||||
"Utility": "Utility",
|
||||
"Decrease indent": "Decrease indent",
|
||||
"Increase indent": "Increase indent",
|
||||
"Clear formatting": "Clear formatting",
|
||||
"Code block": "Code block",
|
||||
"Experimental": "Experimental",
|
||||
"Strikethrough": "Strikethrough",
|
||||
"Undo": "Undo",
|
||||
"Redo": "Redo",
|
||||
"Backlinks": "Backlinks",
|
||||
"Last updated by": "Last updated by",
|
||||
"Last updated": "Last updated",
|
||||
"Stats": "Stats",
|
||||
"Word count": "Word count",
|
||||
"Characters": "Characters",
|
||||
"Incoming links": "Incoming links",
|
||||
"Outgoing links": "Outgoing links",
|
||||
"Incoming links ({{count}})": "Incoming links ({{count}})",
|
||||
"Outgoing links ({{count}})": "Outgoing links ({{count}})",
|
||||
"No pages link here yet.": "No pages link here yet.",
|
||||
"This page doesn't link to other pages yet.": "This page doesn't link to other pages yet.",
|
||||
"Verified until {{date}}": "Verified until {{date}}",
|
||||
"Labels": "Labels",
|
||||
"Add label": "Add label",
|
||||
"No labels yet": "No labels yet",
|
||||
"Already added": "Already added",
|
||||
"Invalid label name": "Invalid label name",
|
||||
"No matches": "No matches",
|
||||
"Search or create…": "Search or create…",
|
||||
"Remove label {{name}}": "Remove label {{name}}",
|
||||
"Failed to add label": "Failed to add label",
|
||||
"Failed to remove label": "Failed to remove label",
|
||||
"No pages with this label": "No pages with this label",
|
||||
"Pages tagged with this label will appear here.": "Pages tagged with this label will appear here.",
|
||||
"No pages match your search.": "No pages match your search.",
|
||||
"Updated {{date}}": "Updated {{date}}",
|
||||
"{{count}} page_one": "{{count}} page",
|
||||
"{{count}} page_other": "{{count}} pages"
|
||||
"Failed to load this synced block": "Failed to load this synced block"
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -27,3 +27,23 @@
|
||||
background: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-5))
|
||||
}
|
||||
}
|
||||
|
||||
.skipLink {
|
||||
position: fixed;
|
||||
left: 8px;
|
||||
top: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--mantine-color-blue-6);
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
z-index: 1000;
|
||||
transform: translateY(-150%);
|
||||
|
||||
&:focus {
|
||||
transform: translateY(0);
|
||||
outline: 2px solid var(--mantine-color-blue-3);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { TableOfContents } from "@/features/editor/components/table-of-contents/
|
||||
import { useAtomValue } from "jotai";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
|
||||
import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
|
||||
|
||||
export default function Aside() {
|
||||
const [{ tab }] = useAtom(asideStateAtom);
|
||||
@@ -31,10 +30,6 @@ export default function Aside() {
|
||||
component = <AsideChatPanel />;
|
||||
title = "AI Chat";
|
||||
break;
|
||||
case "details":
|
||||
component = <PageDetailsAside />;
|
||||
title = "Details";
|
||||
break;
|
||||
default:
|
||||
component = null;
|
||||
title = null;
|
||||
|
||||
@@ -81,7 +81,11 @@ export default function GlobalAppShell({
|
||||
const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute && !isAiRoute;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
<>
|
||||
<a href="#main-content" className={classes.skipLink}>
|
||||
{t("Skip to main content")}
|
||||
</a>
|
||||
<AppShell
|
||||
header={{ height: 45 }}
|
||||
navbar={{
|
||||
width: isSpaceRoute ? sidebarWidth : 300,
|
||||
@@ -147,14 +151,13 @@ export default function GlobalAppShell({
|
||||
? t("Table of contents")
|
||||
: asideTab === "chat"
|
||||
? t("AI Chat")
|
||||
: asideTab === "details"
|
||||
? t("Details")
|
||||
: undefined
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Aside />
|
||||
</AppShell.Aside>
|
||||
)}
|
||||
</AppShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ export const desktopSidebarAtom = atomWithWebStorage<boolean>(
|
||||
|
||||
export const desktopAsideAtom = atom<boolean>(false);
|
||||
|
||||
// Valid `tab` values: "" | "comments" | "toc" | "chat" | "details"
|
||||
type AsideStateType = {
|
||||
tab: string;
|
||||
isAsideOpen: boolean;
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function AppVersion() {
|
||||
>
|
||||
<Indicator
|
||||
label={t("New update")}
|
||||
color="gray"
|
||||
color="dark"
|
||||
inline
|
||||
size={16}
|
||||
position="middle-end"
|
||||
|
||||
@@ -42,11 +42,6 @@ function pickInitialsColor(name: string) {
|
||||
return SAFE_INITIALS_COLORS[hashName(name) % SAFE_INITIALS_COLORS.length];
|
||||
}
|
||||
|
||||
function sanitizeInitialsSource(name: string) {
|
||||
const sanitized = name.replace(/[^\p{L}\p{N}\s]/gu, " ").trim();
|
||||
return sanitized || name;
|
||||
}
|
||||
|
||||
export const CustomAvatar = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
CustomAvatarProps
|
||||
@@ -54,13 +49,12 @@ export const CustomAvatar = React.forwardRef<
|
||||
const avatarLink = getAvatarUrl(avatarUrl, type);
|
||||
const resolvedColor =
|
||||
!color || color === "initials" ? pickInitialsColor(name ?? "") : color;
|
||||
const initialsSource = sanitizeInitialsSource(name ?? "");
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
ref={ref}
|
||||
src={avatarLink}
|
||||
name={initialsSource}
|
||||
name={name}
|
||||
alt={name}
|
||||
color={resolvedColor}
|
||||
{...props}
|
||||
|
||||
@@ -118,20 +118,10 @@ export function PageVerificationBadge({
|
||||
|
||||
if (status === "none" && readOnly) return null;
|
||||
|
||||
const tooltipLabel =
|
||||
status === "verified" && verificationInfo?.expiresAt
|
||||
? t("Verified until {{date}}", {
|
||||
date: new Date(verificationInfo.expiresAt).toLocaleDateString(
|
||||
undefined,
|
||||
{ month: "long", day: "numeric", year: "numeric" },
|
||||
),
|
||||
})
|
||||
: getStatusLabel(status, t);
|
||||
|
||||
return (
|
||||
<>
|
||||
{status !== "none" ? (
|
||||
<Tooltip label={tooltipLabel} withArrow openDelay={250}>
|
||||
<Tooltip label={getStatusLabel(status, t)} withArrow openDelay={250}>
|
||||
<Group
|
||||
gap={4}
|
||||
onClick={open}
|
||||
|
||||
@@ -28,18 +28,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.colorSwatch:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--mantine-color-blue-6);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.removeColor:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--mantine-color-blue-6);
|
||||
}
|
||||
|
||||
.buttonRoot {
|
||||
height: 34px;
|
||||
padding-left: rem(8);
|
||||
|
||||
@@ -27,7 +27,7 @@ import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
|
||||
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { showAiMenuAtom, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
import { userAtom, workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||
|
||||
export interface BubbleMenuItem {
|
||||
name: string;
|
||||
@@ -46,9 +46,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
|
||||
const user = useAtomValue(userAtom);
|
||||
const editorToolbarEnabled =
|
||||
user?.settings?.preferences?.editorToolbar ?? false;
|
||||
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
||||
const showCommentPopupRef = useRef(showCommentPopup);
|
||||
const showAiMenuRef = useRef(showAiMenu);
|
||||
@@ -152,7 +149,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
return isTextSelected(editor);
|
||||
},
|
||||
options: {
|
||||
placement: editorToolbarEnabled ? "bottom" : "top",
|
||||
placement: "top",
|
||||
offset: 8,
|
||||
onHide: () => {
|
||||
setIsNodeSelectorOpen(false);
|
||||
@@ -191,60 +188,56 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
<div className={classes.divider} />
|
||||
</>
|
||||
)}
|
||||
{!editorToolbarEnabled && (
|
||||
<>
|
||||
<NodeSelector
|
||||
editor={props.editor}
|
||||
isOpen={isNodeSelectorOpen}
|
||||
setIsOpen={() => {
|
||||
setIsNodeSelectorOpen(!isNodeSelectorOpen);
|
||||
setIsTextAlignmentOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
<NodeSelector
|
||||
editor={props.editor}
|
||||
isOpen={isNodeSelectorOpen}
|
||||
setIsOpen={() => {
|
||||
setIsNodeSelectorOpen(!isNodeSelectorOpen);
|
||||
setIsTextAlignmentOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextAlignmentSelector
|
||||
editor={props.editor}
|
||||
isOpen={isTextAlignmentSelectorOpen}
|
||||
setIsOpen={() => {
|
||||
setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen);
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
<TextAlignmentSelector
|
||||
editor={props.editor}
|
||||
isOpen={isTextAlignmentSelectorOpen}
|
||||
setIsOpen={() => {
|
||||
setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen);
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ActionIcon.Group>
|
||||
{items.map((item, index) => (
|
||||
<Tooltip key={index} label={t(item.name)} withArrow>
|
||||
<ActionIcon
|
||||
key={index}
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="0"
|
||||
aria-label={t(item.name)}
|
||||
className={clsx({ [classes.active]: item.isActive() })}
|
||||
style={{ border: "none" }}
|
||||
onClick={item.command}
|
||||
>
|
||||
<item.icon style={{ width: rem(16) }} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
))}
|
||||
</ActionIcon.Group>
|
||||
<ActionIcon.Group>
|
||||
{items.map((item, index) => (
|
||||
<Tooltip key={index} label={t(item.name)} withArrow>
|
||||
<ActionIcon
|
||||
key={index}
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="0"
|
||||
aria-label={t(item.name)}
|
||||
className={clsx({ [classes.active]: item.isActive() })}
|
||||
style={{ border: "none" }}
|
||||
onClick={item.command}
|
||||
>
|
||||
<item.icon style={{ width: rem(16) }} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
))}
|
||||
</ActionIcon.Group>
|
||||
|
||||
<LinkSelector />
|
||||
<LinkSelector />
|
||||
|
||||
<ColorSelector
|
||||
editor={props.editor}
|
||||
isOpen={isColorSelectorOpen}
|
||||
setIsOpen={() => {
|
||||
setIsColorSelectorOpen(!isColorSelectorOpen);
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsTextAlignmentOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<ColorSelector
|
||||
editor={props.editor}
|
||||
isOpen={isColorSelectorOpen}
|
||||
setIsOpen={() => {
|
||||
setIsColorSelectorOpen(!isColorSelectorOpen);
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsTextAlignmentOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tooltip label={t(commentItem.name)} withArrow withinPortal={false}>
|
||||
<ActionIcon
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Button,
|
||||
Popover,
|
||||
rem,
|
||||
ScrollArea,
|
||||
Text,
|
||||
Tooltip,
|
||||
SimpleGrid,
|
||||
@@ -113,63 +114,6 @@ const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const COLOR_GRID_COLS = 5;
|
||||
|
||||
function focusSwatch(grid: "text" | "highlight", index: number) {
|
||||
const el = document.querySelector<HTMLElement>(
|
||||
`[data-color-grid="${grid}"][data-color-index="${index}"]`,
|
||||
);
|
||||
el?.focus();
|
||||
}
|
||||
|
||||
function handleColorKeyNav(
|
||||
e: React.KeyboardEvent<HTMLDivElement>,
|
||||
index: number,
|
||||
grid: "text" | "highlight",
|
||||
) {
|
||||
const cols = COLOR_GRID_COLS;
|
||||
const total =
|
||||
grid === "text" ? TEXT_COLORS.length : HIGHLIGHT_COLORS.length;
|
||||
const col = index % cols;
|
||||
|
||||
if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
if (index < total - 1) focusSwatch(grid, index + 1);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
if (index > 0) focusSwatch(grid, index - 1);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
const next = index + cols;
|
||||
if (next < total) {
|
||||
focusSwatch(grid, next);
|
||||
} else if (grid === "text") {
|
||||
focusSwatch("highlight", Math.min(col, HIGHLIGHT_COLORS.length - 1));
|
||||
} else if (grid === "highlight") {
|
||||
document
|
||||
.querySelector<HTMLElement>('[data-color-grid="remove"]')
|
||||
?.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const prev = index - cols;
|
||||
if (prev >= 0) {
|
||||
focusSwatch(grid, prev);
|
||||
} else if (grid === "highlight") {
|
||||
const lastRowStart =
|
||||
Math.floor((TEXT_COLORS.length - 1) / cols) * cols;
|
||||
focusSwatch("text", Math.min(lastRowStart + col, TEXT_COLORS.length - 1));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
editor,
|
||||
isOpen,
|
||||
@@ -213,20 +157,13 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
width={220}
|
||||
opened={isOpen}
|
||||
onChange={setIsOpen}
|
||||
trapFocus
|
||||
withArrow
|
||||
>
|
||||
<Popover width={220} opened={isOpen} withArrow>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("Text color")} withArrow>
|
||||
<Button
|
||||
variant="default"
|
||||
radius="0"
|
||||
rightSection={<IconChevronDown size={16} />}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
data-text-color={activeColorItem?.color || ""}
|
||||
data-highlight-color={activeHighlightItem?.color || ""}
|
||||
@@ -244,8 +181,9 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown onMouseDown={(e) => e.preventDefault()}>
|
||||
<Stack gap="md" p="2px">
|
||||
<Popover.Dropdown>
|
||||
<ScrollArea.Autosize type="scroll" mah="400">
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
{t("Text color")}
|
||||
@@ -269,10 +207,6 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
<Box
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-autofocus={index === 0 ? true : undefined}
|
||||
data-color-grid="text"
|
||||
data-color-index={index}
|
||||
className={classes.colorSwatch}
|
||||
aria-label={t(name)}
|
||||
aria-pressed={!!editorState[`text_${color}`]}
|
||||
onClick={applyTextColor}
|
||||
@@ -280,9 +214,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
applyTextColor();
|
||||
return;
|
||||
}
|
||||
handleColorKeyNav(e, index, "text");
|
||||
}}
|
||||
style={{
|
||||
width: rem(28),
|
||||
@@ -335,9 +267,6 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
<Box
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-color-grid="highlight"
|
||||
data-color-index={index}
|
||||
className={classes.colorSwatch}
|
||||
aria-label={t(name)}
|
||||
aria-pressed={!!editorState[`highlight_${color}`]}
|
||||
onClick={applyHighlight}
|
||||
@@ -345,9 +274,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
applyHighlight();
|
||||
return;
|
||||
}
|
||||
handleColorKeyNav(e, index, "highlight");
|
||||
}}
|
||||
style={{
|
||||
width: rem(28),
|
||||
@@ -383,27 +310,16 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
<Button
|
||||
variant="default"
|
||||
fullWidth
|
||||
data-color-grid="remove"
|
||||
className={classes.removeColor}
|
||||
onClick={() => {
|
||||
editor.commands.unsetColor();
|
||||
editor.commands.unsetHighlight();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const lastRowStart =
|
||||
Math.floor(
|
||||
(HIGHLIGHT_COLORS.length - 1) / COLOR_GRID_COLS,
|
||||
) * COLOR_GRID_COLS;
|
||||
focusSwatch("highlight", lastRowStart);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("Remove color")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -155,7 +155,7 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover opened={isOpen} onChange={setIsOpen} withArrow>
|
||||
<Popover opened={isOpen} withArrow>
|
||||
<Popover.Target>
|
||||
<Tooltip
|
||||
label={t("Turn into")}
|
||||
|
||||
+31
-30
@@ -7,7 +7,7 @@ import {
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
} from "@tabler/icons-react";
|
||||
import { Menu, Button, Tooltip, rem } from "@mantine/core";
|
||||
import { Popover, Button, ScrollArea, Tooltip, rem } from "@mantine/core";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -82,22 +82,15 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
|
||||
const activeItem = items.filter((item) => item.isActive()).pop() ?? items[0];
|
||||
|
||||
return (
|
||||
<Menu
|
||||
shadow="md"
|
||||
position="bottom-start"
|
||||
withArrow={false}
|
||||
opened={isOpen}
|
||||
onChange={setIsOpen}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Tooltip label={t("Text align")} withArrow disabled={isOpen}>
|
||||
<Popover opened={isOpen} withArrow>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("Text align")} withArrow withinPortal={false} disabled={isOpen}>
|
||||
<Button
|
||||
variant="default"
|
||||
style={{ border: "none", height: "34px" }}
|
||||
px="5"
|
||||
radius="0"
|
||||
rightSection={<IconChevronDown size={16} />}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-label={t("Text align")}
|
||||
aria-haspopup="menu"
|
||||
@@ -106,25 +99,33 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
|
||||
<activeItem.icon style={{ width: rem(16) }} stroke={2} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
</Popover.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
{items.map((item, index) => (
|
||||
<Menu.Item
|
||||
key={index}
|
||||
leftSection={<item.icon size={16} />}
|
||||
rightSection={
|
||||
activeItem.name === item.name ? <IconCheck size={16} /> : null
|
||||
}
|
||||
onClick={() => {
|
||||
item.command();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
{t(item.name)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<Popover.Dropdown>
|
||||
<ScrollArea.Autosize type="scroll" mah={400}>
|
||||
<Button.Group orientation="vertical">
|
||||
{items.map((item, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="default"
|
||||
leftSection={<item.icon size={16} />}
|
||||
rightSection={
|
||||
activeItem.name === item.name && <IconCheck size={16} />
|
||||
}
|
||||
justify="left"
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
item.command();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
{t(item.name)}
|
||||
</Button>
|
||||
))}
|
||||
</Button.Group>
|
||||
</ScrollArea.Autosize>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
import { CommandProps, EmojiMenuItemType } from "./types";
|
||||
import { buildEmojiIndex, getFrequentlyUsedEmoji, sortFrequentlyUsedEmoji } from "./utils";
|
||||
import { SearchIndex } from "emoji-mart";
|
||||
import { getFrequentlyUsedEmoji, sortFrequentlyUsedEmoji } from "./utils";
|
||||
|
||||
const MAX_RESULTS = 5;
|
||||
|
||||
const searchEmoji = async (query: string): Promise<EmojiMenuItemType[]> => {
|
||||
if (query === "") {
|
||||
return sortFrequentlyUsedEmoji(getFrequentlyUsedEmoji());
|
||||
const searchEmoji = async (value: string): Promise<EmojiMenuItemType[]> => {
|
||||
if (value === "") {
|
||||
const frequentlyUsedEmoji = getFrequentlyUsedEmoji();
|
||||
return sortFrequentlyUsedEmoji(frequentlyUsedEmoji);
|
||||
}
|
||||
|
||||
const q = query.toLowerCase();
|
||||
const index = await buildEmojiIndex();
|
||||
|
||||
return index
|
||||
.filter((e) => e.name.includes(q) || e.id.includes(q))
|
||||
.slice(0, MAX_RESULTS)
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
emoji: entry.native,
|
||||
const emojis = await SearchIndex.search(value);
|
||||
const results = emojis.map((emoji: any) => {
|
||||
return {
|
||||
id: emoji.id,
|
||||
emoji: emoji.skins[0].native,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).insertContent(entry.native + " ").run();
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertContent(emoji.skins[0].native + " ")
|
||||
.run();
|
||||
},
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export const getEmojiItems = async ({
|
||||
|
||||
@@ -1,208 +1,154 @@
|
||||
import { Loader, Paper, ScrollArea, Text, UnstyledButton } from "@mantine/core";
|
||||
import clsx from "clsx";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { EmojiMenuItemType } from "./types";
|
||||
import {
|
||||
EmojiCategory,
|
||||
EmojiIndexEntry,
|
||||
getEmojiCategories,
|
||||
incrementEmojiUsage,
|
||||
} from "./utils";
|
||||
ActionIcon,
|
||||
Loader,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { EmojiMenuItemType } from "./types";
|
||||
import clsx from "clsx";
|
||||
import classes from "./emoji-menu.module.css";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { GRID_COLUMNS, incrementEmojiUsage } from "./utils";
|
||||
|
||||
const COLS = 8;
|
||||
|
||||
const CAT_ICONS: Record<string, string> = {
|
||||
people: "😀",
|
||||
nature: "🌿",
|
||||
foods: "🍕",
|
||||
activity: "🎮",
|
||||
places: "🗺️",
|
||||
objects: "🔧",
|
||||
symbols: "💯",
|
||||
flags: "🚩",
|
||||
};
|
||||
|
||||
function EmojiList({
|
||||
const EmojiList = ({
|
||||
items,
|
||||
isLoading,
|
||||
command,
|
||||
editor,
|
||||
range,
|
||||
query = "",
|
||||
}: {
|
||||
items: EmojiMenuItemType[];
|
||||
isLoading: boolean;
|
||||
command: (item: EmojiMenuItemType) => void;
|
||||
command: any;
|
||||
editor: any;
|
||||
range: any;
|
||||
query?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [idx, setIdx] = useState(0);
|
||||
const [cats, setCats] = useState<EmojiCategory[]>([]);
|
||||
const [activeCat, setActiveCat] = useState("");
|
||||
const [focusZone, setFocusZone] = useState<"grid" | "tabs">("grid");
|
||||
const listViewport = useRef<HTMLDivElement>(null);
|
||||
const gridViewport = useRef<HTMLDivElement>(null);
|
||||
const catBar = useRef<HTMLDivElement>(null);
|
||||
}) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const searching = query.length > 0;
|
||||
const browseLoading = !searching && cats.length === 0;
|
||||
const gridItems = cats.find((c) => c.id === activeCat)?.emojis ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
getEmojiCategories().then((data) => {
|
||||
setCats(data);
|
||||
setActiveCat((prev) => prev || data[0]?.id || "");
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => { setIdx(0); }, [query, activeCat]);
|
||||
|
||||
useEffect(() => { if (searching) setFocusZone("grid"); }, [searching]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusZone !== "tabs") return;
|
||||
catBar.current?.querySelector<HTMLElement>(`[data-cat="${activeCat}"]`)?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
}, [activeCat, focusZone]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusZone === "tabs") return;
|
||||
const vp = searching ? listViewport.current : gridViewport.current;
|
||||
vp?.querySelector<HTMLElement>(`[data-i="${idx}"]`)?.scrollIntoView({ block: "nearest" });
|
||||
}, [idx, searching, focusZone]);
|
||||
|
||||
const pickSearchItem = useCallback(
|
||||
(i: number) => {
|
||||
const item = items[i];
|
||||
if (!item) return;
|
||||
command(item);
|
||||
incrementEmojiUsage(item.id);
|
||||
},
|
||||
[command, items],
|
||||
);
|
||||
|
||||
const pickGridItem = useCallback(
|
||||
(entry: EmojiIndexEntry) => {
|
||||
editor.chain().focus().deleteRange(range).insertContent(entry.native + " ").run();
|
||||
incrementEmojiUsage(entry.id);
|
||||
},
|
||||
[editor, range],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (searching) {
|
||||
if (e.key === "ArrowDown") { e.preventDefault(); setIdx((i) => Math.min(i + 1, items.length - 1)); }
|
||||
else if (e.key === "ArrowUp") { e.preventDefault(); setIdx((i) => Math.max(i - 1, 0)); }
|
||||
else if (e.key === "Enter") { e.preventDefault(); pickSearchItem(idx); }
|
||||
} else if (focusZone === "tabs") {
|
||||
const catIdx = cats.findIndex((c) => c.id === activeCat);
|
||||
if (e.key === "ArrowRight") { e.preventDefault(); const next = cats[Math.min(catIdx + 1, cats.length - 1)]; if (next) setActiveCat(next.id); }
|
||||
else if (e.key === "ArrowLeft") { e.preventDefault(); const prev = cats[Math.max(catIdx - 1, 0)]; if (prev) setActiveCat(prev.id); }
|
||||
else if (e.key === "ArrowDown" || e.key === "Enter") { e.preventDefault(); setFocusZone("grid"); }
|
||||
else if (e.key === "ArrowUp") { e.preventDefault(); }
|
||||
} else {
|
||||
const total = gridItems.length;
|
||||
if (e.key === "ArrowRight") { e.preventDefault(); setIdx((i) => Math.min(i + 1, total - 1)); }
|
||||
else if (e.key === "ArrowLeft") { e.preventDefault(); setIdx((i) => Math.max(i - 1, 0)); }
|
||||
else if (e.key === "ArrowDown") { e.preventDefault(); setIdx((i) => Math.min(i + COLS, total - 1)); }
|
||||
else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (idx < COLS) setFocusZone("tabs");
|
||||
else setIdx((i) => Math.max(i - COLS, 0));
|
||||
}
|
||||
else if (e.key === "Enter") { e.preventDefault(); if (gridItems[idx]) pickGridItem(gridItems[idx]); }
|
||||
const selectItem = useCallback(
|
||||
(index: number) => {
|
||||
const item = items[index];
|
||||
if (item) {
|
||||
command(item);
|
||||
incrementEmojiUsage(item.id);
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [searching, items, idx, gridItems, pickSearchItem, pickGridItem, focusZone, cats, activeCat]);
|
||||
},
|
||||
[command, items]
|
||||
);
|
||||
|
||||
return (
|
||||
useEffect(() => {
|
||||
const navigationKeys = [
|
||||
"ArrowRight",
|
||||
"ArrowLeft",
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"Enter",
|
||||
];
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (navigationKeys.includes(e.key)) {
|
||||
e.preventDefault();
|
||||
|
||||
if (e.key === "ArrowRight") {
|
||||
setSelectedIndex(
|
||||
selectedIndex + 1 < items.length ? selectedIndex + 1 : selectedIndex
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === "ArrowLeft") {
|
||||
setSelectedIndex(
|
||||
selectedIndex - 1 >= 0 ? selectedIndex - 1 : selectedIndex
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === "ArrowUp") {
|
||||
setSelectedIndex(
|
||||
selectedIndex - GRID_COLUMNS >= 0
|
||||
? selectedIndex - GRID_COLUMNS
|
||||
: selectedIndex
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
setSelectedIndex(
|
||||
selectedIndex + GRID_COLUMNS < items.length
|
||||
? selectedIndex + GRID_COLUMNS
|
||||
: selectedIndex
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === "Enter") {
|
||||
selectItem(selectedIndex);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [items, selectedIndex, setSelectedIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [items]);
|
||||
|
||||
useEffect(() => {
|
||||
viewportRef.current
|
||||
?.querySelector(`[data-item-index="${selectedIndex}"]`)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
}, [selectedIndex]);
|
||||
|
||||
return items.length > 0 || isLoading ? (
|
||||
<Paper
|
||||
id="emoji-command"
|
||||
p={0}
|
||||
p="0"
|
||||
shadow="md"
|
||||
withBorder
|
||||
style={{ width: 280 }}
|
||||
role="listbox"
|
||||
aria-label={t("Emoji picker")}
|
||||
aria-label="Emoji results"
|
||||
aria-activedescendant={
|
||||
items.length > 0 ? `emoji-command-option-${selectedIndex}` : undefined
|
||||
}
|
||||
>
|
||||
{searching ? (
|
||||
<>
|
||||
{isLoading && <Loader m="xs" size="xs" color="blue" type="dots" />}
|
||||
<ScrollArea.Autosize mah={260} scrollbarSize={6} viewportRef={listViewport}>
|
||||
<div style={{ padding: 4 }}>
|
||||
{items.length === 0 && !isLoading ? (
|
||||
<Text size="sm" c="dimmed" p="xs">{t("No results")}</Text>
|
||||
) : items.map((item, i) => (
|
||||
<UnstyledButton
|
||||
key={item.id}
|
||||
data-i={i}
|
||||
w="100%"
|
||||
className={clsx(classes.row, { [classes.active]: i === idx })}
|
||||
onClick={() => pickSearchItem(i)}
|
||||
onMouseEnter={() => setIdx(i)}
|
||||
role="option"
|
||||
aria-selected={i === idx}
|
||||
>
|
||||
<span style={{ fontSize: 20, lineHeight: 1, minWidth: 26 }}>{item.emoji}</span>
|
||||
<Text size="sm" c="dimmed" ff="monospace" span>:{item.id}:</Text>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea.Autosize>
|
||||
</>
|
||||
) : browseLoading ? (
|
||||
<Loader m="xs" size="xs" color="blue" type="dots" />
|
||||
) : (
|
||||
<>
|
||||
<div className={classes.catBar} role="tablist" ref={catBar}>
|
||||
{cats.map((c) => {
|
||||
const isActive = c.id === activeCat;
|
||||
const isFocused = isActive && focusZone === "tabs";
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
data-cat={c.id}
|
||||
title={c.id}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
className={clsx(classes.catTab, {
|
||||
[classes.catTabActive]: isActive,
|
||||
[classes.catTabFocused]: isFocused,
|
||||
})}
|
||||
onClick={() => { setActiveCat(c.id); setFocusZone("grid"); }}
|
||||
onMouseEnter={() => setFocusZone("grid")}
|
||||
>
|
||||
{CAT_ICONS[c.id] ?? "🔣"}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ScrollArea.Autosize mah={220} scrollbarSize={6} viewportRef={gridViewport}>
|
||||
<div className={classes.grid} style={{ gridTemplateColumns: `repeat(${COLS}, 1fr)` }}>
|
||||
{gridItems.map((entry, i) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
data-i={i}
|
||||
title={`:${entry.id}:`}
|
||||
className={clsx(classes.emojiBtn, { [classes.active]: i === idx })}
|
||||
onClick={() => pickGridItem(entry)}
|
||||
onMouseEnter={() => setIdx(i)}
|
||||
>
|
||||
{entry.native}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea.Autosize>
|
||||
</>
|
||||
{isLoading && <Loader m="xs" color="blue" type="dots" />}
|
||||
{items.length > 0 && (
|
||||
<ScrollArea.Autosize
|
||||
viewportRef={viewportRef}
|
||||
mah={250}
|
||||
scrollbarSize={8}
|
||||
pr="5"
|
||||
>
|
||||
<SimpleGrid cols={GRID_COLUMNS} p="xs" spacing="xs">
|
||||
{items.map((item, index: number) => (
|
||||
<ActionIcon
|
||||
data-item-index={index}
|
||||
id={`emoji-command-option-${index}`}
|
||||
role="option"
|
||||
aria-selected={index === selectedIndex}
|
||||
aria-label={item.id}
|
||||
variant="transparent"
|
||||
key={item.id}
|
||||
className={clsx(classes.menuBtn, {
|
||||
[classes.selectedItem]: index === selectedIndex,
|
||||
})}
|
||||
onClick={() => selectItem(index)}
|
||||
>
|
||||
<Text size="xl">{item.emoji}</Text>
|
||||
</ActionIcon>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</ScrollArea.Autosize>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default EmojiList;
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
.menuBtn {
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
|
||||
&:hover {
|
||||
@mixin light {
|
||||
background: var(--mantine-color-gray-1);
|
||||
background: var(--mantine-color-gray-2);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
@@ -16,7 +12,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
.selectedItem {
|
||||
@mixin light {
|
||||
background: var(--mantine-color-gray-2);
|
||||
}
|
||||
@@ -25,83 +21,3 @@
|
||||
background: var(--mantine-color-gray-light);
|
||||
}
|
||||
}
|
||||
|
||||
.catBar {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 4px 6px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
@mixin light {
|
||||
border-bottom: 1px solid var(--mantine-color-gray-2);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
border-bottom: 1px solid var(--mantine-color-dark-4);
|
||||
}
|
||||
}
|
||||
|
||||
.catTab {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
padding: 4px 5px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
@mixin light {
|
||||
background: var(--mantine-color-gray-1);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background: var(--mantine-color-gray-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.catTabActive {
|
||||
@mixin light {
|
||||
background: var(--mantine-color-gray-2);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background: var(--mantine-color-gray-light);
|
||||
}
|
||||
}
|
||||
|
||||
.catTabFocused {
|
||||
outline: 1px solid var(--mantine-color-blue-filled);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.emojiBtn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
padding: 3px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
aspect-ratio: 1 / 1;
|
||||
|
||||
&:hover {
|
||||
@mixin light {
|
||||
background: var(--mantine-color-gray-1);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background: var(--mantine-color-gray-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ReactRenderer, useEditor } from "@tiptap/react";
|
||||
import EmojiList from "./emoji-list";
|
||||
import { init } from "emoji-mart";
|
||||
import {
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
@@ -36,6 +37,10 @@ const renderEmojiItems = () => {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
clientRect: () => DOMRect;
|
||||
}) => {
|
||||
init({
|
||||
data: async () => (await import("@emoji-mart/data")).default,
|
||||
});
|
||||
|
||||
component = new ReactRenderer(EmojiList, {
|
||||
props: { isLoading: true, items: [] },
|
||||
editor: props.editor,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { CommandProps, EmojiMartFrequentlyType, EmojiMenuItemType } from "./types";
|
||||
import { CommandProps } from "./types";
|
||||
import { getEmojiDataFromNative } from "emoji-mart";
|
||||
import { EmojiMartFrequentlyType, EmojiMenuItemType } from "./types";
|
||||
|
||||
export const GRID_COLUMNS = 10;
|
||||
|
||||
export const LOCAL_STORAGE_FREQUENT_KEY = "emoji-mart.frequently";
|
||||
|
||||
@@ -15,76 +19,41 @@ export const DEFAULT_FREQUENTLY_USED_EMOJI_MART = `{
|
||||
"rocket": 1
|
||||
}`;
|
||||
|
||||
export type EmojiIndexEntry = { id: string; name: string; native: string };
|
||||
|
||||
let _emojiIndex: EmojiIndexEntry[] | null = null;
|
||||
|
||||
export const buildEmojiIndex = async (): Promise<EmojiIndexEntry[]> => {
|
||||
if (_emojiIndex) return _emojiIndex;
|
||||
const { default: data } = await import("@emoji-mart/data");
|
||||
_emojiIndex = (Object.values((data as any).emojis) as any[])
|
||||
.filter((e) => e.id && e.name && e.skins?.[0]?.native)
|
||||
.map((e) => ({
|
||||
id: e.id as string,
|
||||
name: (e.name as string).toLowerCase(),
|
||||
native: e.skins[0].native as string,
|
||||
}));
|
||||
return _emojiIndex;
|
||||
};
|
||||
|
||||
export const incrementEmojiUsage = (emojiId: string) => {
|
||||
const stored = JSON.parse(
|
||||
localStorage.getItem(LOCAL_STORAGE_FREQUENT_KEY) || DEFAULT_FREQUENTLY_USED_EMOJI_MART,
|
||||
const frequentlyUsedEmoji =
|
||||
JSON.parse(localStorage.getItem(LOCAL_STORAGE_FREQUENT_KEY) || DEFAULT_FREQUENTLY_USED_EMOJI_MART);
|
||||
frequentlyUsedEmoji[emojiId]
|
||||
? (frequentlyUsedEmoji[emojiId] += 1)
|
||||
: (frequentlyUsedEmoji[emojiId] = 1);
|
||||
localStorage.setItem(
|
||||
LOCAL_STORAGE_FREQUENT_KEY,
|
||||
JSON.stringify(frequentlyUsedEmoji)
|
||||
);
|
||||
stored[emojiId] = (stored[emojiId] ?? 0) + 1;
|
||||
localStorage.setItem(LOCAL_STORAGE_FREQUENT_KEY, JSON.stringify(stored));
|
||||
};
|
||||
|
||||
export const sortFrequentlyUsedEmoji = async (
|
||||
frequentlyUsedEmoji: EmojiMartFrequentlyType,
|
||||
frequentlyUsedEmoji: EmojiMartFrequentlyType
|
||||
): Promise<EmojiMenuItemType[]> => {
|
||||
const index = await buildEmojiIndex();
|
||||
const results: EmojiMenuItemType[] = Object.entries(frequentlyUsedEmoji)
|
||||
.map(([id, count]): EmojiMenuItemType | null => {
|
||||
const entry = index.find((e) => e.id === id);
|
||||
if (!entry) return null;
|
||||
return {
|
||||
const data = await Promise.all(
|
||||
Object.entries(frequentlyUsedEmoji).map(
|
||||
async ([id, count]): Promise<EmojiMenuItemType> => ({
|
||||
id,
|
||||
count,
|
||||
emoji: entry.native,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).insertContent(entry.native + " ").run();
|
||||
emoji: (await getEmojiDataFromNative(id))?.native,
|
||||
command: async ({ editor, range }: CommandProps) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertContent((await getEmojiDataFromNative(id))?.native + " ")
|
||||
.run();
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter((e): e is EmojiMenuItemType => e !== null);
|
||||
return results.sort((a, b) => (b.count ?? 0) - (a.count ?? 0)).slice(0, 5);
|
||||
};
|
||||
|
||||
export const getFrequentlyUsedEmoji = (): EmojiMartFrequentlyType => {
|
||||
return JSON.parse(
|
||||
localStorage.getItem(LOCAL_STORAGE_FREQUENT_KEY) || DEFAULT_FREQUENTLY_USED_EMOJI_MART,
|
||||
})
|
||||
)
|
||||
);
|
||||
return data.sort((a, b) => b.count - a.count);
|
||||
};
|
||||
|
||||
export type EmojiCategory = { id: string; emojis: EmojiIndexEntry[] };
|
||||
|
||||
let _cats: EmojiCategory[] | null = null;
|
||||
|
||||
export const getEmojiCategories = async (): Promise<EmojiCategory[]> => {
|
||||
if (_cats) return _cats;
|
||||
const [{ default: data }, index] = await Promise.all([
|
||||
import("@emoji-mart/data"),
|
||||
buildEmojiIndex(),
|
||||
]);
|
||||
const byId = new Map(index.map((e) => [e.id, e]));
|
||||
_cats = ((data as any).categories as { id: string; emojis: string[] }[])
|
||||
.map((cat) => ({
|
||||
id: cat.id,
|
||||
emojis: cat.emojis
|
||||
.map((id) => byId.get(id))
|
||||
.filter((e): e is EmojiIndexEntry => !!e),
|
||||
}))
|
||||
.filter((c) => c.emojis.length > 0);
|
||||
return _cats;
|
||||
};
|
||||
export const getFrequentlyUsedEmoji = () => {
|
||||
return JSON.parse(localStorage.getItem(LOCAL_STORAGE_FREQUENT_KEY) || DEFAULT_FREQUENTLY_USED_EMOJI_MART);
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
.fixedToolbar {
|
||||
position: fixed;
|
||||
top: calc(var(--app-shell-header-offset, 0rem) + 45px);
|
||||
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
|
||||
inset-inline-end: var(--app-shell-aside-offset, 0rem);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--mantine-color-body);
|
||||
border-bottom: 1px solid
|
||||
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.fixedToolbar::-webkit-scrollbar {
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.fixedToolbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.fixedToolbar::-webkit-scrollbar-thumb {
|
||||
background: light-dark(
|
||||
var(--mantine-color-gray-4),
|
||||
var(--mantine-color-dark-3)
|
||||
);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.inner > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
height: 45px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
flex-shrink: 0;
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
margin: 0 4px;
|
||||
background: light-dark(
|
||||
var(--mantine-color-gray-3),
|
||||
var(--mantine-color-dark-4)
|
||||
);
|
||||
}
|
||||
|
||||
.active,
|
||||
.active:hover {
|
||||
color: var(--mantine-color-blue-6);
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
}
|
||||
|
||||
@media print {
|
||||
.fixedToolbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { FC } from "react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
import { useToolbarState } from "./use-toolbar-state";
|
||||
import { BlockTypeGroup } from "./groups/block-type-group";
|
||||
import { InlineMarksGroup } from "./groups/inline-marks-group";
|
||||
import { ColorGroup } from "./groups/color-group";
|
||||
import { ListsGroup } from "./groups/lists-group";
|
||||
import { LinkGroup } from "./groups/link-group";
|
||||
import { AlignmentGroup } from "./groups/alignment-group";
|
||||
import { MediaGroup } from "./groups/media-group";
|
||||
import { QuickInsertsGroup } from "./groups/quick-inserts-group";
|
||||
import { MoreInsertsGroup } from "./groups/more-inserts-group";
|
||||
import { HistoryGroup } from "./groups/history-group";
|
||||
import { AskAiGroup } from "./groups/ask-ai-group";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import classes from "./fixed-toolbar.module.css";
|
||||
|
||||
export const FixedToolbar: FC = () => {
|
||||
const editor = useAtomValue(pageEditorAtom);
|
||||
const state = useToolbarState(editor);
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
|
||||
|
||||
if (!editor || !state) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={classes.fixedToolbar}
|
||||
role="toolbar"
|
||||
aria-label="Editor toolbar"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className={classes.inner}>
|
||||
{/* {isGenerativeAiEnabled && (
|
||||
<>
|
||||
<AskAiGroup />
|
||||
<div className={classes.divider} />
|
||||
</>
|
||||
)} */}
|
||||
<BlockTypeGroup editor={editor} />
|
||||
<div className={classes.divider} />
|
||||
<InlineMarksGroup editor={editor} state={state} />
|
||||
<div className={classes.divider} />
|
||||
<ColorGroup editor={editor} />
|
||||
<div className={classes.divider} />
|
||||
<ListsGroup editor={editor} state={state} />
|
||||
<div className={classes.divider} />
|
||||
<LinkGroup />
|
||||
<div className={classes.divider} />
|
||||
<AlignmentGroup editor={editor} />
|
||||
<div className={classes.divider} />
|
||||
<MediaGroup editor={editor} />
|
||||
<div className={classes.divider} />
|
||||
<QuickInsertsGroup editor={editor} />
|
||||
<MoreInsertsGroup editor={editor} />
|
||||
<div className={classes.divider} />
|
||||
<HistoryGroup editor={editor} state={state} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={classes.spacer} aria-hidden />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { TextAlignmentSelector } from "@/features/editor/components/bubble-menu/text-alignment-selector";
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
export const AlignmentGroup: FC<Props> = ({ editor }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setIsOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<TextAlignmentSelector
|
||||
editor={editor}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
import { FC } from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
|
||||
export const AskAiGroup: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const setShowAiMenu = useSetAtom(showAiMenuAtom);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
size="xs"
|
||||
leftSection={<IconSparkles size={14} />}
|
||||
onClick={() => setShowAiMenu(true)}
|
||||
>
|
||||
{t("Ask AI")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,108 +0,0 @@
|
||||
import { FC } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
import { Button, Menu } from "@mantine/core";
|
||||
import {
|
||||
IconBlockquote,
|
||||
IconBraces,
|
||||
IconChevronDown,
|
||||
IconH1,
|
||||
IconH2,
|
||||
IconH3,
|
||||
IconMenu4,
|
||||
IconTypography,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
export const BlockTypeGroup: FC<Props> = ({ editor }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const state = useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => ({
|
||||
isHeading1: ctx.editor.isActive("heading", { level: 1 }),
|
||||
isHeading2: ctx.editor.isActive("heading", { level: 2 }),
|
||||
isHeading3: ctx.editor.isActive("heading", { level: 3 }),
|
||||
isBlockquote: ctx.editor.isActive("blockquote"),
|
||||
isCodeBlock: ctx.editor.isActive("codeBlock"),
|
||||
}),
|
||||
});
|
||||
|
||||
let label = t("Normal text");
|
||||
if (state.isHeading1) label = t("Heading 1");
|
||||
else if (state.isHeading2) label = t("Heading 2");
|
||||
else if (state.isHeading3) label = t("Heading 3");
|
||||
else if (state.isBlockquote) label = t("Quote");
|
||||
else if (state.isCodeBlock) label = t("Code block");
|
||||
|
||||
return (
|
||||
<Menu shadow="md" position="bottom-start" withArrow={false}>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
size="xs"
|
||||
rightSection={<IconChevronDown size={14} />}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconTypography size={16} />}
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleNode("paragraph", "paragraph").run()
|
||||
}
|
||||
>
|
||||
{t("Text")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconH1 size={16} />}
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleHeading({ level: 1 }).run()
|
||||
}
|
||||
>
|
||||
{t("Heading 1")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconH2 size={16} />}
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleHeading({ level: 2 }).run()
|
||||
}
|
||||
>
|
||||
{t("Heading 2")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconH3 size={16} />}
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleHeading({ level: 3 }).run()
|
||||
}
|
||||
>
|
||||
{t("Heading 3")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconBlockquote size={16} />}
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
>
|
||||
{t("Quote")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconBraces size={16} />}
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
>
|
||||
{t("Code block")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconMenu4 size={16} />}
|
||||
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
||||
>
|
||||
{t("Divider")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { ColorSelector } from "@/features/editor/components/bubble-menu/color-selector";
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
export const ColorGroup: FC<Props> = ({ editor }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setIsOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<ColorSelector editor={editor} isOpen={isOpen} setIsOpen={setIsOpen} />
|
||||
);
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
import { FC } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { IconArrowBackUp, IconArrowForwardUp } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ToolbarState } from "../use-toolbar-state";
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
state: ToolbarState;
|
||||
}
|
||||
|
||||
export const HistoryGroup: FC<Props> = ({ editor, state }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ActionIcon.Group>
|
||||
<Tooltip label={t("Undo")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
size="md"
|
||||
aria-label={t("Undo")}
|
||||
disabled={!state.canUndo}
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
>
|
||||
<IconArrowBackUp size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("Redo")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
size="md"
|
||||
aria-label={t("Redo")}
|
||||
disabled={!state.canRedo}
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
>
|
||||
<IconArrowForwardUp size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ActionIcon.Group>
|
||||
);
|
||||
};
|
||||
-131
@@ -1,131 +0,0 @@
|
||||
import { FC } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconBold,
|
||||
IconChevronDown,
|
||||
IconClearFormatting,
|
||||
IconCode,
|
||||
IconIndentDecrease,
|
||||
IconIndentIncrease,
|
||||
IconItalic,
|
||||
IconStrikethrough,
|
||||
IconSubscript,
|
||||
IconSuperscript,
|
||||
IconUnderline,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import type { ToolbarState } from "../use-toolbar-state";
|
||||
import classes from "../fixed-toolbar.module.css";
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
state: ToolbarState;
|
||||
}
|
||||
|
||||
export const InlineMarksGroup: FC<Props> = ({ editor, state }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ActionIcon.Group>
|
||||
<Tooltip label={t("Bold")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
size="md"
|
||||
aria-label={t("Bold")}
|
||||
aria-pressed={state.isBold}
|
||||
className={clsx({ [classes.active]: state.isBold })}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
>
|
||||
<IconBold size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("Underline")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
size="md"
|
||||
aria-label={t("Underline")}
|
||||
aria-pressed={state.isUnderline}
|
||||
className={clsx({ [classes.active]: state.isUnderline })}
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
>
|
||||
<IconUnderline size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("Italic")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
size="md"
|
||||
aria-label={t("Italic")}
|
||||
aria-pressed={state.isItalic}
|
||||
className={clsx({ [classes.active]: state.isItalic })}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
>
|
||||
<IconItalic size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Menu shadow="md" position="bottom-start" withArrow={false}>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
size="md"
|
||||
aria-label={t("More inline formatting")}
|
||||
>
|
||||
<IconChevronDown size={14} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconStrikethrough size={16} />}
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
>
|
||||
{t("Strikethrough")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconCode size={16} />}
|
||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||
>
|
||||
{t("Inline code")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconSubscript size={16} />}
|
||||
onClick={() => editor.chain().focus().toggleSubscript().run()}
|
||||
>
|
||||
{t("Subscript")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconSuperscript size={16} />}
|
||||
onClick={() => editor.chain().focus().toggleSuperscript().run()}
|
||||
>
|
||||
{t("Superscript")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<IconIndentIncrease size={16} />}
|
||||
onClick={() => editor.chain().focus().indent().run()}
|
||||
>
|
||||
{t("Increase indent")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconIndentDecrease size={16} />}
|
||||
onClick={() => editor.chain().focus().outdent().run()}
|
||||
>
|
||||
{t("Decrease indent")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<IconClearFormatting size={16} />}
|
||||
onClick={() => editor.chain().focus().unsetAllMarks().run()}
|
||||
>
|
||||
{t("Clear formatting")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</ActionIcon.Group>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import { FC } from "react";
|
||||
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector";
|
||||
|
||||
export const LinkGroup: FC = () => {
|
||||
return <LinkSelector />;
|
||||
};
|
||||
@@ -1,61 +0,0 @@
|
||||
import { FC } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { IconCheckbox, IconList, IconListNumbers } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import type { ToolbarState } from "../use-toolbar-state";
|
||||
import classes from "../fixed-toolbar.module.css";
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
state: ToolbarState;
|
||||
}
|
||||
|
||||
export const ListsGroup: FC<Props> = ({ editor, state }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ActionIcon.Group>
|
||||
<Tooltip label={t("Bullet List")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
size="md"
|
||||
aria-label={t("Bullet List")}
|
||||
aria-pressed={state.isBulletList}
|
||||
className={clsx({ [classes.active]: state.isBulletList })}
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
>
|
||||
<IconList size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("Numbered List")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
size="md"
|
||||
aria-label={t("Numbered List")}
|
||||
aria-pressed={state.isOrderedList}
|
||||
className={clsx({ [classes.active]: state.isOrderedList })}
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
>
|
||||
<IconListNumbers size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("To-do List")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
size="md"
|
||||
aria-label={t("To-do List")}
|
||||
aria-pressed={state.isTaskList}
|
||||
className={clsx({ [classes.active]: state.isTaskList })}
|
||||
onClick={() => editor.chain().focus().toggleTaskList().run()}
|
||||
>
|
||||
<IconCheckbox size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ActionIcon.Group>
|
||||
);
|
||||
};
|
||||
@@ -1,118 +0,0 @@
|
||||
import { FC } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconFileTypePdf,
|
||||
IconMovie,
|
||||
IconMusic,
|
||||
IconPaperclip,
|
||||
IconPhoto,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action";
|
||||
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action";
|
||||
import { uploadAudioAction } from "@/features/editor/components/audio/upload-audio-action";
|
||||
import { uploadAttachmentAction } from "@/features/editor/components/attachment/upload-attachment-action";
|
||||
import { uploadPdfAction } from "@/features/editor/components/pdf/upload-pdf-action";
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
type UploadFn = (
|
||||
file: File,
|
||||
editor: Editor,
|
||||
pos: number,
|
||||
pageId: string,
|
||||
...rest: any[]
|
||||
) => void;
|
||||
|
||||
function pickFile(
|
||||
editor: Editor,
|
||||
accept: string,
|
||||
multiple: boolean,
|
||||
upload: UploadFn,
|
||||
extra?: boolean,
|
||||
) {
|
||||
// @ts-ignore — editor.storage.pageId is set by PageEditor.onCreate
|
||||
const pageId = editor.storage?.pageId as string | undefined;
|
||||
if (!pageId) return;
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = accept;
|
||||
input.multiple = multiple;
|
||||
input.style.display = "none";
|
||||
document.body.appendChild(input);
|
||||
input.onchange = () => {
|
||||
if (input.files?.length) {
|
||||
for (const file of input.files) {
|
||||
const pos = editor.view.state.selection.from;
|
||||
if (extra !== undefined) {
|
||||
upload(file, editor, pos, pageId, extra);
|
||||
} else {
|
||||
upload(file, editor, pos, pageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
input.remove();
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
export const MediaGroup: FC<Props> = ({ editor }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Menu shadow="md" position="bottom-start" withArrow={false}>
|
||||
<Menu.Target>
|
||||
<Tooltip label={t("Insert media")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
size="md"
|
||||
aria-label={t("Insert media")}
|
||||
>
|
||||
<IconPhoto size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconPhoto size={16} />}
|
||||
onClick={() => pickFile(editor, "image/*", true, uploadImageAction)}
|
||||
>
|
||||
{t("Image")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconMovie size={16} />}
|
||||
onClick={() => pickFile(editor, "video/*", true, uploadVideoAction)}
|
||||
>
|
||||
{t("Video")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconMusic size={16} />}
|
||||
onClick={() => pickFile(editor, "audio/*", true, uploadAudioAction)}
|
||||
>
|
||||
{t("Audio")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconFileTypePdf size={16} />}
|
||||
onClick={() =>
|
||||
pickFile(editor, "application/pdf", false, uploadPdfAction)
|
||||
}
|
||||
>
|
||||
PDF
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconPaperclip size={16} />}
|
||||
onClick={() =>
|
||||
pickFile(editor, "", true, uploadAttachmentAction, true)
|
||||
}
|
||||
>
|
||||
{t("File attachment")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
-217
@@ -1,217 +0,0 @@
|
||||
import { FC } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconAppWindow,
|
||||
IconCalendar,
|
||||
IconCaretRightFilled,
|
||||
IconChevronDown,
|
||||
IconInfoCircle,
|
||||
IconMath,
|
||||
IconMathFunction,
|
||||
IconRotate2,
|
||||
IconSitemap,
|
||||
IconTag,
|
||||
} from "@tabler/icons-react";
|
||||
import IconExcalidraw from "@/components/icons/icon-excalidraw";
|
||||
import IconMermaid from "@/components/icons/icon-mermaid";
|
||||
import IconDrawio from "@/components/icons/icon-drawio";
|
||||
import {
|
||||
AirtableIcon,
|
||||
FigmaIcon,
|
||||
FramerIcon,
|
||||
GoogleDriveIcon,
|
||||
GoogleSheetsIcon,
|
||||
LoomIcon,
|
||||
MiroIcon,
|
||||
TypeformIcon,
|
||||
VimeoIcon,
|
||||
YoutubeIcon,
|
||||
} from "@/components/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
export const MoreInsertsGroup: FC<Props> = ({ editor }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const setEmbed = (provider: string) =>
|
||||
editor.chain().focus().setEmbed({ provider }).run();
|
||||
|
||||
const insertDate = () => {
|
||||
const currentDate = new Date().toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
editor.chain().focus().insertContent(currentDate).run();
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu shadow="md" position="bottom-start" withArrow={false} width={240}>
|
||||
<Menu.Target>
|
||||
<Tooltip label={t("More inserts")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
size="md"
|
||||
aria-label={t("More inserts")}
|
||||
>
|
||||
<IconChevronDown size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown mah={400} style={{ overflowY: "auto" }}>
|
||||
<Menu.Label>{t("Advanced")}</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={<IconInfoCircle size={16} />}
|
||||
onClick={() => editor.chain().focus().toggleCallout().run()}
|
||||
>
|
||||
{t("Callout")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconCaretRightFilled size={16} />}
|
||||
onClick={() => editor.chain().focus().setDetails().run()}
|
||||
>
|
||||
{t("Toggle block")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconTag size={16} />}
|
||||
onClick={() =>
|
||||
editor.chain().focus().setStatus({ text: "", color: "gray" }).run()
|
||||
}
|
||||
>
|
||||
{t("Status")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconSitemap size={16} />}
|
||||
onClick={() => editor.chain().focus().insertSubpages().run()}
|
||||
>
|
||||
{t("Subpages")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconRotate2 size={16} />}
|
||||
onClick={() =>
|
||||
editor.chain().focus().insertTransclusionSource().run()
|
||||
}
|
||||
>
|
||||
{t("Synced block")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Label>{t("Diagrams")}</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={<IconMermaid size={16} />}
|
||||
onClick={() =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setCodeBlock({ language: "mermaid" })
|
||||
.insertContent("flowchart LR\n A --> B")
|
||||
.run()
|
||||
}
|
||||
>
|
||||
{t("Mermaid diagram")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconDrawio size={16} />}
|
||||
onClick={() => editor.chain().focus().setDrawio().run()}
|
||||
>
|
||||
Draw.io
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconExcalidraw size={16} />}
|
||||
onClick={() => editor.chain().focus().setExcalidraw().run()}
|
||||
>
|
||||
Excalidraw
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Label>{t("Embeds")}</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={<IconAppWindow size={16} />}
|
||||
onClick={() => setEmbed("iframe")}
|
||||
>
|
||||
Iframe
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<YoutubeIcon size={16} />}
|
||||
onClick={() => setEmbed("youtube")}
|
||||
>
|
||||
YouTube
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<VimeoIcon size={16} />}
|
||||
onClick={() => setEmbed("vimeo")}
|
||||
>
|
||||
Vimeo
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<LoomIcon size={16} />} onClick={() => setEmbed("loom")}>
|
||||
Loom
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<FigmaIcon size={16} />}
|
||||
onClick={() => setEmbed("figma")}
|
||||
>
|
||||
Figma
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<AirtableIcon size={16} />}
|
||||
onClick={() => setEmbed("airtable")}
|
||||
>
|
||||
Airtable
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<TypeformIcon size={16} />}
|
||||
onClick={() => setEmbed("typeform")}
|
||||
>
|
||||
Typeform
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<MiroIcon size={16} />} onClick={() => setEmbed("miro")}>
|
||||
Miro
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<FramerIcon size={16} />}
|
||||
onClick={() => setEmbed("framer")}
|
||||
>
|
||||
Framer
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<GoogleDriveIcon size={16} />}
|
||||
onClick={() => setEmbed("gdrive")}
|
||||
>
|
||||
Google Drive
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<GoogleSheetsIcon size={16} />}
|
||||
onClick={() => setEmbed("gsheets")}
|
||||
>
|
||||
Google Sheets
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Label>{t("Utility")}</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={<IconCalendar size={16} />}
|
||||
onClick={insertDate}
|
||||
>
|
||||
{t("Date")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconMathFunction size={16} />}
|
||||
onClick={() => editor.chain().focus().setMathInline().run()}
|
||||
>
|
||||
{t("Math inline")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconMath size={16} />}
|
||||
onClick={() => editor.chain().focus().setMathBlock().run()}
|
||||
>
|
||||
{t("Math block")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
-117
@@ -1,117 +0,0 @@
|
||||
import { FC } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconAt,
|
||||
IconColumns2,
|
||||
IconColumns3,
|
||||
IconMoodSmile,
|
||||
IconTable,
|
||||
} from "@tabler/icons-react";
|
||||
import { IconColumns4 } from "@/components/icons/icon-columns-4";
|
||||
import { IconColumns5 } from "@/components/icons/icon-columns-5";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
export const QuickInsertsGroup: FC<Props> = ({ editor }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ActionIcon.Group>
|
||||
<Tooltip label={t("Mention")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
size="md"
|
||||
aria-label={t("Mention")}
|
||||
onClick={() => editor.chain().focus().insertContent("@").run()}
|
||||
>
|
||||
<IconAt size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("Emoji")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
size="md"
|
||||
aria-label={t("Emoji")}
|
||||
onClick={() => editor.chain().focus().insertContent(":").run()}
|
||||
>
|
||||
<IconMoodSmile size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Menu shadow="md" position="bottom-start" withArrow={false}>
|
||||
<Menu.Target>
|
||||
<Tooltip label={t("Columns")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
size="md"
|
||||
aria-label={t("Columns")}
|
||||
>
|
||||
<IconColumns2 size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconColumns2 size={16} />}
|
||||
onClick={() =>
|
||||
editor.chain().focus().insertColumns({ layout: "two_equal" }).run()
|
||||
}
|
||||
>
|
||||
{t("{{count}} Columns", { count: 2 })}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconColumns3 size={16} />}
|
||||
onClick={() =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertColumns({ layout: "three_equal" })
|
||||
.run()
|
||||
}
|
||||
>
|
||||
{t("{{count}} Columns", { count: 3 })}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconColumns4 size={16} />}
|
||||
onClick={() =>
|
||||
editor.chain().focus().insertColumns({ layout: "four_equal" }).run()
|
||||
}
|
||||
>
|
||||
{t("{{count}} Columns", { count: 4 })}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconColumns5 size={16} />}
|
||||
onClick={() =>
|
||||
editor.chain().focus().insertColumns({ layout: "five_equal" }).run()
|
||||
}
|
||||
>
|
||||
{t("{{count}} Columns", { count: 5 })}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<Tooltip label={t("Table")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
size="md"
|
||||
aria-label={t("Table")}
|
||||
onClick={() =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||||
.run()
|
||||
}
|
||||
>
|
||||
<IconTable size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ActionIcon.Group>
|
||||
);
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
|
||||
export interface ToolbarState {
|
||||
isBold: boolean;
|
||||
isItalic: boolean;
|
||||
isUnderline: boolean;
|
||||
isStrike: boolean;
|
||||
isCode: boolean;
|
||||
isSubscript: boolean;
|
||||
isSuperscript: boolean;
|
||||
isBulletList: boolean;
|
||||
isOrderedList: boolean;
|
||||
isTaskList: boolean;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
}
|
||||
|
||||
// Undo/redo come from either StarterKit's history or the Yjs collaboration
|
||||
// history extension. During the brief moment a page is rendered with the
|
||||
// static editor (mainExtensions only, undoRedo disabled), neither is loaded
|
||||
// and editor.can().undo/redo is undefined.
|
||||
function safeCan(editor: Editor, command: "undo" | "redo"): boolean {
|
||||
const can = editor.can() as Record<string, unknown>;
|
||||
const fn = can[command];
|
||||
return typeof fn === "function" ? (fn as () => boolean)() : false;
|
||||
}
|
||||
|
||||
export function useToolbarState(editor: Editor | null): ToolbarState | null {
|
||||
return useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => {
|
||||
if (!ctx.editor) return null;
|
||||
return {
|
||||
isBold: ctx.editor.isActive("bold"),
|
||||
isItalic: ctx.editor.isActive("italic"),
|
||||
isUnderline: ctx.editor.isActive("underline"),
|
||||
isStrike: ctx.editor.isActive("strike"),
|
||||
isCode: ctx.editor.isActive("code"),
|
||||
isSubscript: ctx.editor.isActive("subscript"),
|
||||
isSuperscript: ctx.editor.isActive("superscript"),
|
||||
isBulletList: ctx.editor.isActive("bulletList"),
|
||||
isOrderedList: ctx.editor.isActive("orderedList"),
|
||||
isTaskList: ctx.editor.isActive("taskList"),
|
||||
canUndo: safeCan(ctx.editor, "undo"),
|
||||
canRedo: safeCan(ctx.editor, "redo"),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
IconColumns3,
|
||||
IconColumns2,
|
||||
IconTag,
|
||||
IconMoodSmile,
|
||||
IconRotate2,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
@@ -478,15 +477,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Emoji",
|
||||
description: "Insert emoji.",
|
||||
searchTerms: ["emoji", "icon", "smiley", "emoticon", "symbol", "reaction"],
|
||||
icon: IconMoodSmile,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).insertContent(":").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Subpages (Child pages)",
|
||||
description: "List all subpages of the current page",
|
||||
|
||||
@@ -25,7 +25,7 @@ const recalculateLinks = (nodePos: NodePos[]) => {
|
||||
(acc, item) => {
|
||||
const label = item.node.textContent;
|
||||
const level = Number(item.node.attrs.level);
|
||||
if (label.length && level <= 4) {
|
||||
if (label.length && level <= 3) {
|
||||
acc.push({
|
||||
label,
|
||||
level,
|
||||
|
||||
@@ -3,27 +3,20 @@ import React from "react";
|
||||
import { TitleEditor } from "@/features/editor/title-editor";
|
||||
import PageEditor from "@/features/editor/page-editor";
|
||||
import {
|
||||
ActionIcon,
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Popover,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { useAtom } from "jotai";
|
||||
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { PageVerificationBadge } from "@/ee/page-verification";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IContributor } from "@/features/page/types/page.types.ts";
|
||||
import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-toolbar";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||
import clsx from "clsx";
|
||||
|
||||
const MemoizedTitleEditor = React.memo(TitleEditor);
|
||||
const MemoizedPageEditor = React.memo(PageEditor);
|
||||
@@ -59,11 +52,6 @@ export function FullEditor({
|
||||
}: FullEditorProps) {
|
||||
const [user] = useAtom(userAtom);
|
||||
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
|
||||
const editorToolbarEnabled =
|
||||
user.settings?.preferences?.editorToolbar ?? false;
|
||||
const userPageEditMode =
|
||||
user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||
const isEditMode = userPageEditMode === PageEditMode.Edit;
|
||||
|
||||
return (
|
||||
<Container
|
||||
@@ -71,7 +59,6 @@ export function FullEditor({
|
||||
size={!fullPageWidth && 900}
|
||||
className={classes.editor}
|
||||
>
|
||||
{editorToolbarEnabled && editable && isEditMode && <FixedToolbar />}
|
||||
<MemoizedTitleEditor
|
||||
pageId={pageId}
|
||||
slugId={slugId}
|
||||
@@ -106,7 +93,6 @@ function PageByline({
|
||||
readOnly,
|
||||
}: PageBylineProps) {
|
||||
const { t } = useTranslation();
|
||||
const toggleAside = useToggleAside();
|
||||
|
||||
const otherContributors = (contributors ?? []).filter(
|
||||
(c) => c.id !== creator?.id,
|
||||
@@ -116,8 +102,8 @@ function PageByline({
|
||||
<Group
|
||||
gap="sm"
|
||||
mb="md"
|
||||
className={clsx("print-hide", classes.byline)}
|
||||
style={{ marginTop: "-0.5em" }}
|
||||
className="print-hide"
|
||||
style={{ marginTop: "-0.5em", paddingLeft: "3rem" }}
|
||||
>
|
||||
{creator && (
|
||||
<Popover position="bottom-start" shadow="md" width={280} withArrow>
|
||||
@@ -179,17 +165,6 @@ function PageByline({
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
<Tooltip label={t("Details")} withArrow openDelay={250}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("Details")}
|
||||
onClick={() => toggleAside("details")}
|
||||
>
|
||||
<IconInfoCircle size={20} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<PageVerificationBadge readOnly={readOnly} />
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -9,15 +9,3 @@
|
||||
}
|
||||
}
|
||||
|
||||
.byline {
|
||||
padding-left: 3rem;
|
||||
|
||||
@media (max-width: $mantine-breakpoint-sm) {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
@media print {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useComputedColorScheme } from "@mantine/core";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ILabel } from "@/features/label/types/label.types.ts";
|
||||
import { getLabelColor } from "@/features/label/utils/label-colors.ts";
|
||||
import classes from "@/features/label/label.module.css";
|
||||
|
||||
type LabelChipProps = {
|
||||
label: Pick<ILabel, "id" | "name">;
|
||||
onRemove?: () => void;
|
||||
asLink?: boolean;
|
||||
};
|
||||
|
||||
export function LabelChip({ label, onRemove, asLink }: LabelChipProps) {
|
||||
const { t } = useTranslation();
|
||||
const scheme = useComputedColorScheme("light");
|
||||
const c = getLabelColor(label.name, scheme);
|
||||
|
||||
const nameNode = asLink ? (
|
||||
<Link
|
||||
to={`/labels/${encodeURIComponent(label.name)}`}
|
||||
className={classes.chipLink}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className={classes.chipName}>{label.name}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className={classes.chipName}>{label.name}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<span className={classes.chip} style={{ background: c.bg, color: c.fg }}>
|
||||
{nameNode}
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
className={classes.chipX}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
aria-label={t("Remove label {{name}}", { name: label.name })}
|
||||
>
|
||||
<IconX size={12} stroke={2} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Skeleton } from "@mantine/core";
|
||||
import classes from "@/features/label/label.module.css";
|
||||
|
||||
type LabelPageRowSkeletonProps = {
|
||||
titleWidth?: number;
|
||||
metaWidth?: number;
|
||||
};
|
||||
|
||||
export function LabelPageRowSkeleton({
|
||||
titleWidth = 220,
|
||||
metaWidth = 180,
|
||||
}: LabelPageRowSkeletonProps) {
|
||||
return (
|
||||
<div className={classes.row} aria-hidden="true">
|
||||
<div className={classes.rowMain}>
|
||||
<div className={classes.rowIcon}>
|
||||
<Skeleton height={18} width={18} radius="sm" />
|
||||
</div>
|
||||
<div className={classes.rowBody}>
|
||||
<Skeleton height={15} width={titleWidth} radius="xs" />
|
||||
<div className={classes.rowMeta}>
|
||||
<Skeleton height={18} width={18} radius="sm" />
|
||||
<Skeleton height={12} width={metaWidth} radius="xs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { ThemeIcon, Tooltip } from "@mantine/core";
|
||||
import { IconFileDescription } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ILabelPageItem } from "@/features/label/types/label.types.ts";
|
||||
import { LabelChip } from "@/features/label/components/label-chip.tsx";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils";
|
||||
import { formatLabelListDate } from "@/features/label/utils/format-label-date.ts";
|
||||
import classes from "@/features/label/label.module.css";
|
||||
|
||||
type LabelPageRowProps = {
|
||||
page: ILabelPageItem;
|
||||
currentLabelName: string;
|
||||
};
|
||||
|
||||
const MAX_VISIBLE_CHIPS = 3;
|
||||
|
||||
export function LabelPageRow({ page, currentLabelName }: LabelPageRowProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const otherLabels = page.labels.filter((l) => l.name !== currentLabelName);
|
||||
const visibleLabels = otherLabels.slice(0, MAX_VISIBLE_CHIPS);
|
||||
const hiddenLabels = otherLabels.slice(MAX_VISIBLE_CHIPS);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={buildPageUrl(page.space?.slug, page.slugId, page.title ?? undefined)}
|
||||
className={classes.row}
|
||||
>
|
||||
<div className={classes.rowMain}>
|
||||
<div className={classes.rowIcon}>
|
||||
{page.icon ? (
|
||||
<span style={{ fontSize: 16, lineHeight: 1 }}>{page.icon}</span>
|
||||
) : (
|
||||
<ThemeIcon variant="transparent" color="gray" size={18}>
|
||||
<IconFileDescription size={18} />
|
||||
</ThemeIcon>
|
||||
)}
|
||||
</div>
|
||||
<div className={classes.rowBody}>
|
||||
<div className={classes.rowTitle}>
|
||||
{page.title || t("Untitled")}
|
||||
</div>
|
||||
<div className={classes.rowMeta}>
|
||||
{page.space && (
|
||||
<>
|
||||
<CustomAvatar
|
||||
name={page.space.name}
|
||||
avatarUrl={page.space.logo ?? undefined}
|
||||
type={AvatarIconType.SPACE_ICON}
|
||||
color="initials"
|
||||
variant="filled"
|
||||
size={18}
|
||||
/>
|
||||
<span>{page.space.name}</span>
|
||||
<span className={classes.metaDot} aria-hidden="true">
|
||||
•
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className={classes.rowDate}>
|
||||
{t("Updated {{date}}", {
|
||||
date: formatLabelListDate(new Date(page.updatedAt)),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{/* {otherLabels.length > 0 && (
|
||||
<div className={classes.rowChips}>
|
||||
{visibleLabels.map((label) => (
|
||||
<LabelChip key={label.id} label={label} asLink />
|
||||
))}
|
||||
{hiddenLabels.length > 0 && (
|
||||
<Tooltip
|
||||
label={hiddenLabels.map((l) => l.name).join(", ")}
|
||||
withArrow
|
||||
openDelay={200}
|
||||
>
|
||||
<span className={classes.chipMore}>
|
||||
+{hiddenLabels.length}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import { useMemo, useRef, useState, KeyboardEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useComputedColorScheme } from "@mantine/core";
|
||||
import { ILabel } from "@/features/label/types/label.types.ts";
|
||||
import { useWorkspaceLabelsQuery } from "@/features/label/queries/label-query.ts";
|
||||
import { getLabelColor } from "@/features/label/utils/label-colors.ts";
|
||||
import { normalizeLabelName } from "@/features/label/utils/normalize-label.ts";
|
||||
import classes from "@/features/label/label.module.css";
|
||||
|
||||
type LabelPickerProps = {
|
||||
applied: ILabel[];
|
||||
enabled: boolean;
|
||||
onAdd: (name: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const NAME_PATTERN = /^[a-z0-9_-][a-z0-9_~-]*$/;
|
||||
const MAX_LABEL_NAME_LENGTH = 100;
|
||||
|
||||
function isValidLabelName(name: string): boolean {
|
||||
return (
|
||||
name.length > 0 &&
|
||||
name.length <= MAX_LABEL_NAME_LENGTH &&
|
||||
NAME_PATTERN.test(name)
|
||||
);
|
||||
}
|
||||
|
||||
export function LabelPicker({
|
||||
applied,
|
||||
enabled,
|
||||
onAdd,
|
||||
onClose,
|
||||
}: LabelPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const scheme = useComputedColorScheme("light");
|
||||
const [query, setQuery] = useState("");
|
||||
const [hover, setHover] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const normalized = normalizeLabelName(query);
|
||||
const { data } = useWorkspaceLabelsQuery(normalized, enabled);
|
||||
|
||||
const appliedNames = useMemo(
|
||||
() => new Set(applied.map((l) => l.name.toLowerCase())),
|
||||
[applied],
|
||||
);
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
const items = data?.items ?? [];
|
||||
return items.filter((l) => !appliedNames.has(l.name.toLowerCase()));
|
||||
}, [data, appliedNames]);
|
||||
|
||||
const exact = suggestions.find((l) => l.name === normalized);
|
||||
const canCreate =
|
||||
!exact && !appliedNames.has(normalized) && isValidLabelName(normalized);
|
||||
|
||||
const total = suggestions.length + (canCreate ? 1 : 0);
|
||||
|
||||
const select = (idx: number) => {
|
||||
if (idx < suggestions.length) {
|
||||
onAdd(suggestions[idx].name);
|
||||
} else if (canCreate) {
|
||||
onAdd(normalized);
|
||||
}
|
||||
setQuery("");
|
||||
setHover(0);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHover((h) => Math.min(Math.max(total - 1, 0), h + 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHover((h) => Math.max(0, h - 1));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (total === 0) return;
|
||||
select(hover);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.popover}>
|
||||
<div className={classes.popoverSearch}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
autoFocus
|
||||
maxLength={MAX_LABEL_NAME_LENGTH}
|
||||
placeholder={t("Search or create…")}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setHover(0);
|
||||
}}
|
||||
onKeyDown={onKey}
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.popoverList}>
|
||||
{total === 0 && (
|
||||
<div className={classes.popoverEmpty}>
|
||||
{normalized.length === 0
|
||||
? t("No labels yet")
|
||||
: appliedNames.has(normalized)
|
||||
? t("Already added")
|
||||
: !isValidLabelName(normalized)
|
||||
? t("Invalid label name")
|
||||
: t("No matches")}
|
||||
</div>
|
||||
)}
|
||||
{suggestions.map((s, i) => {
|
||||
const c = getLabelColor(s.name, scheme);
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className={clsx(
|
||||
classes.popoverItem,
|
||||
hover === i && classes.popoverItemHover,
|
||||
)}
|
||||
onMouseEnter={() => setHover(i)}
|
||||
onClick={() => select(i)}
|
||||
>
|
||||
<span
|
||||
className={classes.popoverItemDot}
|
||||
style={{ background: c.dot }}
|
||||
/>
|
||||
<span className={classes.popoverItemName}>{s.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{canCreate && (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
classes.popoverItem,
|
||||
hover === suggestions.length && classes.popoverItemHover,
|
||||
)}
|
||||
onMouseEnter={() => setHover(suggestions.length)}
|
||||
onClick={() => select(suggestions.length)}
|
||||
>
|
||||
<span className={classes.popoverCreatePlus}>
|
||||
<IconPlus size={12} stroke={2} />
|
||||
</span>
|
||||
<span className={classes.popoverItemName}>
|
||||
{t("Create")} <b>"{normalized}"</b>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Divider, Popover, Stack, Text } from "@mantine/core";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LabelChip } from "@/features/label/components/label-chip.tsx";
|
||||
import { LabelPicker } from "@/features/label/components/label-picker.tsx";
|
||||
import {
|
||||
useAddLabelsMutation,
|
||||
usePageLabelsQuery,
|
||||
useRemoveLabelMutation,
|
||||
} from "@/features/label/queries/label-query.ts";
|
||||
import classes from "@/features/label/label.module.css";
|
||||
|
||||
type LabelsSectionProps = {
|
||||
pageId: string;
|
||||
canEdit: boolean;
|
||||
};
|
||||
|
||||
export function LabelsSection({ pageId, canEdit }: LabelsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { data } = usePageLabelsQuery(pageId);
|
||||
const addMutation = useAddLabelsMutation(pageId);
|
||||
const removeMutation = useRemoveLabelMutation(pageId);
|
||||
|
||||
const labels = data?.items ?? [];
|
||||
|
||||
if (!canEdit && labels.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleAdd = (name: string) => {
|
||||
addMutation.mutate({ pageId, names: [name] });
|
||||
};
|
||||
|
||||
const handleRemove = (labelId: string) => {
|
||||
removeMutation.mutate({ pageId, labelId });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider />
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={500} c="dimmed">
|
||||
{t("Labels")}
|
||||
</Text>
|
||||
<div className={classes.labelsWrap}>
|
||||
{labels.map((label) => (
|
||||
<LabelChip
|
||||
key={label.id}
|
||||
label={label}
|
||||
asLink
|
||||
onRemove={canEdit ? () => handleRemove(label.id) : undefined}
|
||||
/>
|
||||
))}
|
||||
{canEdit && (
|
||||
<Popover
|
||||
opened={open}
|
||||
onChange={setOpen}
|
||||
position="bottom-end"
|
||||
shadow="lg"
|
||||
withinPortal
|
||||
offset={6}
|
||||
>
|
||||
<Popover.Target>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(classes.addBtn, open && classes.addBtnOpen)}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<IconPlus size={12} stroke={2} />
|
||||
<span>
|
||||
{labels.length === 0 ? t("Add label") : t("Add")}
|
||||
</span>
|
||||
</button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={0} className={classes.popover}>
|
||||
<LabelPicker
|
||||
applied={labels}
|
||||
enabled={open}
|
||||
onAdd={(name) => handleAdd(name)}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
.labelsWrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chipName {
|
||||
letter-spacing: 0.005em;
|
||||
}
|
||||
|
||||
.chipX {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: currentColor;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
margin-right: -4px;
|
||||
margin-left: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.chipX:hover {
|
||||
opacity: 1;
|
||||
background: light-dark(rgba(0, 0, 0, 0.08), rgba(255, 255, 255, 0.12));
|
||||
}
|
||||
|
||||
.addBtn {
|
||||
appearance: none;
|
||||
border: 1px dashed
|
||||
light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
|
||||
background: transparent;
|
||||
color: var(--mantine-color-dimmed);
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 100ms ease,
|
||||
border-color 100ms ease,
|
||||
color 100ms ease;
|
||||
}
|
||||
|
||||
.addBtn:hover {
|
||||
background: light-dark(rgba(0, 0, 0, 0.03), rgba(255, 255, 255, 0.04));
|
||||
color: var(--mantine-color-text);
|
||||
border-color: light-dark(
|
||||
var(--mantine-color-gray-5),
|
||||
var(--mantine-color-dark-2)
|
||||
);
|
||||
}
|
||||
|
||||
.addBtnOpen {
|
||||
background: var(--mantine-color-body);
|
||||
border-style: solid;
|
||||
border-color: light-dark(
|
||||
var(--mantine-color-gray-5),
|
||||
var(--mantine-color-dark-2)
|
||||
);
|
||||
color: var(--mantine-color-text);
|
||||
}
|
||||
|
||||
.popover {
|
||||
width: 240px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.popoverSearch {
|
||||
padding: 8px 8px 4px;
|
||||
border-bottom: 1px solid
|
||||
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.popoverSearch input {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
padding: 4px 4px;
|
||||
color: var(--mantine-color-text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.popoverSearch input::placeholder {
|
||||
color: var(--mantine-color-placeholder);
|
||||
}
|
||||
|
||||
.popoverList {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.popoverEmpty {
|
||||
padding: 12px 8px;
|
||||
color: var(--mantine-color-dimmed);
|
||||
font-size: 12.5px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.popoverItem {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--mantine-color-text);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.popoverItemHover {
|
||||
background: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
}
|
||||
|
||||
.popoverItemDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.popoverItemName {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.popoverCreatePlus {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.headerChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.005em;
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
transition: filter 100ms ease;
|
||||
}
|
||||
|
||||
.headerChip:hover {
|
||||
filter: brightness(0.97);
|
||||
}
|
||||
|
||||
.headerDot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 12px;
|
||||
margin: 0 -12px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: background-color 80ms ease;
|
||||
}
|
||||
|
||||
.row + .row {
|
||||
border-top: 1px solid
|
||||
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-0),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
}
|
||||
|
||||
.row:hover + .row,
|
||||
.row:has(+ .row:hover) {
|
||||
border-top-color: transparent;
|
||||
}
|
||||
|
||||
.rowMain {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rowIcon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--mantine-color-dimmed);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.rowBody {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.rowTitle {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--mantine-color-text);
|
||||
line-height: 1.3;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.rowChips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chipMore {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: var(--mantine-color-dimmed);
|
||||
background: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rowMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
color: var(--mantine-color-dimmed);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.rowDate {
|
||||
color: var(--mantine-color-dimmed);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.metaDot {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.chipLink {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.chipLink:hover {
|
||||
filter: brightness(0.97);
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import {
|
||||
keepPreviousData,
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
addLabelsToPage,
|
||||
getLabelInfo,
|
||||
getPageLabels,
|
||||
getWorkspaceLabels,
|
||||
removeLabelFromPage,
|
||||
searchPagesByLabel,
|
||||
} from "@/features/label/services/label-service.ts";
|
||||
import {
|
||||
IAddLabels,
|
||||
ILabel,
|
||||
IRemoveLabel,
|
||||
} from "@/features/label/types/label.types.ts";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const PAGE_LABELS_KEY = (pageId: string) => ["page-labels", pageId];
|
||||
const WORKSPACE_LABELS_KEY = (query?: string) => ["workspace-labels", query ?? ""];
|
||||
|
||||
export function usePageLabelsQuery(pageId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: PAGE_LABELS_KEY(pageId ?? ""),
|
||||
queryFn: () => getPageLabels({ pageId: pageId as string, limit: 100 }),
|
||||
enabled: !!pageId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkspaceLabelsQuery(query: string, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: WORKSPACE_LABELS_KEY(query),
|
||||
queryFn: () => getWorkspaceLabels({ type: "page", query, limit: 50 }),
|
||||
enabled,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddLabelsMutation(pageId: string | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<ILabel[], Error, IAddLabels>({
|
||||
mutationFn: (data) => addLabelsToPage(data),
|
||||
onSuccess: (added) => {
|
||||
queryClient.setQueryData<IPagination<ILabel>>(
|
||||
PAGE_LABELS_KEY(pageId ?? ""),
|
||||
(cache) => {
|
||||
if (!cache) return cache;
|
||||
const existing = new Set(cache.items.map((l) => l.id));
|
||||
const additions = added.filter((l) => !existing.has(l.id));
|
||||
if (additions.length === 0) return cache;
|
||||
return { ...cache, items: [...cache.items, ...additions] };
|
||||
},
|
||||
);
|
||||
|
||||
queryClient.setQueriesData<IPagination<ILabel>>(
|
||||
{ queryKey: ["workspace-labels"] },
|
||||
(cache) => {
|
||||
if (!cache) return cache;
|
||||
const existing = new Set(cache.items.map((l) => l.id));
|
||||
const additions = added.filter((l) => !existing.has(l.id));
|
||||
if (additions.length === 0) return cache;
|
||||
return {
|
||||
...cache,
|
||||
items: [...cache.items, ...additions].sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["label-pages"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["label-info"] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
notifications.show({
|
||||
message: error?.response?.data?.message ?? t("Failed to add label"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveLabelMutation(pageId: string | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<void, Error, IRemoveLabel>({
|
||||
mutationFn: (data) => removeLabelFromPage(data),
|
||||
onSuccess: (_data, variables) => {
|
||||
const cache = queryClient.getQueryData<IPagination<ILabel>>(
|
||||
PAGE_LABELS_KEY(pageId ?? ""),
|
||||
);
|
||||
if (cache) {
|
||||
queryClient.setQueryData<IPagination<ILabel>>(
|
||||
PAGE_LABELS_KEY(pageId ?? ""),
|
||||
{
|
||||
...cache,
|
||||
items: cache.items.filter((l) => l.id !== variables.labelId),
|
||||
},
|
||||
);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["workspace-labels"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["label-pages"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["label-info"] });
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: t("Failed to remove label"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useLabelInfoQuery(name: string, spaceId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["label-info", name, spaceId ?? ""],
|
||||
queryFn: () => getLabelInfo({ name, type: "page", spaceId }),
|
||||
enabled: !!name,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
const LABEL_PAGES_LIMIT = 25;
|
||||
|
||||
export function useLabelPagesQuery(
|
||||
name: string,
|
||||
query: string,
|
||||
spaceId?: string,
|
||||
) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["label-pages", name, query, spaceId ?? ""],
|
||||
queryFn: ({ pageParam }) =>
|
||||
searchPagesByLabel({
|
||||
name,
|
||||
query,
|
||||
spaceId,
|
||||
cursor: pageParam,
|
||||
limit: LABEL_PAGES_LIMIT,
|
||||
}),
|
||||
enabled: !!name,
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage
|
||||
? (lastPage.meta.nextCursor ?? undefined)
|
||||
: undefined,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import api from "@/lib/api-client";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import {
|
||||
IAddLabels,
|
||||
ILabel,
|
||||
ILabelInfo,
|
||||
ILabelInfoParams,
|
||||
ILabelPageItem,
|
||||
IListLabelsParams,
|
||||
IPageLabelsParams,
|
||||
IRemoveLabel,
|
||||
ISearchPagesByLabelParams,
|
||||
} from "@/features/label/types/label.types.ts";
|
||||
|
||||
export async function getPageLabels(
|
||||
params: IPageLabelsParams,
|
||||
): Promise<IPagination<ILabel>> {
|
||||
const req = await api.post<IPagination<ILabel>>("/pages/labels", params);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getWorkspaceLabels(
|
||||
params: IListLabelsParams,
|
||||
): Promise<IPagination<ILabel>> {
|
||||
const req = await api.post<IPagination<ILabel>>("/labels", params);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function addLabelsToPage(
|
||||
data: IAddLabels,
|
||||
): Promise<ILabel[]> {
|
||||
const req = await api.post<ILabel[]>("/pages/labels/add", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function removeLabelFromPage(data: IRemoveLabel): Promise<void> {
|
||||
await api.post("/pages/labels/remove", data);
|
||||
}
|
||||
|
||||
export async function getLabelInfo(
|
||||
params: ILabelInfoParams,
|
||||
): Promise<ILabelInfo> {
|
||||
const req = await api.post<ILabelInfo>("/labels/info", params);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function searchPagesByLabel(
|
||||
params: ISearchPagesByLabelParams,
|
||||
): Promise<IPagination<ILabelPageItem>> {
|
||||
const req = await api.post<IPagination<ILabelPageItem>>(
|
||||
"/labels/pages",
|
||||
params,
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
export type LabelType = "page" | "space";
|
||||
|
||||
export interface ILabel {
|
||||
id: string;
|
||||
name: string;
|
||||
type: LabelType;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface IAddLabels {
|
||||
pageId: string;
|
||||
names: string[];
|
||||
}
|
||||
|
||||
export interface IRemoveLabel {
|
||||
pageId: string;
|
||||
labelId: string;
|
||||
}
|
||||
|
||||
export interface IPageLabelsParams {
|
||||
pageId: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface IListLabelsParams {
|
||||
type: LabelType;
|
||||
query?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface ILabelInfo {
|
||||
name: string;
|
||||
usageCount: number;
|
||||
}
|
||||
|
||||
export interface ILabelPageItem {
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string | null;
|
||||
icon: string | null;
|
||||
spaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
space: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
logo: string | null;
|
||||
} | null;
|
||||
creator: { id: string; name: string; avatarUrl: string | null } | null;
|
||||
labels: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export interface ISearchPagesByLabelParams {
|
||||
labelId?: string;
|
||||
name?: string;
|
||||
spaceId?: string;
|
||||
query?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface ILabelInfoParams {
|
||||
name: string;
|
||||
type: LabelType;
|
||||
spaceId?: string;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { format, isThisYear, isToday, isYesterday } from "date-fns";
|
||||
import i18n from "@/i18n.ts";
|
||||
|
||||
export function formatLabelListDate(date: Date): string {
|
||||
if (isToday(date)) {
|
||||
return i18n.t("Today, {{time}}", { time: format(date, "h:mma") });
|
||||
}
|
||||
if (isYesterday(date)) {
|
||||
return i18n.t("Yesterday, {{time}}", { time: format(date, "h:mma") });
|
||||
}
|
||||
if (isThisYear(date)) {
|
||||
return format(date, "MMM dd");
|
||||
}
|
||||
return format(date, "MMM dd, yyyy");
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
type LabelColor = {
|
||||
bg: string;
|
||||
fg: string;
|
||||
dot: string;
|
||||
};
|
||||
|
||||
const LABEL_PALETTE: Record<string, LabelColor> = {
|
||||
slate: { bg: "#eef1f5", fg: "#3b475a", dot: "#6b7a90" },
|
||||
blue: { bg: "#e6f0ff", fg: "#1e4fbf", dot: "#3b82f6" },
|
||||
green: { bg: "#e3f5ea", fg: "#1f7a47", dot: "#22a05a" },
|
||||
amber: { bg: "#fbf0d9", fg: "#8a5a00", dot: "#d99c1f" },
|
||||
red: { bg: "#fde6e6", fg: "#a02b2b", dot: "#dc4a4a" },
|
||||
purple: { bg: "#efe9fb", fg: "#5a3aa8", dot: "#8b6bd9" },
|
||||
pink: { bg: "#fce6ee", fg: "#a8336d", dot: "#dc6699" },
|
||||
teal: { bg: "#daf1ee", fg: "#1f6f6a", dot: "#2fa39a" },
|
||||
};
|
||||
|
||||
const PALETTE_KEYS = Object.keys(LABEL_PALETTE);
|
||||
|
||||
const DARK_PALETTE: Record<string, LabelColor> = {
|
||||
slate: { bg: "#2a3140", fg: "#c8d3e3", dot: "#7e8da8" },
|
||||
blue: { bg: "#152a52", fg: "#a9c4ff", dot: "#5b9aff" },
|
||||
green: { bg: "#143b27", fg: "#9ce3b8", dot: "#3ec97c" },
|
||||
amber: { bg: "#3d2c0e", fg: "#f5cf85", dot: "#e6b34a" },
|
||||
red: { bg: "#401a1a", fg: "#f1a8a8", dot: "#e26565" },
|
||||
purple: { bg: "#2a1f4d", fg: "#c8b4f4", dot: "#a48ce6" },
|
||||
pink: { bg: "#3c1a2a", fg: "#f3a9c9", dot: "#e07ab0" },
|
||||
teal: { bg: "#103633", fg: "#92d5cf", dot: "#48b8af" },
|
||||
};
|
||||
|
||||
function hashName(name: string): number {
|
||||
// Per-char accumulation with 31. Note: 31 ≡ -1 (mod 8), so the low bits of
|
||||
// this hash are highly correlated across short strings — `% 8` would cluster.
|
||||
let h = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
h = (Math.imul(h, 31) + name.charCodeAt(i)) | 0;
|
||||
}
|
||||
// Murmur3 fmix32 finalizer — avalanches high bits into low bits so the
|
||||
// subsequent `% palette.length` (small power of two) is well-distributed.
|
||||
h ^= h >>> 16;
|
||||
h = Math.imul(h, 0x85ebca6b);
|
||||
h ^= h >>> 13;
|
||||
h = Math.imul(h, 0xc2b2ae35);
|
||||
h ^= h >>> 16;
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
export function getLabelColor(
|
||||
name: string,
|
||||
scheme: "light" | "dark" = "light",
|
||||
): LabelColor {
|
||||
const key = PALETTE_KEYS[hashName(name) % PALETTE_KEYS.length];
|
||||
const palette = scheme === "dark" ? DARK_PALETTE : LABEL_PALETTE;
|
||||
return palette[key];
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function normalizeLabelName(name: string): string {
|
||||
return name.trim().replace(/\s+/g, "-").toLowerCase();
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import {
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useBacklinksQuery } from "@/features/page-details/queries/backlinks-query.ts";
|
||||
import {
|
||||
BacklinkDirection,
|
||||
IBacklinkPageItem,
|
||||
} from "@/features/page-details/types/backlink.types.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { getPageIcon } from "@/lib";
|
||||
|
||||
interface BacklinksListProps {
|
||||
pageId: string;
|
||||
direction: BacklinkDirection;
|
||||
enabled: boolean;
|
||||
onItemClick: () => void;
|
||||
}
|
||||
|
||||
export function BacklinksList({
|
||||
pageId,
|
||||
direction,
|
||||
enabled,
|
||||
onItemClick,
|
||||
}: BacklinksListProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||
useBacklinksQuery(pageId, direction, enabled);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="sm">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const items: IBacklinkPageItem[] =
|
||||
data?.pages.flatMap((page) => page.items) ?? [];
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" size="sm" py="md">
|
||||
{direction === "incoming"
|
||||
? t("No pages link here yet.")
|
||||
: t("This page doesn't link to other pages yet.")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
|
||||
return;
|
||||
}
|
||||
onItemClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
{items.map((item) => (
|
||||
<UnstyledButton
|
||||
key={item.id}
|
||||
component={Link}
|
||||
to={
|
||||
item.space?.slug
|
||||
? buildPageUrl(
|
||||
item.space.slug,
|
||||
item.slugId,
|
||||
item.title ?? undefined,
|
||||
)
|
||||
: "#"
|
||||
}
|
||||
onClick={handleClick}
|
||||
style={{ padding: "8px 4px", borderRadius: 4, userSelect: "none" }}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{getPageIcon(item.icon ?? "")}
|
||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} lineClamp={1}>
|
||||
{item.title || t("Untitled")}
|
||||
</Text>
|
||||
{item.space?.name && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{item.space.name}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
{hasNextPage && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
loading={isFetchingNextPage}
|
||||
onClick={() => fetchNextPage()}
|
||||
mt="xs"
|
||||
>
|
||||
{t("Load more")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { Modal, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useBacklinksCountQuery } from "@/features/page-details/queries/backlinks-query.ts";
|
||||
import { BacklinksList } from "./backlinks-list";
|
||||
|
||||
interface BacklinksModalProps {
|
||||
pageId: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function BacklinksModal({
|
||||
pageId,
|
||||
opened,
|
||||
onClose,
|
||||
}: BacklinksModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: counts } = useBacklinksCountQuery(pageId);
|
||||
|
||||
return (
|
||||
<Modal.Root opened={opened} onClose={onClose} size={640} yOffset="10vh">
|
||||
<Modal.Overlay />
|
||||
<Modal.Content>
|
||||
<Modal.Header>
|
||||
<Modal.Title fw={500}>{t("Backlinks")}</Modal.Title>
|
||||
<Modal.CloseButton />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Stack gap="lg">
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500} c="dimmed">
|
||||
{t("Incoming links ({{count}})", {
|
||||
count: counts?.incoming ?? 0,
|
||||
})}
|
||||
</Text>
|
||||
<BacklinksList
|
||||
pageId={pageId}
|
||||
direction="incoming"
|
||||
enabled={opened}
|
||||
onItemClick={onClose}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500} c="dimmed">
|
||||
{t("Outgoing links ({{count}})", {
|
||||
count: counts?.outgoing ?? 0,
|
||||
})}
|
||||
</Text>
|
||||
<BacklinksList
|
||||
pageId={pageId}
|
||||
direction="outgoing"
|
||||
enabled={opened}
|
||||
onItemClick={onClose}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import {
|
||||
Divider,
|
||||
Group,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { IconChevronRight } from "@tabler/icons-react";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { useBacklinksCountQuery } from "@/features/page-details/queries/backlinks-query.ts";
|
||||
import { BacklinksModal } from "./backlinks-modal";
|
||||
import { formattedDate, timeAgo } from "@/lib/time.ts";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { LabelsSection } from "@/features/label/components/labels-section.tsx";
|
||||
|
||||
export function PageDetailsAside() {
|
||||
const { pageSlug } = useParams();
|
||||
const { data: page } = usePageQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
});
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const { data: counts, isLoading: countsLoading } = useBacklinksCountQuery(page?.id);
|
||||
const [modalOpened, { open: openModal, close: closeModal }] =
|
||||
useDisclosure(false);
|
||||
|
||||
if (!page) return null;
|
||||
|
||||
const wordCount: number =
|
||||
pageEditor?.storage?.characterCount?.words?.() ?? 0;
|
||||
const characterCount: number =
|
||||
pageEditor?.storage?.characterCount?.characters?.() ?? 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack gap="md">
|
||||
<PeopleSection
|
||||
creator={page.creator}
|
||||
lastUpdatedBy={page.lastUpdatedBy}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<StatsSection
|
||||
wordCount={wordCount}
|
||||
characterCount={characterCount}
|
||||
createdAt={page.createdAt}
|
||||
updatedAt={page.updatedAt}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<BacklinksSection
|
||||
incomingCount={counts?.incoming ?? 0}
|
||||
outgoingCount={counts?.outgoing ?? 0}
|
||||
isLoading={countsLoading}
|
||||
onClick={openModal}
|
||||
/>
|
||||
|
||||
<LabelsSection
|
||||
pageId={page.id}
|
||||
canEdit={page.permissions?.canEdit ?? false}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<BacklinksModal
|
||||
pageId={page.id}
|
||||
opened={modalOpened}
|
||||
onClose={closeModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PeopleSection({
|
||||
creator,
|
||||
lastUpdatedBy,
|
||||
}: {
|
||||
creator: { id: string; name: string; avatarUrl: string } | null;
|
||||
lastUpdatedBy: { id: string; name: string; avatarUrl: string } | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<PersonRow label={t("Created by")} person={creator} />
|
||||
<PersonRow label={t("Last updated by")} person={lastUpdatedBy} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonRow({
|
||||
label,
|
||||
person,
|
||||
}: {
|
||||
label: string;
|
||||
person: { id: string; name: string; avatarUrl: string } | null;
|
||||
}) {
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
{person ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<CustomAvatar
|
||||
avatarUrl={person.avatarUrl}
|
||||
name={person.name}
|
||||
size={20}
|
||||
radius="xl"
|
||||
/>
|
||||
<Text size="sm" lineClamp={1}>
|
||||
{person.name}
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsSection({
|
||||
wordCount,
|
||||
characterCount,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
}: {
|
||||
wordCount: number;
|
||||
characterCount: number;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={500} c="dimmed">
|
||||
{t("Stats")}
|
||||
</Text>
|
||||
<StatRow label={t("Word count")} value={String(wordCount)} />
|
||||
<StatRow label={t("Characters")} value={String(characterCount)} />
|
||||
<StatRow
|
||||
label={t("Created")}
|
||||
value={formattedDate(new Date(createdAt))}
|
||||
/>
|
||||
<StatRow
|
||||
label={t("Last updated")}
|
||||
value={timeAgo(new Date(updatedAt))}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function StatRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm">{value}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function BacklinksSection({
|
||||
incomingCount,
|
||||
outgoingCount,
|
||||
isLoading,
|
||||
onClick,
|
||||
}: {
|
||||
incomingCount: number;
|
||||
outgoingCount: number;
|
||||
isLoading: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={500} c="dimmed">
|
||||
{t("Backlinks")}
|
||||
</Text>
|
||||
<BacklinksRow
|
||||
label={t("Incoming links")}
|
||||
count={incomingCount}
|
||||
isLoading={isLoading}
|
||||
onClick={onClick}
|
||||
/>
|
||||
<BacklinksRow
|
||||
label={t("Outgoing links")}
|
||||
count={outgoingCount}
|
||||
isLoading={isLoading}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function BacklinksRow({
|
||||
label,
|
||||
count,
|
||||
isLoading,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
count: number;
|
||||
isLoading: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<UnstyledButton
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: "4px 4px",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{isLoading ? (
|
||||
<Skeleton height={18} width={20} />
|
||||
) : (
|
||||
<Text size="sm">{count}</Text>
|
||||
)}
|
||||
<IconChevronRight size={16} stroke={2} color="var(--mantine-color-dimmed)" />
|
||||
</Group>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
getBacklinks,
|
||||
getBacklinksCount,
|
||||
} from "@/features/page-details/services/backlinks-service.ts";
|
||||
import {
|
||||
BacklinkDirection,
|
||||
IBacklinkCount,
|
||||
} from "@/features/page-details/types/backlink.types.ts";
|
||||
|
||||
const BACKLINKS_STALE_TIME = 30 * 1000;
|
||||
const BACKLINKS_PAGE_LIMIT = 100;
|
||||
|
||||
export function useBacklinksCountQuery(pageId: string | undefined) {
|
||||
return useQuery<IBacklinkCount>({
|
||||
queryKey: ["backlinks-count", pageId],
|
||||
queryFn: () => getBacklinksCount(pageId as string),
|
||||
enabled: !!pageId,
|
||||
staleTime: BACKLINKS_STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
export function useBacklinksQuery(
|
||||
pageId: string | undefined,
|
||||
direction: BacklinkDirection,
|
||||
enabled: boolean,
|
||||
) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["backlinks", pageId, direction],
|
||||
queryFn: ({ pageParam }) =>
|
||||
getBacklinks({
|
||||
pageId: pageId as string,
|
||||
direction,
|
||||
cursor: pageParam,
|
||||
limit: BACKLINKS_PAGE_LIMIT,
|
||||
}),
|
||||
enabled: enabled && !!pageId,
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage
|
||||
? (lastPage.meta.nextCursor ?? undefined)
|
||||
: undefined,
|
||||
staleTime: BACKLINKS_STALE_TIME,
|
||||
});
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import api from "@/lib/api-client";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import {
|
||||
IBacklinkCount,
|
||||
IBacklinkPageItem,
|
||||
IBacklinksListParams,
|
||||
} from "@/features/page-details/types/backlink.types.ts";
|
||||
|
||||
export async function getBacklinksCount(
|
||||
pageId: string,
|
||||
): Promise<IBacklinkCount> {
|
||||
const req = await api.post<IBacklinkCount>("/pages/backlinks-count", {
|
||||
pageId,
|
||||
});
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getBacklinks(
|
||||
params: IBacklinksListParams,
|
||||
): Promise<IPagination<IBacklinkPageItem>> {
|
||||
const req = await api.post<IPagination<IBacklinkPageItem>>(
|
||||
"/pages/backlinks",
|
||||
params,
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
export type BacklinkDirection = "incoming" | "outgoing";
|
||||
|
||||
export interface IBacklinkCount {
|
||||
incoming: number;
|
||||
outgoing: number;
|
||||
}
|
||||
|
||||
export interface IBacklinkPageItem {
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string | null;
|
||||
icon: string | null;
|
||||
spaceId: string;
|
||||
space: { id: string; slug: string; name: string } | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface IBacklinksListParams {
|
||||
pageId: string;
|
||||
direction: BacklinkDirection;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useMemo, useEffect } from "react";
|
||||
import {
|
||||
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");
|
||||
@@ -1,57 +0,0 @@
|
||||
import { userAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import { updateUser } from "@/features/user/services/user-service";
|
||||
import { Badge, Group, Switch, Text } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ResponsiveSettingsRow,
|
||||
ResponsiveSettingsContent,
|
||||
ResponsiveSettingsControl,
|
||||
} from "@/components/ui/responsive-settings-row";
|
||||
|
||||
export default function FixedToolbarPref() {
|
||||
const { t } = useTranslation();
|
||||
const [user, setUser] = useAtom(userAtom);
|
||||
const [checked, setChecked] = useState(
|
||||
user.settings?.preferences?.editorToolbar ?? false,
|
||||
);
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
setChecked(value);
|
||||
try {
|
||||
const updatedUser = await updateUser({ editorToolbar: value });
|
||||
setUser(updatedUser);
|
||||
} catch {
|
||||
setChecked(!value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponsiveSettingsRow>
|
||||
<ResponsiveSettingsContent>
|
||||
<Group gap="xs">
|
||||
<Text size="md">{t("Fixed editor toolbar")}</Text>
|
||||
<Badge size="xs" color="gray" variant="light">
|
||||
{t("Experimental")}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"Show a formatting toolbar above the editor with quick access to common actions.",
|
||||
)}
|
||||
</Text>
|
||||
</ResponsiveSettingsContent>
|
||||
|
||||
<ResponsiveSettingsControl>
|
||||
<Switch
|
||||
labelPosition="left"
|
||||
defaultChecked={checked}
|
||||
onChange={handleChange}
|
||||
aria-label={t("Toggle fixed editor toolbar")}
|
||||
/>
|
||||
</ResponsiveSettingsControl>
|
||||
</ResponsiveSettingsRow>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,6 @@ export interface IUser {
|
||||
deletedAt: Date;
|
||||
fullPageWidth: boolean; // used for update
|
||||
pageEditMode: string; // used for update
|
||||
editorToolbar: boolean; // used for update
|
||||
notificationPageUpdates: boolean; // used for update
|
||||
notificationPageUserMention: boolean; // used for update
|
||||
notificationCommentUserMention: boolean; // used for update
|
||||
@@ -38,7 +37,6 @@ export interface IUserSettings {
|
||||
preferences: {
|
||||
fullPageWidth: boolean;
|
||||
pageEditMode: string;
|
||||
editorToolbar: boolean;
|
||||
};
|
||||
notifications?: {
|
||||
"page.updated"?: boolean;
|
||||
|
||||
@@ -60,10 +60,6 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
||||
}
|
||||
}, [data, isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = i18n.resolvedLanguage || i18n.language || "en-US";
|
||||
}, [i18n.language, i18n.resolvedLanguage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (entitlements) {
|
||||
setEntitlements(entitlements);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import AccountLanguage from "@/features/user/components/account-language.tsx";
|
||||
import AccountTheme from "@/features/user/components/account-theme.tsx";
|
||||
import PageWidthPref from "@/features/user/components/page-width-pref.tsx";
|
||||
import PageEditPref from "@/features/user/components/page-state-pref";
|
||||
import FixedToolbarPref from "@/features/user/components/fixed-toolbar-pref";
|
||||
import NotificationPref from "@/features/user/components/notification-pref";
|
||||
import { getAppName } from "@/lib/config.ts";
|
||||
import { Divider } from "@mantine/core";
|
||||
@@ -38,10 +37,6 @@ export default function AccountPreferences() {
|
||||
|
||||
<Divider my={"md"} />
|
||||
|
||||
<FixedToolbarPref />
|
||||
|
||||
<Divider my={"md"} />
|
||||
|
||||
<NotificationPref />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 {}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Label } from '@docmost/db/types/entity.types';
|
||||
import { LabelRepo, LabelType } from '@docmost/db/repos/label/label.repo';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { normalizeLabelName } from './utils';
|
||||
|
||||
@Injectable()
|
||||
export class LabelService {
|
||||
constructor(
|
||||
private readonly labelRepo: LabelRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
|
||||
async addLabelsToPage(
|
||||
pageId: string,
|
||||
names: string[],
|
||||
workspaceId: string,
|
||||
): Promise<Label[]> {
|
||||
const attached: Label[] = [];
|
||||
await executeTx(this.db, async (trx) => {
|
||||
for (const name of names) {
|
||||
const label = await this.labelRepo.findOrCreate(
|
||||
name.trim(),
|
||||
workspaceId,
|
||||
LabelType.PAGE,
|
||||
trx,
|
||||
);
|
||||
await this.labelRepo.addLabelToPage(pageId, label.id, trx);
|
||||
attached.push(label);
|
||||
}
|
||||
});
|
||||
return attached;
|
||||
}
|
||||
|
||||
async removeLabelFromPage(
|
||||
pageId: string,
|
||||
labelId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await executeTx(this.db, async (trx) => {
|
||||
const label = await this.labelRepo.findById(labelId, trx);
|
||||
if (!label || label.workspaceId !== workspaceId) {
|
||||
throw new NotFoundException('Label not found');
|
||||
}
|
||||
|
||||
await this.labelRepo.removeLabelFromPage(
|
||||
pageId,
|
||||
labelId,
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
|
||||
const count = await this.labelRepo.getLabelPageCount(
|
||||
labelId,
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
if (count === 0) {
|
||||
await this.labelRepo.deleteLabel(labelId, workspaceId, trx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getPageLabels(pageId: string, pagination: PaginationOptions) {
|
||||
return this.labelRepo.findLabelsByPageId(pageId, pagination);
|
||||
}
|
||||
|
||||
async getLabels(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
type: LabelType,
|
||||
pagination: PaginationOptions,
|
||||
) {
|
||||
return this.labelRepo.findLabels(
|
||||
workspaceId,
|
||||
userId,
|
||||
type,
|
||||
pagination,
|
||||
);
|
||||
}
|
||||
|
||||
async findPagesByLabel(
|
||||
labelId: string,
|
||||
userId: string,
|
||||
opts: {
|
||||
spaceId?: string;
|
||||
query?: string;
|
||||
pagination: PaginationOptions;
|
||||
},
|
||||
) {
|
||||
const result = await this.labelRepo.findPagesByLabelId(labelId, userId, opts);
|
||||
if (result.items.length === 0) return result;
|
||||
|
||||
const accessibleIds = await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds: result.items.map((p) => p.id),
|
||||
userId,
|
||||
spaceId: opts.spaceId,
|
||||
});
|
||||
const accessible = new Set(accessibleIds);
|
||||
return {
|
||||
items: result.items.filter((p) => accessible.has(p.id)),
|
||||
meta: result.meta,
|
||||
};
|
||||
}
|
||||
|
||||
async getLabelInfo(
|
||||
name: string,
|
||||
type: LabelType,
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
spaceId?: string,
|
||||
) {
|
||||
const normalized = normalizeLabelName(name);
|
||||
const label = await this.labelRepo.findByNameAndWorkspace(
|
||||
normalized,
|
||||
workspaceId,
|
||||
type,
|
||||
);
|
||||
|
||||
// Uniform response shape — never expose whether the label row actually
|
||||
// exists. Real labels with no accessible pages and nonexistent labels
|
||||
// both look like { name, usageCount: 0 } from the outside.
|
||||
const usageCount = label
|
||||
? await this.labelRepo.getLabelPageCountForUser(
|
||||
label.id,
|
||||
userId,
|
||||
spaceId,
|
||||
)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
name: normalized,
|
||||
usageCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// Canonical form for label names: trimmed, whitespace collapsed to hyphens,
|
||||
// lowercase. Applied at storage AND every lookup path so callers don't have
|
||||
// to know the rule.
|
||||
export function normalizeLabelName(name: string): string {
|
||||
return name.trim().replace(/\s+/g, '-').toLowerCase();
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { IsIn, IsNotEmpty, IsString } from 'class-validator';
|
||||
import { PageIdDto } from './page.dto';
|
||||
|
||||
export type BacklinkDirection = 'incoming' | 'outgoing';
|
||||
|
||||
export class BacklinksListDto extends PageIdDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsIn(['incoming', 'outgoing'])
|
||||
direction: BacklinkDirection;
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { PageService } from './services/page.service';
|
||||
import { BacklinkService } from './services/backlink.service';
|
||||
import { PageAccessService } from './page-access/page-access.service';
|
||||
import { CreatePageDto } from './dto/create-page.dto';
|
||||
import { UpdatePageDto } from './dto/update-page.dto';
|
||||
@@ -39,9 +38,6 @@ import { RecentPageDto } from './dto/recent-page.dto';
|
||||
import { CreatedByUserDto } from './dto/created-by-user.dto';
|
||||
import { DuplicatePageDto } from './dto/duplicate-page.dto';
|
||||
import { DeletedPageDto } from './dto/deleted-page.dto';
|
||||
import { BacklinksListDto } from './dto/backlink.dto';
|
||||
import { LabelService } from '../label/label.service';
|
||||
import { AddLabelsDto, RemoveLabelDto } from '../label/dto/label.dto';
|
||||
import {
|
||||
jsonToHtml,
|
||||
jsonToMarkdown,
|
||||
@@ -62,8 +58,6 @@ export class PageController {
|
||||
private readonly pageHistoryService: PageHistoryService,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
private readonly backlinkService: BacklinkService,
|
||||
private readonly labelService: LabelService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
@@ -102,100 +96,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(
|
||||
@Body() dto: PageIdDto,
|
||||
@AuthUser() user: User,
|
||||
): Promise<{ incoming: number; outgoing: number }> {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
return this.backlinkService.countByPageId(page.id, user.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('backlinks')
|
||||
async getBacklinks(
|
||||
@Body() dto: BacklinksListDto,
|
||||
@Body() pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
return this.backlinkService.findByPageId(
|
||||
page.id,
|
||||
dto.direction,
|
||||
user.id,
|
||||
pagination,
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('create')
|
||||
async create(
|
||||
|
||||
@@ -3,28 +3,15 @@ import { PageService } from './services/page.service';
|
||||
import { PageController } from './page.controller';
|
||||
import { PageHistoryService } from './services/page-history.service';
|
||||
import { TrashCleanupService } from './services/trash-cleanup.service';
|
||||
import { BacklinkService } from './services/backlink.service';
|
||||
import { StorageModule } from '../../integrations/storage/storage.module';
|
||||
import { CollaborationModule } from '../../collaboration/collaboration.module';
|
||||
import { WatcherModule } from '../watcher/watcher.module';
|
||||
import { TransclusionModule } from './transclusion/transclusion.module';
|
||||
import { LabelModule } from '../label/label.module';
|
||||
|
||||
@Module({
|
||||
controllers: [PageController],
|
||||
providers: [
|
||||
PageService,
|
||||
PageHistoryService,
|
||||
TrashCleanupService,
|
||||
BacklinkService,
|
||||
],
|
||||
providers: [PageService, PageHistoryService, TrashCleanupService],
|
||||
exports: [PageService, PageHistoryService],
|
||||
imports: [
|
||||
StorageModule,
|
||||
CollaborationModule,
|
||||
WatcherModule,
|
||||
TransclusionModule,
|
||||
LabelModule,
|
||||
],
|
||||
imports: [StorageModule, CollaborationModule, WatcherModule, TransclusionModule],
|
||||
})
|
||||
export class PageModule {}
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { BacklinkService } from './backlink.service';
|
||||
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
|
||||
describe('BacklinkService.countByPageId', () => {
|
||||
let service: BacklinkService;
|
||||
let backlinkRepo: jest.Mocked<BacklinkRepo>;
|
||||
let permissionRepo: jest.Mocked<PagePermissionRepo>;
|
||||
|
||||
const pageId = '00000000-0000-0000-0000-000000000001';
|
||||
const userId = '00000000-0000-0000-0000-000000000099';
|
||||
|
||||
beforeEach(async () => {
|
||||
const backlinkRepoMock: jest.Mocked<Partial<BacklinkRepo>> = {
|
||||
findRelatedPageIds: jest.fn(),
|
||||
findPagesByIdsPaginated: jest.fn(),
|
||||
};
|
||||
const permissionRepoMock: jest.Mocked<Partial<PagePermissionRepo>> = {
|
||||
filterAccessiblePageIds: jest.fn(),
|
||||
};
|
||||
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
BacklinkService,
|
||||
{ provide: BacklinkRepo, useValue: backlinkRepoMock },
|
||||
{ provide: PagePermissionRepo, useValue: permissionRepoMock },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(BacklinkService);
|
||||
backlinkRepo = module.get(BacklinkRepo) as jest.Mocked<BacklinkRepo>;
|
||||
permissionRepo = module.get(
|
||||
PagePermissionRepo,
|
||||
) as jest.Mocked<PagePermissionRepo>;
|
||||
});
|
||||
|
||||
it('returns post-filter counts for both directions', async () => {
|
||||
backlinkRepo.findRelatedPageIds.mockImplementation(async (_id, dir) =>
|
||||
dir === 'incoming' ? ['a', 'b', 'c'] : ['x', 'y'],
|
||||
);
|
||||
permissionRepo.filterAccessiblePageIds.mockImplementation(
|
||||
async ({ pageIds }) =>
|
||||
pageIds.filter((id) => id !== 'b' && id !== 'y'),
|
||||
);
|
||||
|
||||
const result = await service.countByPageId(pageId, userId);
|
||||
|
||||
expect(result).toEqual({ incoming: 2, outgoing: 1 });
|
||||
expect(permissionRepo.filterAccessiblePageIds).toHaveBeenCalledWith({
|
||||
pageIds: ['a', 'b', 'c'],
|
||||
userId,
|
||||
});
|
||||
expect(permissionRepo.filterAccessiblePageIds).toHaveBeenCalledWith({
|
||||
pageIds: ['x', 'y'],
|
||||
userId,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips the permission filter when there are no candidates', async () => {
|
||||
backlinkRepo.findRelatedPageIds.mockResolvedValue([]);
|
||||
permissionRepo.filterAccessiblePageIds.mockResolvedValue([]);
|
||||
|
||||
const result = await service.countByPageId(pageId, userId);
|
||||
|
||||
expect(result).toEqual({ incoming: 0, outgoing: 0 });
|
||||
expect(permissionRepo.filterAccessiblePageIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes the userId to findRelatedPageIds so the repo can apply space membership filtering', async () => {
|
||||
backlinkRepo.findRelatedPageIds.mockResolvedValue([]);
|
||||
|
||||
await service.countByPageId(pageId, userId);
|
||||
|
||||
expect(backlinkRepo.findRelatedPageIds).toHaveBeenCalledWith(
|
||||
pageId,
|
||||
'incoming',
|
||||
userId,
|
||||
);
|
||||
expect(backlinkRepo.findRelatedPageIds).toHaveBeenCalledWith(
|
||||
pageId,
|
||||
'outgoing',
|
||||
userId,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BacklinkService.findByPageId', () => {
|
||||
let service: BacklinkService;
|
||||
let backlinkRepo: jest.Mocked<BacklinkRepo>;
|
||||
let permissionRepo: jest.Mocked<PagePermissionRepo>;
|
||||
|
||||
const pageId = '00000000-0000-0000-0000-000000000001';
|
||||
const userId = '00000000-0000-0000-0000-000000000099';
|
||||
|
||||
beforeEach(async () => {
|
||||
const backlinkRepoMock: jest.Mocked<Partial<BacklinkRepo>> = {
|
||||
findRelatedPageIds: jest.fn(),
|
||||
findPagesByIdsPaginated: jest.fn(),
|
||||
};
|
||||
const permissionRepoMock: jest.Mocked<Partial<PagePermissionRepo>> = {
|
||||
filterAccessiblePageIds: jest.fn(),
|
||||
};
|
||||
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
BacklinkService,
|
||||
{ provide: BacklinkRepo, useValue: backlinkRepoMock },
|
||||
{ provide: PagePermissionRepo, useValue: permissionRepoMock },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(BacklinkService);
|
||||
backlinkRepo = module.get(BacklinkRepo) as jest.Mocked<BacklinkRepo>;
|
||||
permissionRepo = module.get(
|
||||
PagePermissionRepo,
|
||||
) as jest.Mocked<PagePermissionRepo>;
|
||||
});
|
||||
|
||||
it('passes filtered ids through to the paginated repo call', async () => {
|
||||
backlinkRepo.findRelatedPageIds.mockResolvedValue(['a', 'b']);
|
||||
permissionRepo.filterAccessiblePageIds.mockResolvedValue(['a']);
|
||||
backlinkRepo.findPagesByIdsPaginated.mockResolvedValue({
|
||||
items: [],
|
||||
meta: {
|
||||
limit: 20,
|
||||
hasNextPage: false,
|
||||
hasPrevPage: false,
|
||||
nextCursor: null,
|
||||
prevCursor: null,
|
||||
},
|
||||
} as any);
|
||||
|
||||
await service.findByPageId(pageId, 'incoming', userId, { limit: 20 } as any);
|
||||
|
||||
expect(backlinkRepo.findPagesByIdsPaginated).toHaveBeenCalledWith(
|
||||
['a'],
|
||||
expect.objectContaining({ limit: 20 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('hands an empty list to the repo when there are no accessible ids', async () => {
|
||||
backlinkRepo.findRelatedPageIds.mockResolvedValue([]);
|
||||
backlinkRepo.findPagesByIdsPaginated.mockResolvedValue({
|
||||
items: [],
|
||||
meta: {
|
||||
limit: 20,
|
||||
hasNextPage: false,
|
||||
hasPrevPage: false,
|
||||
nextCursor: null,
|
||||
prevCursor: null,
|
||||
},
|
||||
} as any);
|
||||
|
||||
await service.findByPageId(pageId, 'incoming', userId, { limit: 20 } as any);
|
||||
|
||||
expect(backlinkRepo.findPagesByIdsPaginated).toHaveBeenCalledWith(
|
||||
[],
|
||||
expect.objectContaining({ limit: 20 }),
|
||||
);
|
||||
expect(permissionRepo.filterAccessiblePageIds).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
|
||||
export type BacklinkDirection = 'incoming' | 'outgoing';
|
||||
|
||||
@Injectable()
|
||||
export class BacklinkService {
|
||||
constructor(
|
||||
private readonly backlinkRepo: BacklinkRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
) {}
|
||||
|
||||
async countByPageId(
|
||||
pageId: string,
|
||||
userId: string,
|
||||
): Promise<{ incoming: number; outgoing: number }> {
|
||||
const [incomingIds, outgoingIds] = await Promise.all([
|
||||
this.accessibleRelatedIds(pageId, 'incoming', userId),
|
||||
this.accessibleRelatedIds(pageId, 'outgoing', userId),
|
||||
]);
|
||||
return { incoming: incomingIds.length, outgoing: outgoingIds.length };
|
||||
}
|
||||
|
||||
async findByPageId(
|
||||
pageId: string,
|
||||
direction: BacklinkDirection,
|
||||
userId: string,
|
||||
pagination: PaginationOptions,
|
||||
) {
|
||||
const accessibleIds = await this.accessibleRelatedIds(
|
||||
pageId,
|
||||
direction,
|
||||
userId,
|
||||
);
|
||||
return this.backlinkRepo.findPagesByIdsPaginated(accessibleIds, pagination);
|
||||
}
|
||||
|
||||
private async accessibleRelatedIds(
|
||||
pageId: string,
|
||||
direction: BacklinkDirection,
|
||||
userId: string,
|
||||
): Promise<string[]> {
|
||||
const candidateIds = await this.backlinkRepo.findRelatedPageIds(
|
||||
pageId,
|
||||
direction,
|
||||
userId,
|
||||
);
|
||||
if (candidateIds.length === 0) return [];
|
||||
return this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds: candidateIds,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -425,7 +425,11 @@ export class PageService {
|
||||
|
||||
if (pageIdsToMove.length > 1) {
|
||||
// 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();
|
||||
|
||||
|
||||
@@ -22,10 +22,6 @@ export class UpdateUserDto extends PartialType(
|
||||
@IsIn(['read', 'edit'])
|
||||
pageEditMode: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
editorToolbar: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
locale: string;
|
||||
|
||||
@@ -61,14 +61,6 @@ export class UserService {
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateUserDto.editorToolbar !== 'undefined') {
|
||||
return this.userRepo.updatePreference(
|
||||
userId,
|
||||
'editorToolbar',
|
||||
updateUserDto.editorToolbar,
|
||||
);
|
||||
}
|
||||
|
||||
const notificationSettings: Record<string, NotificationSettingKey> = {
|
||||
notificationPageUpdates: 'page.updated',
|
||||
notificationPageUserMention: 'page.userMention',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -7,20 +7,10 @@ import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import {
|
||||
executeWithCursorPagination,
|
||||
emptyCursorPaginationResult,
|
||||
} from '@docmost/db/pagination/cursor-pagination';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
|
||||
@Injectable()
|
||||
export class BacklinkRepo {
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
) {}
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
async findById(
|
||||
backlinkId: string,
|
||||
@@ -79,84 +69,4 @@ export class BacklinkRepo {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db.deleteFrom('backlinks').where('id', '=', backlinkId).execute();
|
||||
}
|
||||
|
||||
async findRelatedPageIds(
|
||||
pageId: string,
|
||||
direction: 'incoming' | 'outgoing',
|
||||
userId: string,
|
||||
): Promise<string[]> {
|
||||
const userSpaceIds = this.spaceMemberRepo.getUserSpaceIdsQuery(userId);
|
||||
|
||||
if (direction === 'incoming') {
|
||||
const rows = await this.db
|
||||
.selectFrom('backlinks')
|
||||
.innerJoin('pages', 'pages.id', 'backlinks.sourcePageId')
|
||||
.select('backlinks.sourcePageId as relatedId')
|
||||
.where('backlinks.targetPageId', '=', pageId)
|
||||
.where('pages.deletedAt', 'is', null)
|
||||
.where('pages.spaceId', 'in', userSpaceIds)
|
||||
.execute();
|
||||
return rows.map((r) => r.relatedId);
|
||||
}
|
||||
|
||||
const rows = await this.db
|
||||
.selectFrom('backlinks')
|
||||
.innerJoin('pages', 'pages.id', 'backlinks.targetPageId')
|
||||
.select('backlinks.targetPageId as relatedId')
|
||||
.where('backlinks.sourcePageId', '=', pageId)
|
||||
.where('pages.deletedAt', 'is', null)
|
||||
.where('pages.spaceId', 'in', userSpaceIds)
|
||||
.execute();
|
||||
return rows.map((r) => r.relatedId);
|
||||
}
|
||||
|
||||
async findPagesByIdsPaginated(
|
||||
pageIds: string[],
|
||||
pagination: PaginationOptions,
|
||||
) {
|
||||
if (pageIds.length === 0) {
|
||||
return emptyCursorPaginationResult<{
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string | null;
|
||||
icon: string | null;
|
||||
spaceId: string;
|
||||
updatedAt: Date;
|
||||
space: { id: string; slug: string; name: string } | null;
|
||||
}>(pagination.limit);
|
||||
}
|
||||
|
||||
const query = this.db
|
||||
.selectFrom('pages')
|
||||
.select((eb) => [
|
||||
'pages.id',
|
||||
'pages.slugId',
|
||||
'pages.title',
|
||||
'pages.icon',
|
||||
'pages.spaceId',
|
||||
'pages.updatedAt',
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('spaces')
|
||||
.select(['spaces.id', 'spaces.slug', 'spaces.name'])
|
||||
.whereRef('spaces.id', '=', 'pages.spaceId'),
|
||||
).as('space'),
|
||||
])
|
||||
.where('pages.deletedAt', 'is', null)
|
||||
.where('pages.id', 'in', pageIds);
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [
|
||||
{ expression: 'pages.updatedAt', direction: 'desc', key: 'updatedAt' },
|
||||
{ expression: 'pages.id', direction: 'desc', key: 'id' },
|
||||
],
|
||||
parseCursor: (cursor) => ({
|
||||
updatedAt: new Date(cursor.updatedAt),
|
||||
id: cursor.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,345 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
||||
import { Label } from '@docmost/db/types/entity.types';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { normalizeLabelName } from '../../../core/label/utils';
|
||||
|
||||
export const LabelType = {
|
||||
PAGE: 'page',
|
||||
SPACE: 'space',
|
||||
} as const;
|
||||
|
||||
export type LabelType = (typeof LabelType)[keyof typeof LabelType];
|
||||
|
||||
@Injectable()
|
||||
export class LabelRepo {
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
) {}
|
||||
|
||||
async findById(
|
||||
labelId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<Label | undefined> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.selectFrom('labels')
|
||||
.selectAll()
|
||||
.where('id', '=', labelId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async findByNameAndWorkspace(
|
||||
name: string,
|
||||
workspaceId: string,
|
||||
type: LabelType,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<Label | undefined> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.selectFrom('labels')
|
||||
.selectAll()
|
||||
.where('name', '=', normalizeLabelName(name))
|
||||
.where('type', '=', type)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async findOrCreate(
|
||||
name: string,
|
||||
workspaceId: string,
|
||||
type: LabelType,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<Label> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const normalizedName = normalizeLabelName(name);
|
||||
|
||||
// DO UPDATE (rather than DO NOTHING) so RETURNING always emits a row,
|
||||
// even on conflict. Avoids a race where a follow-up SELECT could miss a
|
||||
// row inserted by a concurrent transaction. The set is a no-op write.
|
||||
return db
|
||||
.insertInto('labels')
|
||||
.values({ name: normalizedName, type, workspaceId })
|
||||
.onConflict((oc) =>
|
||||
oc
|
||||
.columns(['name', 'type', 'workspaceId'])
|
||||
.doUpdateSet({ name: normalizedName }),
|
||||
)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
async findLabelsByPageId(pageId: string, pagination: PaginationOptions) {
|
||||
const query = this.db
|
||||
.selectFrom('labels')
|
||||
.innerJoin('pageLabels', 'pageLabels.labelId', 'labels.id')
|
||||
.select([
|
||||
'labels.id',
|
||||
'labels.name',
|
||||
'labels.type',
|
||||
'labels.createdAt',
|
||||
'labels.updatedAt',
|
||||
'labels.workspaceId',
|
||||
'pageLabels.id as joinId',
|
||||
])
|
||||
.where('pageLabels.pageId', '=', pageId)
|
||||
.where('labels.type', '=', LabelType.PAGE);
|
||||
|
||||
const result = await executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [
|
||||
{ expression: 'pageLabels.id', direction: 'asc', key: 'joinId' },
|
||||
],
|
||||
parseCursor: (cursor) => ({
|
||||
joinId: cursor.joinId,
|
||||
}),
|
||||
});
|
||||
|
||||
// joinId is an internal pagination cursor; don't leak it to callers.
|
||||
return {
|
||||
...result,
|
||||
items: result.items.map(({ joinId: _joinId, ...rest }) => rest),
|
||||
};
|
||||
}
|
||||
|
||||
async findLabels(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
type: LabelType,
|
||||
pagination: PaginationOptions,
|
||||
) {
|
||||
// Label visibility is scoped to space membership: a label surfaces if it
|
||||
// is attached to any non-deleted page in a space the user belongs to.
|
||||
// Per-page permission restrictions intentionally do not narrow this
|
||||
// further — labels are a space-level concept, not a page-level one.
|
||||
let query = this.db
|
||||
.selectFrom('labels')
|
||||
.select(['id', 'name', 'type', 'createdAt', 'updatedAt', 'workspaceId'])
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('type', '=', type)
|
||||
.where(
|
||||
'id',
|
||||
'in',
|
||||
this.db
|
||||
.selectFrom('pageLabels')
|
||||
.innerJoin('pages', 'pages.id', 'pageLabels.pageId')
|
||||
.select('pageLabels.labelId')
|
||||
.where('pages.deletedAt', 'is', null)
|
||||
.where(
|
||||
'pages.spaceId',
|
||||
'in',
|
||||
this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (pagination.query) {
|
||||
query = query.where(
|
||||
'name',
|
||||
'like',
|
||||
`%${pagination.query.toLowerCase()}%`,
|
||||
);
|
||||
}
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [
|
||||
{ expression: 'name', direction: 'asc' },
|
||||
{ expression: 'id', direction: 'asc' },
|
||||
],
|
||||
parseCursor: (cursor) => ({
|
||||
name: cursor.name,
|
||||
id: cursor.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async addLabelToPage(
|
||||
pageId: string,
|
||||
labelId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.insertInto('pageLabels')
|
||||
.values({ pageId, labelId })
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
async removeLabelFromPage(
|
||||
pageId: string,
|
||||
labelId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.deleteFrom('pageLabels')
|
||||
.where('pageId', '=', pageId)
|
||||
.where('labelId', '=', labelId)
|
||||
.where((eb) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('labels')
|
||||
.select('id')
|
||||
.whereRef('labels.id', '=', 'pageLabels.labelId')
|
||||
.where('labels.workspaceId', '=', workspaceId),
|
||||
),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async getPageLabelCount(
|
||||
pageId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<number> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const result = await db
|
||||
.selectFrom('pageLabels')
|
||||
.select((eb) => eb.fn.count('id').as('count'))
|
||||
.where('pageId', '=', pageId)
|
||||
.executeTakeFirst();
|
||||
|
||||
return Number(result?.count ?? 0);
|
||||
}
|
||||
|
||||
async getLabelPageCount(
|
||||
labelId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<number> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const result = await db
|
||||
.selectFrom('pageLabels')
|
||||
.innerJoin('labels', 'labels.id', 'pageLabels.labelId')
|
||||
.select((eb) => eb.fn.count('pageLabels.id').as('count'))
|
||||
.where('pageLabels.labelId', '=', labelId)
|
||||
.where('labels.workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
return Number(result?.count ?? 0);
|
||||
}
|
||||
|
||||
async deleteLabel(
|
||||
labelId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.deleteFrom('labels')
|
||||
.where('id', '=', labelId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findPagesByLabelId(
|
||||
labelId: string,
|
||||
userId: string,
|
||||
opts: {
|
||||
spaceId?: string;
|
||||
query?: string;
|
||||
pagination: PaginationOptions;
|
||||
},
|
||||
) {
|
||||
let query = this.db
|
||||
.selectFrom('pages')
|
||||
.innerJoin('pageLabels', 'pageLabels.pageId', 'pages.id')
|
||||
.select((eb) => [
|
||||
'pages.id',
|
||||
'pages.slugId',
|
||||
'pages.title',
|
||||
'pages.icon',
|
||||
'pages.spaceId',
|
||||
'pages.createdAt',
|
||||
'pages.updatedAt',
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('spaces')
|
||||
.select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo'])
|
||||
.whereRef('spaces.id', '=', 'pages.spaceId'),
|
||||
).as('space'),
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('users')
|
||||
.select(['users.id', 'users.name', 'users.avatarUrl'])
|
||||
.whereRef('users.id', '=', 'pages.creatorId'),
|
||||
).as('creator'),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('labels')
|
||||
.innerJoin('pageLabels as pl', 'pl.labelId', 'labels.id')
|
||||
.select(['labels.id', 'labels.name'])
|
||||
.whereRef('pl.pageId', '=', 'pages.id')
|
||||
.where('labels.type', '=', LabelType.PAGE)
|
||||
.orderBy('pl.id', 'asc'),
|
||||
).as('labels'),
|
||||
])
|
||||
.where('pageLabels.labelId', '=', labelId)
|
||||
.where('pages.deletedAt', 'is', null);
|
||||
|
||||
if (opts.spaceId) {
|
||||
query = query.where('pages.spaceId', '=', opts.spaceId);
|
||||
} else {
|
||||
query = query.where(
|
||||
'pages.spaceId',
|
||||
'in',
|
||||
this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.query) {
|
||||
query = query.where('pages.title', 'ilike', `%${opts.query}%`);
|
||||
}
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: opts.pagination.limit,
|
||||
cursor: opts.pagination.cursor,
|
||||
beforeCursor: opts.pagination.beforeCursor,
|
||||
fields: [
|
||||
{ expression: 'pages.updatedAt', direction: 'desc', key: 'updatedAt' },
|
||||
{ expression: 'pages.id', direction: 'desc', key: 'id' },
|
||||
],
|
||||
parseCursor: (cursor) => ({
|
||||
updatedAt: new Date(cursor.updatedAt),
|
||||
id: cursor.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async getLabelPageCountForUser(
|
||||
labelId: string,
|
||||
userId: string,
|
||||
spaceId?: string,
|
||||
): Promise<number> {
|
||||
let query = this.db
|
||||
.selectFrom('pageLabels')
|
||||
.innerJoin('pages', 'pages.id', 'pageLabels.pageId')
|
||||
.select((eb) => eb.fn.count('pageLabels.id').as('count'))
|
||||
.where('pageLabels.labelId', '=', labelId)
|
||||
.where('pages.deletedAt', 'is', null);
|
||||
|
||||
if (spaceId) {
|
||||
query = query.where('pages.spaceId', '=', spaceId);
|
||||
} else {
|
||||
query = query.where(
|
||||
'pages.spaceId',
|
||||
'in',
|
||||
this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
|
||||
);
|
||||
}
|
||||
|
||||
const result = await query.executeTakeFirst();
|
||||
return Number(result?.count ?? 0);
|
||||
}
|
||||
}
|
||||
-18
@@ -459,15 +459,6 @@ export interface Watchers {
|
||||
createdAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
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>;
|
||||
|
||||
+1
-1
@@ -131,7 +131,7 @@
|
||||
"brace-expansion@^5": "5.0.5",
|
||||
"@xmldom/xmldom": "0.8.13",
|
||||
"handlebars": "4.7.9",
|
||||
"axios": "1.16.0",
|
||||
"axios": "1.15.0",
|
||||
"langsmith": "0.5.19",
|
||||
"follow-redirects": "1.16.0",
|
||||
"protobufjs": "7.5.5"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { TableCell as TiptapTableCell } from "@tiptap/extension-table";
|
||||
export const TableCell = TiptapTableCell.extend({
|
||||
name: "tableCell",
|
||||
content:
|
||||
"(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | audio | subpages | attachment | mathBlock | details | codeBlock)+",
|
||||
"(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | attachment | mathBlock | details | codeBlock)+",
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { TableHeader as TiptapTableHeader } from "@tiptap/extension-table";
|
||||
export const TableHeader = TiptapTableHeader.extend({
|
||||
name: "tableHeader",
|
||||
content:
|
||||
"(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | audio | subpages | attachment | mathBlock | details | codeBlock)+",
|
||||
"(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | attachment | mathBlock | details | codeBlock)+",
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
|
||||
Generated
+9
-9
@@ -37,7 +37,7 @@ overrides:
|
||||
brace-expansion@^5: 5.0.5
|
||||
'@xmldom/xmldom': 0.8.13
|
||||
handlebars: 4.7.9
|
||||
axios: 1.16.0
|
||||
axios: 1.15.0
|
||||
langsmith: 0.5.19
|
||||
follow-redirects: 1.16.0
|
||||
protobufjs: 7.5.5
|
||||
@@ -296,8 +296,8 @@ importers:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
axios:
|
||||
specifier: 1.16.0
|
||||
version: 1.16.0
|
||||
specifier: 1.15.0
|
||||
version: 1.15.0
|
||||
blueimp-load-image:
|
||||
specifier: ^5.16.0
|
||||
version: 5.16.0
|
||||
@@ -5449,8 +5449,8 @@ packages:
|
||||
avvio@9.1.0:
|
||||
resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==}
|
||||
|
||||
axios@1.16.0:
|
||||
resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==}
|
||||
axios@1.15.0:
|
||||
resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==}
|
||||
|
||||
babel-jest@30.3.0:
|
||||
resolution: {integrity: sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==}
|
||||
@@ -15853,7 +15853,7 @@ snapshots:
|
||||
'@fastify/error': 4.0.0
|
||||
fastq: 1.17.1
|
||||
|
||||
axios@1.16.0:
|
||||
axios@1.15.0:
|
||||
dependencies:
|
||||
follow-redirects: 1.16.0
|
||||
form-data: 4.0.5
|
||||
@@ -19187,7 +19187,7 @@ snapshots:
|
||||
'@yarnpkg/lockfile': 1.1.0
|
||||
'@yarnpkg/parsers': 3.0.2
|
||||
'@zkochan/js-yaml': 0.0.7
|
||||
axios: 1.16.0
|
||||
axios: 1.15.0
|
||||
cli-cursor: 3.1.0
|
||||
cli-spinners: 2.6.1
|
||||
cliui: 8.0.1
|
||||
@@ -19728,7 +19728,7 @@ snapshots:
|
||||
|
||||
postmark@4.0.7:
|
||||
dependencies:
|
||||
axios: 1.16.0
|
||||
axios: 1.15.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
@@ -21105,7 +21105,7 @@ snapshots:
|
||||
typesense@3.0.5(@babel/runtime@7.29.2):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
axios: 1.16.0
|
||||
axios: 1.15.0
|
||||
loglevel: 1.9.2
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
|
||||
Reference in New Issue
Block a user