mirror of
https://github.com/docmost/docmost.git
synced 2026-06-22 09:01:37 +10:00
Merge branch 'main' into feat/bases
This commit is contained in:
@@ -1000,7 +1000,7 @@
|
||||
"Search pages and spaces...": "Search pages and spaces...",
|
||||
"No results found": "No results found",
|
||||
"You don't have permission to create pages here": "You don't have permission to create pages here",
|
||||
"Chat menu": "Chat menu",
|
||||
"Chat menu for {{title}}": "Chat menu for {{title}}",
|
||||
"API key menu": "API key menu",
|
||||
"Jump to comment selection": "Jump to comment selection",
|
||||
"Slash commands": "Slash commands",
|
||||
@@ -1086,7 +1086,7 @@
|
||||
"Filter": "Filter",
|
||||
"Page title": "Page title",
|
||||
"Page content": "Page content",
|
||||
"Member actions": "Member actions",
|
||||
"Member actions for {{name}}": "Member actions for {{name}}",
|
||||
"Toggle password visibility": "Toggle password visibility",
|
||||
"Send comment": "Send comment",
|
||||
"Token actions": "Token actions",
|
||||
|
||||
@@ -105,7 +105,7 @@ export default function GlobalSidebar() {
|
||||
|
||||
<Divider my="xs" />
|
||||
<div className={classes.section}>
|
||||
<Text className={classes.sectionHeader}>{t("Favorite spaces")}</Text>
|
||||
<Text component="h2" className={classes.sectionHeader}>{t("Favorite spaces")}</Text>
|
||||
{!isFavoritesPending && sortedFavoriteSpaces.length === 0 ? (
|
||||
<Text size="xs" c="dimmed" pl="xs" py={4}>
|
||||
{t("Favorite spaces appear here")}
|
||||
|
||||
@@ -16,13 +16,10 @@ interface CustomAvatarProps {
|
||||
mt?: string | number;
|
||||
}
|
||||
|
||||
// `color.shade` pairs whose contrast meets WCAG AA (4.5:1) in BOTH variants:
|
||||
// - filled: white text on the shade as bg
|
||||
// - light: shade as text on the color's light-bg (10% color.6 over white)
|
||||
// Avoids lime/yellow/green/orange — even their dark shades have weak
|
||||
// contrast. grape and indigo were bumped from .7 to darker shades because
|
||||
// the original picks failed: grape.7 was 4.02/3.61 (both fail) and
|
||||
// indigo.7 was 4.98/4.39 (light fails by a hair).
|
||||
// color.shade picks whose FILLED variant (white text on the shade) meets WCAG AA 4.5:1.
|
||||
// Avoids lime/yellow/green/orange, too light even at dark shades.
|
||||
// For non-filled variants, initials text is forced to the .9 shade at render time:
|
||||
// Mantine otherwise caps light-variant placeholder text at .6, dropping contrast to ~3:1.
|
||||
const SAFE_INITIALS_COLORS: MantineColor[] = [
|
||||
"blue.8",
|
||||
"cyan.9",
|
||||
@@ -54,12 +51,21 @@ function sanitizeInitialsSource(name: string) {
|
||||
export const CustomAvatar = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
CustomAvatarProps
|
||||
>(({ avatarUrl, name, type, color, ...props }: CustomAvatarProps, ref) => {
|
||||
>(({ avatarUrl, name, type, color, variant, ...props }: CustomAvatarProps, ref) => {
|
||||
const avatarLink = getAvatarUrl(avatarUrl, type);
|
||||
const resolvedColor =
|
||||
!color || color === "initials" ? pickInitialsColor(name ?? "") : color;
|
||||
const isInitials = !color || color === "initials";
|
||||
const resolvedColor = isInitials ? pickInitialsColor(name ?? "") : color;
|
||||
const initialsSource = sanitizeInitialsSource(name ?? "");
|
||||
|
||||
const placeholderStyles =
|
||||
isInitials && variant !== "filled"
|
||||
? {
|
||||
placeholder: {
|
||||
color: `var(--mantine-color-${resolvedColor.split(".")[0]}-9)`,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
ref={ref}
|
||||
@@ -67,6 +73,8 @@ export const CustomAvatar = React.forwardRef<
|
||||
name={initialsSource}
|
||||
alt={name}
|
||||
color={resolvedColor}
|
||||
variant={variant}
|
||||
styles={placeholderStyles}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { UnstyledButton } from "@mantine/core";
|
||||
import { type ComponentPropsWithoutRef, forwardRef } from "react";
|
||||
|
||||
// Menu.Item hard-codes role="menuitem"; use as its `component` to restore role="menuitemradio" so aria-checked works.
|
||||
export const RadioMenuItem = forwardRef<
|
||||
HTMLButtonElement,
|
||||
ComponentPropsWithoutRef<"button">
|
||||
>((props, ref) => (
|
||||
<UnstyledButton ref={ref} {...props} role="menuitemradio" />
|
||||
));
|
||||
|
||||
RadioMenuItem.displayName = "RadioMenuItem";
|
||||
@@ -66,6 +66,8 @@ export default function AiChatSidebarItem({
|
||||
[chat.updatedAt, i18n.language],
|
||||
);
|
||||
|
||||
const chatTitle = chat.title || t("Untitled chat");
|
||||
|
||||
useEffect(() => {
|
||||
if (renaming) {
|
||||
// Wait for the input to be mounted before selecting.
|
||||
@@ -120,9 +122,7 @@ export default function AiChatSidebarItem({
|
||||
className={classes.chatItem}
|
||||
data-active={isActive || undefined}
|
||||
>
|
||||
<span className={classes.chatItemTitle}>
|
||||
{chat.title || t("Untitled chat")}
|
||||
</span>
|
||||
<span className={classes.chatItemTitle}>{chatTitle}</span>
|
||||
<span className={classes.chatItemDate}>{formattedDate}</span>
|
||||
<div className={classes.chatItemActions}>
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
@@ -132,7 +132,7 @@ export default function AiChatSidebarItem({
|
||||
size="xs"
|
||||
color="gray"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
aria-label={t("Chat menu")}
|
||||
aria-label={t("Chat menu for {{title}}", { title: chatTitle })}
|
||||
>
|
||||
<IconDots size={14} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef, useEffect, useState } from "react";
|
||||
import { useCallback, useId, useRef, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconArrowUp, IconPaperclip, IconPlayerStopFilled, IconX, IconFile, IconPhoto, IconPlus, IconAt, IconFileText } from "@tabler/icons-react";
|
||||
import { Popover } from "@mantine/core";
|
||||
@@ -107,6 +107,7 @@ export default function ChatInput({
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
||||
const [plusMenuOpen, setPlusMenuOpen] = useState(false);
|
||||
const plusMenuId = useId();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const onSendRef = useRef(onSend);
|
||||
onSendRef.current = onSend;
|
||||
@@ -342,6 +343,7 @@ export default function ChatInput({
|
||||
position="top-start"
|
||||
width={220}
|
||||
shadow="md"
|
||||
withRoles={false}
|
||||
trapFocus
|
||||
returnFocus
|
||||
>
|
||||
@@ -351,13 +353,17 @@ export default function ChatInput({
|
||||
className={classes.plusButton}
|
||||
onClick={() => setPlusMenuOpen((o) => !o)}
|
||||
aria-label="Add content"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={plusMenuOpen}
|
||||
aria-controls={plusMenuOpen ? plusMenuId : undefined}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
</button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={4}>
|
||||
<Popover.Dropdown id={plusMenuId} role="menu" p={4}>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={classes.plusMenuItem}
|
||||
onClick={() => {
|
||||
fileInputRef.current?.click();
|
||||
@@ -377,6 +383,7 @@ export default function ChatInput({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={classes.plusMenuItem}
|
||||
onClick={() => {
|
||||
editor?.commands.insertContent("@");
|
||||
@@ -385,7 +392,7 @@ export default function ChatInput({
|
||||
}}
|
||||
>
|
||||
<IconAt size={16} className={classes.plusMenuIcon} />
|
||||
Mention a page
|
||||
{t("Mention a page")}
|
||||
</button>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
@@ -76,7 +76,6 @@
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-lg) var(--mantine-spacing-lg);
|
||||
}
|
||||
|
||||
/* Empty state - Notion AI style centered layout */
|
||||
.emptyState {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -58,6 +58,7 @@ export default function PdfRenderPage() {
|
||||
title={data.title}
|
||||
content={data.content}
|
||||
pageId={data.pageId}
|
||||
printMode
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -787,18 +787,34 @@ export const getSuggestionItems = ({
|
||||
for (const [group, items] of Object.entries(CommandGroups)) {
|
||||
const filteredItems = items.filter((item) => {
|
||||
if (excludeItems?.has(item.title)) return false;
|
||||
const translatedTitle = i18n.t(item.title);
|
||||
const translatedDescription = i18n.t(item.description);
|
||||
return (
|
||||
fuzzyMatch(search, item.title) ||
|
||||
fuzzyMatch(search, translatedTitle) ||
|
||||
item.description.toLowerCase().includes(search) ||
|
||||
translatedDescription.toLowerCase().includes(search) ||
|
||||
(item.searchTerms &&
|
||||
item.searchTerms.some((term: string) => term.includes(search)))
|
||||
item.searchTerms.some(
|
||||
(term: string) =>
|
||||
term.includes(search) ||
|
||||
i18n.t(term).toLowerCase().includes(search),
|
||||
))
|
||||
);
|
||||
});
|
||||
|
||||
if (filteredItems.length) {
|
||||
filteredGroups[group] = filteredItems.sort((a, b) => {
|
||||
const aTitle = a.title.toLowerCase().includes(search) ? 0 : 1;
|
||||
const bTitle = b.title.toLowerCase().includes(search) ? 0 : 1;
|
||||
const aTitle =
|
||||
a.title.toLowerCase().includes(search) ||
|
||||
i18n.t(a.title).toLowerCase().includes(search)
|
||||
? 0
|
||||
: 1;
|
||||
const bTitle =
|
||||
b.title.toLowerCase().includes(search) ||
|
||||
i18n.t(b.title).toLowerCase().includes(search)
|
||||
? 0
|
||||
: 1;
|
||||
return aTitle - bTitle;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ interface PageEditorProps {
|
||||
title: string;
|
||||
content: any;
|
||||
pageId?: string;
|
||||
printMode?: boolean;
|
||||
/**
|
||||
* When rendering inside a public share, pass the share's id (or key). Lookups
|
||||
* for transclusion content then resolve against the share graph instead of
|
||||
@@ -28,6 +29,7 @@ export default function ReadonlyPageEditor({
|
||||
title,
|
||||
content,
|
||||
pageId,
|
||||
printMode = false,
|
||||
shareId,
|
||||
}: PageEditorProps) {
|
||||
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
|
||||
@@ -48,8 +50,12 @@ export default function ReadonlyPageEditor({
|
||||
}, []);
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
const excludedExtensions = new Set([
|
||||
"uniqueID",
|
||||
...(printMode ? ["tableHeaderPin", "tableReadonlySort"] : []),
|
||||
]);
|
||||
const filteredExtensions = mainExtensions.filter(
|
||||
(ext) => ext.name !== "uniqueID",
|
||||
(ext) => !excludedExtensions.has(ext.name),
|
||||
);
|
||||
|
||||
return [
|
||||
@@ -59,7 +65,7 @@ export default function ReadonlyPageEditor({
|
||||
updateDocument: false,
|
||||
}),
|
||||
];
|
||||
}, []);
|
||||
}, [printMode]);
|
||||
|
||||
const titleExtensions = [
|
||||
Document.extend({
|
||||
|
||||
@@ -163,8 +163,13 @@
|
||||
|
||||
@media print {
|
||||
.tableWrapper.tableHeaderPinned table tr:first-child {
|
||||
position: static;
|
||||
transform: none;
|
||||
position: static !important;
|
||||
top: auto !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.tableReadonlySortChevron {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,4 +273,4 @@
|
||||
.prosemirror-dropcursor-inline {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,9 @@ export default function GroupMembersList() {
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
c="gray"
|
||||
aria-label={t("Member actions")}
|
||||
aria-label={t("Member actions for {{name}}", {
|
||||
name: user.name,
|
||||
})}
|
||||
>
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Group,
|
||||
@@ -31,6 +31,7 @@ import classes from "../notification.module.css";
|
||||
|
||||
export function NotificationPopover() {
|
||||
const { t } = useTranslation();
|
||||
const titleId = useId();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [tab, setTab] = useState<NotificationTab>("direct");
|
||||
const [filter, setFilter] = useState<NotificationFilter>("all");
|
||||
@@ -83,10 +84,11 @@ export function NotificationPopover() {
|
||||
|
||||
<Popover.Dropdown
|
||||
p={0}
|
||||
aria-labelledby={titleId}
|
||||
style={{ width: "min(420px, calc(100vw - 24px))" }}
|
||||
>
|
||||
<Group justify="space-between" px="md" py="sm">
|
||||
<Title order={2} fz="sm" fw={600}>
|
||||
<Title id={titleId} order={2} fz="sm" fw={600}>
|
||||
{t("Notifications")}
|
||||
</Title>
|
||||
<Group gap={4}>
|
||||
|
||||
@@ -35,6 +35,7 @@ import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import classes from "@/features/page/tree/styles/tree.module.css";
|
||||
|
||||
export interface NodeMenuProps {
|
||||
node: SpaceTreeNode;
|
||||
@@ -124,8 +125,9 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
c="gray"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
className={classes.actionIcon}
|
||||
aria-label={t("Page menu for {{name}}", { name: getPageTitle(node.name, node.isBase, t) })}
|
||||
tabIndex={-1}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -209,13 +209,13 @@ function PageArrow({ isOpen, hasChildren, onToggle }: PageArrowProps) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={classes.actionIcon}
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "var(--mantine-color-gray-6)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
@@ -228,7 +228,8 @@ function PageArrow({ isOpen, hasChildren, onToggle }: PageArrowProps) {
|
||||
<ActionIcon
|
||||
size={20}
|
||||
variant="subtle"
|
||||
c="gray"
|
||||
color="gray"
|
||||
className={classes.actionIcon}
|
||||
aria-label={isOpen ? t("Collapse") : t("Expand")}
|
||||
aria-expanded={isOpen}
|
||||
tabIndex={-1}
|
||||
@@ -280,8 +281,9 @@ function CreateNode({
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
c="gray"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
className={classes.actionIcon}
|
||||
aria-label={t("Create subpage of {{name}}", { name: node.name || t("untitled") })}
|
||||
tabIndex={-1}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -57,6 +57,10 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.actionIcon {
|
||||
color: light-dark(var(--mantine-color-dark-3), var(--mantine-color-gray-4));
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
/* min-width: 0 lets a flex child shrink below its content size — required
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||
import { SpaceFilterMenu } from "@/features/space/components/space-filter-menu";
|
||||
import { RadioMenuItem } from "@/components/ui/radio-menu-item";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import classes from "./search-spotlight-filters.module.css";
|
||||
@@ -175,7 +176,7 @@ export function SearchSpotlightFilters({
|
||||
{contentTypeOptions.map((option) => (
|
||||
<Menu.Item
|
||||
key={option.value}
|
||||
role="menuitemradio"
|
||||
component={RadioMenuItem}
|
||||
aria-checked={contentType === option.value}
|
||||
onClick={() =>
|
||||
!option.disabled &&
|
||||
|
||||
@@ -13,6 +13,7 @@ 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";
|
||||
import { RadioMenuItem } from "@/components/ui/radio-menu-item";
|
||||
|
||||
type SpaceFilterMenuProps = {
|
||||
value: string | null;
|
||||
@@ -75,7 +76,7 @@ export function SpaceFilterMenu({
|
||||
|
||||
<ScrollArea.Autosize mah={280}>
|
||||
<Menu.Item
|
||||
role="menuitemradio"
|
||||
component={RadioMenuItem}
|
||||
aria-checked={!value}
|
||||
onClick={() => onChange(null)}
|
||||
>
|
||||
@@ -103,7 +104,7 @@ export function SpaceFilterMenu({
|
||||
{orderedSpaces.map((space) => (
|
||||
<Menu.Item
|
||||
key={space.id}
|
||||
role="menuitemradio"
|
||||
component={RadioMenuItem}
|
||||
aria-checked={value === space.id}
|
||||
onClick={() => onChange(space.id)}
|
||||
>
|
||||
|
||||
@@ -210,7 +210,9 @@ export default function SpaceMembersList({
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
c="gray"
|
||||
aria-label={t("Member actions")}
|
||||
aria-label={t("Member actions for {{name}}", {
|
||||
name: member.name,
|
||||
})}
|
||||
>
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
|
||||
+7
-2
@@ -12,9 +12,14 @@ import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
|
||||
interface Props {
|
||||
userId: string;
|
||||
name: string;
|
||||
deactivatedAt: Date | null;
|
||||
}
|
||||
export default function MemberActionMenu({ userId, deactivatedAt }: Props) {
|
||||
export default function MemberActionMenu({
|
||||
userId,
|
||||
name,
|
||||
deactivatedAt,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const deleteWorkspaceMemberMutation = useDeleteWorkspaceMemberMutation();
|
||||
const deactivateMutation = useDeactivateWorkspaceMemberMutation();
|
||||
@@ -86,7 +91,7 @@ export default function MemberActionMenu({ userId, deactivatedAt }: Props) {
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
c="gray"
|
||||
aria-label={t("Member actions")}
|
||||
aria-label={t("Member actions for {{name}}", { name })}
|
||||
>
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
|
||||
+1
@@ -111,6 +111,7 @@ export default function WorkspaceMembersTable() {
|
||||
{isAdmin && (
|
||||
<MemberActionMenu
|
||||
userId={user.id}
|
||||
name={user.name}
|
||||
deactivatedAt={user.deactivatedAt}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user