snapshot of https://github.com/docmost/docmost.git @ 6205bbeb908fe846f87dd6a2cbf562e24777db38 (2026-09-15)
This commit is contained in:
183
apps/client/src/components/common/avatar-uploader.tsx
Normal file
183
apps/client/src/components/common/avatar-uploader.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import React, { useRef } from "react";
|
||||
import { Menu, Box, Loader } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconTrash, IconUpload } from "@tabler/icons-react";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
|
||||
interface AvatarUploaderProps {
|
||||
currentImageUrl?: string | null;
|
||||
fallbackName?: string;
|
||||
radius?: string | number;
|
||||
size?: string | number;
|
||||
variant?: string;
|
||||
type: AvatarIconType;
|
||||
onUpload: (file: File) => Promise<void>;
|
||||
onRemove: () => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function AvatarUploader({
|
||||
currentImageUrl,
|
||||
fallbackName,
|
||||
radius,
|
||||
variant,
|
||||
size,
|
||||
type,
|
||||
onUpload,
|
||||
onRemove,
|
||||
isLoading = false,
|
||||
disabled = false,
|
||||
}: AvatarUploaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileInputChange = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file || disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 10MB)
|
||||
const maxSizeInBytes = 10 * 1024 * 1024;
|
||||
if (file.size > maxSizeInBytes) {
|
||||
notifications.show({
|
||||
message: t("Image exceeds 10MB limit."),
|
||||
color: "red",
|
||||
});
|
||||
// Reset the input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onUpload(file);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
notifications.show({
|
||||
message: t("Failed to upload image"),
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
|
||||
// Reset the input so the same file can be selected again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
} else {
|
||||
console.error("File input ref is null!");
|
||||
}
|
||||
};
|
||||
|
||||
const actionLabel = {
|
||||
[AvatarIconType.AVATAR]: t("Change avatar"),
|
||||
[AvatarIconType.SPACE_ICON]: t("Change space icon"),
|
||||
[AvatarIconType.WORKSPACE_ICON]: t("Change workspace icon"),
|
||||
}[type];
|
||||
|
||||
// Per WCAG 2.5.3 (Label in Name), the accessible name must include the
|
||||
// visible text. When no image is set, the avatar renders the name's
|
||||
// initials, so prepend the name to the action label.
|
||||
const ariaLabel =
|
||||
!currentImageUrl && fallbackName
|
||||
? `${fallbackName} – ${actionLabel}`
|
||||
: actionLabel;
|
||||
|
||||
const handleRemove = async () => {
|
||||
if (disabled) return;
|
||||
|
||||
try {
|
||||
await onRemove();
|
||||
notifications.show({
|
||||
message: t("Image removed successfully"),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
notifications.show({
|
||||
message: t("Failed to remove image"),
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileInputChange}
|
||||
accept="image/png,image/jpeg,image/jpg"
|
||||
aria-label={ariaLabel}
|
||||
tabIndex={-1}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
|
||||
<Menu shadow="md" width={200} withArrow disabled={disabled || isLoading}>
|
||||
<Menu.Target>
|
||||
<Box style={{ position: "relative", display: "inline-block" }}>
|
||||
<CustomAvatar
|
||||
component="button"
|
||||
size={size}
|
||||
avatarUrl={currentImageUrl}
|
||||
name={fallbackName}
|
||||
aria-label={ariaLabel}
|
||||
aria-haspopup="menu"
|
||||
style={{
|
||||
cursor: disabled || isLoading ? "default" : "pointer",
|
||||
opacity: isLoading ? 0.6 : 1,
|
||||
}}
|
||||
radius={radius}
|
||||
variant={variant}
|
||||
type={type}
|
||||
/>
|
||||
{isLoading && (
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
zIndex: 200,
|
||||
}}
|
||||
>
|
||||
<Loader size="sm" />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconUpload size={16} />}
|
||||
disabled={isLoading || disabled}
|
||||
onClick={handleUploadClick}
|
||||
>
|
||||
{t("Upload image")}
|
||||
</Menu.Item>
|
||||
|
||||
{currentImageUrl && (
|
||||
<Menu.Item
|
||||
leftSection={<IconTrash size={16} />}
|
||||
color="red"
|
||||
onClick={handleRemove}
|
||||
disabled={isLoading || disabled}
|
||||
>
|
||||
{t("Remove image")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
33
apps/client/src/components/common/copy-button.tsx
Normal file
33
apps/client/src/components/common/copy-button.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/core/src/components/CopyButton/CopyButton.tsx - MIT
|
||||
// modified to use the polyfilled clipboard api
|
||||
import React from "react";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { useProps } from "@mantine/core";
|
||||
|
||||
interface CopyButtonProps {
|
||||
/** Children callback, provides current status and copy function as an argument */
|
||||
children: (payload: { copied: boolean; copy: () => void }) => React.ReactNode;
|
||||
|
||||
/** Value that is copied to the clipboard when the button is clicked */
|
||||
value: string;
|
||||
|
||||
/** Copied status timeout in ms @default `1000` */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
timeout: 1000,
|
||||
} satisfies Partial<CopyButtonProps>;
|
||||
|
||||
export function CopyButton(props: CopyButtonProps) {
|
||||
const { children, timeout, value, ...others } = useProps(
|
||||
"CopyButton",
|
||||
defaultProps,
|
||||
props,
|
||||
);
|
||||
const clipboard = useClipboard({ timeout });
|
||||
const copy = () => clipboard.copy(value);
|
||||
return <>{children({ copy, copied: clipboard.copied, ...others })}</>;
|
||||
}
|
||||
|
||||
CopyButton.displayName = "@mantine/core/CopyButton";
|
||||
40
apps/client/src/components/common/copy.tsx
Normal file
40
apps/client/src/components/common/copy.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ActionIcon, MantineColor, MantineSize, Tooltip } from "@mantine/core";
|
||||
import { CopyButton } from "@/components/common/copy-button";
|
||||
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface CopyProps {
|
||||
text: string;
|
||||
size?: MantineSize;
|
||||
color?: MantineColor;
|
||||
/** Override the accessible name (and tooltip) when not yet copied. Lets callers disambiguate adjacent copy buttons for screen readers. */
|
||||
label?: string;
|
||||
}
|
||||
export default function CopyTextButton({ text, size, label }: CopyProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const copyLabel = label ?? t("Copy");
|
||||
|
||||
return (
|
||||
<CopyButton value={text} timeout={2000}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip
|
||||
label={copied ? t("Copied") : copyLabel}
|
||||
withArrow
|
||||
position="right"
|
||||
>
|
||||
<ActionIcon
|
||||
color={copied ? "teal" : "gray"}
|
||||
variant="subtle"
|
||||
onClick={copy}
|
||||
size={size}
|
||||
aria-label={copied ? t("Copied") : copyLabel}
|
||||
>
|
||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
);
|
||||
}
|
||||
232
apps/client/src/components/common/export-modal.tsx
Normal file
232
apps/client/src/components/common/export-modal.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Group,
|
||||
Text,
|
||||
Select,
|
||||
Switch,
|
||||
Divider,
|
||||
Tooltip,
|
||||
Badge,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
exportPage,
|
||||
exportPageToDocx,
|
||||
} from "@/features/page/services/page-service.ts";
|
||||
import { useState } from "react";
|
||||
import { ExportFormat } from "@/features/page/types/page.types.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { exportSpace } from "@/features/space/services/space-service";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||
|
||||
interface ExportModalProps {
|
||||
id: string;
|
||||
type: "space" | "page";
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ExportModal({
|
||||
id,
|
||||
type,
|
||||
open,
|
||||
onClose,
|
||||
}: ExportModalProps) {
|
||||
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
||||
const [includeChildren, setIncludeChildren] = useState<boolean>(false);
|
||||
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
|
||||
const [isExporting, setIsExporting] = useState<boolean>(false);
|
||||
const { t } = useTranslation();
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
const isDocx = format === ExportFormat.Docx;
|
||||
const docxEntitled = useHasFeature(Feature.DOCX_EXPORT);
|
||||
const blockedByLicense = isDocx && !docxEntitled;
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
if (type === "page") {
|
||||
if (format === ExportFormat.Docx) {
|
||||
await exportPageToDocx({ pageId: id });
|
||||
} else {
|
||||
await exportPage({
|
||||
pageId: id,
|
||||
format,
|
||||
includeChildren,
|
||||
includeAttachments,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (type === "space") {
|
||||
await exportSpace({ spaceId: id, format, includeAttachments });
|
||||
}
|
||||
notifications.show({
|
||||
message: t("Export successful"),
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
message: "Export failed:" + err.response?.data.message,
|
||||
color: "red",
|
||||
});
|
||||
console.error("export error", err);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (format: ExportFormat) => {
|
||||
setFormat(format);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal.Root
|
||||
opened={open}
|
||||
onClose={onClose}
|
||||
size={500}
|
||||
padding="xl"
|
||||
yOffset="10vh"
|
||||
xOffset={0}
|
||||
mah={400}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
<Modal.Header py={0}>
|
||||
<Modal.Title fw={500}>{t(`Export ${type}`)}</Modal.Title>
|
||||
<Modal.CloseButton aria-label={t("Close")} />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="md">{t("Format")}</Text>
|
||||
</div>
|
||||
<ExportFormatSelection
|
||||
format={format}
|
||||
onChange={handleChange}
|
||||
includeDocx={type === "page"}
|
||||
docxEntitled={docxEntitled}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{type === "page" && !isDocx && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="md">{t("Include subpages")}</Text>
|
||||
</div>
|
||||
<Switch
|
||||
onChange={(event) =>
|
||||
setIncludeChildren(event.currentTarget.checked)
|
||||
}
|
||||
checked={includeChildren}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" wrap="nowrap" mt="md">
|
||||
<div>
|
||||
<Text size="md">{t("Include attachments")}</Text>
|
||||
</div>
|
||||
<Switch
|
||||
onChange={(event) =>
|
||||
setIncludeAttachments(event.currentTarget.checked)
|
||||
}
|
||||
checked={includeAttachments}
|
||||
/>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "space" && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="md">{t("Include attachments")}</Text>
|
||||
</div>
|
||||
<Switch
|
||||
onChange={(event) =>
|
||||
setIncludeAttachments(event.currentTarget.checked)
|
||||
}
|
||||
checked={includeAttachments}
|
||||
/>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group justify="center" mt="md">
|
||||
<Button onClick={onClose} variant="default">
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Tooltip label={upgradeLabel} disabled={!blockedByLicense} withArrow>
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
loading={isExporting}
|
||||
disabled={blockedByLicense}
|
||||
data-disabled={blockedByLicense || undefined}
|
||||
>
|
||||
{t("Export")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExportFormatSelection {
|
||||
format: ExportFormat;
|
||||
onChange: (value: string) => void;
|
||||
includeDocx?: boolean;
|
||||
docxEntitled?: boolean;
|
||||
}
|
||||
function ExportFormatSelection({
|
||||
format,
|
||||
onChange,
|
||||
includeDocx,
|
||||
docxEntitled,
|
||||
}: ExportFormatSelection) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const data = [
|
||||
{ value: "markdown", label: "Markdown" },
|
||||
{ value: "html", label: "HTML" },
|
||||
...(includeDocx
|
||||
? [{ value: "docx", label: "Word (.docx)", disabled: !docxEntitled }]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<Select
|
||||
data={data}
|
||||
defaultValue={format}
|
||||
onChange={onChange}
|
||||
styles={{ wrapper: { maxWidth: 140 }, option: { opacity: 1 } }}
|
||||
comboboxProps={{ width: 200 }}
|
||||
allowDeselect={false}
|
||||
withCheckIcon={false}
|
||||
aria-label={t("Select export format")}
|
||||
renderOption={({ option }) =>
|
||||
option.value === "docx" && !docxEntitled ? (
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{option.label}
|
||||
</Text>
|
||||
<Badge size="xs" mt={4}>
|
||||
{t("Enterprise")}
|
||||
</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<Text size="sm">{option.label}</Text>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
20
apps/client/src/components/common/no-table-results.tsx
Normal file
20
apps/client/src/components/common/no-table-results.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Table, Text } from "@mantine/core";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface NoTableResultsProps {
|
||||
colSpan: number;
|
||||
text?: string;
|
||||
}
|
||||
export default function NoTableResults({ colSpan, text }: NoTableResultsProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={colSpan}>
|
||||
<Text fw={500} c="dimmed" ta="center">
|
||||
{text || t("No results found...")}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
18
apps/client/src/components/common/page-list-icon.tsx
Normal file
18
apps/client/src/components/common/page-list-icon.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ThemeIcon } from "@mantine/core";
|
||||
import { IconFileDescription, IconTable } from "@tabler/icons-react";
|
||||
|
||||
type Props = {
|
||||
icon?: string | null;
|
||||
isBase?: boolean;
|
||||
};
|
||||
|
||||
export function PageListIcon({ icon, isBase }: Props) {
|
||||
if (icon) {
|
||||
return <>{icon}</>;
|
||||
}
|
||||
return (
|
||||
<ThemeIcon variant="transparent" color="gray" size={18}>
|
||||
{isBase ? <IconTable size={18} /> : <IconFileDescription size={18} />}
|
||||
</ThemeIcon>
|
||||
);
|
||||
}
|
||||
44
apps/client/src/components/common/paginate.tsx
Normal file
44
apps/client/src/components/common/paginate.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Button, Group } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface PagePaginationProps {
|
||||
hasPrevPage: boolean;
|
||||
hasNextPage: boolean;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
export default function Paginate({
|
||||
hasPrevPage,
|
||||
hasNextPage,
|
||||
onPrev,
|
||||
onNext,
|
||||
}: PagePaginationProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!hasPrevPage && !hasNextPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Group mt="md" justify="flex-end">
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-sm"
|
||||
onClick={onPrev}
|
||||
disabled={!hasPrevPage}
|
||||
>
|
||||
{t("Prev")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-sm"
|
||||
onClick={onNext}
|
||||
disabled={!hasNextPage}
|
||||
>
|
||||
{t("Next")}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
109
apps/client/src/components/common/recent-changes.tsx
Normal file
109
apps/client/src/components/common/recent-changes.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
Text,
|
||||
Group,
|
||||
UnstyledButton,
|
||||
Badge,
|
||||
Table,
|
||||
Button,
|
||||
} from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
|
||||
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils.ts";
|
||||
import { formattedDate } from "@/lib/time.ts";
|
||||
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { PageListIcon } from "@/components/common/page-list-icon";
|
||||
import { IconFiles } from "@tabler/icons-react";
|
||||
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
||||
import { getSpaceUrl } from "@/lib/config.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getInitialsColor } from "@/lib/get-initials-color.ts";
|
||||
import rowClasses from "@/components/ui/clickable-table-row.module.css";
|
||||
|
||||
interface Props {
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
export default function RecentChanges({ spaceId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isError, hasNextPage, fetchNextPage, isFetchingNextPage } = useRecentChangesQuery(spaceId);
|
||||
const pages = data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return <PageListSkeleton />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <Text>{t("Failed to fetch recent pages")}</Text>;
|
||||
}
|
||||
|
||||
return pages.length > 0 ? (
|
||||
<>
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Tbody>
|
||||
{pages.map((page) => (
|
||||
<Table.Tr key={page.id} className={rowClasses.row}>
|
||||
<Table.Td>
|
||||
<UnstyledButton
|
||||
className={rowClasses.link}
|
||||
component={Link}
|
||||
to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
|
||||
>
|
||||
<Group wrap="nowrap">
|
||||
<PageListIcon icon={page.icon} isBase={page.isBase} />
|
||||
|
||||
<Text fw={500} size="md" lineClamp={1}>
|
||||
{getPageTitle(page.title, page.isBase, t)}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Table.Td>
|
||||
{!spaceId && (
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={getInitialsColor(page?.space.name)}
|
||||
variant="light"
|
||||
component={Link}
|
||||
to={getSpaceUrl(page?.space.slug)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{page?.space.name}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td>
|
||||
<Text
|
||||
c="dimmed"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
size="xs"
|
||||
fw={500}
|
||||
>
|
||||
{formattedDate(page.updatedAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
{hasNextPage && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
fullWidth
|
||||
mt="sm"
|
||||
mb="xl"
|
||||
onClick={() => fetchNextPage()}
|
||||
loading={isFetchingNextPage}
|
||||
>
|
||||
{t("Load more")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={IconFiles}
|
||||
title={t("No pages yet")}
|
||||
description={t("Pages you create will show up here.")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
40
apps/client/src/components/common/search-input.tsx
Normal file
40
apps/client/src/components/common/search-input.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { TextInput, Group } from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface SearchInputProps {
|
||||
placeholder?: string;
|
||||
ariaLabel?: string;
|
||||
debounceDelay?: number;
|
||||
onSearch: (value: string) => void;
|
||||
}
|
||||
|
||||
export function SearchInput({
|
||||
placeholder,
|
||||
ariaLabel,
|
||||
debounceDelay = 500,
|
||||
onSearch,
|
||||
}: SearchInputProps) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState("");
|
||||
const [debouncedValue] = useDebouncedValue(value, debounceDelay);
|
||||
|
||||
useEffect(() => {
|
||||
onSearch(debouncedValue);
|
||||
}, [debouncedValue, onSearch]);
|
||||
|
||||
return (
|
||||
<Group mb="sm">
|
||||
<TextInput
|
||||
size="sm"
|
||||
placeholder={placeholder || t("Search...")}
|
||||
aria-label={ariaLabel || placeholder || t("Search")}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.currentTarget.value)}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
24
apps/client/src/components/common/user-info.tsx
Normal file
24
apps/client/src/components/common/user-info.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import React from "react";
|
||||
import { IUser } from '@/features/user/types/user.types.ts';
|
||||
|
||||
interface UserInfoProps {
|
||||
user: Partial<IUser>;
|
||||
size?: string;
|
||||
}
|
||||
export function UserInfo({ user, size }: UserInfoProps) {
|
||||
return (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<CustomAvatar avatarUrl={user?.avatarUrl} name={user?.name} size={size} />
|
||||
<div>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
{user?.name}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{user?.email}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
32
apps/client/src/components/icons/airtable-icon.tsx
Normal file
32
apps/client/src/components/icons/airtable-icon.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function AirtableIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 215"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path
|
||||
fill="#ffbf00"
|
||||
d="M114.259 2.701 18.86 42.176c-5.305 2.195-5.25 9.73.089 11.847l95.797 37.989a35.544 35.544 0 0 0 26.208 0l95.799-37.99c5.337-2.115 5.393-9.65.086-11.846L141.442 2.7a35.549 35.549 0 0 0-27.183 0"
|
||||
/>
|
||||
<path
|
||||
fill="#26b5f8"
|
||||
d="M136.35 112.757v94.902c0 4.514 4.55 7.605 8.746 5.942l106.748-41.435a6.39 6.39 0 0 0 4.035-5.941V71.322c0-4.514-4.551-7.604-8.747-5.941l-106.748 41.434a6.392 6.392 0 0 0-4.035 5.942"
|
||||
/>
|
||||
<path
|
||||
fill="#ed3049"
|
||||
d="m111.423 117.654-31.68 15.296-3.217 1.555L9.65 166.548C5.411 168.593 0 165.504 0 160.795V71.72c0-1.704.874-3.175 2.046-4.283a7.266 7.266 0 0 1 1.618-1.213c1.598-.959 3.878-1.215 5.816-.448l101.41 40.18c5.155 2.045 5.56 9.268.533 11.697"
|
||||
/>
|
||||
<path
|
||||
fillOpacity={0.25}
|
||||
d="m111.423 117.654-31.68 15.296L2.045 67.438a7.266 7.266 0 0 1 1.618-1.213c1.598-.959 3.878-1.215 5.816-.448l101.41 40.18c5.155 2.045 5.56 9.268.533 11.697"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
20
apps/client/src/components/icons/confluence-icon.tsx
Normal file
20
apps/client/src/components/icons/confluence-icon.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { rem } from "@mantine/core";
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function ConfluenceIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path d="M.87 18.257c-.248.382-.53.875-.763 1.245a.764.764 0 0 0 .255 1.04l4.965 3.054a.764.764 0 0 0 1.058-.26c.199-.332.454-.763.733-1.221 1.967-3.247 3.945-2.853 7.508-1.146l4.957 2.337a.764.764 0 0 0 1.028-.382l2.364-5.346a.764.764 0 0 0-.382-1 599.851 599.851 0 0 1-4.965-2.361C10.911 10.97 5.224 11.185.87 18.257zM23.131 5.743c.249-.405.531-.875.764-1.25a.764.764 0 0 0-.256-1.034L18.675.404a.764.764 0 0 0-1.058.26c-.195.335-.451.763-.734 1.225-1.966 3.246-3.945 2.85-7.508 1.146L4.437.694a.764.764 0 0 0-1.027.382L1.046 6.422a.764.764 0 0 0 .382 1c1.039.49 3.105 1.467 4.965 2.361 6.698 3.246 12.392 3.029 16.738-4.04z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
23
apps/client/src/components/icons/figma-icon.tsx
Normal file
23
apps/client/src/components/icons/figma-icon.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function FigmaIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<g fill="none" fillRule="evenodd" transform="translate(4)">
|
||||
<circle cx={12} cy={12} r={4} fill="#19bcfe" />
|
||||
<path fill="#09cf83" d="M4 24a4 4 0 0 0 4-4v-4H4a4 4 0 1 0 0 8z" />
|
||||
<path fill="#a259ff" d="M4 16h4V8H4a4 4 0 1 0 0 8z" />
|
||||
<path fill="#f24e1e" d="M4 8h4V0H4a4 4 0 1 0 0 8z" />
|
||||
<path fill="#ff7262" d="M12 8H8V0h4a4 4 0 1 1 0 8z" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
17
apps/client/src/components/icons/framer-icon.tsx
Normal file
17
apps/client/src/components/icons/framer-icon.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function FramerIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path d="M4 0h16v8h-8zm0 8h8l8 8H4zm0 8h8v8z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
24
apps/client/src/components/icons/google-drive-icon.tsx
Normal file
24
apps/client/src/components/icons/google-drive-icon.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function GoogleDriveIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 87.3 78"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8h-27.5c0 1.55.4 3.1 1.2 4.5z" fill="#0066da" />
|
||||
<path d="m43.65 25-13.75-23.8c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44a9.06 9.06 0 0 0 -1.2 4.5h27.5z" fill="#00ac47" />
|
||||
<path d="m73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75 7.65-13.25c.8-1.4 1.2-2.95 1.2-4.5h-27.502l5.852 11.5z"
|
||||
fill="#ea4335" />
|
||||
<path d="m43.65 25 13.75-23.8c-1.35-.8-2.9-1.2-4.5-1.2h-18.5c-1.6 0-3.15.45-4.5 1.2z" fill="#00832d" />
|
||||
<path d="m59.8 53h-32.3l-13.75 23.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z" fill="#2684fc" />
|
||||
<path d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3l-13.75 23.8 16.15 28h27.45c0-1.55-.4-3.1-1.2-4.5z"
|
||||
fill="#ffba00" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
33
apps/client/src/components/icons/google-icon.tsx
Normal file
33
apps/client/src/components/icons/google-icon.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { rem } from "@mantine/core";
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function GoogleIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
preserveAspectRatio="xMidYMid"
|
||||
viewBox="0 0 256 262"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622 38.755 30.023 2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055-34.523 0-63.824-22.773-74.269-54.25l-1.531.13-40.298 31.187-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82 0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782"
|
||||
/>
|
||||
<path
|
||||
fill="#EB4335"
|
||||
d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0 79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
23
apps/client/src/components/icons/google-sheets-icon.tsx
Normal file
23
apps/client/src/components/icons/google-sheets-icon.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function GoogleSheetsIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 48 48"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path fill="#43a047" d="M37,45H11c-1.657,0-3-1.343-3-3V6c0-1.657,1.343-3,3-3h19l10,10v29C40,43.657,38.657,45,37,45z"/>
|
||||
<path fill="#c8e6c9" d="M40 13L30 13 30 3z"/>
|
||||
<path fill="#2e7d32" d="M30 13L40 23 40 13z"/>
|
||||
<path
|
||||
fill="#e8f5e9"
|
||||
d="M31,23H17h-2v2v2v2v2v2v2v2h18v-2v-2v-2v-2v-2v-2v-2H31z M17,25h4v2h-4V25z M17,29h4v2h-4V29z M17,33h4v2h-4V33z M31,35h-8v-2h8V35z M31,31h-8v-2h8V31z M31,27h-8v-2h8V27z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
27
apps/client/src/components/icons/icon-columns-4.tsx
Normal file
27
apps/client/src/components/icons/icon-columns-4.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { rem } from "@mantine/core";
|
||||
|
||||
type Props = {
|
||||
size?: number | string;
|
||||
stroke?: number;
|
||||
};
|
||||
|
||||
export function IconColumns4({ size = 24, stroke = 2 }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={rem(size)}
|
||||
height={rem(size)}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M3 4a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-16" />
|
||||
<path d="M7.5 3v18" />
|
||||
<path d="M12 3v18" />
|
||||
<path d="M16.5 3v18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
28
apps/client/src/components/icons/icon-columns-5.tsx
Normal file
28
apps/client/src/components/icons/icon-columns-5.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { rem } from "@mantine/core";
|
||||
|
||||
type Props = {
|
||||
size?: number | string;
|
||||
stroke?: number;
|
||||
};
|
||||
|
||||
export function IconColumns5({ size = 24, stroke = 2 }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={rem(size)}
|
||||
height={rem(size)}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M3 4a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-16" />
|
||||
<path d="M6.6 3v18" />
|
||||
<path d="M10.2 3v18" />
|
||||
<path d="M13.8 3v18" />
|
||||
<path d="M17.4 3v18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
20
apps/client/src/components/icons/icon-drawio.tsx
Normal file
20
apps/client/src/components/icons/icon-drawio.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
function IconDrawio({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="#F08705"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path d="M19.69 13.419h-2.527l-2.667-4.555a1.292 1.292 0 001.035-1.28V4.16c0-.725-.576-1.312-1.302-1.312H9.771c-.726 0-1.312.576-1.312 1.301v3.435c0 .619.426 1.152 1.034 1.28l-2.666 4.555H4.309c-.725 0-1.312.576-1.312 1.301v3.435c0 .725.576 1.312 1.302 1.312h4.458c.726 0 1.312-.576 1.312-1.302v-3.434c0-.726-.576-1.312-1.301-1.312h-.437l2.645-4.523h2.059l2.656 4.523h-.438c-.725 0-1.312.576-1.312 1.301v3.435c0 .725.576 1.312 1.302 1.312H19.7c.726 0 1.312-.576 1.312-1.302v-3.434c0-.726-.576-1.312-1.301-1.312zM24 22.976c0 .565-.459 1.024-1.013 1.024H1.024A1.022 1.022 0 010 22.987V1.024C0 .459.459 0 1.013 0h21.963C23.541 0 24 .459 24 1.013z"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default IconDrawio;
|
||||
20
apps/client/src/components/icons/icon-excalidraw.tsx
Normal file
20
apps/client/src/components/icons/icon-excalidraw.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { rem } from "@mantine/core";
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
function IconExcalidraw({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="#6965DB"
|
||||
viewBox="0 0 24 24"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path d="M23.943 19.806a.196.196 0 00-.168-.034c-1.26-1.855-2.873-3.61-4.419-5.315l-.252-.284c-.001-.073-.067-.12-.134-.15l-.084-.084c-.05-.1-.169-.167-.286-.1-.47.234-.907.585-1.327.919-.554.434-1.109.87-1.63 1.354a5.058 5.058 0 00-.588.618c-.084.117-.017.217.084.267-.37.368-.74.736-1.109 1.12a.19.19 0 00-.05.134c0 .05.033.1.067.117l.655.502v.016c.924.92 2.554 2.173 4.285 3.527.251.201.52.402.773.602.117.134.234.285.335.418.05.066.169.084.236.033.033.034.084.067.118.1a.24.24 0 00.1.034.153.153 0 00.135-.066.237.237 0 00.033-.1c.017 0 .017.016.034.016a.192.192 0 00.134-.05l3.058-3.327c.12-.116.014-.267 0-.267zm-7.628-.134l-1.546-1.17-.15-.1c-.035-.017-.068-.05-.102-.067l-.117-.1c.66-.66 1.33-1.308 2-1.956-.488.484-1.463 1.906-1.261 2.373.002 0 .018.042.067.084l1.11.936zm4.1 3.126l-1.277-.97a26.906 26.906 0 00-1.58-1.504c.69.518 1.277.97 1.361 1.053.673.585.638.485 1.093.87l.554.4c-.074.103-.151.148-.151.151zm.336.25l-.034-.016a.913.913 0 00.152-.117zM.587 3.476c.034.217.085.435.118.636.201 1.103.403 2.106.772 2.858l.152.568c.05.217.134.485.219.552a66.769 66.769 0 003.578 2.942.177.177 0 00.219 0s0 .016.016.016a.153.153 0 00.118.05.191.191 0 00.134-.05c1.798-1.989 3.142-3.627 4.1-4.998.068-.066.084-.167.084-.25.067-.067.118-.151.185-.201.067-.067.067-.184 0-.235l-.017-.016c0-.033-.017-.084-.05-.1-.42-.401-.722-.685-1.042-.986a93.555 93.555 0 01-2.352-2.273c-.017-.017-.034-.034-.067-.034-.336-.117-1.025-.234-1.882-.385-1.277-.216-3.008-.517-4.57-.986 0 0-.101 0-.118.017l-.05.05C.05.714.022.707 0 .718c.017.1.017.167.05.284 0 .033.068.301.068.334zm7.191 4.78l-.033.034a.036.036 0 01.033-.034zM6.553 2.238c.101.1.521.502.622.585-.437-.2-1.529-.702-2.034-.869.505.1 1.194.201 1.412.284zM.79 1.403c.252.434.454 1.939.655 3.41-.118-.469-.201-.936-.302-1.372C.992 2.673.84 1.988.638 1.386c.124 0 .152.021.152.017zm-.286-.369c0-.016 0-.033-.017-.033.085 0 .135.017.202.05 0 .006-.145-.017-.185-.017zm23.17-.217c.017-.066-.336-.367-.219-.384.253-.017.253-.401 0-.401-.335.017-.688.1-1.008.15-.587.117-1.192.234-1.78.367a79.696 79.696 0 00-3.949.937c-.403.117-.857.2-1.243.401-.135.067-.118.2-.05.284-.034.017-.051.017-.085.034-.117.017-.218.034-.335.05-.102.017-.152.1-.135.2 0 .017.017.05.017.067-.706.936-1.496 1.923-2.353 2.976-.84.969-1.73 1.989-2.62 3.042-2.84 3.31-6.05 7.07-9.594 10.38a.161.161 0 000 .234c.016.016.033.033.05.033-.05.05-.101.085-.152.134-.033.034-.05.067-.05.1a.364.364 0 00-.067.084c-.067.067-.067.184.017.234.067.066.185.066.235-.017.017-.017.017-.033.033-.033a.265.265 0 01.37 0c.202.217.404.435.588.618l-.42-.35c-.067-.067-.184-.05-.234.016-.068.066-.051.184.016.234l4.469 3.727c.034.034.067.034.118.034a.15.15 0 00.117-.05l.101-.1c.017.016.05.016.067.016.05 0 .084-.016.118-.05 6.049-6.05 10.922-10.614 16.5-14.693.05-.033.067-.1.067-.15.067 0 .118-.05.15-.117 1.026-3.125 1.228-5.9 1.295-7.27 0-.059.016-.038.016-.068.017-.033.017-.05.017-.05a.978.978 0 00-.067-.619zm-10.82 4.915c.268-.301.537-.619.806-.903-1.73 2.273-4.603 5.766-8.67 9.929 2.773-3.059 5.562-6.218 7.864-9.026zM5.14 23.466c-.016-.017-.016-.017 0-.017zm2.504-2.156c.135-.15.27-.284.42-.434 0 0 0 .016.017.016-.224.198-.433.418-.437.418zm.69-.668c.099-.1.14-.173.284-.318.992-1.02 2.017-2.04 3.059-3.076l.016-.016c.252-.2.555-.418.824-.619a228.063 228.063 0 00-4.184 4.029zM14.852 3.91c-.554.719-1.176 1.671-1.697 2.423-1.646 2.374-6.94 8.174-7.057 8.274a1189.647 1189.647 0 01-4.839 4.597l-.1.1c-.085-.1-.085-.25.016-.334C8.652 11.966 13.19 6.133 15.021 3.576c-.05.116-.084.216-.168.334zm2.906 3.427c-.671-.386-.99-.987-.806-1.572l.05-.2a.775.775 0 01.085-.167 1.9 1.9 0 01.756-.703c.016 0 .033 0 .05-.016-.017-.034-.017-.084-.017-.134.017-.1.085-.167.202-.167.202 0 .824.184 1.059.384.067.05.134.117.202.184.084.1.218.268.285.401.034.017.067.184.118.268.033.134.067.284.05.418-.017.016 0 .116-.017.116a1.605 1.605 0 01-.218.619c-.03.03.006.012-.05.067a1.22 1.22 0 01-.32.334 1.49 1.49 0 01-1.26.234 2.191 2.191 0 00-.169-.066zm4.37 1.403c0 .017-.017.05 0 .067-.034 0-.05.017-.085.034a109.886 109.886 0 00-3.915 3.025c1.11-.986 2.218-1.989 3.378-2.975.336-.301.571-.686.638-1.12l.168-1.003v-.033c.085-.201.404-.118.353.1-.004-.001-.173.795-.537 1.905z"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default IconExcalidraw;
|
||||
20
apps/client/src/components/icons/icon-mermaid.tsx
Normal file
20
apps/client/src/components/icons/icon-mermaid.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { rem } from "@mantine/core";
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
function IconMermaid({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="#FF3670"
|
||||
viewBox="0 0 24 24"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path d="M23.99 2.115A12.223 12.223 0 0012 10.149 12.223 12.223 0 00.01 2.115a12.23 12.23 0 005.32 10.604 6.562 6.562 0 012.845 5.423v3.754h7.65v-3.754a6.561 6.561 0 012.844-5.423 12.223 12.223 0 005.32-10.604z"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default IconMermaid;
|
||||
11
apps/client/src/components/icons/icon-people-circle.tsx
Normal file
11
apps/client/src/components/icons/icon-people-circle.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ThemeIcon } from "@mantine/core";
|
||||
import React from "react";
|
||||
import { IconUsersGroup } from "@tabler/icons-react";
|
||||
|
||||
export function IconGroupCircle() {
|
||||
return (
|
||||
<ThemeIcon variant="light" size="lg" color="gray" radius="xl">
|
||||
<IconUsersGroup stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
);
|
||||
}
|
||||
11
apps/client/src/components/icons/index.ts
Normal file
11
apps/client/src/components/icons/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { AirtableIcon } from "./airtable-icon.tsx";
|
||||
export { FigmaIcon } from "./figma-icon.tsx";
|
||||
export { TypeformIcon } from "./typeform-icon.tsx";
|
||||
export { VimeoIcon } from "./vimeo-icon.tsx";
|
||||
export { MiroIcon } from "./miro-icon.tsx";
|
||||
export { GoogleDriveIcon } from "./google-drive-icon.tsx";
|
||||
export { GoogleSheetsIcon } from "./google-sheets-icon.tsx";
|
||||
export { FramerIcon } from "./framer-icon.tsx";
|
||||
export { LoomIcon } from "./loom-icon.tsx";
|
||||
export { YoutubeIcon } from "./youtube-icon.tsx";
|
||||
|
||||
19
apps/client/src/components/icons/loom-icon.tsx
Normal file
19
apps/client/src/components/icons/loom-icon.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function LoomIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="#625DF5"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path
|
||||
d="M24 10.665h-7.018l6.078-3.509-1.335-2.312-6.078 3.509 3.508-6.077L16.843.94l-3.508 6.077V0h-2.67v7.018L7.156.94 4.844 2.275l3.509 6.077-6.078-3.508L.94 7.156l6.078 3.509H0v2.67h7.017L.94 16.844l1.335 2.313 6.077-3.508-3.509 6.077 2.312 1.335 3.509-6.078V24h2.67v-7.017l3.508 6.077 2.312-1.335-3.509-6.078 6.078 3.509 1.335-2.313-6.077-3.508h7.017v-2.67H24zm-12 4.966a3.645 3.645 0 1 1 0-7.29 3.645 3.645 0 0 1 0 7.29z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
18
apps/client/src/components/icons/miro-icon.tsx
Normal file
18
apps/client/src/components/icons/miro-icon.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function MiroIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path
|
||||
d="M17.392 0H13.9L17 4.808 10.444 0H6.949l3.102 6.3L3.494 0H0l3.05 8.131L0 24h3.494L10.05 6.985 6.949 24h3.494L17 5.494 13.899 24h3.493L24 3.672 17.392 0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
20
apps/client/src/components/icons/openid-icon.tsx
Normal file
20
apps/client/src/components/icons/openid-icon.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { rem } from "@mantine/core";
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function OpenIdIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path d="M14.54.889l-3.63 1.773v18.17c-4.15-.52-7.27-2.78-7.27-5.5 0-2.58 2.8-4.75 6.63-5.41v-2.31C4.42 8.322 0 11.502 0 15.332c0 3.96 4.74 7.24 10.91 7.78l3.63-1.71V.888m.64 6.724v2.31c1.43.25 2.71.7 3.76 1.31l-1.97 1.11 7.03 1.53-.5-5.21-1.87 1.06c-1.74-1.06-3.96-1.81-6.45-2.11z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
18
apps/client/src/components/icons/typeform-icon.tsx
Normal file
18
apps/client/src/components/icons/typeform-icon.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function TypeformIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path
|
||||
d="M15.502 13.035c-.5 0-.756-.411-.756-.917 0-.505.252-.894.756-.894.513 0 .756.407.756.894-.004.515-.261.917-.756.917Zm-4.888-1.81c.292 0 .414.17.414.317 0 .357-.365.514-1.126.536 0-.442.253-.854.712-.854Zm-3.241 1.81c-.473 0-.67-.384-.67-.917 0-.527.202-.894.67-.894.477 0 .702.38.702.894 0 .537-.234.917-.702.917Zm-3.997-2.334h-.738l1.224 2.808c-.234.519-.36.648-.522.648-.171 0-.333-.138-.45-.259l-.324.43c.22.232.522.366.832.366.387 0 .685-.224.856-.626l1.413-3.371h-.725l-.738 2.012-.828-2.008Zm19.553.523c.36 0 .432.246.432.823v1.516H24v-1.914c0-.689-.473-.988-.91-.988-.386 0-.742.241-.94.688a.901.901 0 0 0-.891-.688c-.365 0-.73.232-.927.666v-.626h-.64v2.857h.64v-1.22c0-.617.324-1.114.765-1.114.36 0 .427.246.427.823v1.516h.64l-.005-1.225c0-.617.329-1.114.77-1.114Zm-5.1-.523h-.324v2.857h.639v-1.095c0-.693.306-1.163.76-1.163.118 0 .217.005.325.05l.099-.676c-.081-.009-.153-.018-.225-.018-.45 0-.774.309-.964.707V10.7h-.31Zm-2.327-.045c-.846 0-1.418.644-1.418 1.458 0 .845.58 1.475 1.418 1.475.85 0 1.431-.648 1.431-1.475-.004-.818-.594-1.458-1.431-1.458Zm-4.852 2.38c-.333 0-.581-.17-.685-.515.847-.036 1.675-.242 1.675-.988 0-.43-.423-.872-1.03-.872-.82 0-1.374.666-1.374 1.457 0 .828.545 1.476 1.36 1.476.567 0 .927-.228 1.21-.559l-.31-.42c-.329.335-.531.42-.846.42Zm-3.151-2.38c-.324 0-.648.188-.774.483v-.438h-.64v3.98h.64v-1.422c.135.205.445.34.72.34.85 0 1.3-.631 1.3-1.48-.004-.841-.445-1.463-1.246-1.463Zm-4.483-1.1H0v.622h1.18v3.38h.67v-3.38h1.166v-.622Zm9.502 1.145h-.383v.572h.383v2.285h.639v-2.285h.621v-.572h-.621v-.447c0-.286.117-.385.382-.385.1 0 .19.027.311.068l.144-.537c-.117-.067-.351-.094-.504-.094-.612 0-.972.367-.972 1.002v.393Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
19
apps/client/src/components/icons/vimeo-icon.tsx
Normal file
19
apps/client/src/components/icons/vimeo-icon.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function VimeoIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="#1AB7EA"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path
|
||||
d="M23.9765 6.4168c-.105 2.338-1.739 5.5429-4.894 9.6088-3.2679 4.247-6.0258 6.3699-8.2898 6.3699-1.409 0-2.578-1.294-3.553-3.881l-1.9179-7.1138c-.719-2.584-1.488-3.878-2.312-3.878-.179 0-.806.378-1.8809 1.132l-1.129-1.457a315.06 315.06 0 003.501-3.1279c1.579-1.368 2.765-2.085 3.5539-2.159 1.867-.18 3.016 1.1 3.447 3.838.465 2.953.789 4.789.971 5.5069.5389 2.45 1.1309 3.674 1.7759 3.674.502 0 1.256-.796 2.265-2.385 1.004-1.589 1.54-2.797 1.612-3.628.144-1.371-.395-2.061-1.614-2.061-.574 0-1.167.121-1.777.391 1.186-3.8679 3.434-5.7568 6.7619-5.6368 2.4729.06 3.6279 1.664 3.4929 4.7969z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
19
apps/client/src/components/icons/youtube-icon.tsx
Normal file
19
apps/client/src/components/icons/youtube-icon.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function YoutubeIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="#FF0000"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path
|
||||
d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
.header {
|
||||
height: 100%;
|
||||
margin-bottom: rem(120px);
|
||||
background-color: var(--mantine-color-body);
|
||||
border-bottom: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
padding-left: var(--mantine-spacing-md);
|
||||
padding-right: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.brandIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: block;
|
||||
line-height: 1;
|
||||
padding: rem(8px) rem(12px);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
text-decoration: none;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
}
|
||||
183
apps/client/src/components/layouts/global/app-header.tsx
Normal file
183
apps/client/src/components/layouts/global/app-header.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Group,
|
||||
Text,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import classes from "./app-header.module.css";
|
||||
import React from "react";
|
||||
import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
desktopSidebarAtom,
|
||||
mobileSidebarAtom,
|
||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useTrial from "@/ee/hooks/use-trial.tsx";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import {
|
||||
SearchControl,
|
||||
SearchMobileControl,
|
||||
} from "@/features/search/components/search-control.tsx";
|
||||
import {
|
||||
searchSpotlight,
|
||||
shareSearchSpotlight,
|
||||
} from "@/features/search/constants.ts";
|
||||
import { NotificationPopover } from "@/features/notification/components/notification-popover.tsx";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
|
||||
const links = [
|
||||
{ link: APP_ROUTE.HOME, label: "Home" },
|
||||
];
|
||||
|
||||
export function AppHeader() {
|
||||
const { t } = useTranslation();
|
||||
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
||||
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
|
||||
|
||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
||||
const { isTrial, trialDaysLeft } = useTrial();
|
||||
const location = useLocation();
|
||||
const toggleAside = useToggleAside();
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
|
||||
|
||||
const isPageRoute = location.pathname.includes("/p/");
|
||||
|
||||
const items = links.map((link) => (
|
||||
<Link key={link.label} to={link.link} className={classes.link}>
|
||||
{t(link.label)}
|
||||
</Link>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
|
||||
<Group wrap="nowrap">
|
||||
<Tooltip label={t("Sidebar toggle")}>
|
||||
<SidebarToggle
|
||||
aria-label={t("Sidebar toggle")}
|
||||
opened={mobileOpened}
|
||||
onClick={toggleMobile}
|
||||
hiddenFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={t("Sidebar toggle")}>
|
||||
<SidebarToggle
|
||||
aria-label={t("Sidebar toggle")}
|
||||
opened={desktopOpened}
|
||||
onClick={toggleDesktop}
|
||||
visibleFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Link to="/home" className={classes.brand} aria-label="Docmost">
|
||||
<Box hiddenFrom="sm" className={classes.brandIcon}>
|
||||
<img
|
||||
src="/icons/favicon-32x32.png"
|
||||
alt="Docmost"
|
||||
width={22}
|
||||
height={22}
|
||||
/>
|
||||
</Box>
|
||||
<Text
|
||||
size="lg"
|
||||
fw={600}
|
||||
style={{ userSelect: "none" }}
|
||||
visibleFrom="sm"
|
||||
>
|
||||
Docmost
|
||||
</Text>
|
||||
</Link>
|
||||
|
||||
<Group ml={50} gap={5} className={classes.links} visibleFrom="sm">
|
||||
{items}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
<Group visibleFrom="sm">
|
||||
<SearchControl onClick={searchSpotlight.open} />
|
||||
</Group>
|
||||
<Group hiddenFrom="sm">
|
||||
<SearchMobileControl onSearch={searchSpotlight.open} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<Group px={"xl"} wrap="nowrap">
|
||||
{aiChatEnabled && (
|
||||
<>
|
||||
<UnstyledButton
|
||||
component={Link}
|
||||
to="/ai"
|
||||
className={classes.link}
|
||||
visibleFrom="sm"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
|
||||
return;
|
||||
}
|
||||
if (isPageRoute) {
|
||||
e.preventDefault();
|
||||
toggleAside("chat");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("AI Chat")}
|
||||
</UnstyledButton>
|
||||
<Tooltip label={t("AI Chat")} openDelay={250} withArrow>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
to="/ai"
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
size="sm"
|
||||
hiddenFrom="sm"
|
||||
aria-label={t("AI Chat")}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
|
||||
return;
|
||||
}
|
||||
if (isPageRoute) {
|
||||
e.preventDefault();
|
||||
toggleAside("chat");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<IconSparkles size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
<NotificationPopover />
|
||||
{isCloud() && isTrial && trialDaysLeft !== 0 && (
|
||||
<Badge
|
||||
variant="light"
|
||||
style={{ cursor: "pointer" }}
|
||||
component={Link}
|
||||
to={APP_ROUTE.SETTINGS.WORKSPACE.BILLING}
|
||||
visibleFrom="xs"
|
||||
>
|
||||
{trialDaysLeft === 1
|
||||
? "1 day left"
|
||||
: `${trialDaysLeft} days left`}
|
||||
</Badge>
|
||||
)}
|
||||
<TopMenu />
|
||||
</Group>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
.header, .navbar, .aside {
|
||||
background-color: light-dark(#f6f7f9, var(--mantine-color-dark-8));
|
||||
}
|
||||
|
||||
.navbar, .aside {
|
||||
@media (max-width: $mantine-breakpoint-sm) {
|
||||
width: 350px;
|
||||
}
|
||||
}
|
||||
|
||||
.aside {
|
||||
@media (max-width: $mantine-breakpoint-sm) {
|
||||
margin-top: 45px;
|
||||
}
|
||||
}
|
||||
|
||||
.resizeHandle {
|
||||
width: 3px;
|
||||
cursor: col-resize;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
|
||||
&:hover, &:active {
|
||||
width: 5px;
|
||||
background: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-5))
|
||||
}
|
||||
}
|
||||
101
apps/client/src/components/layouts/global/aside.tsx
Normal file
101
apps/client/src/components/layouts/global/aside.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { ActionIcon, Box, Group, ScrollArea, Title, Tooltip } from "@mantine/core";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import { useAtom } from "jotai";
|
||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import React, { lazy, ReactNode, Suspense, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
|
||||
|
||||
const CommentListWithTabs = lazy(
|
||||
() => import("@/features/comment/components/comment-list-with-tabs.tsx"),
|
||||
);
|
||||
const TableOfContents = lazy(() =>
|
||||
import(
|
||||
"@/features/editor/components/table-of-contents/table-of-contents.tsx"
|
||||
).then((m) => ({ default: m.TableOfContents })),
|
||||
);
|
||||
const AsideChatPanel = lazy(
|
||||
() => import("@/ee/ai-chat/components/aside-chat-panel"),
|
||||
);
|
||||
const PageDetailsAside = lazy(() =>
|
||||
import("@/features/page-details/components/page-details-aside.tsx").then(
|
||||
(m) => ({ default: m.PageDetailsAside }),
|
||||
),
|
||||
);
|
||||
|
||||
export default function Aside() {
|
||||
const [{ tab, isAsideOpen }, setAsideState] = useAtom(asideStateAtom);
|
||||
const { t } = useTranslation();
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const closeAside = () => setAsideState((s) => ({ ...s, isAsideOpen: false }));
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAsideOpen) return;
|
||||
document.getElementById(ASIDE_PANEL_ID)?.focus();
|
||||
}, [isAsideOpen, tab]);
|
||||
|
||||
let title: string;
|
||||
let component: ReactNode;
|
||||
|
||||
switch (tab) {
|
||||
case "comments":
|
||||
component = <CommentListWithTabs />;
|
||||
title = "Comments";
|
||||
break;
|
||||
case "toc":
|
||||
component = <TableOfContents editor={pageEditor} />;
|
||||
title = "Table of contents";
|
||||
break;
|
||||
case "chat":
|
||||
component = <AsideChatPanel />;
|
||||
title = "AI Chat";
|
||||
break;
|
||||
case "details":
|
||||
component = <PageDetailsAside />;
|
||||
title = "Details";
|
||||
break;
|
||||
default:
|
||||
component = null;
|
||||
title = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box p="md" style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
{component && (
|
||||
<>
|
||||
{tab !== "chat" && (
|
||||
<Group justify="space-between" wrap="nowrap" mb="md">
|
||||
<Title order={2} size="h6" fw={500}>{t(title)}</Title>
|
||||
<Tooltip label={t("Close")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={closeAside}
|
||||
aria-label={t("Close")}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Suspense fallback={null}>
|
||||
{tab === "comments" || tab === "chat" ? (
|
||||
component
|
||||
) : (
|
||||
<ScrollArea
|
||||
style={{ height: "85vh" }}
|
||||
scrollbarSize={5}
|
||||
type="scroll"
|
||||
>
|
||||
<div style={{ paddingBottom: "200px" }}>{component}</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</Suspense>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
174
apps/client/src/components/layouts/global/global-app-shell.tsx
Normal file
174
apps/client/src/components/layouts/global/global-app-shell.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { AppShell, Container } from "@mantine/core";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
asideStateAtom,
|
||||
desktopSidebarAtom,
|
||||
mobileSidebarAtom,
|
||||
sidebarWidthAtom,
|
||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
||||
|
||||
const AiChatSidebar = React.lazy(
|
||||
() => import("@/ee/ai-chat/components/ai-chat-sidebar.tsx"),
|
||||
);
|
||||
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
||||
import Aside from "@/components/layouts/global/aside.tsx";
|
||||
import classes from "./app-shell.module.css";
|
||||
import { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
|
||||
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
|
||||
import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx";
|
||||
|
||||
export default function GlobalAppShell({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
useTrialEndAction();
|
||||
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
||||
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
|
||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||
const [{ isAsideOpen, tab: asideTab }] = useAtom(asideStateAtom);
|
||||
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const sidebarRef = useRef(null);
|
||||
|
||||
const startResizing = React.useCallback((mouseDownEvent) => {
|
||||
mouseDownEvent.preventDefault();
|
||||
setIsResizing(true);
|
||||
}, []);
|
||||
|
||||
const stopResizing = React.useCallback(() => {
|
||||
setIsResizing(false);
|
||||
}, []);
|
||||
|
||||
const resize = React.useCallback(
|
||||
(mouseMoveEvent) => {
|
||||
if (isResizing) {
|
||||
const newWidth =
|
||||
mouseMoveEvent.clientX -
|
||||
sidebarRef.current.getBoundingClientRect().left;
|
||||
if (newWidth < 220) {
|
||||
setSidebarWidth(220);
|
||||
return;
|
||||
}
|
||||
if (newWidth > 600) {
|
||||
setSidebarWidth(600);
|
||||
return;
|
||||
}
|
||||
setSidebarWidth(newWidth);
|
||||
}
|
||||
},
|
||||
[isResizing],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
//https://codesandbox.io/p/sandbox/kz9de
|
||||
window.addEventListener("mousemove", resize);
|
||||
window.addEventListener("mouseup", stopResizing);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", resize);
|
||||
window.removeEventListener("mouseup", stopResizing);
|
||||
};
|
||||
}, [resize, stopResizing]);
|
||||
|
||||
const location = useLocation();
|
||||
const isSettingsRoute = location.pathname.startsWith("/settings");
|
||||
const isSpaceRoute = location.pathname.startsWith("/s/");
|
||||
const isAiRoute = location.pathname.startsWith("/ai");
|
||||
const isPageRoute = location.pathname.includes("/p/");
|
||||
const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute && !isAiRoute;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SkipToMain />
|
||||
<AppShell
|
||||
header={{ height: 45 }}
|
||||
navbar={{
|
||||
width: isSpaceRoute ? sidebarWidth : 300,
|
||||
breakpoint: "sm",
|
||||
collapsed: {
|
||||
mobile: !mobileOpened,
|
||||
desktop: !desktopOpened,
|
||||
},
|
||||
}}
|
||||
aside={
|
||||
isPageRoute && {
|
||||
width: 350,
|
||||
breakpoint: "sm",
|
||||
collapsed: { mobile: !isAsideOpen, desktop: !isAsideOpen },
|
||||
}
|
||||
}
|
||||
padding="md"
|
||||
>
|
||||
<AppShell.Header px="md" className={classes.header}>
|
||||
<AppHeader />
|
||||
</AppShell.Header>
|
||||
<AppShell.Navbar
|
||||
className={classes.navbar}
|
||||
withBorder={false}
|
||||
ref={sidebarRef}
|
||||
aria-label={
|
||||
isSpaceRoute
|
||||
? t("Space navigation")
|
||||
: isSettingsRoute
|
||||
? t("Settings navigation")
|
||||
: isAiRoute
|
||||
? t("AI navigation")
|
||||
: t("Main navigation")
|
||||
}
|
||||
>
|
||||
{isSpaceRoute && (
|
||||
<div className={classes.resizeHandle} onMouseDown={startResizing} />
|
||||
)}
|
||||
{isSpaceRoute && <SpaceSidebar />}
|
||||
{isSettingsRoute && <SettingsSidebar />}
|
||||
{isAiRoute && (
|
||||
<React.Suspense fallback={null}>
|
||||
<AiChatSidebar />
|
||||
</React.Suspense>
|
||||
)}
|
||||
{showGlobalSidebar && <GlobalSidebar />}
|
||||
</AppShell.Navbar>
|
||||
<AppShell.Main id={MAIN_CONTENT_ID} tabIndex={-1}>
|
||||
{isSettingsRoute ? (
|
||||
<Container size={900} pb={80}>
|
||||
{children}
|
||||
</Container>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</AppShell.Main>
|
||||
|
||||
{isPageRoute && (
|
||||
<AppShell.Aside
|
||||
id={ASIDE_PANEL_ID}
|
||||
tabIndex={-1}
|
||||
className={classes.aside}
|
||||
p="md"
|
||||
withBorder={false}
|
||||
aria-label={
|
||||
asideTab === "comments"
|
||||
? t("Comments")
|
||||
: asideTab === "toc"
|
||||
? t("Table of contents")
|
||||
: asideTab === "chat"
|
||||
? t("AI Chat")
|
||||
: asideTab === "details"
|
||||
? t("Details")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Aside />
|
||||
</AppShell.Aside>
|
||||
)}
|
||||
</AppShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
.navbar {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: var(--mantine-spacing-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding-bottom: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.link {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
padding-left: var(--mantine-spacing-xs);
|
||||
min-height: 30px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&[data-active] {
|
||||
&,
|
||||
& :hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6));
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||
}
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
|
||||
@mixin hover {
|
||||
background-color: transparent;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.linkIcon {
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
margin-right: var(--mantine-spacing-sm);
|
||||
width: rem(16px);
|
||||
height: rem(16px);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
color: var(--mantine-color-dimmed);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bottomSection {
|
||||
padding-top: var(--mantine-spacing-xs);
|
||||
border-top: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
.spaceItem {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
text-decoration: none;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
padding-left: var(--mantine-spacing-xs);
|
||||
min-height: 30px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
186
apps/client/src/components/layouts/global/global-sidebar.tsx
Normal file
186
apps/client/src/components/layouts/global/global-sidebar.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ScrollArea, Text, Divider, Modal, UnstyledButton, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconHome,
|
||||
IconClock,
|
||||
IconStar,
|
||||
IconLayoutGrid,
|
||||
IconSettings,
|
||||
IconUserPlus,
|
||||
IconTemplate,
|
||||
} from "@tabler/icons-react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import classes from "./global-sidebar.module.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar";
|
||||
import { useFavoritesQuery } from "@/features/favorite/queries/favorite-query";
|
||||
import { getSpaceUrl } from "@/lib/config";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { WorkspaceInviteForm } from "@/features/workspace/components/members/components/workspace-invite-form";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||
|
||||
export default function GlobalSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const [active, setActive] = useState(location.pathname);
|
||||
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
||||
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
||||
const hasTemplates = useHasFeature(Feature.TEMPLATES);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
const mainNavItems = [
|
||||
{ label: "Home", icon: IconHome, path: "/home" },
|
||||
{ label: "Favorites", icon: IconStar, path: "/favorites" },
|
||||
{ label: "Spaces", icon: IconLayoutGrid, path: "/spaces" },
|
||||
{
|
||||
label: "Templates",
|
||||
icon: IconTemplate,
|
||||
path: "/templates",
|
||||
disabled: !hasTemplates,
|
||||
},
|
||||
];
|
||||
const { data: favoriteSpacesData, isPending: isFavoritesPending } = useFavoritesQuery("space");
|
||||
const favoriteSpaces = favoriteSpacesData?.pages.flatMap((p) => p.items) ?? [];
|
||||
const sortedFavoriteSpaces = [...favoriteSpaces]
|
||||
.filter((fav) => fav.space)
|
||||
.sort((a, b) => {
|
||||
const cmp = (a.space!.name ?? "").localeCompare(b.space!.name ?? "", undefined, { sensitivity: "base" });
|
||||
return cmp !== 0 ? cmp : a.id.localeCompare(b.id);
|
||||
});
|
||||
const [inviteOpened, { open: openInvite, close: closeInvite }] =
|
||||
useDisclosure(false);
|
||||
|
||||
useEffect(() => {
|
||||
setActive(location.pathname);
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleNavClick = () => {
|
||||
if (mobileSidebarOpened) {
|
||||
toggleMobileSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.navbar}>
|
||||
<ScrollArea w="100%" style={{ flex: 1 }}>
|
||||
<div className={classes.section}>
|
||||
{mainNavItems.map((item) =>
|
||||
item.disabled ? (
|
||||
<Tooltip
|
||||
key={item.label}
|
||||
label={upgradeLabel}
|
||||
position="right"
|
||||
withArrow
|
||||
>
|
||||
<UnstyledButton
|
||||
className={classes.link}
|
||||
data-disabled
|
||||
aria-disabled="true"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<item.icon className={classes.linkIcon} stroke={2} />
|
||||
<span>{t(item.label)}</span>
|
||||
</UnstyledButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Link
|
||||
key={item.label}
|
||||
className={classes.link}
|
||||
data-active={active === item.path || undefined}
|
||||
aria-current={active === item.path ? "page" : undefined}
|
||||
to={item.path}
|
||||
onClick={handleNavClick}
|
||||
>
|
||||
<item.icon className={classes.linkIcon} stroke={2} />
|
||||
<span>{t(item.label)}</span>
|
||||
</Link>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Divider my="xs" />
|
||||
<div className={classes.section}>
|
||||
<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")}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
{sortedFavoriteSpaces.slice(0, 10).map((fav) => (
|
||||
<Link
|
||||
key={fav.id}
|
||||
className={classes.spaceItem}
|
||||
to={getSpaceUrl(fav.space!.slug)}
|
||||
onClick={handleNavClick}
|
||||
>
|
||||
<CustomAvatar
|
||||
name={fav.space!.name}
|
||||
avatarUrl={fav.space!.logo}
|
||||
type={AvatarIconType.SPACE_ICON}
|
||||
color="initials"
|
||||
variant="filled"
|
||||
size={20}
|
||||
/>
|
||||
<Text size="sm" fw={500} lineClamp={1}>
|
||||
{fav.space!.name}
|
||||
</Text>
|
||||
</Link>
|
||||
))}
|
||||
{sortedFavoriteSpaces.length > 10 && (
|
||||
<Link
|
||||
className={classes.spaceItem}
|
||||
to="/spaces"
|
||||
onClick={handleNavClick}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("View all")}
|
||||
</Text>
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</ScrollArea>
|
||||
|
||||
<div className={classes.bottomSection}>
|
||||
<UnstyledButton
|
||||
className={classes.link}
|
||||
onClick={openInvite}
|
||||
>
|
||||
<IconUserPlus className={classes.linkIcon} stroke={2} />
|
||||
<span>{t("Invite People")}</span>
|
||||
</UnstyledButton>
|
||||
<Link
|
||||
className={classes.link}
|
||||
data-active={active.startsWith("/settings") || undefined}
|
||||
aria-current={active.startsWith("/settings") ? "page" : undefined}
|
||||
to="/settings/account/profile"
|
||||
onClick={handleNavClick}
|
||||
>
|
||||
<IconSettings className={classes.linkIcon} stroke={2} />
|
||||
<span>{t("Settings")}</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
size="550"
|
||||
opened={inviteOpened}
|
||||
onClose={closeInvite}
|
||||
title={t("Invite new members")}
|
||||
centered
|
||||
>
|
||||
<Divider size="xs" mb="xs" />
|
||||
<ScrollArea h="80%">
|
||||
<WorkspaceInviteForm onClose={closeInvite} />
|
||||
</ScrollArea>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { atomWithWebStorage } from "@/lib/jotai-helper.ts";
|
||||
import { atom } from "jotai";
|
||||
|
||||
export const mobileSidebarAtom = atom<boolean>(false);
|
||||
|
||||
export const desktopSidebarAtom = atomWithWebStorage<boolean>(
|
||||
"showSidebar",
|
||||
true,
|
||||
);
|
||||
|
||||
export const desktopAsideAtom = atom<boolean>(false);
|
||||
|
||||
// Valid `tab` values: "" | "comments" | "toc" | "chat" | "details"
|
||||
type AsideStateType = {
|
||||
tab: string;
|
||||
isAsideOpen: boolean;
|
||||
};
|
||||
|
||||
export const asideStateAtom = atom<AsideStateType>({
|
||||
tab: "",
|
||||
isAsideOpen: false,
|
||||
});
|
||||
|
||||
export const sidebarWidthAtom = atomWithWebStorage<number>('sidebarWidth', 300);
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useAtom } from "jotai";
|
||||
|
||||
export function useToggleSidebar(sidebarAtom: any) {
|
||||
const [sidebarState, setSidebarState] = useAtom(sidebarAtom);
|
||||
return () => {
|
||||
setSidebarState(!sidebarState);
|
||||
}
|
||||
}
|
||||
23
apps/client/src/components/layouts/global/layout.tsx
Normal file
23
apps/client/src/components/layouts/global/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { UserProvider } from "@/features/user/user-provider.tsx";
|
||||
import { Outlet, useParams } from "react-router-dom";
|
||||
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
||||
import { PosthogUser } from "@/ee/components/posthog-user.tsx";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import { SearchSpotlight } from "@/features/search/components/search-spotlight.tsx";
|
||||
import React from "react";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
|
||||
export default function Layout() {
|
||||
const { spaceSlug } = useParams();
|
||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||
|
||||
return (
|
||||
<UserProvider>
|
||||
<GlobalAppShell>
|
||||
<Outlet />
|
||||
</GlobalAppShell>
|
||||
{isCloud() && <PosthogUser />}
|
||||
<SearchSpotlight spaceId={space?.id} />
|
||||
</UserProvider>
|
||||
);
|
||||
}
|
||||
203
apps/client/src/components/layouts/global/top-menu.tsx
Normal file
203
apps/client/src/components/layouts/global/top-menu.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
Group,
|
||||
Menu,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconBrightnessFilled,
|
||||
IconBrush,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconDeviceDesktop,
|
||||
IconLogout,
|
||||
IconMoon,
|
||||
IconSettings,
|
||||
IconSun,
|
||||
IconUser,
|
||||
IconUserCircle,
|
||||
IconUsers,
|
||||
} from "@tabler/icons-react";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { getSpaceUrl } from "@/lib/config.ts";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { usePersonalSpaceQuery } from "@/ee/personal-space/queries/personal-space-query";
|
||||
import CreatePersonalSpaceModal from "@/ee/personal-space/components/create-personal-space-modal";
|
||||
import { useAtom } from "jotai";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { Link } from "react-router-dom";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import useAuth from "@/features/auth/hooks/use-auth.ts";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
|
||||
export default function TopMenu() {
|
||||
const { t } = useTranslation();
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const { logout } = useAuth();
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
|
||||
const user = currentUser?.user;
|
||||
const workspace = currentUser?.workspace;
|
||||
|
||||
const hasPersonalSpaces = useHasFeature(Feature.PERSONAL_SPACES);
|
||||
const settingEnabled = workspace?.settings?.spaces?.allowPersonal === true;
|
||||
const { data: personalSpace } = usePersonalSpaceQuery(hasPersonalSpaces);
|
||||
const [
|
||||
createOpened,
|
||||
{ open: openCreate, close: closeCreate },
|
||||
] = useDisclosure(false);
|
||||
|
||||
if (!user || !workspace) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu width={250} position="bottom-end" withArrow shadow={"lg"}>
|
||||
<Menu.Target>
|
||||
<UnstyledButton>
|
||||
<Group gap={7} wrap={"nowrap"}>
|
||||
<CustomAvatar
|
||||
avatarUrl={workspace?.logo}
|
||||
name={workspace?.name}
|
||||
variant="filled"
|
||||
size="sm"
|
||||
type={AvatarIconType.WORKSPACE_ICON}
|
||||
/>
|
||||
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
|
||||
{workspace?.name}
|
||||
</Text>
|
||||
<IconChevronDown size={16} />
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>{t("Workspace")}</Menu.Label>
|
||||
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={APP_ROUTE.SETTINGS.WORKSPACE.GENERAL}
|
||||
leftSection={<IconSettings size={16} />}
|
||||
>
|
||||
{t("Workspace settings")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={APP_ROUTE.SETTINGS.WORKSPACE.MEMBERS}
|
||||
leftSection={<IconUsers size={16} />}
|
||||
>
|
||||
{t("Manage members")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Label>{t("Account")}</Menu.Label>
|
||||
<Menu.Item component={Link} to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}>
|
||||
<Group wrap={"nowrap"}>
|
||||
<CustomAvatar
|
||||
size={"sm"}
|
||||
avatarUrl={user.avatarUrl}
|
||||
name={user.name}
|
||||
/>
|
||||
|
||||
<div style={{ width: 190 }}>
|
||||
<Text size="sm" fw={500} lineClamp={1}>
|
||||
{user.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate="end">
|
||||
{user.email}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}
|
||||
leftSection={<IconUserCircle size={16} />}
|
||||
>
|
||||
{t("My profile")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={APP_ROUTE.SETTINGS.ACCOUNT.PREFERENCES}
|
||||
leftSection={<IconBrush size={16} />}
|
||||
>
|
||||
{t("My preferences")}
|
||||
</Menu.Item>
|
||||
|
||||
{personalSpace ? (
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={getSpaceUrl(personalSpace.slug)}
|
||||
leftSection={<IconUser size={16} />}
|
||||
>
|
||||
{t("Personal space")}
|
||||
</Menu.Item>
|
||||
) : (
|
||||
hasPersonalSpaces &&
|
||||
settingEnabled && (
|
||||
<Menu.Item
|
||||
onClick={openCreate}
|
||||
leftSection={<IconUser size={16} />}
|
||||
>
|
||||
{t("Create personal space")}
|
||||
</Menu.Item>
|
||||
)
|
||||
)}
|
||||
|
||||
<Menu.Sub>
|
||||
<Menu.Sub.Target>
|
||||
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
|
||||
{t("Theme")}
|
||||
</Menu.Sub.Item>
|
||||
</Menu.Sub.Target>
|
||||
|
||||
<Menu.Sub.Dropdown>
|
||||
<Menu.Item
|
||||
onClick={() => setColorScheme("light")}
|
||||
leftSection={<IconSun size={16} />}
|
||||
rightSection={
|
||||
colorScheme === "light" ? <IconCheck size={16} /> : null
|
||||
}
|
||||
>
|
||||
{t("Light")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => setColorScheme("dark")}
|
||||
leftSection={<IconMoon size={16} />}
|
||||
rightSection={
|
||||
colorScheme === "dark" ? <IconCheck size={16} /> : null
|
||||
}
|
||||
>
|
||||
{t("Dark")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => setColorScheme("auto")}
|
||||
leftSection={<IconDeviceDesktop size={16} />}
|
||||
rightSection={
|
||||
colorScheme === "auto" ? <IconCheck size={16} /> : null
|
||||
}
|
||||
>
|
||||
{t("System settings")}
|
||||
</Menu.Item>
|
||||
</Menu.Sub.Dropdown>
|
||||
</Menu.Sub>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}>
|
||||
{t("Logout")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<CreatePersonalSpaceModal opened={createOpened} onClose={closeCreate} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
59
apps/client/src/components/settings/app-version.tsx
Normal file
59
apps/client/src/components/settings/app-version.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useAppVersion } from "@/features/workspace/queries/workspace-query.ts";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import classes from "@/components/settings/settings.module.css";
|
||||
import { Indicator, Text, Tooltip } from "@mantine/core";
|
||||
import React from "react";
|
||||
import semverGt from "semver/functions/gt";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function AppVersion() {
|
||||
const { t } = useTranslation();
|
||||
const { data: appVersion } = useAppVersion(!isCloud());
|
||||
let hasUpdate = false;
|
||||
try {
|
||||
hasUpdate =
|
||||
appVersion &&
|
||||
parseFloat(appVersion.latestVersion) > 0 &&
|
||||
semverGt(appVersion.latestVersion, appVersion.currentVersion);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.text}>
|
||||
<Tooltip
|
||||
label={t("{{latestVersion}} is available", {
|
||||
latestVersion: `v${appVersion?.latestVersion}`,
|
||||
})}
|
||||
disabled={!hasUpdate}
|
||||
>
|
||||
<Indicator
|
||||
label={t("New update")}
|
||||
color="gray"
|
||||
inline
|
||||
size={16}
|
||||
position="middle-end"
|
||||
style={{ cursor: "pointer" }}
|
||||
disabled={!hasUpdate}
|
||||
onClick={() => {
|
||||
window.open(
|
||||
"https://github.com/docmost/docmost/releases",
|
||||
"_blank",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
component="a"
|
||||
mr={45}
|
||||
href="https://github.com/docmost/docmost/releases"
|
||||
target="_blank"
|
||||
>
|
||||
{appVersion?.currentVersion && <>v{appVersion?.currentVersion}</>}
|
||||
</Text>
|
||||
</Indicator>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { atom, WritableAtom } from "jotai";
|
||||
|
||||
export const settingsOriginAtom: WritableAtom<string | null, [string | null], void> = atom(
|
||||
null,
|
||||
(get, set, newValue) => {
|
||||
if (get(settingsOriginAtom) !== newValue) {
|
||||
set(settingsOriginAtom, newValue);
|
||||
}
|
||||
}
|
||||
);
|
||||
108
apps/client/src/components/settings/settings-queries.tsx
Normal file
108
apps/client/src/components/settings/settings-queries.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import {
|
||||
getBilling,
|
||||
getBillingPlans,
|
||||
} from "@/ee/billing/services/billing-service.ts";
|
||||
import { getSpaces } from "@/features/space/services/space-service.ts";
|
||||
import { getGroups } from "@/features/group/services/group-service.ts";
|
||||
import { QueryParams } from "@/lib/types.ts";
|
||||
import { getWorkspaceMembers } from "@/features/workspace/services/workspace-service.ts";
|
||||
import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
|
||||
import { getSsoProviders } from "@/ee/security/services/security-service.ts";
|
||||
import { getShares } from "@/features/share/services/share-service.ts";
|
||||
import { getApiKeys } from "@/ee/api-key";
|
||||
import { getAuditLogs } from "@/ee/audit/services/audit-service";
|
||||
import { getVerificationList } from "@/ee/page-verification/services/page-verification-service";
|
||||
import { getScimTokens } from "@/ee/scim/services/scim-token-service";
|
||||
|
||||
export const prefetchWorkspaceMembers = () => {
|
||||
const params: QueryParams = { limit: 100, query: "" };
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["workspaceMembers", params],
|
||||
queryFn: () => getWorkspaceMembers(params),
|
||||
});
|
||||
};
|
||||
|
||||
export const prefetchSpaces = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["spaces", {}],
|
||||
queryFn: () => getSpaces({}),
|
||||
});
|
||||
};
|
||||
|
||||
export const prefetchGroups = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["groups", {}],
|
||||
queryFn: () => getGroups({}),
|
||||
});
|
||||
};
|
||||
|
||||
export const prefetchBilling = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["billing"],
|
||||
queryFn: () => getBilling(),
|
||||
});
|
||||
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["billing-plans"],
|
||||
queryFn: () => getBillingPlans(),
|
||||
});
|
||||
};
|
||||
|
||||
export const prefetchLicense = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["license"],
|
||||
queryFn: () => getLicenseInfo(),
|
||||
});
|
||||
};
|
||||
|
||||
export const prefetchSsoProviders = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["sso-providers"],
|
||||
queryFn: () => getSsoProviders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const prefetchShares = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["share-list", {}],
|
||||
queryFn: () => getShares({}),
|
||||
});
|
||||
};
|
||||
|
||||
export const prefetchApiKeys = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["api-key-list", {}],
|
||||
queryFn: () => getApiKeys({}),
|
||||
});
|
||||
};
|
||||
|
||||
export const prefetchApiKeyManagement = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["api-key-list", { adminView: true }],
|
||||
queryFn: () => getApiKeys({ adminView: true }),
|
||||
});
|
||||
};
|
||||
|
||||
export const prefetchAuditLogs = () => {
|
||||
const params = { limit: 50 };
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["audit-logs", params],
|
||||
queryFn: () => getAuditLogs(params),
|
||||
});
|
||||
};
|
||||
|
||||
export const prefetchVerifiedPages = () => {
|
||||
const params = { limit: 50 };
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["verification-list", params],
|
||||
queryFn: () => getVerificationList(params),
|
||||
});
|
||||
};
|
||||
|
||||
export const prefetchScimTokens = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["scim-token-list", { cursor: undefined }],
|
||||
queryFn: () => getScimTokens({}),
|
||||
});
|
||||
};
|
||||
319
apps/client/src/components/settings/settings-sidebar.tsx
Normal file
319
apps/client/src/components/settings/settings-sidebar.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Group, Text, ScrollArea, ActionIcon, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconUser,
|
||||
IconSettings,
|
||||
IconUsers,
|
||||
IconArrowLeft,
|
||||
IconUsersGroup,
|
||||
IconSpaces,
|
||||
IconBrush,
|
||||
IconCoin,
|
||||
IconLock,
|
||||
IconKey,
|
||||
IconWorld,
|
||||
IconSparkles,
|
||||
IconHistory,
|
||||
IconShieldCheck,
|
||||
} from "@tabler/icons-react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import classes from "./settings.module.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||
import {
|
||||
prefetchApiKeyManagement,
|
||||
prefetchApiKeys,
|
||||
prefetchBilling,
|
||||
prefetchGroups,
|
||||
prefetchLicense,
|
||||
prefetchScimTokens,
|
||||
prefetchShares,
|
||||
prefetchSpaces,
|
||||
prefetchSsoProviders,
|
||||
prefetchWorkspaceMembers,
|
||||
prefetchAuditLogs,
|
||||
prefetchVerifiedPages,
|
||||
} from "@/components/settings/settings-queries.tsx";
|
||||
import AppVersion from "@/components/settings/app-version.tsx";
|
||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
import { useSettingsNavigation } from "@/hooks/use-settings-navigation";
|
||||
|
||||
type DataItem = {
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
path: string;
|
||||
feature?: string;
|
||||
role?: "admin" | "owner";
|
||||
env?: "cloud" | "selfhosted";
|
||||
};
|
||||
|
||||
type DataGroup = {
|
||||
heading: string;
|
||||
items: DataItem[];
|
||||
};
|
||||
|
||||
const groupedData: DataGroup[] = [
|
||||
{
|
||||
heading: "Account",
|
||||
items: [
|
||||
{ label: "Profile", icon: IconUser, path: "/settings/account/profile" },
|
||||
{
|
||||
label: "Preferences",
|
||||
icon: IconBrush,
|
||||
path: "/settings/account/preferences",
|
||||
},
|
||||
{
|
||||
label: "API keys",
|
||||
icon: IconKey,
|
||||
path: "/settings/account/api-keys",
|
||||
feature: Feature.API_KEYS,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Workspace",
|
||||
items: [
|
||||
{ label: "General", icon: IconSettings, path: "/settings/workspace" },
|
||||
{ label: "Members", icon: IconUsers, path: "/settings/members" },
|
||||
{
|
||||
label: "Billing",
|
||||
icon: IconCoin,
|
||||
path: "/settings/billing",
|
||||
role: "admin",
|
||||
env: "cloud",
|
||||
},
|
||||
{
|
||||
label: "Security & SSO",
|
||||
icon: IconLock,
|
||||
path: "/settings/security",
|
||||
feature: Feature.SECURITY_SETTINGS,
|
||||
role: "admin",
|
||||
},
|
||||
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
||||
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
||||
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
|
||||
{
|
||||
label: "Verified pages",
|
||||
icon: IconShieldCheck,
|
||||
path: "/settings/verifications",
|
||||
feature: Feature.PAGE_VERIFICATION,
|
||||
},
|
||||
{
|
||||
label: "API management",
|
||||
icon: IconKey,
|
||||
path: "/settings/api-keys",
|
||||
feature: Feature.API_KEYS,
|
||||
role: "admin",
|
||||
},
|
||||
{
|
||||
label: "AI settings",
|
||||
icon: IconSparkles,
|
||||
path: "/settings/ai",
|
||||
role: "admin",
|
||||
},
|
||||
{
|
||||
label: "Audit logs & SIEM",
|
||||
icon: IconHistory,
|
||||
path: "/settings/audit",
|
||||
feature: Feature.AUDIT_LOGS,
|
||||
role: "owner",
|
||||
env: "selfhosted",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "System",
|
||||
items: [
|
||||
{
|
||||
label: "License & Edition",
|
||||
icon: IconKey,
|
||||
path: "/settings/license",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function SettingsSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const [active, setActive] = useState(location.pathname);
|
||||
const { goBack } = useSettingsNavigation();
|
||||
const { isAdmin, isOwner } = useUserRole();
|
||||
const [entitlements] = useAtom(entitlementAtom);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
||||
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
||||
|
||||
useEffect(() => {
|
||||
setActive(location.pathname);
|
||||
}, [location.pathname]);
|
||||
|
||||
const hasFeature = (f: string) =>
|
||||
entitlements?.features?.includes(f) ?? false;
|
||||
|
||||
const canShowItem = (item: DataItem) => {
|
||||
if (item.env === "cloud" && !isCloud()) return false;
|
||||
if (item.env === "selfhosted" && isCloud()) return false;
|
||||
if (item.role === "admin" && !isAdmin) return false;
|
||||
if (item.role === "owner" && !isOwner) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const isItemDisabled = (item: DataItem) => {
|
||||
if (!item.feature) return false;
|
||||
return !hasFeature(item.feature);
|
||||
};
|
||||
|
||||
const menuItems = groupedData.map((group) => {
|
||||
if (group.heading === "System" && (!isAdmin || isCloud())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={group.heading}>
|
||||
<Text c="dimmed" className={classes.linkHeader}>
|
||||
{t(group.heading)}
|
||||
</Text>
|
||||
{group.items.map((item) => {
|
||||
if (!canShowItem(item)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let prefetchHandler: any;
|
||||
switch (item.label) {
|
||||
case "Members":
|
||||
prefetchHandler = prefetchWorkspaceMembers;
|
||||
break;
|
||||
case "Spaces":
|
||||
prefetchHandler = prefetchSpaces;
|
||||
break;
|
||||
case "Groups":
|
||||
prefetchHandler = prefetchGroups;
|
||||
break;
|
||||
case "Billing":
|
||||
prefetchHandler = prefetchBilling;
|
||||
break;
|
||||
case "License & Edition":
|
||||
if (entitlements?.tier !== "free") {
|
||||
prefetchHandler = prefetchLicense;
|
||||
}
|
||||
break;
|
||||
case "Security & SSO":
|
||||
prefetchHandler = () => {
|
||||
prefetchSsoProviders();
|
||||
prefetchScimTokens();
|
||||
};
|
||||
break;
|
||||
case "Public sharing":
|
||||
prefetchHandler = prefetchShares;
|
||||
break;
|
||||
case "API keys":
|
||||
prefetchHandler = prefetchApiKeys;
|
||||
break;
|
||||
case "API management":
|
||||
prefetchHandler = prefetchApiKeyManagement;
|
||||
break;
|
||||
case "Audit logs & SIEM":
|
||||
prefetchHandler = prefetchAuditLogs;
|
||||
break;
|
||||
case "Verified pages":
|
||||
prefetchHandler = prefetchVerifiedPages;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const isDisabled = isItemDisabled(item);
|
||||
|
||||
if (isDisabled) {
|
||||
return (
|
||||
<Tooltip
|
||||
key={item.label}
|
||||
label={upgradeLabel}
|
||||
position="right"
|
||||
withArrow
|
||||
>
|
||||
<span
|
||||
className={classes.link}
|
||||
data-disabled
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
tabIndex={0}
|
||||
style={{
|
||||
opacity: 0.5,
|
||||
cursor: "not-allowed",
|
||||
}}
|
||||
>
|
||||
<item.icon className={classes.linkIcon} stroke={2} />
|
||||
<span>{t(item.label)}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
onMouseEnter={prefetchHandler}
|
||||
className={classes.link}
|
||||
data-active={active.startsWith(item.path) || undefined}
|
||||
key={item.label}
|
||||
to={item.path}
|
||||
onClick={() => {
|
||||
if (mobileSidebarOpened) {
|
||||
toggleMobileSidebar();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<item.icon className={classes.linkIcon} stroke={2} />
|
||||
<span>{t(item.label)}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={classes.navbar}>
|
||||
<Group className={classes.title} justify="flex-start">
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
goBack();
|
||||
if (mobileSidebarOpened) {
|
||||
toggleMobileSidebar();
|
||||
}
|
||||
}}
|
||||
variant="transparent"
|
||||
c="gray"
|
||||
aria-label={t("Back")}
|
||||
>
|
||||
<IconArrowLeft stroke={2} />
|
||||
</ActionIcon>
|
||||
<Text fw={500}>{t("Settings")}</Text>
|
||||
</Group>
|
||||
|
||||
<ScrollArea w="100%">{menuItems}</ScrollArea>
|
||||
|
||||
{!isCloud() && <AppVersion />}
|
||||
|
||||
{isCloud() && (
|
||||
<div className={classes.text}>
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
component="a"
|
||||
href="mailto:help@docmost.com"
|
||||
>
|
||||
help@docmost.com
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
apps/client/src/components/settings/settings-title.tsx
Normal file
13
apps/client/src/components/settings/settings-title.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import { Divider, Title } from '@mantine/core';
|
||||
|
||||
export default function SettingsTitle({ title }: { title: string }) {
|
||||
return (
|
||||
<>
|
||||
<Title order={1} size="h3">
|
||||
{title}
|
||||
</Title>
|
||||
<Divider my="md" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
64
apps/client/src/components/settings/settings.module.css
Normal file
64
apps/client/src/components/settings/settings.module.css
Normal file
@@ -0,0 +1,64 @@
|
||||
.navbar {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: var(--mantine-spacing-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.title {
|
||||
padding-bottom: var(--mantine-spacing-sm);
|
||||
border-bottom: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
.link {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
padding-left: var(--mantine-spacing-xs) ;
|
||||
min-height: 30px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||
}
|
||||
|
||||
&[data-active] {
|
||||
&,
|
||||
& :hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6));
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.linkIcon {
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
margin-right: var(--mantine-spacing-sm);
|
||||
width: rem(16px);
|
||||
height: rem(16px);
|
||||
}
|
||||
|
||||
.linkHeader {
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.text {
|
||||
padding-left: var(--mantine-spacing-xs) ;
|
||||
padding-top: 10px;
|
||||
}
|
||||
19
apps/client/src/components/theme-toggle.module.css
Normal file
19
apps/client/src/components/theme-toggle.module.css
Normal file
@@ -0,0 +1,19 @@
|
||||
.dark {
|
||||
@mixin dark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@mixin light {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.light {
|
||||
@mixin light {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
28
apps/client/src/components/theme-toggle.tsx
Normal file
28
apps/client/src/components/theme-toggle.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
useComputedColorScheme,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { IconMoon, IconSun } from "@tabler/icons-react";
|
||||
import classes from "./theme-toggle.module.css";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setColorScheme } = useMantineColorScheme();
|
||||
const computedColorScheme = useComputedColorScheme();
|
||||
|
||||
return (
|
||||
<Tooltip label="Toggle Color Scheme">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setColorScheme(computedColorScheme === "light" ? "dark" : "light");
|
||||
}}
|
||||
aria-label="Toggle color scheme"
|
||||
>
|
||||
<IconSun className={classes.light} size={18} stroke={1.5} />
|
||||
<IconMoon className={classes.dark} size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
50
apps/client/src/components/ui/auto-tooltip-text.tsx
Normal file
50
apps/client/src/components/ui/auto-tooltip-text.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useRef, useState, ReactNode } from "react";
|
||||
import { Text, TextProps, Tooltip } from "@mantine/core";
|
||||
|
||||
type AutoTooltipTextProps = TextProps & {
|
||||
children: ReactNode;
|
||||
tooltipLabel?: string;
|
||||
tooltipProps?: Omit<
|
||||
React.ComponentProps<typeof Tooltip>,
|
||||
"children" | "label"
|
||||
>;
|
||||
};
|
||||
|
||||
export function AutoTooltipText({
|
||||
children,
|
||||
tooltipLabel,
|
||||
tooltipProps,
|
||||
...textProps
|
||||
}: AutoTooltipTextProps) {
|
||||
const textRef = useRef<HTMLParagraphElement>(null);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
const element = textRef.current;
|
||||
if (element) {
|
||||
setIsTruncated(element.scrollWidth > element.clientWidth);
|
||||
}
|
||||
};
|
||||
|
||||
const label = tooltipLabel ?? (typeof children === "string" ? children : "");
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={label}
|
||||
disabled={!isTruncated || !label}
|
||||
multiline
|
||||
withArrow
|
||||
withinPortal={false}
|
||||
{...tooltipProps}
|
||||
>
|
||||
<Text
|
||||
ref={textRef}
|
||||
truncate
|
||||
onMouseEnter={handleMouseEnter}
|
||||
{...textProps}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
68
apps/client/src/components/ui/card-carousel.module.css
Normal file
68
apps/client/src/components/ui/card-carousel.module.css
Normal file
@@ -0,0 +1,68 @@
|
||||
.root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.track {
|
||||
display: flex;
|
||||
gap: var(--mantine-spacing-md);
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
padding: 2px;
|
||||
margin: -2px;
|
||||
}
|
||||
|
||||
.track::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.track > * {
|
||||
scroll-snap-align: start;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease, background-color 120ms ease, transform 120ms ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.root:hover .arrow.visible,
|
||||
.arrow.visible:focus-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.arrow:hover {
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.arrow:active {
|
||||
transform: translateY(-50%) scale(0.95);
|
||||
}
|
||||
|
||||
.arrowLeft {
|
||||
left: -14px;
|
||||
}
|
||||
|
||||
.arrowRight {
|
||||
right: -14px;
|
||||
}
|
||||
77
apps/client/src/components/ui/card-carousel.tsx
Normal file
77
apps/client/src/components/ui/card-carousel.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "./card-carousel.module.css";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
export default function CardCarousel({ children, ariaLabel }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
const updateScrollState = useCallback(() => {
|
||||
const el = trackRef.current;
|
||||
if (!el) return;
|
||||
const maxScroll = el.scrollWidth - el.clientWidth;
|
||||
setCanScrollLeft(el.scrollLeft > 1);
|
||||
setCanScrollRight(el.scrollLeft < maxScroll - 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
updateScrollState();
|
||||
const el = trackRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new ResizeObserver(updateScrollState);
|
||||
observer.observe(el);
|
||||
for (const child of Array.from(el.children)) {
|
||||
observer.observe(child);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [updateScrollState, children]);
|
||||
|
||||
const scrollBy = (direction: 1 | -1) => {
|
||||
const el = trackRef.current;
|
||||
if (!el) return;
|
||||
el.scrollBy({ left: direction * el.clientWidth * 0.85, behavior: "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<div
|
||||
ref={trackRef}
|
||||
className={classes.track}
|
||||
onScroll={updateScrollState}
|
||||
{...(ariaLabel ? { role: "region", "aria-label": ariaLabel } : {})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`${classes.arrow} ${classes.arrowLeft} ${canScrollLeft ? classes.visible : ""}`}
|
||||
onClick={() => scrollBy(-1)}
|
||||
aria-label={t("Scroll left")}
|
||||
tabIndex={canScrollLeft ? 0 : -1}
|
||||
>
|
||||
<IconChevronLeft size={18} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`${classes.arrow} ${classes.arrowRight} ${canScrollRight ? classes.visible : ""}`}
|
||||
onClick={() => scrollBy(1)}
|
||||
aria-label={t("Scroll right")}
|
||||
tabIndex={canScrollRight ? 0 : -1}
|
||||
>
|
||||
<IconChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
apps/client/src/components/ui/checkbox-menu-item.tsx
Normal file
12
apps/client/src/components/ui/checkbox-menu-item.tsx
Normal file
@@ -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="menuitemcheckbox" so aria-checked works.
|
||||
export const CheckboxMenuItem = forwardRef<
|
||||
HTMLButtonElement,
|
||||
ComponentPropsWithoutRef<"button">
|
||||
>((props, ref) => (
|
||||
<UnstyledButton ref={ref} {...props} role="menuitemcheckbox" />
|
||||
));
|
||||
|
||||
CheckboxMenuItem.displayName = "CheckboxMenuItem";
|
||||
29
apps/client/src/components/ui/clickable-table-row.module.css
Normal file
29
apps/client/src/components/ui/clickable-table-row.module.css
Normal file
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Focus styling for list-style tables (recent changes, favorites, all
|
||||
* spaces, groups, verified pages, shares).
|
||||
*
|
||||
* Per WAI-ARIA Authoring Practices and Adrian Roselli's guidance on table
|
||||
* accessibility (https://adrianroselli.com/2020/02/block-links-cards-clickable-regions-etc.html),
|
||||
* data tables should not be made fully clickable. Only the title cell is the
|
||||
* link, and that link is what receives Tab focus.
|
||||
*
|
||||
* - `.row` adds a subtle background tint when the row contains the focused
|
||||
* element, so keyboard users can see which row they're inspecting.
|
||||
* - `.link` adds a visible :focus-visible outline on the title link itself.
|
||||
*
|
||||
* No stretched-link pseudo here on purpose: absolutely-positioned pseudos
|
||||
* inside table cells cause column reflow on focus in Chromium.
|
||||
*/
|
||||
|
||||
.row:focus-within {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
}
|
||||
|
||||
.link:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
84
apps/client/src/components/ui/custom-avatar.tsx
Normal file
84
apps/client/src/components/ui/custom-avatar.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React from "react";
|
||||
import { Avatar, MantineColor } from "@mantine/core";
|
||||
import { getAvatarUrl } from "@/lib/config.ts";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
|
||||
interface CustomAvatarProps {
|
||||
avatarUrl?: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
size?: string | number;
|
||||
radius?: string | number;
|
||||
variant?: string;
|
||||
style?: any;
|
||||
component?: any;
|
||||
type?: AvatarIconType;
|
||||
mt?: string | number;
|
||||
}
|
||||
|
||||
// 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",
|
||||
"grape.9",
|
||||
"indigo.8",
|
||||
"pink.8",
|
||||
"red.8",
|
||||
"violet.7",
|
||||
];
|
||||
|
||||
function hashName(input: string) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
hash = (hash << 5) - hash + input.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
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
|
||||
>(({ avatarUrl, name, type, color, variant, ...props }: CustomAvatarProps, ref) => {
|
||||
const avatarLink = getAvatarUrl(avatarUrl, type);
|
||||
const isInitials = !color || color === "initials";
|
||||
const pickedColor = isInitials ? pickInitialsColor(name ?? "") : color;
|
||||
const hue = pickedColor.split(".")[0];
|
||||
const initialsSource = sanitizeInitialsSource(name ?? "");
|
||||
|
||||
const resolvedColor = variant === "filled" ? pickedColor : hue;
|
||||
|
||||
const placeholderStyles =
|
||||
isInitials && variant !== "filled"
|
||||
? {
|
||||
placeholder: {
|
||||
color: `var(--mantine-color-${hue}-9)`,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
ref={ref}
|
||||
src={avatarLink}
|
||||
name={initialsSource}
|
||||
alt={name}
|
||||
color={resolvedColor}
|
||||
variant={variant}
|
||||
styles={placeholderStyles}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Modal, Button, Group, Divider } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DestinationPicker } from "./destination-picker";
|
||||
import {
|
||||
DestinationPickerModalProps,
|
||||
DestinationSelection,
|
||||
} from "./destination-picker.types";
|
||||
|
||||
export function DestinationPickerModal({
|
||||
opened,
|
||||
onClose,
|
||||
title,
|
||||
actionLabel,
|
||||
onSelect,
|
||||
loading,
|
||||
excludePageId,
|
||||
pageLimit,
|
||||
initialSpaceId,
|
||||
searchSpacesOnly,
|
||||
}: DestinationPickerModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selection, setSelection] = useState<DestinationSelection | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
setSelection(null);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
return (
|
||||
<Modal.Root
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
size={550}
|
||||
padding="lg"
|
||||
yOffset="10vh"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content>
|
||||
<Modal.Header py={0}>
|
||||
<Modal.Title fw={500}>{title}</Modal.Title>
|
||||
<Modal.CloseButton aria-label={t("Close")} />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<DestinationPicker
|
||||
onSelectionChange={setSelection}
|
||||
excludePageId={excludePageId}
|
||||
pageLimit={pageLimit}
|
||||
initialSpaceId={initialSpaceId}
|
||||
searchSpacesOnly={searchSpacesOnly}
|
||||
/>
|
||||
|
||||
<Divider my="md" />
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
{t("Close")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => selection && onSelect(selection)}
|
||||
disabled={!selection}
|
||||
loading={loading}
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
.searchInput {
|
||||
margin-bottom: var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.scrollArea {
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.row {
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||
transition: background-color 150ms ease;
|
||||
user-select: none;
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-0),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.selected {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-blue-0),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
border-left: 2px solid var(--mantine-primary-color-filled);
|
||||
}
|
||||
|
||||
.spaceRow {
|
||||
composes: row;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pageRow {
|
||||
composes: row;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
flex-shrink: 0;
|
||||
transition: transform 150ms ease;
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.chevronExpanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.loadMore {
|
||||
text-align: center;
|
||||
padding: 6px;
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
cursor: pointer;
|
||||
|
||||
@mixin hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
}
|
||||
|
||||
.searchResult {
|
||||
composes: row;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
}
|
||||
|
||||
.spaceName {
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { ActionIcon, TextInput, ScrollArea, Loader } from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { IconSearch, IconFileDescription } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query";
|
||||
import { ISpace } from "@/features/space/types/space.types";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { DestinationSelection } from "./destination-picker.types";
|
||||
import { SpaceRow } from "./space-row";
|
||||
import classes from "./destination-picker.module.css";
|
||||
|
||||
type DestinationPickerProps = {
|
||||
onSelectionChange: (selection: DestinationSelection | null) => void;
|
||||
excludePageId?: string;
|
||||
pageLimit?: number;
|
||||
initialSpaceId?: string;
|
||||
searchSpacesOnly?: boolean;
|
||||
};
|
||||
|
||||
export function DestinationPicker({
|
||||
onSelectionChange,
|
||||
excludePageId,
|
||||
pageLimit = 15,
|
||||
initialSpaceId,
|
||||
searchSpacesOnly,
|
||||
}: DestinationPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selection, setSelection] = useState<DestinationSelection | null>(null);
|
||||
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: spacesData, isLoading: spacesLoading } = useGetSpacesQuery({
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const searchEnabled =
|
||||
!searchSpacesOnly && debouncedQuery && debouncedQuery.length >= 2;
|
||||
|
||||
const { data: searchData, isLoading: searchLoading } =
|
||||
useSearchSuggestionsQuery({
|
||||
query: searchEnabled ? debouncedQuery : "",
|
||||
includePages: true,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
const isSearching = !!searchEnabled;
|
||||
|
||||
const filteredSpaces = useMemo(() => {
|
||||
const items = spacesData?.items ?? [];
|
||||
if (!searchSpacesOnly || !debouncedQuery) return items;
|
||||
const fold = (s: string) =>
|
||||
s
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.toLocaleLowerCase();
|
||||
const term = fold(debouncedQuery);
|
||||
return items.filter((s) => fold(s.name).includes(term));
|
||||
}, [spacesData, searchSpacesOnly, debouncedQuery]);
|
||||
|
||||
const selectedId =
|
||||
selection?.type === "space" ? selection.spaceId : selection?.pageId ?? null;
|
||||
|
||||
const updateSelection = useCallback(
|
||||
(next: DestinationSelection | null) => {
|
||||
setSelection(next);
|
||||
onSelectionChange(next);
|
||||
},
|
||||
[onSelectionChange],
|
||||
);
|
||||
|
||||
const handleSearchResultClick = (page: Partial<IPage>) => {
|
||||
if (!page.space || !page.id) return;
|
||||
|
||||
updateSelection({
|
||||
type: "page",
|
||||
spaceId: page.space.id,
|
||||
pageId: page.id,
|
||||
page,
|
||||
space: page.space,
|
||||
});
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
const handleSelectSpace = useCallback(
|
||||
(space: ISpace) => {
|
||||
updateSelection({ type: "space", spaceId: space.id, space });
|
||||
},
|
||||
[updateSelection],
|
||||
);
|
||||
|
||||
const handleSelectPage = useCallback(
|
||||
(page: Partial<IPage>, space: ISpace) => {
|
||||
if (!page.id) return;
|
||||
updateSelection({
|
||||
type: "page",
|
||||
spaceId: page.spaceId ?? space.id,
|
||||
pageId: page.id,
|
||||
page,
|
||||
space,
|
||||
});
|
||||
},
|
||||
[updateSelection],
|
||||
);
|
||||
|
||||
// Pre-select space when initialSpaceId is set and spaces have loaded.
|
||||
// Only runs once: skip if user has already made a selection.
|
||||
useEffect(() => {
|
||||
if (!initialSpaceId || selection) return;
|
||||
const match = spacesData?.items?.find((s) => s.id === initialSpaceId);
|
||||
if (match) {
|
||||
updateSelection({ type: "space", spaceId: match.id, space: match });
|
||||
requestAnimationFrame(() => {
|
||||
const el = viewportRef.current?.querySelector<HTMLElement>(
|
||||
`[data-space-id="${match.id}"]`,
|
||||
);
|
||||
el?.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
}
|
||||
}, [initialSpaceId, selection, spacesData, updateSelection]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextInput
|
||||
leftSection={<IconSearch size={16} />}
|
||||
placeholder={
|
||||
searchSpacesOnly
|
||||
? t("Search spaces...")
|
||||
: t("Search pages and spaces...")
|
||||
}
|
||||
aria-label={
|
||||
searchSpacesOnly
|
||||
? t("Search spaces...")
|
||||
: t("Search pages and spaces...")
|
||||
}
|
||||
variant="filled"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
className={classes.searchInput}
|
||||
/>
|
||||
|
||||
<ScrollArea
|
||||
h="50vh"
|
||||
offsetScrollbars
|
||||
className={classes.scrollArea}
|
||||
viewportRef={viewportRef}
|
||||
>
|
||||
{isSearching ? (
|
||||
searchLoading ? (
|
||||
<div className={classes.emptyState}>
|
||||
<Loader size="xs" />
|
||||
</div>
|
||||
) : searchData?.pages && searchData.pages.length > 0 ? (
|
||||
searchData.pages.map(
|
||||
(page) =>
|
||||
page && (
|
||||
<div
|
||||
key={page.id}
|
||||
className={classes.searchResult}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleSearchResultClick(page)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSearchResultClick(page);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={classes.iconWrapper}>
|
||||
{page.icon ? (
|
||||
page.icon
|
||||
) : (
|
||||
<ActionIcon
|
||||
component="div"
|
||||
variant="transparent"
|
||||
c="gray"
|
||||
size={22}
|
||||
>
|
||||
<IconFileDescription size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
<div className={classes.pageTitle}>
|
||||
{page.title || t("Untitled")}
|
||||
</div>
|
||||
{page.space && (
|
||||
<div className={classes.spaceName}>
|
||||
{page.space.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)
|
||||
) : (
|
||||
<div className={classes.emptyState}>{t("No results found")}</div>
|
||||
)
|
||||
) : spacesLoading ? (
|
||||
<div className={classes.emptyState}>
|
||||
<Loader size="xs" />
|
||||
</div>
|
||||
) : filteredSpaces.length === 0 ? (
|
||||
<div className={classes.emptyState}>
|
||||
{searchSpacesOnly && debouncedQuery
|
||||
? t("No spaces found")
|
||||
: t("No results found")}
|
||||
</div>
|
||||
) : (
|
||||
filteredSpaces.map((space) => (
|
||||
<SpaceRow
|
||||
key={space.id}
|
||||
space={space}
|
||||
limit={pageLimit}
|
||||
selectedId={selectedId}
|
||||
excludePageId={excludePageId}
|
||||
onSelectSpace={handleSelectSpace}
|
||||
onSelectPage={handleSelectPage}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ScrollArea>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ISpace } from "@/features/space/types/space.types";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
|
||||
export type DestinationSelection =
|
||||
| { type: "space"; spaceId: string; space: ISpace }
|
||||
| {
|
||||
type: "page";
|
||||
spaceId: string;
|
||||
pageId: string;
|
||||
page: Partial<IPage>;
|
||||
space: Partial<ISpace>;
|
||||
};
|
||||
|
||||
export type DestinationPickerModalProps = {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
actionLabel: string;
|
||||
onSelect: (selection: DestinationSelection) => void | Promise<void>;
|
||||
loading?: boolean;
|
||||
excludePageId?: string;
|
||||
pageLimit?: number;
|
||||
initialSpaceId?: string;
|
||||
searchSpacesOnly?: boolean;
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Loader } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getSidebarPages } from "@/features/page/services/page-service";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { IPagination } from "@/lib/types";
|
||||
import { PageRow } from "./page-row";
|
||||
import classes from "./destination-picker.module.css";
|
||||
|
||||
type PageChildrenProps = {
|
||||
spaceId: string;
|
||||
pageId?: string;
|
||||
depth: number;
|
||||
limit: number;
|
||||
selectedId: string | null;
|
||||
excludePageId?: string;
|
||||
onSelectPage: (page: Partial<IPage>) => void;
|
||||
};
|
||||
|
||||
export function PageChildren({
|
||||
spaceId,
|
||||
pageId,
|
||||
depth,
|
||||
limit,
|
||||
selectedId,
|
||||
excludePageId,
|
||||
onSelectPage,
|
||||
}: PageChildrenProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data, isLoading, hasNextPage, fetchNextPage } = useInfiniteQuery({
|
||||
queryKey: ["destination-pages", spaceId, pageId ?? "root"],
|
||||
queryFn: ({ pageParam }) =>
|
||||
getSidebarPages({
|
||||
spaceId,
|
||||
pageId,
|
||||
limit,
|
||||
cursor: pageParam,
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage: IPagination<IPage>) =>
|
||||
lastPage.meta?.nextCursor ?? undefined,
|
||||
});
|
||||
|
||||
const pages = data?.pages.flatMap((page) => page.items) ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={classes.emptyState}>
|
||||
<Loader size="xs" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pages.length === 0) {
|
||||
return (
|
||||
<div className={classes.emptyState}>
|
||||
{pageId ? t("No pages inside") : t("No pages in this space")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{pages.map((page) => (
|
||||
<PageRow
|
||||
key={page.id}
|
||||
page={page}
|
||||
depth={depth}
|
||||
limit={limit}
|
||||
selectedId={selectedId}
|
||||
excludePageId={excludePageId}
|
||||
onSelect={onSelectPage}
|
||||
/>
|
||||
))}
|
||||
{hasNextPage && (
|
||||
<div
|
||||
className={classes.loadMore}
|
||||
onClick={() => fetchNextPage()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{t("Load more")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
116
apps/client/src/components/ui/destination-picker/page-row.tsx
Normal file
116
apps/client/src/components/ui/destination-picker/page-row.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { KeyboardEvent, useState } from "react";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { IconChevronRight, IconFileDescription } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { getPageTitle } from "@/features/page/page.utils";
|
||||
import { PageChildren } from "./page-children";
|
||||
import classes from "./destination-picker.module.css";
|
||||
|
||||
type PageRowProps = {
|
||||
page: Partial<IPage>;
|
||||
depth: number;
|
||||
limit: number;
|
||||
selectedId: string | null;
|
||||
excludePageId?: string;
|
||||
onSelect: (page: Partial<IPage>) => void;
|
||||
};
|
||||
|
||||
export function PageRow({
|
||||
page,
|
||||
depth,
|
||||
limit,
|
||||
selectedId,
|
||||
excludePageId,
|
||||
onSelect,
|
||||
}: PageRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const isExcluded = page.id === excludePageId;
|
||||
const isSelected = page.id === selectedId;
|
||||
|
||||
const rowClasses = [
|
||||
classes.pageRow,
|
||||
isSelected && classes.selected,
|
||||
isExcluded && classes.disabled,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const handleSelect = () => {
|
||||
if (!isExcluded) onSelect(page);
|
||||
};
|
||||
|
||||
const handleRowKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSelect();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={rowClasses}
|
||||
style={{ paddingLeft: depth * 20 + 12 }}
|
||||
role="button"
|
||||
tabIndex={isExcluded ? -1 : 0}
|
||||
aria-disabled={isExcluded || undefined}
|
||||
onClick={handleSelect}
|
||||
onKeyDown={handleRowKeyDown}
|
||||
>
|
||||
{page.hasChildren ? (
|
||||
<ActionIcon
|
||||
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
aria-label={expanded ? t("Collapse") : t("Expand")}
|
||||
aria-expanded={expanded}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
>
|
||||
<IconChevronRight size={14} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<div style={{ width: 20, flexShrink: 0 }} />
|
||||
)}
|
||||
|
||||
<div className={classes.iconWrapper}>
|
||||
{page.icon ? (
|
||||
page.icon
|
||||
) : (
|
||||
<ActionIcon
|
||||
component="div"
|
||||
variant="transparent"
|
||||
c="gray"
|
||||
size={22}
|
||||
>
|
||||
<IconFileDescription size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={classes.pageTitle}>
|
||||
{getPageTitle(page.title, page.isBase, t)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && page.hasChildren && (
|
||||
<PageChildren
|
||||
spaceId={page.spaceId}
|
||||
pageId={page.id}
|
||||
depth={depth + 1}
|
||||
limit={limit}
|
||||
selectedId={selectedId}
|
||||
excludePageId={excludePageId}
|
||||
onSelectPage={onSelect}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
130
apps/client/src/components/ui/destination-picker/space-row.tsx
Normal file
130
apps/client/src/components/ui/destination-picker/space-row.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { KeyboardEvent, useState } from "react";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { IconChevronRight, IconLock } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ISpace } from "@/features/space/types/space.types";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { SpaceRole } from "@/lib/types";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types";
|
||||
import { PageChildren } from "./page-children";
|
||||
import classes from "./destination-picker.module.css";
|
||||
|
||||
type SpaceRowProps = {
|
||||
space: ISpace;
|
||||
limit: number;
|
||||
selectedId: string | null;
|
||||
excludePageId?: string;
|
||||
onSelectSpace: (space: ISpace) => void;
|
||||
onSelectPage: (page: Partial<IPage>, space: ISpace) => void;
|
||||
};
|
||||
|
||||
export function SpaceRow({
|
||||
space,
|
||||
limit,
|
||||
selectedId,
|
||||
excludePageId,
|
||||
onSelectSpace,
|
||||
onSelectPage,
|
||||
}: SpaceRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const writable =
|
||||
!!space.membership?.role && space.membership.role !== SpaceRole.READER;
|
||||
const isSelected = space.id === selectedId;
|
||||
|
||||
const rowClasses = [
|
||||
classes.spaceRow,
|
||||
isSelected && classes.selected,
|
||||
!writable && classes.disabled,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const handleSelect = () => {
|
||||
if (writable) onSelectSpace(space);
|
||||
};
|
||||
|
||||
const handleRowKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSelect();
|
||||
}
|
||||
};
|
||||
|
||||
const rowContent = (
|
||||
<div
|
||||
className={rowClasses}
|
||||
data-space-id={space.id}
|
||||
role="button"
|
||||
tabIndex={writable ? 0 : -1}
|
||||
aria-disabled={!writable || undefined}
|
||||
onClick={handleSelect}
|
||||
onKeyDown={handleRowKeyDown}
|
||||
>
|
||||
{writable ? (
|
||||
<ActionIcon
|
||||
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
aria-label={expanded ? t("Collapse") : t("Expand")}
|
||||
aria-expanded={expanded}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
>
|
||||
<IconChevronRight size={14} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<div style={{ width: 20, flexShrink: 0 }} />
|
||||
)}
|
||||
|
||||
<CustomAvatar
|
||||
name={space.name}
|
||||
avatarUrl={space.logo}
|
||||
type={AvatarIconType.SPACE_ICON}
|
||||
size={22}
|
||||
/>
|
||||
|
||||
<div className={classes.pageTitle}>{space.name}</div>
|
||||
|
||||
{!writable && (
|
||||
<IconLock
|
||||
size={14}
|
||||
color="var(--mantine-color-gray-5)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{writable ? (
|
||||
rowContent
|
||||
) : (
|
||||
<Tooltip
|
||||
label={t("You don't have permission to create pages here")}
|
||||
position="right"
|
||||
withArrow
|
||||
>
|
||||
<div>{rowContent}</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{expanded && writable && (
|
||||
<PageChildren
|
||||
spaceId={space.id}
|
||||
depth={1}
|
||||
limit={limit}
|
||||
selectedId={selectedId}
|
||||
excludePageId={excludePageId}
|
||||
onSelectPage={(page) => onSelectPage(page, space)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
45
apps/client/src/components/ui/document-title.test.tsx
Normal file
45
apps/client/src/components/ui/document-title.test.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { HelmetProvider } from "react-helmet-async";
|
||||
import { DocumentTitle } from "./document-title.tsx";
|
||||
|
||||
const renderTitle = (ui: React.ReactNode) =>
|
||||
render(<HelmetProvider>{ui}</HelmetProvider>);
|
||||
|
||||
describe("DocumentTitle", () => {
|
||||
beforeEach(() => {
|
||||
document.head.innerHTML = "<title>Docmost</title>";
|
||||
});
|
||||
|
||||
it("appends the app name", () => {
|
||||
renderTitle(<DocumentTitle title="Home" />);
|
||||
expect(document.title).toBe("Home - Docmost");
|
||||
});
|
||||
|
||||
it("omits the app name when asked", () => {
|
||||
renderTitle(<DocumentTitle title="My page" withAppName={false} />);
|
||||
expect(document.title).toBe("My page");
|
||||
});
|
||||
|
||||
it("falls back to the app name without a title", () => {
|
||||
renderTitle(<DocumentTitle />);
|
||||
expect(document.title).toBe("Docmost");
|
||||
});
|
||||
|
||||
it("never renders an empty title", () => {
|
||||
renderTitle(<DocumentTitle title="Spaces" />);
|
||||
const titles = Array.from(document.querySelectorAll("head > title"));
|
||||
expect(titles.every((node) => node.textContent !== "")).toBe(true);
|
||||
});
|
||||
|
||||
it("renders extra head children", () => {
|
||||
renderTitle(
|
||||
<DocumentTitle title="Shared">
|
||||
<meta name="robots" content="noindex" />
|
||||
</DocumentTitle>,
|
||||
);
|
||||
expect(
|
||||
document.querySelector('head > meta[name="robots"]')?.getAttribute("content"),
|
||||
).toBe("noindex");
|
||||
});
|
||||
});
|
||||
29
apps/client/src/components/ui/document-title.tsx
Normal file
29
apps/client/src/components/ui/document-title.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { getAppName } from "@/lib/config.ts";
|
||||
|
||||
type DocumentTitleProps = {
|
||||
title?: string;
|
||||
withAppName?: boolean;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function DocumentTitle({
|
||||
title,
|
||||
withAppName = true,
|
||||
children,
|
||||
}: DocumentTitleProps) {
|
||||
const appName = getAppName();
|
||||
|
||||
let documentTitle = appName;
|
||||
if (title) {
|
||||
documentTitle = withAppName ? `${title} - ${appName}` : title;
|
||||
}
|
||||
|
||||
return (
|
||||
<Helmet>
|
||||
<title>{documentTitle}</title>
|
||||
{children}
|
||||
</Helmet>
|
||||
);
|
||||
}
|
||||
162
apps/client/src/components/ui/emoji-picker.tsx
Normal file
162
apps/client/src/components/ui/emoji-picker.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React, { ReactNode, useEffect, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Popover,
|
||||
Button,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { useClickOutside, useDisclosure, useWindowEvent } from "@mantine/hooks";
|
||||
import { Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// Load the picker module AND the emoji data in parallel inside the lazy
|
||||
// resolution, then bind the data into the component. React.lazy only finishes
|
||||
// suspending once both are in memory, so the Suspense boundary hides the
|
||||
// Remove button until the Picker can render with real content.
|
||||
const Picker = React.lazy(async () => {
|
||||
const [pickerModule, dataModule] = await Promise.all([
|
||||
import("@slidoapp/emoji-mart-react"),
|
||||
import("@slidoapp/emoji-mart-data"),
|
||||
]);
|
||||
const PickerComp = pickerModule.default;
|
||||
const data = dataModule.default;
|
||||
return {
|
||||
default: (props: any) => <PickerComp {...props} data={data} />,
|
||||
};
|
||||
});
|
||||
|
||||
export interface EmojiPickerInterface {
|
||||
onEmojiSelect: (emoji: any) => void;
|
||||
icon: ReactNode;
|
||||
removeEmojiAction: () => void;
|
||||
readOnly: boolean;
|
||||
actionIconProps?: {
|
||||
size?: string;
|
||||
variant?: string;
|
||||
c?: string;
|
||||
tabIndex?: number;
|
||||
};
|
||||
}
|
||||
|
||||
function EmojiPicker({
|
||||
onEmojiSelect,
|
||||
icon,
|
||||
removeEmojiAction,
|
||||
readOnly,
|
||||
actionIconProps,
|
||||
}: EmojiPickerInterface) {
|
||||
const { t } = useTranslation();
|
||||
const [opened, handlers] = useDisclosure(false);
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const [target, setTarget] = useState<HTMLElement | null>(null);
|
||||
const [dropdown, setDropdown] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
useClickOutside(
|
||||
() => handlers.close(),
|
||||
["mousedown", "touchstart"],
|
||||
[dropdown, target],
|
||||
);
|
||||
|
||||
// We need this because the default Mantine popover closeOnEscape does not work
|
||||
useWindowEvent("keydown", (event) => {
|
||||
if (opened && event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
handlers.close();
|
||||
}
|
||||
});
|
||||
|
||||
// emoji-mart's built-in autoFocus calls .focus() without preventScroll, which
|
||||
// makes the browser scroll every scrollable ancestor of the search input to
|
||||
// bring it on screen — including the page editor's scroll container, so the
|
||||
// page jumps to the top whenever the picker is opened from a scrolled-down
|
||||
// position. The search input lives inside the <em-emoji-picker> custom
|
||||
// element's shadow root, so we poll for it after the dropdown mounts and
|
||||
// focus it ourselves with preventScroll.
|
||||
useEffect(() => {
|
||||
if (!opened || !dropdown) return;
|
||||
let cancelled = false;
|
||||
let rafId = 0;
|
||||
const tryFocus = (attempts: number) => {
|
||||
if (cancelled) return;
|
||||
const pickerEl = dropdown.querySelector("em-emoji-picker");
|
||||
const input = pickerEl?.shadowRoot?.querySelector<HTMLInputElement>(
|
||||
'input[type="search"]',
|
||||
);
|
||||
if (input) {
|
||||
input.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
if (attempts < 60) {
|
||||
rafId = requestAnimationFrame(() => tryFocus(attempts + 1));
|
||||
}
|
||||
};
|
||||
rafId = requestAnimationFrame(() => tryFocus(0));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [opened, dropdown]);
|
||||
|
||||
const handleEmojiSelect = (emoji) => {
|
||||
onEmojiSelect(emoji);
|
||||
handlers.close();
|
||||
};
|
||||
|
||||
const handleRemoveEmoji = () => {
|
||||
removeEmojiAction();
|
||||
handlers.close();
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onClose={handlers.close}
|
||||
width={332}
|
||||
position="bottom"
|
||||
disabled={readOnly}
|
||||
closeOnEscape={true}
|
||||
>
|
||||
<Popover.Target ref={setTarget}>
|
||||
<ActionIcon
|
||||
c={actionIconProps?.c || "gray"}
|
||||
variant={actionIconProps?.variant || "transparent"}
|
||||
size={actionIconProps?.size}
|
||||
tabIndex={actionIconProps?.tabIndex}
|
||||
onClick={handlers.toggle}
|
||||
aria-label={t("Pick emoji")}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={opened}
|
||||
>
|
||||
{icon}
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Suspense fallback={null}>
|
||||
<Popover.Dropdown bg="000" style={{ border: "none" }} ref={setDropdown}>
|
||||
<Picker
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
perLine={8}
|
||||
skinTonePosition="search"
|
||||
theme={colorScheme}
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
c="gray"
|
||||
size="xs"
|
||||
style={{
|
||||
position: "absolute",
|
||||
zIndex: 2,
|
||||
bottom: "1rem",
|
||||
right: "1rem",
|
||||
}}
|
||||
onClick={handleRemoveEmoji}
|
||||
>
|
||||
{t("Remove")}
|
||||
</Button>
|
||||
</Popover.Dropdown>
|
||||
</Suspense>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default EmojiPicker;
|
||||
8
apps/client/src/components/ui/empty-state.module.css
Normal file
8
apps/client/src/components/ui/empty-state.module.css
Normal file
@@ -0,0 +1,8 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
30
apps/client/src/components/ui/empty-state.tsx
Normal file
30
apps/client/src/components/ui/empty-state.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
import { type TablerIcon } from "@tabler/icons-react";
|
||||
import { ReactNode } from "react";
|
||||
import classes from "./empty-state.module.css";
|
||||
|
||||
type EmptyStateProps = {
|
||||
icon: TablerIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
};
|
||||
|
||||
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Stack align="center" gap="xs">
|
||||
<Icon size={40} stroke={1.5} color="var(--mantine-color-dimmed)" />
|
||||
<Text size="lg" fw={500}>
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text size="sm" c="dimmed" maw={350}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{action}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
apps/client/src/components/ui/error-404.module.css
Normal file
24
apps/client/src/components/ui/error-404.module.css
Normal file
@@ -0,0 +1,24 @@
|
||||
.root {
|
||||
padding-top: 80px;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.description {
|
||||
max-width: 500px;
|
||||
margin: auto;
|
||||
margin-top: var(--mantine-spacing-xl);
|
||||
margin-bottom: calc(1.5 * var(--mantine-spacing-xl));
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family:
|
||||
Greycliff CF,
|
||||
var(--mantine-font-family);
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 30px;
|
||||
|
||||
@media (max-width: $mantine-breakpoint-sm) {
|
||||
font-size: 25px;
|
||||
}
|
||||
}
|
||||
26
apps/client/src/components/ui/error-404.tsx
Normal file
26
apps/client/src/components/ui/error-404.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Title, Text, Button, Container, Group } from "@mantine/core";
|
||||
import classes from "./error-404.module.css";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DocumentTitle } from "@/components/ui/document-title.tsx";
|
||||
|
||||
export function Error404() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentTitle title={t("404 page not found")} />
|
||||
<Container className={classes.root}>
|
||||
<Title className={classes.title}>{t("404 page not found")}</Title>
|
||||
<Text c="dimmed" size="lg" ta="center" className={classes.description}>
|
||||
{t("Sorry, we can't find the page you are looking for.")}
|
||||
</Text>
|
||||
<Group justify="center">
|
||||
<Button component={Link} to={"/home"} variant="subtle" size="md">
|
||||
{t("Take me back to homepage")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
21
apps/client/src/components/ui/page-list-skeleton.tsx
Normal file
21
apps/client/src/components/ui/page-list-skeleton.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Skeleton } from '@mantine/core';
|
||||
|
||||
export default function PageListSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
apps/client/src/components/ui/radio-menu-item.tsx
Normal file
12
apps/client/src/components/ui/radio-menu-item.tsx
Normal file
@@ -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";
|
||||
47
apps/client/src/components/ui/responsive-settings-row.tsx
Normal file
47
apps/client/src/components/ui/responsive-settings-row.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Box } from "@mantine/core";
|
||||
import React from "react";
|
||||
|
||||
interface ResponsiveSettingsRowProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ResponsiveSettingsRow({ children }: ResponsiveSettingsRowProps) {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: "1rem",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface ResponsiveSettingsContentProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ResponsiveSettingsContent({ children }: ResponsiveSettingsContentProps) {
|
||||
return (
|
||||
<Box style={{ flex: "1 1 300px", minWidth: 0 }}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface ResponsiveSettingsControlProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ResponsiveSettingsControl({ children }: ResponsiveSettingsControlProps) {
|
||||
return (
|
||||
<Box style={{ flex: "0 0 auto" }}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
68
apps/client/src/components/ui/role-select-menu.tsx
Normal file
68
apps/client/src/components/ui/role-select-menu.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { IconCheck, IconChevronDown } from "@tabler/icons-react";
|
||||
import { Group, Text, Menu, Button } from "@mantine/core";
|
||||
import { IRoleData } from "@/lib/types.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface RoleButtonProps extends React.ComponentPropsWithoutRef<"button"> {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const RoleButton = forwardRef<HTMLButtonElement, RoleButtonProps>(
|
||||
({ name, ...others }: RoleButtonProps, ref) => (
|
||||
<Button
|
||||
variant="default"
|
||||
ref={ref}
|
||||
style={{
|
||||
border: "none",
|
||||
}}
|
||||
rightSection={<IconChevronDown size="1rem" />}
|
||||
{...others}
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
),
|
||||
);
|
||||
|
||||
interface RoleMenuProps {
|
||||
roles: IRoleData[];
|
||||
roleName: string;
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function RoleSelectMenu({
|
||||
roles,
|
||||
roleName,
|
||||
onChange,
|
||||
disabled,
|
||||
}: RoleMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Menu withArrow>
|
||||
<Menu.Target>
|
||||
<RoleButton name={t(roleName)} disabled={disabled} />
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
{roles?.map((item) => (
|
||||
<Menu.Item
|
||||
onClick={() => onChange && onChange(item.value)}
|
||||
key={item.value}
|
||||
>
|
||||
<Group flex="1" gap="xs">
|
||||
<div>
|
||||
<Text size="sm">{t(item.label)}</Text>
|
||||
<Text size="xs" opacity={0.65}>
|
||||
{t(item.description)}
|
||||
</Text>
|
||||
</div>
|
||||
{item.label === roleName && <IconCheck size={20} />}
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
35
apps/client/src/components/ui/sidebar-toggle-button.tsx
Normal file
35
apps/client/src/components/ui/sidebar-toggle-button.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
import {
|
||||
IconLayoutSidebarRightCollapse,
|
||||
IconLayoutSidebarRightExpand
|
||||
} from "@tabler/icons-react";
|
||||
import { ActionIcon, BoxProps, ElementProps, MantineColor, MantineSize } from "@mantine/core";
|
||||
|
||||
export interface SidebarToggleProps extends BoxProps, ElementProps<"button"> {
|
||||
size?: MantineSize | `compact-${MantineSize}` | (string & {});
|
||||
color?: MantineColor;
|
||||
opened?: boolean;
|
||||
}
|
||||
|
||||
const SidebarToggle = React.forwardRef<HTMLButtonElement, SidebarToggleProps>(
|
||||
({ opened, size = "sm", ...others }, ref) => {
|
||||
return (
|
||||
<ActionIcon
|
||||
size={size}
|
||||
aria-expanded={opened}
|
||||
{...others}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
ref={ref}
|
||||
>
|
||||
{opened ? (
|
||||
<IconLayoutSidebarRightExpand />
|
||||
) : (
|
||||
<IconLayoutSidebarRightCollapse />
|
||||
)}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default SidebarToggle;
|
||||
27
apps/client/src/components/ui/skip-to-main.module.css
Normal file
27
apps/client/src/components/ui/skip-to-main.module.css
Normal file
@@ -0,0 +1,27 @@
|
||||
.skipLink {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 9999;
|
||||
padding: 8px 16px;
|
||||
background: var(--mantine-color-body);
|
||||
color: var(--mantine-color-text);
|
||||
border: 2px solid var(--mantine-color-blue-6);
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
transform: translateY(-200%);
|
||||
transition: transform 0.15s ease-out;
|
||||
}
|
||||
|
||||
.skipLink:focus {
|
||||
transform: translateY(0);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.skipLink {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
13
apps/client/src/components/ui/skip-to-main.tsx
Normal file
13
apps/client/src/components/ui/skip-to-main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "./skip-to-main.module.css";
|
||||
|
||||
export const MAIN_CONTENT_ID = "main-content";
|
||||
|
||||
export function SkipToMain() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<a href={`#${MAIN_CONTENT_ID}`} className={classes.skipLink}>
|
||||
{t("Skip to main content")}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user