mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5384ee8aae | |||
| 97c3ea7da8 | |||
| 4af2b032dd | |||
| c52d9a850d | |||
| 588e5bc17f | |||
| a2bd0edd82 | |||
| ca0f0638c9 | |||
| f13e6a3691 | |||
| dcb7b86df8 | |||
| 45c6e72c6d | |||
| a51456deb3 | |||
| 3ffe7e7671 | |||
| a7fe6c9af3 | |||
| 52c673261b | |||
| 60c0a53a1f | |||
| 66fae19034 | |||
| 37ea6bb92b | |||
| 762816adbc | |||
| d1b24b15d5 | |||
| 877b7ad0df | |||
| e98d931aaa | |||
| ba7d102a72 | |||
| ab1f00e919 | |||
| 34cb31ff43 | |||
| aac95c2b2e | |||
| 0dd6ef5196 | |||
| 5cd11002d1 | |||
| 5334f7ae08 | |||
| df1de2b822 | |||
| deb93ef767 | |||
| 5bef4c4b55 | |||
| 72bff1ec8a | |||
| c12b257098 | |||
| f6da244c33 | |||
| ab55e0bed9 | |||
| 84ae9a2c31 | |||
| 5c4eb32c26 | |||
| 10b8f11e0b | |||
| 0a4c3bd633 | |||
| 580cf52fd3 | |||
| ee1fd65a19 | |||
| 323c5f5978 | |||
| bdb34a202c | |||
| 40278b2d9a | |||
| a69ef1f3c9 | |||
| 6e98568e5b | |||
| 8b65ad3cfa | |||
| 533a14369c | |||
| 0ec6440506 | |||
| 6fde025ce4 | |||
| 18bbe6ecf6 | |||
| a48f6c7a85 | |||
| ec9f45f310 | |||
| dd053c4152 | |||
| 5565034486 | |||
| 42cfac97aa | |||
| f369c2f8bf | |||
| 08f91aa60c | |||
| 0fe50c179c | |||
| ae249f720d | |||
| 0a9e76f600 | |||
| 912b9159f0 | |||
| 307f4a1351 | |||
| 021a286d99 | |||
| 6869d4cb02 | |||
| c311ee915e | |||
| b195b0e3b5 | |||
| c783ccad1e | |||
| 619c56102c | |||
| 7c41c1360b | |||
| f3a1b47ccf | |||
| af234465f0 | |||
| 5a1aeed989 | |||
| 6ea4ce72ec | |||
| 8041d9c3bd | |||
| 516d14fe27 |
+5
-5
@@ -147,6 +147,10 @@ DISCORD_SERVER_ID=
|
||||
# DISCORD_SERVER_ID and DISCORD_SERVER_ROLES must be set together.
|
||||
DISCORD_SERVER_ROLES=
|
||||
|
||||
# –––––––––––––– IMPORTS ––––––––––––––
|
||||
NOTION_CLIENT_ID=
|
||||
NOTION_CLIENT_SECRET=
|
||||
|
||||
# –––––––––––––––– OPTIONAL ––––––––––––––––
|
||||
|
||||
# Base64 encoded private key and certificate for HTTPS termination. This is only
|
||||
@@ -201,14 +205,10 @@ SENTRY_TUNNEL=
|
||||
|
||||
# To support sending outgoing transactional emails such as "document updated" or
|
||||
# "you've been invited" you'll need to provide authentication for an SMTP server
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
SMTP_SERVICE=
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_EMAIL=
|
||||
SMTP_REPLY_EMAIL=
|
||||
SMTP_TLS_CIPHERS=
|
||||
SMTP_SECURE=true
|
||||
|
||||
# The default interface language. See translate.getoutline.com for a list of
|
||||
# available language codes and their rough percentage translated.
|
||||
|
||||
@@ -15,6 +15,8 @@ requestInfoDefaultTitles:
|
||||
|
||||
requestInfoLabelToAdd: more information needed
|
||||
|
||||
requestInfoUserstoExclude:
|
||||
- tommoor
|
||||
|
||||
# Configuration for new-pr-welcome - https://github.com/behaviorbot/new-pr-welcome
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
run-linters:
|
||||
if: startsWith(github.actor, 'codegen-sh')
|
||||
name: Run linters
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
# Give the default GITHUB_TOKEN write permission to commit and push the
|
||||
# added or changed files to the repository.
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'yarn'
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn lint --fix
|
||||
|
||||
- name: Commit changes
|
||||
uses: stefanzweifel/git-auto-commit-action@v5
|
||||
with:
|
||||
commit_message: 'Applied automatic fixes'
|
||||
@@ -171,6 +171,10 @@
|
||||
"description": "smtp.example.com (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_SERVICE": {
|
||||
"description": "Well-known SMTP service name for nodemailer (optional, e.g. 'gmail', 'SES')",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_PORT": {
|
||||
"description": "1234 (optional)",
|
||||
"required": false
|
||||
|
||||
@@ -149,7 +149,7 @@ export const searchInCollection = createAction({
|
||||
},
|
||||
|
||||
perform: ({ activeCollectionId }) => {
|
||||
history.push(searchPath(undefined, { collectionId: activeCollectionId }));
|
||||
history.push(searchPath({ collectionId: activeCollectionId }));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -693,7 +693,7 @@ export const searchInDocument = createAction({
|
||||
return !!document?.isActive;
|
||||
},
|
||||
perform: ({ activeDocumentId }) => {
|
||||
history.push(searchPath(undefined, { documentId: activeDocumentId }));
|
||||
history.push(searchPath({ documentId: activeDocumentId }));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -806,15 +806,15 @@ export const openRandomDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const searchDocumentsForQuery = (searchQuery: string) =>
|
||||
export const searchDocumentsForQuery = (query: string) =>
|
||||
createAction({
|
||||
id: "search",
|
||||
name: ({ t }) =>
|
||||
t(`Search documents for "{{searchQuery}}"`, { searchQuery }),
|
||||
t(`Search documents for "{{searchQuery}}"`, { searchQuery: query }),
|
||||
analyticsName: "Search documents",
|
||||
section: DocumentSection,
|
||||
icon: <SearchIcon />,
|
||||
perform: () => history.push(searchPath(searchQuery)),
|
||||
perform: () => history.push(searchPath({ query })),
|
||||
visible: ({ location }) => location.pathname !== searchPath(),
|
||||
});
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
|
||||
name: searchQuery.query,
|
||||
analyticsName: "Navigate to recent search query",
|
||||
icon: <SearchIcon />,
|
||||
perform: () => history.push(searchPath(searchQuery.query)),
|
||||
perform: () => history.push(searchPath({ query: searchQuery.query })),
|
||||
});
|
||||
|
||||
export const navigateToDrafts = createAction({
|
||||
@@ -62,6 +62,15 @@ export const navigateToDrafts = createAction({
|
||||
visible: ({ location }) => location.pathname !== draftsPath(),
|
||||
});
|
||||
|
||||
export const navigateToSearch = createAction({
|
||||
name: ({ t }) => t("Search"),
|
||||
analyticsName: "Navigate to search",
|
||||
section: NavigationSection,
|
||||
icon: <SearchIcon />,
|
||||
perform: () => history.push(searchPath()),
|
||||
visible: ({ location }) => location.pathname !== searchPath(),
|
||||
});
|
||||
|
||||
export const navigateToArchive = createAction({
|
||||
name: ({ t }) => t("Archive"),
|
||||
analyticsName: "Navigate to archive",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import ErrorSuspended from "~/scenes/ErrorSuspended";
|
||||
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
|
||||
import Layout from "~/components/Layout";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import Sidebar from "~/components/Sidebar";
|
||||
|
||||
@@ -7,17 +7,43 @@ import User from "~/models/User";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import Avatar, { AvatarSize } from "./Avatar";
|
||||
|
||||
/**
|
||||
* Props for the AvatarWithPresence component
|
||||
*/
|
||||
type Props = {
|
||||
/** The user to display the avatar for */
|
||||
user: User;
|
||||
/** Whether the user is currently present in the document */
|
||||
isPresent: boolean;
|
||||
/** Whether the user is currently editing the document */
|
||||
isEditing: boolean;
|
||||
/** Whether the user is currently observing the document */
|
||||
isObserving: boolean;
|
||||
/** Whether this avatar represents the current user */
|
||||
isCurrentUser: boolean;
|
||||
/** Optional click handler for the avatar */
|
||||
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||
/** Size of the avatar, defaults to AvatarSize.Large */
|
||||
size?: AvatarSize;
|
||||
/** Optional inline styles to apply to the avatar wrapper */
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
/**
|
||||
* AvatarWithPresence component displays a user's avatar with visual indicators
|
||||
* for their current status (present, editing, observing).
|
||||
*
|
||||
* The component shows different visual states:
|
||||
* - Present users have full opacity
|
||||
* - Non-present users have reduced opacity
|
||||
* - Observing users have a colored border matching their user color
|
||||
* - Hovering shows a colored border
|
||||
*
|
||||
* A tooltip displays the user's name and current status.
|
||||
*
|
||||
* @param props - Component properties
|
||||
* @returns React component
|
||||
*/
|
||||
function AvatarWithPresence({
|
||||
onClick,
|
||||
user,
|
||||
@@ -64,16 +90,33 @@ function AvatarWithPresence({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Centered container for tooltip content
|
||||
*/
|
||||
const Centered = styled.div`
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
/**
|
||||
* Props for the AvatarPresence styled component
|
||||
*/
|
||||
type AvatarWrapperProps = {
|
||||
/** Whether the user is currently present */
|
||||
$isPresent: boolean;
|
||||
/** Whether the user is currently observing */
|
||||
$isObserving: boolean;
|
||||
/** The user's color for border highlighting */
|
||||
$color: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Styled component that wraps the Avatar and provides visual indicators
|
||||
* for the user's presence status.
|
||||
*
|
||||
* - Adjusts opacity based on presence
|
||||
* - Adds colored borders for observing users
|
||||
* - Handles hover effects
|
||||
*/
|
||||
const AvatarPresence = styled.div<AvatarWrapperProps>`
|
||||
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
|
||||
transition: opacity 250ms ease-in-out;
|
||||
|
||||
@@ -80,6 +80,10 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
} 0 0 0 1px inset;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
box-shadow: ${`rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.inputBorderFocused} 0 0 0 1px inset`};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: ${props.theme.textTertiary};
|
||||
background: none;
|
||||
|
||||
@@ -49,7 +49,7 @@ function Collaborators(props: Props) {
|
||||
() =>
|
||||
orderBy(
|
||||
filter(
|
||||
users.orderedData,
|
||||
users.all,
|
||||
(u) =>
|
||||
(presentIds.includes(u.id) ||
|
||||
document.collaboratorIds.includes(u.id)) &&
|
||||
@@ -58,7 +58,7 @@ function Collaborators(props: Props) {
|
||||
[(u) => presentIds.includes(u.id), "id"],
|
||||
["asc", "asc"]
|
||||
),
|
||||
[document.collaboratorIds, users.orderedData, presentIds]
|
||||
[document.collaboratorIds, users.all, presentIds]
|
||||
);
|
||||
|
||||
// load any users we don't yet have in memory
|
||||
|
||||
@@ -11,6 +11,7 @@ import Collection from "~/models/Collection";
|
||||
import Editor from "~/components/Editor";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import { withUIExtensions } from "~/editor/extensions";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Text from "./Text";
|
||||
@@ -24,6 +25,7 @@ type Props = {
|
||||
function CollectionDescription({ collection }: Props) {
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser({ rejectOnEmpty: true });
|
||||
const can = usePolicy(collection);
|
||||
|
||||
const handleSave = React.useMemo(
|
||||
@@ -65,6 +67,7 @@ function CollectionDescription({ collection }: Props) {
|
||||
maxLength={CollectionValidation.maxDescriptionLength}
|
||||
canUpdate={can.update}
|
||||
readOnly={!can.update}
|
||||
userId={user.id}
|
||||
editorStyle={editorStyle}
|
||||
embedsDisabled
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,12 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import {
|
||||
AuthenticationFailed,
|
||||
AuthorizationFailed,
|
||||
DocumentTooLarge,
|
||||
TooManyConnections,
|
||||
} from "@shared/collaboration/CloseEvents";
|
||||
import Fade from "~/components/Fade";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
@@ -14,21 +20,21 @@ function ConnectionStatus() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const codeToMessage = {
|
||||
1009: {
|
||||
[DocumentTooLarge.code]: {
|
||||
title: t("Document is too large"),
|
||||
body: t(
|
||||
"This document has reached the maximum size and can no longer be edited"
|
||||
),
|
||||
},
|
||||
4401: {
|
||||
[AuthenticationFailed.code]: {
|
||||
title: t("Authentication failed"),
|
||||
body: t("Please try logging out and back in again"),
|
||||
},
|
||||
4403: {
|
||||
[AuthorizationFailed.code]: {
|
||||
title: t("Authorization failed"),
|
||||
body: t("You may have lost access to this document, try reloading"),
|
||||
},
|
||||
4503: {
|
||||
[TooManyConnections.code]: {
|
||||
title: t("Too many users connected to document"),
|
||||
body: t("Your edits will sync once other users leave the document"),
|
||||
},
|
||||
|
||||
@@ -2,16 +2,11 @@ import { HomeIcon } from "outline-icons";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Optional } from "utility-types";
|
||||
import Flex from "~/components/Flex";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import InputSelect from "~/components/InputSelect";
|
||||
import { IconWrapper } from "~/components/Sidebar/components/SidebarLink";
|
||||
import { InputSelectNew, Option } from "~/components/InputSelectNew";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type DefaultCollectionInputSelectProps = Optional<
|
||||
React.ComponentProps<typeof InputSelect>
|
||||
> & {
|
||||
type DefaultCollectionInputSelectProps = {
|
||||
onSelectCollection: (collection: string) => void;
|
||||
defaultCollectionId: string | null;
|
||||
};
|
||||
@@ -19,7 +14,6 @@ type DefaultCollectionInputSelectProps = Optional<
|
||||
const DefaultCollectionInputSelect = ({
|
||||
onSelectCollection,
|
||||
defaultCollectionId,
|
||||
...rest
|
||||
}: DefaultCollectionInputSelectProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { collections } = useStores();
|
||||
@@ -47,36 +41,26 @@ const DefaultCollectionInputSelect = ({
|
||||
void fetchData();
|
||||
}, [fetchError, t, fetching, collections]);
|
||||
|
||||
const options = React.useMemo(
|
||||
const options: Option[] = React.useMemo(
|
||||
() =>
|
||||
collections.nonPrivate.reduce(
|
||||
(acc, collection) => [
|
||||
...acc,
|
||||
{
|
||||
label: (
|
||||
<Flex align="center">
|
||||
<IconWrapper>
|
||||
<CollectionIcon collection={collection} />
|
||||
</IconWrapper>
|
||||
{collection.name}
|
||||
</Flex>
|
||||
),
|
||||
type: "item",
|
||||
label: collection.name,
|
||||
value: collection.id,
|
||||
icon: <CollectionIcon collection={collection} />,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
label: (
|
||||
<Flex align="center">
|
||||
<IconWrapper>
|
||||
<HomeIcon />
|
||||
</IconWrapper>
|
||||
{t("Home")}
|
||||
</Flex>
|
||||
),
|
||||
type: "item",
|
||||
label: t("Home"),
|
||||
value: "home",
|
||||
icon: <HomeIcon />,
|
||||
},
|
||||
]
|
||||
] satisfies Option[]
|
||||
),
|
||||
[collections.nonPrivate, t]
|
||||
);
|
||||
@@ -86,13 +70,14 @@ const DefaultCollectionInputSelect = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<InputSelect
|
||||
value={defaultCollectionId ?? "home"}
|
||||
<InputSelectNew
|
||||
options={options}
|
||||
value={defaultCollectionId ?? "home"}
|
||||
onChange={onSelectCollection}
|
||||
ariaLabel={t("Default collection")}
|
||||
label={t("Start view")}
|
||||
hideLabel
|
||||
short
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,7 +23,10 @@ function Dialogs() {
|
||||
key={id}
|
||||
isOpen={modal.isOpen}
|
||||
fullscreen={modal.fullscreen ?? false}
|
||||
onRequestClose={() => dialogs.closeModal(id)}
|
||||
onRequestClose={() => {
|
||||
modal.onClose?.();
|
||||
dialogs.closeModal(id);
|
||||
}}
|
||||
title={modal.title}
|
||||
style={modal.style}
|
||||
>
|
||||
|
||||
+39
-13
@@ -6,7 +6,9 @@ import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { Optional } from "utility-types";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import EditorContainer from "@shared/editor/components/Styles";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { getDataTransferFiles } from "@shared/utils/files";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
import ClickablePadding from "~/components/ClickablePadding";
|
||||
@@ -183,22 +185,46 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
[updateComments]
|
||||
);
|
||||
|
||||
const paragraphs = React.useMemo(() => {
|
||||
if (props.readOnly && typeof props.value === "object") {
|
||||
return ProsemirrorHelper.getPlainParagraphs(props.value);
|
||||
}
|
||||
return undefined;
|
||||
}, [props.readOnly, props.value]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary component="div" reloadOnChunkMissing>
|
||||
<>
|
||||
<LazyLoadedEditor
|
||||
key={props.extensions?.length || 0}
|
||||
ref={mergeRefs([ref, localRef, handleRefChanged])}
|
||||
uploadFile={handleUploadFile}
|
||||
embeds={embeds}
|
||||
userPreferences={preferences}
|
||||
dictionary={dictionary}
|
||||
{...props}
|
||||
onClickLink={handleClickLink}
|
||||
onChange={handleChange}
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
/>
|
||||
{paragraphs ? (
|
||||
<EditorContainer
|
||||
rtl={props.dir === "rtl"}
|
||||
grow={props.grow}
|
||||
style={props.style}
|
||||
editorStyle={props.editorStyle}
|
||||
>
|
||||
<div className="ProseMirror">
|
||||
{paragraphs.map((paragraph, index) => (
|
||||
<p key={index} dir="auto">
|
||||
{paragraph.content?.map((content) => content.text)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</EditorContainer>
|
||||
) : (
|
||||
<LazyLoadedEditor
|
||||
key={props.extensions?.length || 0}
|
||||
ref={mergeRefs([ref, localRef, handleRefChanged])}
|
||||
uploadFile={handleUploadFile}
|
||||
embeds={embeds}
|
||||
userPreferences={preferences}
|
||||
dictionary={dictionary}
|
||||
{...props}
|
||||
onClickLink={handleClickLink}
|
||||
onChange={handleChange}
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
/>
|
||||
)}
|
||||
{props.editorStyle?.paddingBottom && !props.readOnly && (
|
||||
<ClickablePadding
|
||||
onClick={props.readOnly ? undefined : focusAtEnd}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
const Empty = styled.p`
|
||||
color: ${s("textTertiary")};
|
||||
user-select: none;
|
||||
`;
|
||||
const Empty = styled(Text).attrs({
|
||||
type: "tertiary",
|
||||
selectable: false,
|
||||
})``;
|
||||
|
||||
export default Empty;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { s } from "@shared/styles";
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
import Button from "~/components/Button";
|
||||
import CenteredContent from "~/components/CenteredContent";
|
||||
import Heading from "~/components/Heading";
|
||||
import PageTitle from "~/components/PageTitle";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
@@ -77,9 +78,9 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
{showTitle && (
|
||||
<>
|
||||
<PageTitle title={t("Module failed to load")} />
|
||||
<h1>
|
||||
<Heading>
|
||||
<Trans>Loading Failed</Trans>
|
||||
</h1>
|
||||
</Heading>
|
||||
</>
|
||||
)}
|
||||
<Text as="p" type="secondary">
|
||||
@@ -101,9 +102,9 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
{showTitle && (
|
||||
<>
|
||||
<PageTitle title={t("Something Unexpected Happened")} />
|
||||
<h1>
|
||||
<Heading>
|
||||
<Trans>Something Unexpected Happened</Trans>
|
||||
</h1>
|
||||
</Heading>
|
||||
</>
|
||||
)}
|
||||
<Text as="p" type="secondary">
|
||||
|
||||
@@ -234,7 +234,7 @@ const lineStyle = css`
|
||||
width: 1px;
|
||||
height: calc(50% - 14px + 8px);
|
||||
background: ${s("divider")};
|
||||
mix-blend-mode: multiply;
|
||||
mix-blend-mode: ${(props) => (props.theme.isDark ? "lighten" : "multiply")};
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ const lineStyle = css`
|
||||
width: 1px;
|
||||
height: calc(50% - 14px);
|
||||
background: ${s("divider")};
|
||||
mix-blend-mode: multiply;
|
||||
mix-blend-mode: ${(props) => (props.theme.isDark ? "lighten" : "multiply")};
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@ import React from "react";
|
||||
import styled from "styled-components";
|
||||
import { fadeIn } from "~/styles/animations";
|
||||
|
||||
/**
|
||||
* Fade in animation for a component.
|
||||
*
|
||||
* @param timing - The duration of the fade in animation, default is 250ms.
|
||||
*/
|
||||
const Fade = styled.span<{ timing?: number | string }>`
|
||||
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
|
||||
`;
|
||||
@@ -17,7 +22,6 @@ type Props = {
|
||||
*/
|
||||
export const ConditionalFade = ({ animate, children }: Props) => {
|
||||
const [isAnimated] = React.useState(animate);
|
||||
|
||||
return isAnimated ? <Fade>{children}</Fade> : <>{children}</>;
|
||||
};
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ const Backdrop = styled.div`
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: ${s("backdrop")} !important;
|
||||
z-index: ${depths.modalOverlay};
|
||||
z-index: ${depths.overlay};
|
||||
transition: opacity 200ms ease-in-out;
|
||||
opacity: 0;
|
||||
|
||||
|
||||
@@ -60,7 +60,8 @@ function InputSearchPage({
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
history.push(
|
||||
searchPath(ev.currentTarget.value, {
|
||||
searchPath({
|
||||
query: ev.currentTarget.value,
|
||||
collectionId,
|
||||
ref: source,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import { transparentize } from "polished";
|
||||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import Separator from "./ContextMenu/Separator";
|
||||
import Flex from "./Flex";
|
||||
import { LabelText } from "./Input";
|
||||
import Scrollable from "./Scrollable";
|
||||
import { IconWrapper } from "./Sidebar/components/SidebarLink";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "./primitives/Drawer";
|
||||
import {
|
||||
InputSelectRoot,
|
||||
InputSelectContent,
|
||||
InputSelectItem,
|
||||
InputSelectSeparator,
|
||||
InputSelectTrigger,
|
||||
type TriggerButtonProps,
|
||||
} from "./primitives/InputSelect";
|
||||
import {
|
||||
SelectItemIndicator,
|
||||
SelectItem as SelectItemWrapper,
|
||||
SelectButton,
|
||||
} from "./primitives/components/InputSelect";
|
||||
|
||||
type Separator = {
|
||||
/* Denotes a horizontal divider line to be rendered in the menu, */
|
||||
type: "separator";
|
||||
};
|
||||
|
||||
export type Item = {
|
||||
/* Denotes a selectable option in the menu. */
|
||||
type: "item";
|
||||
/* Representative text shown in the menu for this option. */
|
||||
label: string;
|
||||
/* Actual value of this option. */
|
||||
value: string;
|
||||
/* Additional info shown alongside the label. */
|
||||
description?: string;
|
||||
/* An icon shown alongside the label. */
|
||||
icon?: React.ReactElement;
|
||||
};
|
||||
|
||||
export type Option = Item | Separator;
|
||||
|
||||
type Props = {
|
||||
/* Options to display in the select menu. */
|
||||
options: Option[];
|
||||
/* Current chosen value. */
|
||||
value?: string;
|
||||
/* Callback when an option is selected. */
|
||||
onChange: (value: string) => void;
|
||||
/* ARIA label for accessibility. */
|
||||
ariaLabel: string;
|
||||
/* Label for the select menu. */
|
||||
label: string;
|
||||
/* When true, label is hidden in an accessible manner. */
|
||||
hideLabel?: boolean;
|
||||
/* When true, menu is disabled. */
|
||||
disabled?: boolean;
|
||||
/* When true, width of the menu trigger is restricted. Otherwise, takes up the full width of parent. */
|
||||
short?: boolean;
|
||||
} & TriggerButtonProps;
|
||||
|
||||
export function InputSelectNew(props: Props) {
|
||||
const {
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
label,
|
||||
hideLabel,
|
||||
disabled,
|
||||
short,
|
||||
...triggerProps
|
||||
} = props;
|
||||
|
||||
const [localValue, setLocalValue] = React.useState(value);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const triggerRef =
|
||||
React.useRef<React.ElementRef<typeof InputSelectTrigger>>(null);
|
||||
const contentRef =
|
||||
React.useRef<React.ElementRef<typeof InputSelectContent>>(null);
|
||||
|
||||
const isMobile = useMobile();
|
||||
|
||||
const placeholder = `Select a ${ariaLabel.toLowerCase()}`;
|
||||
const optionsHaveIcon = options.some(
|
||||
(opt) => opt.type === "item" && !!opt.icon
|
||||
);
|
||||
|
||||
const renderOption = React.useCallback(
|
||||
(option: Option) => {
|
||||
if (option.type === "separator") {
|
||||
return <InputSelectSeparator />;
|
||||
}
|
||||
|
||||
return (
|
||||
<InputSelectItem key={option.value} value={option.value}>
|
||||
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
|
||||
</InputSelectItem>
|
||||
);
|
||||
},
|
||||
[optionsHaveIcon]
|
||||
);
|
||||
|
||||
const onValueChange = React.useCallback(
|
||||
async (val: string) => {
|
||||
setLocalValue(val);
|
||||
onChange(val);
|
||||
},
|
||||
[onChange, setLocalValue]
|
||||
);
|
||||
|
||||
const enablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "auto";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "none";
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileSelect
|
||||
{...props}
|
||||
value={localValue}
|
||||
onChange={onValueChange}
|
||||
placeholder={placeholder}
|
||||
optionsHaveIcon={optionsHaveIcon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper short={short}>
|
||||
<Label text={label} hidden={hideLabel ?? false} />
|
||||
<InputSelectRoot
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
value={localValue}
|
||||
onValueChange={onValueChange}
|
||||
>
|
||||
<InputSelectTrigger
|
||||
ref={triggerRef}
|
||||
placeholder={placeholder}
|
||||
{...triggerProps}
|
||||
/>
|
||||
<InputSelectContent
|
||||
ref={contentRef}
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
>
|
||||
{options.map(renderOption)}
|
||||
</InputSelectContent>
|
||||
</InputSelectRoot>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
type MobileSelectProps = Props & {
|
||||
placeholder: string;
|
||||
optionsHaveIcon: boolean;
|
||||
};
|
||||
|
||||
function MobileSelect(props: MobileSelectProps) {
|
||||
const {
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
label,
|
||||
hideLabel,
|
||||
disabled,
|
||||
short,
|
||||
placeholder,
|
||||
optionsHaveIcon,
|
||||
...triggerProps
|
||||
} = props;
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const contentRef = React.useRef<React.ElementRef<typeof DrawerContent>>(null);
|
||||
|
||||
const selectedOption = React.useMemo(
|
||||
() =>
|
||||
value
|
||||
? options.find((opt) => opt.type === "item" && opt.value === value)
|
||||
: undefined,
|
||||
[value, options]
|
||||
);
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
async (val: string) => {
|
||||
setOpen(false);
|
||||
onChange(val);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const renderOption = React.useCallback(
|
||||
(option: Option) => {
|
||||
if (option.type === "separator") {
|
||||
return <Separator />;
|
||||
}
|
||||
|
||||
const isSelected = option === selectedOption;
|
||||
|
||||
return (
|
||||
<SelectItemWrapper
|
||||
key={option.value}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
data-state={isSelected ? "checked" : "unchecked"}
|
||||
>
|
||||
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
|
||||
{isSelected && <SelectItemIndicator />}
|
||||
</SelectItemWrapper>
|
||||
);
|
||||
},
|
||||
[handleSelect, selectedOption, optionsHaveIcon]
|
||||
);
|
||||
|
||||
const enablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "auto";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "none";
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Label text={label} hidden={hideLabel ?? false} />
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<SelectButton
|
||||
{...triggerProps}
|
||||
neutral
|
||||
disclosure
|
||||
data-placeholder={selectedOption ? false : ""}
|
||||
>
|
||||
{selectedOption ? (
|
||||
<Option
|
||||
option={selectedOption as Item}
|
||||
optionsHaveIcon={optionsHaveIcon}
|
||||
/>
|
||||
) : (
|
||||
<>{placeholder}</>
|
||||
)}
|
||||
</SelectButton>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent
|
||||
ref={contentRef}
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
>
|
||||
<DrawerTitle hidden={!label}>{label ?? ariaLabel}</DrawerTitle>
|
||||
<StyledScrollable hiddenScrollbars>
|
||||
{options.map(renderOption)}
|
||||
</StyledScrollable>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function Label({ text, hidden }: { text: string; hidden: boolean }) {
|
||||
const labelText = <LabelText>{text}</LabelText>;
|
||||
|
||||
return hidden ? (
|
||||
<VisuallyHidden.Root>{labelText}</VisuallyHidden.Root>
|
||||
) : (
|
||||
labelText
|
||||
);
|
||||
}
|
||||
|
||||
function Option({
|
||||
option,
|
||||
optionsHaveIcon,
|
||||
}: {
|
||||
option: Item;
|
||||
optionsHaveIcon: boolean;
|
||||
}) {
|
||||
const icon = optionsHaveIcon ? (
|
||||
option.icon ? (
|
||||
<IconWrapper>{option.icon}</IconWrapper>
|
||||
) : (
|
||||
<IconSpacer />
|
||||
)
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<OptionContainer align="center">
|
||||
{icon}
|
||||
{option.label}
|
||||
{option.description && (
|
||||
<>
|
||||
|
||||
<Description type="tertiary" size="small" ellipsis>
|
||||
– {option.description}
|
||||
</Description>
|
||||
</>
|
||||
)}
|
||||
</OptionContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled.label<{ short?: boolean }>`
|
||||
display: block;
|
||||
max-width: ${(props) => (props.short ? "350px" : "100%")};
|
||||
`;
|
||||
|
||||
const OptionContainer = styled(Flex)`
|
||||
min-height: 24px;
|
||||
`;
|
||||
|
||||
const Description = styled(Text)`
|
||||
@media (hover: hover) {
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: ${(props) => transparentize(0.5, props.theme.accentText)};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const IconSpacer = styled.div`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const StyledScrollable = styled(Scrollable)`
|
||||
max-height: 75vh;
|
||||
`;
|
||||
@@ -223,14 +223,13 @@ const Heading = styled.p<{ $small?: boolean; $ellipsis?: boolean }>`
|
||||
const Content = styled(Flex)<{ $selected: boolean }>`
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
color: ${(props) => (props.$selected ? props.theme.white : props.theme.text)};
|
||||
color: ${s("text")};
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p<{ $small?: boolean; $selected?: boolean }>`
|
||||
margin: 0;
|
||||
font-size: ${(props) => (props.$small ? 13 : 14)}px;
|
||||
color: ${(props) =>
|
||||
props.$selected ? props.theme.white50 : props.theme.textTertiary};
|
||||
color: ${s("textTertiary")};
|
||||
margin-top: -2px;
|
||||
`;
|
||||
|
||||
@@ -238,8 +237,7 @@ export const Actions = styled(Flex)<{ $selected?: boolean }>`
|
||||
align-self: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: ${(props) =>
|
||||
props.$selected ? props.theme.white : props.theme.textSecondary};
|
||||
color: ${s("textSecondary")};
|
||||
`;
|
||||
|
||||
export default React.forwardRef(ListItem);
|
||||
|
||||
@@ -147,7 +147,7 @@ const Backdrop = styled(Flex)<{ $fullscreen?: boolean }>`
|
||||
props.$fullscreen
|
||||
? transparentize(0.25, props.theme.background)
|
||||
: props.theme.modalBackdrop} !important;
|
||||
z-index: ${depths.modalOverlay};
|
||||
z-index: ${depths.overlay};
|
||||
transition: opacity 50ms ease-in-out;
|
||||
opacity: 0;
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ export const Suggestions = observer(
|
||||
? users.notInDocument(document.id, query)
|
||||
: collection
|
||||
? users.notInCollection(collection.id, query)
|
||||
: users.orderedData
|
||||
: users.activeOrInvited
|
||||
).filter((u) => !u.isSuspended && u.id !== user.id);
|
||||
|
||||
if (isEmail(query)) {
|
||||
@@ -114,7 +114,7 @@ export const Suggestions = observer(
|
||||
}, [
|
||||
getSuggestionForEmail,
|
||||
users,
|
||||
users.orderedData,
|
||||
users.activeOrInvited,
|
||||
groups,
|
||||
groups.orderedData,
|
||||
document?.id,
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import invariant from "invariant";
|
||||
import find from "lodash/find";
|
||||
import isObject from "lodash/isObject";
|
||||
import { action, observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, WithTranslation } from "react-i18next";
|
||||
import semver from "semver";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { toast } from "sonner";
|
||||
import EDITOR_VERSION from "@shared/editor/version";
|
||||
import { FileOperationState, FileOperationType } from "@shared/types";
|
||||
import {
|
||||
FileOperationState,
|
||||
FileOperationType,
|
||||
ImportState,
|
||||
} from "@shared/types";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Collection from "~/models/Collection";
|
||||
import Comment from "~/models/Comment";
|
||||
@@ -18,6 +19,7 @@ import FileOperation from "~/models/FileOperation";
|
||||
import Group from "~/models/Group";
|
||||
import GroupMembership from "~/models/GroupMembership";
|
||||
import GroupUser from "~/models/GroupUser";
|
||||
import Import from "~/models/Import";
|
||||
import Membership from "~/models/Membership";
|
||||
import Notification from "~/models/Notification";
|
||||
import Pin from "~/models/Pin";
|
||||
@@ -103,6 +105,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
subscriptions,
|
||||
fileOperations,
|
||||
notifications,
|
||||
imports,
|
||||
} = this.props;
|
||||
|
||||
const currentUserId = auth?.user?.id;
|
||||
@@ -117,23 +120,10 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("authenticated", (data) => {
|
||||
this.socket.on("authenticated", () => {
|
||||
if (this.socket) {
|
||||
this.socket.authenticated = true;
|
||||
}
|
||||
if (isObject(data) && "editorVersion" in data) {
|
||||
const parsedClientVersion = semver.parse(EDITOR_VERSION);
|
||||
const parsedCurrentVersion = semver.parse(String(data.editorVersion));
|
||||
|
||||
if (
|
||||
parsedClientVersion &&
|
||||
parsedCurrentVersion &&
|
||||
(parsedClientVersion.major < parsedCurrentVersion.major ||
|
||||
parsedClientVersion.minor < parsedCurrentVersion.minor)
|
||||
) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("unauthorized", (err: Error) => {
|
||||
@@ -636,6 +626,23 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on("imports.create", (event: PartialExcept<Import, "id">) => {
|
||||
imports.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("imports.update", (event: PartialExcept<Import, "id">) => {
|
||||
imports.add(event);
|
||||
|
||||
if (
|
||||
event.state === ImportState.Completed &&
|
||||
event.createdBy?.id === auth.user?.id
|
||||
) {
|
||||
toast.success(event.name, {
|
||||
description: this.props.t("Your import completed"),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on(
|
||||
"subscriptions.create",
|
||||
(event: PartialExcept<Subscription, "id">) => {
|
||||
@@ -661,6 +668,10 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("users.delete", (event: WebsocketEntityDeletedEvent) => {
|
||||
users.remove(event.modelId);
|
||||
});
|
||||
|
||||
this.socket.on(
|
||||
"userMemberships.update",
|
||||
async (event: PartialExcept<UserMembership, "id">) => {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
import { Drawer as DrawerPrimitive } from "vaul";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import Flex from "../Flex";
|
||||
import Text from "../Text";
|
||||
import { Overlay } from "./components/Overlay";
|
||||
|
||||
/** Root Drawer component - all the other components are rendered inside it. */
|
||||
const Drawer = (props: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||
<DrawerPrimitive.Root {...props} />
|
||||
);
|
||||
Drawer.displayName = "Drawer";
|
||||
|
||||
/** Drawer's trigger. */
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger;
|
||||
|
||||
/** Drawer's content - renders the overlay and the actual content. */
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>((props, ref) => {
|
||||
const { children, ...rest } = props;
|
||||
|
||||
return (
|
||||
<DrawerPrimitive.Portal>
|
||||
<DrawerPrimitive.Overlay asChild>
|
||||
<Overlay />
|
||||
</DrawerPrimitive.Overlay>
|
||||
<StyledContent ref={ref} {...rest}>
|
||||
{children}
|
||||
</StyledContent>
|
||||
</DrawerPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
DrawerContent.displayName = DrawerPrimitive.Content.displayName;
|
||||
|
||||
/** Drawer's title shown in the center. */
|
||||
const DrawerTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||
>((props, ref) => {
|
||||
const { hidden, children, ...rest } = props;
|
||||
|
||||
const title = (
|
||||
<TitleWrapper justify="center">
|
||||
<Text size="medium" weight="bold">
|
||||
{children}
|
||||
</Text>
|
||||
</TitleWrapper>
|
||||
);
|
||||
|
||||
return (
|
||||
<DrawerPrimitive.Title ref={ref} {...rest} asChild>
|
||||
{hidden ? (
|
||||
<VisuallyHidden.Root>{title}</VisuallyHidden.Root>
|
||||
) : (
|
||||
<>{title}</>
|
||||
)}
|
||||
</DrawerPrimitive.Title>
|
||||
);
|
||||
});
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||
|
||||
/** Styled components. */
|
||||
const StyledContent = styled(DrawerPrimitive.Content)`
|
||||
z-index: ${depths.menu};
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
min-width: 180px;
|
||||
max-width: 100%;
|
||||
min-height: 44px;
|
||||
max-height: 90vh;
|
||||
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
|
||||
background: ${s("menuBackground")};
|
||||
`;
|
||||
|
||||
const TitleWrapper = styled(Flex)`
|
||||
padding: 8px 0;
|
||||
`;
|
||||
|
||||
export { Drawer, DrawerTrigger, DrawerContent, DrawerTitle };
|
||||
@@ -0,0 +1,135 @@
|
||||
import * as InputSelectPrimitive from "@radix-ui/react-select";
|
||||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { Props as ButtonProps } from "~/components/Button";
|
||||
import Separator from "~/components/ContextMenu/Separator";
|
||||
import { fadeAndSlideDown, fadeAndSlideUp } from "~/styles/animations";
|
||||
import {
|
||||
SelectItemIndicator,
|
||||
SelectItem as SelectItemWrapper,
|
||||
SelectButton,
|
||||
} from "./components/InputSelect";
|
||||
|
||||
/** Root InputSelect component - all the other components are rendered inside it. */
|
||||
const InputSelectRoot = InputSelectPrimitive.Root;
|
||||
|
||||
/** InputSelect's trigger. */
|
||||
|
||||
export type TriggerButtonProps = {
|
||||
/** When true, "nude" variant of Button is rendered. */
|
||||
nude?: boolean;
|
||||
/** Optional css class names to pass to the trigger. */
|
||||
className?: string;
|
||||
} & Pick<ButtonProps<unknown>, "borderOnHover">;
|
||||
|
||||
type InputSelectTriggerProps = { placeholder: string } & TriggerButtonProps &
|
||||
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Trigger>;
|
||||
|
||||
const InputSelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof InputSelectPrimitive.Trigger>,
|
||||
InputSelectTriggerProps
|
||||
>((props, ref) => {
|
||||
const { placeholder, children, ...buttonProps } = props;
|
||||
|
||||
return (
|
||||
<InputSelectPrimitive.Trigger ref={ref} asChild>
|
||||
<SelectButton neutral disclosure {...buttonProps}>
|
||||
<InputSelectPrimitive.Value placeholder={placeholder} />
|
||||
</SelectButton>
|
||||
</InputSelectPrimitive.Trigger>
|
||||
);
|
||||
});
|
||||
InputSelectTrigger.displayName = InputSelectPrimitive.Trigger.displayName;
|
||||
|
||||
/** InputSelect's content - renders the options in a scrollable element. */
|
||||
type ContentProps = Omit<
|
||||
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Content>,
|
||||
"position"
|
||||
>;
|
||||
|
||||
const InputSelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof InputSelectPrimitive.Content>,
|
||||
ContentProps
|
||||
>((props, ref) => {
|
||||
const { children, ...rest } = props;
|
||||
|
||||
return (
|
||||
<InputSelectPrimitive.Portal>
|
||||
<StyledContent ref={ref} position={"popper"} {...rest}>
|
||||
<InputSelectPrimitive.Viewport style={{ overscrollBehavior: "none" }}>
|
||||
{children}
|
||||
</InputSelectPrimitive.Viewport>
|
||||
</StyledContent>
|
||||
</InputSelectPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
InputSelectContent.displayName = InputSelectPrimitive.Content.displayName;
|
||||
|
||||
/** Individual InputSelect option rendered in the menu. */
|
||||
const InputSelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof InputSelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Item>
|
||||
>((props, ref) => {
|
||||
const { children, ...rest } = props;
|
||||
|
||||
return (
|
||||
<InputSelectPrimitive.Item ref={ref} {...rest} asChild>
|
||||
<SelectItemWrapper>
|
||||
<InputSelectPrimitive.ItemText>
|
||||
{children}
|
||||
</InputSelectPrimitive.ItemText>
|
||||
<InputSelectPrimitive.ItemIndicator asChild>
|
||||
<SelectItemIndicator />
|
||||
</InputSelectPrimitive.ItemIndicator>
|
||||
</SelectItemWrapper>
|
||||
</InputSelectPrimitive.Item>
|
||||
);
|
||||
});
|
||||
InputSelectItem.displayName = InputSelectPrimitive.Item.displayName;
|
||||
|
||||
/** Horizontal separator rendered between the options. */
|
||||
const InputSelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof InputSelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Separator>
|
||||
>((props, ref) => (
|
||||
<InputSelectPrimitive.Separator ref={ref} asChild>
|
||||
<Separator {...props} />
|
||||
</InputSelectPrimitive.Separator>
|
||||
));
|
||||
InputSelectSeparator.displayName = InputSelectPrimitive.Separator.displayName;
|
||||
|
||||
/** Styled components. */
|
||||
const StyledContent = styled(InputSelectPrimitive.Content)`
|
||||
z-index: ${depths.menu};
|
||||
min-width: var(--radix-select-trigger-width);
|
||||
max-width: 400px;
|
||||
min-height: 44px;
|
||||
max-height: 350px;
|
||||
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
background: ${s("menuBackground")};
|
||||
box-shadow: ${s("menuShadow")};
|
||||
transform-origin: 50% 0;
|
||||
|
||||
&[data-side="bottom"] {
|
||||
animation: ${fadeAndSlideDown} 200ms ease;
|
||||
}
|
||||
|
||||
&[data-side="top"] {
|
||||
animation: ${fadeAndSlideUp} 200ms ease;
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export {
|
||||
InputSelectRoot,
|
||||
InputSelectTrigger,
|
||||
InputSelectContent,
|
||||
InputSelectItem,
|
||||
InputSelectSeparator,
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Reusable components for InputSelect abstraction.
|
||||
*/
|
||||
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import Button, { Inner } from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
export const SelectItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentPropsWithoutRef<"div">
|
||||
>((props, ref) => {
|
||||
const { children, ...rest } = props;
|
||||
|
||||
return (
|
||||
<ItemContainer
|
||||
ref={ref}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
gap={8}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
<IconSpacer />
|
||||
</ItemContainer>
|
||||
);
|
||||
});
|
||||
SelectItem.displayName = "SelectItem";
|
||||
|
||||
export const SelectItemIndicator = React.forwardRef<
|
||||
HTMLSpanElement,
|
||||
React.ComponentPropsWithoutRef<"span">
|
||||
>((props, ref) => (
|
||||
<IndicatorContainer ref={ref} {...props}>
|
||||
<CheckmarkIcon />
|
||||
</IndicatorContainer>
|
||||
));
|
||||
SelectItemIndicator.displayName = "SelectItemIndicator";
|
||||
|
||||
const IconSpacer = styled.div`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export const SelectButton = styled(Button)<{ $nude?: boolean }>`
|
||||
display: block;
|
||||
font-weight: normal;
|
||||
text-transform: none;
|
||||
width: 100%;
|
||||
cursor: var(--pointer);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: ${s("buttonNeutralBackground")};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$nude &&
|
||||
css`
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
`}
|
||||
|
||||
${Inner} {
|
||||
line-height: 28px;
|
||||
padding-left: 12px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
svg {
|
||||
justify-self: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&[data-placeholder=""] {
|
||||
color: ${s("placeholder")};
|
||||
}
|
||||
`;
|
||||
|
||||
const ItemContainer = styled(Flex)`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
cursor: var(--pointer);
|
||||
color: ${s("textSecondary")};
|
||||
background: none;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
outline: 0;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: ${s("accentText")};
|
||||
background: ${s("accent")};
|
||||
|
||||
svg {
|
||||
color: ${s("accentText")};
|
||||
fill: ${s("accentText")};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-state="checked"] {
|
||||
${IconSpacer} {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
font-size: 14px;
|
||||
padding: 4px;
|
||||
padding-left: 8px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const IndicatorContainer = styled.span`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
@@ -0,0 +1,15 @@
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
|
||||
export const Overlay = styled.div`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: ${s("backdrop")};
|
||||
z-index: ${depths.overlay};
|
||||
transition: opacity 50ms ease-in-out;
|
||||
opacity: 0;
|
||||
|
||||
&[data-state="open"] {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
@@ -645,12 +645,11 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
"section" in item ? item.section?.({ t }) : undefined;
|
||||
|
||||
const response = (
|
||||
<>
|
||||
<React.Fragment key={`${index}-${item.name}`}>
|
||||
{currentHeading !== previousHeading && (
|
||||
<Header key={currentHeading}>{currentHeading}</Header>
|
||||
)}
|
||||
<ListItem
|
||||
key={index}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
@@ -659,7 +658,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
onClick: () => handleClickItem(item),
|
||||
})}
|
||||
</ListItem>
|
||||
</>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
previousHeading = currentHeading;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled from "styled-components";
|
||||
@@ -68,12 +69,16 @@ function SuggestionsMenuItem({
|
||||
|
||||
const Subtitle = styled.span<{ $active?: boolean }>`
|
||||
color: ${(props) =>
|
||||
props.$active ? props.theme.white50 : props.theme.textTertiary};
|
||||
props.$active
|
||||
? transparentize(0.35, props.theme.accentText)
|
||||
: props.theme.textTertiary};
|
||||
`;
|
||||
|
||||
const Shortcut = styled.span<{ $active?: boolean }>`
|
||||
color: ${(props) =>
|
||||
props.$active ? props.theme.white50 : props.theme.textTertiary};
|
||||
props.$active
|
||||
? transparentize(0.35, props.theme.accentText)
|
||||
: props.theme.textTertiary};
|
||||
flex-grow: 1;
|
||||
text-align: right;
|
||||
`;
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import { v4 } from "uuid";
|
||||
import { LANGUAGES } from "@shared/editor/extensions/Prism";
|
||||
import Extension, { WidgetProps } from "@shared/editor/lib/Extension";
|
||||
import { codeLanguages } from "@shared/editor/lib/code";
|
||||
import isMarkdown from "@shared/editor/lib/isMarkdown";
|
||||
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
|
||||
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
|
||||
@@ -88,7 +88,7 @@ export default class PasteHandler extends Extension {
|
||||
|
||||
// If the users selection is currently in a code block then paste
|
||||
// as plain text, ignore all formatting and HTML content.
|
||||
if (isInCode(state)) {
|
||||
if (isInCode(state, { inclusive: true })) {
|
||||
event.preventDefault();
|
||||
view.dispatch(state.tr.insertText(text));
|
||||
return true;
|
||||
@@ -122,6 +122,8 @@ export default class PasteHandler extends Extension {
|
||||
}
|
||||
|
||||
// Is the link a link to a document? If so, we can grab the title and insert it.
|
||||
const containsHash = text.includes("#");
|
||||
|
||||
if (isDocumentUrl(text)) {
|
||||
const slug = parseDocumentSlug(text);
|
||||
|
||||
@@ -133,7 +135,7 @@ export default class PasteHandler extends Extension {
|
||||
return;
|
||||
}
|
||||
if (document) {
|
||||
if (state.schema.nodes.mention) {
|
||||
if (state.schema.nodes.mention && !containsHash) {
|
||||
view.dispatch(
|
||||
view.state.tr.replaceWith(
|
||||
state.selection.from,
|
||||
@@ -178,7 +180,7 @@ export default class PasteHandler extends Extension {
|
||||
return;
|
||||
}
|
||||
if (collection) {
|
||||
if (state.schema.nodes.mention) {
|
||||
if (state.schema.nodes.mention && !containsHash) {
|
||||
view.dispatch(
|
||||
view.state.tr.replaceWith(
|
||||
state.selection.from,
|
||||
@@ -226,7 +228,7 @@ export default class PasteHandler extends Extension {
|
||||
state.tr
|
||||
.replaceSelectionWith(
|
||||
state.schema.nodes.code_block.create({
|
||||
language: Object.keys(LANGUAGES).includes(
|
||||
language: Object.keys(codeLanguages).includes(
|
||||
vscodeMeta.mode
|
||||
)
|
||||
? vscodeMeta.mode
|
||||
|
||||
@@ -23,7 +23,7 @@ export default class Suggestion extends Extension {
|
||||
this.options.trigger
|
||||
)}(${`[\\p{L}\/\\p{M}\\d${
|
||||
this.options.allowSpaces ? "\\s{1}" : ""
|
||||
}\\.]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
|
||||
}\\.\\-–_]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
|
||||
"u"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -843,7 +843,7 @@ const EditorContainer = styled(Styles)<{
|
||||
${(props) =>
|
||||
props.userId &&
|
||||
css`
|
||||
.mention[data-id=${props.userId}] {
|
||||
.mention[data-id="${props.userId}"] {
|
||||
color: ${props.theme.textHighlightForeground};
|
||||
background: ${props.theme.textHighlight};
|
||||
|
||||
|
||||
+13
-12
@@ -2,8 +2,11 @@ import { CopyIcon, ExpandedIcon } from "outline-icons";
|
||||
import { Node as ProseMirrorNode } from "prosemirror-model";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { LANGUAGES } from "@shared/editor/extensions/Prism";
|
||||
import { getFrequentCodeLanguages } from "@shared/editor/lib/code";
|
||||
import {
|
||||
getFrequentCodeLanguages,
|
||||
codeLanguages,
|
||||
getLabelForLanguage,
|
||||
} from "@shared/editor/lib/code";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
@@ -14,20 +17,19 @@ export default function codeMenuItems(
|
||||
): MenuItem[] {
|
||||
const node = state.selection.$from.node();
|
||||
|
||||
const allLanguages = Object.entries(LANGUAGES) as [
|
||||
keyof typeof LANGUAGES,
|
||||
string
|
||||
][];
|
||||
const frequentLanguages = getFrequentCodeLanguages();
|
||||
|
||||
const frequentLangMenuItems = frequentLanguages.map((value) => {
|
||||
const label = LANGUAGES[value];
|
||||
const label = codeLanguages[value]?.label;
|
||||
return langToMenuItem({ node, value, label });
|
||||
});
|
||||
|
||||
const remainingLangMenuItems = allLanguages
|
||||
.filter(([value]) => !frequentLanguages.includes(value))
|
||||
.map(([value, label]) => langToMenuItem({ node, value, label }));
|
||||
const remainingLangMenuItems = Object.entries(codeLanguages)
|
||||
.filter(
|
||||
([value]) =>
|
||||
!frequentLanguages.includes(value as keyof typeof codeLanguages)
|
||||
)
|
||||
.map(([value, item]) => langToMenuItem({ node, value, label: item.label }));
|
||||
|
||||
const languageMenuItems = frequentLangMenuItems.length
|
||||
? [
|
||||
@@ -52,8 +54,7 @@ export default function codeMenuItems(
|
||||
visible: !readOnly,
|
||||
name: "code_block",
|
||||
icon: <ExpandedIcon />,
|
||||
// @ts-expect-error We have a fallback for incorrect mapping
|
||||
label: LANGUAGES[node.attrs.language ?? "none"],
|
||||
label: getLabelForLanguage(node.attrs.language ?? "none"),
|
||||
children: languageMenuItems,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* Hook that provides a dictionary of translated UI strings.
|
||||
*
|
||||
* @returns An object containing all translated UI strings used throughout the application
|
||||
*/
|
||||
export default function useDictionary() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import * as React from "react";
|
||||
import useWindowSize from "./useWindowSize";
|
||||
|
||||
/**
|
||||
* Hook to calculate the maximum height for an element based on its position and viewport size.
|
||||
*
|
||||
* @param options Configuration options
|
||||
* @param options.elementRef A ref pointing to the element to calculate max height for
|
||||
* @param options.maxViewportPercentage The maximum height of the element as a percentage of the viewport
|
||||
* @param options.margin The margin to apply to the positioning
|
||||
* @returns Object containing the calculated maxHeight and a function to recalculate it
|
||||
*/
|
||||
const useMaxHeight = ({
|
||||
elementRef,
|
||||
maxViewportPercentage = 90,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Hook to check if a media query matches the current viewport.
|
||||
*
|
||||
* @param query The CSS media query to check against
|
||||
* @returns boolean indicating whether the media query matches
|
||||
*/
|
||||
export default function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState<boolean>(false);
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { breakpoints } from "@shared/styles";
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
|
||||
/**
|
||||
* Hook to detect if the current viewport is mobile-sized.
|
||||
*
|
||||
* @returns boolean indicating whether the current viewport is mobile-sized
|
||||
*/
|
||||
export default function useMobile(): boolean {
|
||||
return useMediaQuery(`(max-width: ${breakpoints.tablet - 1}px)`);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function usePolicy(entity?: string | Model | null) {
|
||||
? entity
|
||||
: entity.id
|
||||
: "";
|
||||
const policy = policies.get(entityId);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
@@ -28,11 +29,11 @@ export default function usePolicy(entity?: string | Model | null) {
|
||||
) {
|
||||
// The policy for this model is missing and we have an authenticated session, attempt to
|
||||
// reload relationships for this model.
|
||||
if (!policies.get(entity.id) && user) {
|
||||
if (!policy && user) {
|
||||
void entity.loadRelations();
|
||||
}
|
||||
}
|
||||
}, [policies, user, entity]);
|
||||
}, [policies, policy, user, entity]);
|
||||
|
||||
return policies.abilities(entityId);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import React from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
/**
|
||||
* Hook to access URL query parameters from the current location.
|
||||
*
|
||||
* @returns URLSearchParams object containing the current URL query parameters
|
||||
*/
|
||||
export default function useQuery() {
|
||||
const location = useLocation();
|
||||
|
||||
|
||||
@@ -24,6 +24,14 @@ export default function useQueryNotices() {
|
||||
);
|
||||
break;
|
||||
}
|
||||
case QueryNotices.UnsubscribeCollection: {
|
||||
toast.success(
|
||||
t("Unsubscribed from collection", {
|
||||
type: "success",
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
}
|
||||
}, [t, notice]);
|
||||
|
||||
@@ -2,6 +2,11 @@ import { MobXProviderContext } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import RootStore from "~/stores";
|
||||
|
||||
/**
|
||||
* Hook to access the MobX stores from the React context.
|
||||
*
|
||||
* @returns The root store containing all application stores
|
||||
*/
|
||||
export default function useStores() {
|
||||
return React.useContext(MobXProviderContext) as typeof RootStore;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Hook that executes a callback when the component unmounts.
|
||||
*
|
||||
* @param callback Function to be called on component unmount
|
||||
*/
|
||||
const useUnmount = (callback: (...args: Array<any>) => any) => {
|
||||
const ref = React.useRef(callback);
|
||||
ref.current = callback;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Hook to get the current viewport height, accounting for mobile virtual keyboards.
|
||||
* Uses the VisualViewport API when available, falling back to window.innerHeight.
|
||||
*
|
||||
* @returns The current viewport height in pixels
|
||||
*/
|
||||
export default function useViewportHeight(): number | void {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/VisualViewport#browser_compatibility
|
||||
// Note: No support in Firefox at time of writing, however this mainly exists
|
||||
|
||||
@@ -13,6 +13,13 @@ const defaultOptions = {
|
||||
throttle: 100,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to track the window's scroll position.
|
||||
*
|
||||
* @param options Configuration options
|
||||
* @param options.throttle Time in milliseconds to throttle the scroll event
|
||||
* @returns Object containing the current scroll position (x, y coordinates)
|
||||
*/
|
||||
export default function useWindowScrollPosition(options: {
|
||||
throttle: number;
|
||||
}): {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { CrossIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import Import from "~/models/Import";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { MenuItem } from "~/types";
|
||||
|
||||
type Props = {
|
||||
/** Import to which actions will be applied. */
|
||||
importModel: Import;
|
||||
/** Callback to handle import cancellation. */
|
||||
onCancel: () => Promise<void>;
|
||||
/** Callback to handle import deletion. */
|
||||
onDelete: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const ImportMenu = observer(
|
||||
({ importModel, onCancel, onDelete }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(importModel);
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
|
||||
const items = React.useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
type: "button",
|
||||
title: t("Cancel"),
|
||||
visible: can.cancel,
|
||||
icon: <CrossIcon />,
|
||||
dangerous: true,
|
||||
onClick: onCancel,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: t("Delete"),
|
||||
visible: can.delete,
|
||||
icon: <TrashIcon />,
|
||||
dangerous: true,
|
||||
onClick: onDelete,
|
||||
},
|
||||
] satisfies MenuItem[],
|
||||
[t, can.delete, can.cancel, onCancel, onDelete]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("Import menu options")}>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -600,7 +600,7 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
*/
|
||||
getSummary = (blocks = 4) => ({
|
||||
...this.data,
|
||||
content: this.data.content.slice(0, blocks),
|
||||
content: this.data.content?.slice(0, blocks),
|
||||
});
|
||||
|
||||
@computed
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { observable } from "mobx";
|
||||
import { ImportableIntegrationService, ImportState } from "@shared/types";
|
||||
import ImportsStore from "~/stores/ImportsStore";
|
||||
import User from "./User";
|
||||
import Model from "./base/Model";
|
||||
import Field from "./decorators/Field";
|
||||
import { AfterChange } from "./decorators/Lifecycle";
|
||||
import Relation from "./decorators/Relation";
|
||||
|
||||
class Import extends Model {
|
||||
static modelName = "Import";
|
||||
|
||||
store: ImportsStore;
|
||||
|
||||
/** The name of the import. */
|
||||
name: string;
|
||||
|
||||
/** Descriptive error message when the import errors out. */
|
||||
error: string | null;
|
||||
|
||||
/** The current state of the import. */
|
||||
@Field
|
||||
@observable
|
||||
state: ImportState;
|
||||
|
||||
/** The external service from which the import is created. */
|
||||
service: ImportableIntegrationService;
|
||||
|
||||
/** The count of documents created in the import. */
|
||||
@observable
|
||||
documentCount: number;
|
||||
|
||||
/** The user who created the import. */
|
||||
@Relation(() => User, {})
|
||||
createdBy: User;
|
||||
|
||||
/** The ID of the user who created the import. */
|
||||
createdById: string;
|
||||
|
||||
/**
|
||||
* Cancel the import – this will stop the import process and mark it as
|
||||
* cancelled at the first opportunity.
|
||||
*/
|
||||
cancel = async () => this.store.cancel(this);
|
||||
|
||||
// hooks
|
||||
|
||||
@AfterChange
|
||||
static removePolicies(model: Import, previousAttributes: Partial<Import>) {
|
||||
if (previousAttributes.state && previousAttributes.state !== model.state) {
|
||||
const { policies } = model.store.rootStore;
|
||||
policies.remove(model.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Import;
|
||||
+14
-14
@@ -43,12 +43,6 @@ export default abstract class Model {
|
||||
this: Model,
|
||||
options: { withoutPolicies?: boolean } = {}
|
||||
): Promise<any> {
|
||||
const relations = getRelationsForModelClass(
|
||||
this.constructor as typeof Model
|
||||
);
|
||||
if (!relations) {
|
||||
return;
|
||||
}
|
||||
// this is to ensure that multiple loads don’t happen in parallel
|
||||
if (this.loadingRelations) {
|
||||
return this.loadingRelations;
|
||||
@@ -56,14 +50,20 @@ export default abstract class Model {
|
||||
|
||||
const promises = [];
|
||||
|
||||
for (const properties of relations.values()) {
|
||||
const store = this.store.rootStore.getStoreForModelName(
|
||||
properties.relationClassResolver().modelName
|
||||
);
|
||||
if ("fetch" in store) {
|
||||
const id = this[properties.idKey];
|
||||
if (id) {
|
||||
promises.push(store.fetch(id as string));
|
||||
const relations = getRelationsForModelClass(
|
||||
this.constructor as typeof Model
|
||||
);
|
||||
|
||||
if (relations) {
|
||||
for (const properties of relations.values()) {
|
||||
const store = this.store.rootStore.getStoreForModelName(
|
||||
properties.relationClassResolver().modelName
|
||||
);
|
||||
if ("fetch" in store) {
|
||||
const id = this[properties.idKey];
|
||||
if (id) {
|
||||
promises.push(store.fetch(id as string));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { observable } from "mobx";
|
||||
import { computed, observable } from "mobx";
|
||||
import Model from "./Model";
|
||||
|
||||
export default abstract class ParanoidModel extends Model {
|
||||
@observable
|
||||
deletedAt: string | undefined;
|
||||
|
||||
@computed
|
||||
get isDeleted(): boolean {
|
||||
return !!this.deletedAt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Switch, Redirect, RouteComponentProps } from "react-router-dom";
|
||||
import DocumentNew from "~/scenes/DocumentNew";
|
||||
import Error404 from "~/scenes/Error404";
|
||||
import Error404 from "~/scenes/Errors/Error404";
|
||||
import AuthenticatedLayout from "~/components/AuthenticatedLayout";
|
||||
import CenteredContent from "~/components/CenteredContent";
|
||||
import PlaceholderDocument from "~/components/PlaceholderDocument";
|
||||
@@ -83,7 +83,7 @@ function AuthenticatedRoutes() {
|
||||
<Route exact path={`/doc/${slug}/insights`} component={Document} />
|
||||
<Route exact path={`/doc/${slug}/edit`} component={Document} />
|
||||
<Route path={`/doc/${slug}`} component={Document} />
|
||||
<Route exact path={`${searchPath()}/:term?`} component={Search} />
|
||||
<Route exact path={`${searchPath()}/:query?`} component={Search} />
|
||||
<Route path="/404" component={Error404} />
|
||||
<SettingsRoutes />
|
||||
<Route component={Error404} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { RouteComponentProps, Switch } from "react-router-dom";
|
||||
import DocumentNew from "~/scenes/DocumentNew";
|
||||
import Error404 from "~/scenes/Error404";
|
||||
import Error404 from "~/scenes/Errors/Error404";
|
||||
import Route from "~/components/ProfiledRoute";
|
||||
import useSettingsConfig from "~/hooks/useSettingsConfig";
|
||||
import lazy from "~/utils/lazyWithRetry";
|
||||
|
||||
@@ -17,7 +17,6 @@ import { s } from "@shared/styles";
|
||||
import { StatusFilter } from "@shared/types";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import Collection from "~/models/Collection";
|
||||
import Search from "~/scenes/Search";
|
||||
import { Action } from "~/components/Actions";
|
||||
import CenteredContent from "~/components/CenteredContent";
|
||||
import { CollectionBreadcrumb } from "~/components/CollectionBreadcrumb";
|
||||
@@ -41,6 +40,7 @@ import { usePinnedDocuments } from "~/hooks/usePinnedDocuments";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { collectionPath, updateCollectionPath } from "~/utils/routeHelpers";
|
||||
import Error404 from "../Errors/Error404";
|
||||
import Actions from "./components/Actions";
|
||||
import DropToImport from "./components/DropToImport";
|
||||
import Empty from "./components/Empty";
|
||||
@@ -139,7 +139,7 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
useCommandBarActions([editCollection], [ui.activeCollectionId ?? "none"]);
|
||||
|
||||
if (!collection && error) {
|
||||
return <Search notFound />;
|
||||
return <Error404 />;
|
||||
}
|
||||
|
||||
const hasOverview = can.update || collection?.hasDescription;
|
||||
|
||||
@@ -9,8 +9,8 @@ import { s } from "@shared/styles";
|
||||
import { NavigationNode, PublicTeam, TOCPosition } from "@shared/types";
|
||||
import type { Theme } from "~/stores/UiStore";
|
||||
import DocumentModel from "~/models/Document";
|
||||
import Error404 from "~/scenes/Error404";
|
||||
import ErrorOffline from "~/scenes/ErrorOffline";
|
||||
import Error404 from "~/scenes/Errors/Error404";
|
||||
import ErrorOffline from "~/scenes/Errors/ErrorOffline";
|
||||
import ClickablePadding from "~/components/ClickablePadding";
|
||||
import {
|
||||
DocumentContextProvider,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useHistory, useLocation } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { UserPreference } from "@shared/types";
|
||||
import InputSelect from "~/components/InputSelect";
|
||||
import { InputSelectNew, Option } from "~/components/InputSelectNew";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
@@ -28,66 +28,80 @@ const CommentSortMenu = () => {
|
||||
const viewingResolved = params.get("resolved") === "";
|
||||
const value = viewingResolved ? "resolved" : preferredSortType;
|
||||
|
||||
const handleSortTypeChange = (type: CommentSortType) => {
|
||||
if (type !== preferredSortType) {
|
||||
user.setPreference(
|
||||
UserPreference.SortCommentsByOrderInDocument,
|
||||
type === CommentSortType.OrderInDocument
|
||||
);
|
||||
void user.save();
|
||||
}
|
||||
};
|
||||
const handleChange = React.useCallback(
|
||||
(val: string) => {
|
||||
if (val === "resolved") {
|
||||
history.push({
|
||||
search: queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
resolved: "",
|
||||
}),
|
||||
pathname: location.pathname,
|
||||
state: { sidebarContext },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const showResolved = () => {
|
||||
history.push({
|
||||
search: queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
resolved: "",
|
||||
}),
|
||||
pathname: location.pathname,
|
||||
state: { sidebarContext },
|
||||
});
|
||||
};
|
||||
const sortType = val as CommentSortType;
|
||||
if (sortType !== preferredSortType) {
|
||||
user.setPreference(
|
||||
UserPreference.SortCommentsByOrderInDocument,
|
||||
sortType === CommentSortType.OrderInDocument
|
||||
);
|
||||
void user.save();
|
||||
}
|
||||
|
||||
const showUnresolved = () => {
|
||||
history.push({
|
||||
search: queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
resolved: undefined,
|
||||
}),
|
||||
pathname: location.pathname,
|
||||
state: { sidebarContext },
|
||||
});
|
||||
};
|
||||
history.push({
|
||||
search: queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
resolved: undefined,
|
||||
}),
|
||||
pathname: location.pathname,
|
||||
state: { sidebarContext },
|
||||
});
|
||||
},
|
||||
[history, location, sidebarContext, user, preferredSortType]
|
||||
);
|
||||
|
||||
const options: Option[] = React.useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
type: "item",
|
||||
label: t("Most recent"),
|
||||
value: CommentSortType.MostRecent,
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
label: t("Order in doc"),
|
||||
value: CommentSortType.OrderInDocument,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
label: t("Resolved"),
|
||||
value: "resolved",
|
||||
},
|
||||
] satisfies Option[],
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
style={{ margin: 0 }}
|
||||
ariaLabel={t("Sort comments")}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={(ev) => {
|
||||
if (ev === "resolved") {
|
||||
showResolved();
|
||||
} else {
|
||||
handleSortTypeChange(ev as CommentSortType);
|
||||
showUnresolved();
|
||||
}
|
||||
}}
|
||||
onChange={handleChange}
|
||||
ariaLabel={t("Sort comments")}
|
||||
label={t("Sort comments")}
|
||||
hideLabel
|
||||
borderOnHover
|
||||
options={[
|
||||
{ value: CommentSortType.MostRecent, label: t("Most recent") },
|
||||
{ value: CommentSortType.OrderInDocument, label: t("Order in doc") },
|
||||
{
|
||||
divider: true,
|
||||
value: "resolved",
|
||||
label: t("Resolved"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Select = styled(InputSelect)`
|
||||
const Select = styled(InputSelectNew)`
|
||||
color: ${s("textSecondary")};
|
||||
`;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Fade from "~/components/Fade";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
@@ -63,7 +64,7 @@ function CommentThread({
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const [autoFocus, setAutoFocus] = React.useState(thread.isNew);
|
||||
const [autoFocus, setAutoFocusOn, setAutoFocusOff] = useBoolean(thread.isNew);
|
||||
|
||||
const can = usePolicy(document);
|
||||
|
||||
@@ -156,9 +157,9 @@ function CommentThread({
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!focused && autoFocus) {
|
||||
setAutoFocus(false);
|
||||
setAutoFocusOff();
|
||||
}
|
||||
}, [focused, autoFocus]);
|
||||
}, [focused, autoFocus, setAutoFocusOff]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (focused) {
|
||||
@@ -273,7 +274,7 @@ function CommentThread({
|
||||
)}
|
||||
</ResizingHeightContainer>
|
||||
{!focused && !recessed && !draft && canReply && (
|
||||
<Reply onClick={() => setAutoFocus(true)}>{t("Reply")}…</Reply>
|
||||
<Reply onClick={setAutoFocusOn}>{t("Reply")}…</Reply>
|
||||
)}
|
||||
</Thread>
|
||||
);
|
||||
|
||||
@@ -6,9 +6,10 @@ import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { RevisionHelper } from "@shared/utils/RevisionHelper";
|
||||
import Document from "~/models/Document";
|
||||
import Revision from "~/models/Revision";
|
||||
import Error402 from "~/scenes/Error402";
|
||||
import Error404 from "~/scenes/Error404";
|
||||
import ErrorOffline from "~/scenes/ErrorOffline";
|
||||
import Error402 from "~/scenes/Errors/Error402";
|
||||
import Error403 from "~/scenes/Errors/Error403";
|
||||
import Error404 from "~/scenes/Errors/Error404";
|
||||
import ErrorOffline from "~/scenes/Errors/ErrorOffline";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
@@ -17,6 +18,7 @@ import useStores from "~/hooks/useStores";
|
||||
import { Properties } from "~/types";
|
||||
import Logger from "~/utils/Logger";
|
||||
import {
|
||||
AuthorizationError,
|
||||
NotFoundError,
|
||||
OfflineError,
|
||||
PaymentRequiredError,
|
||||
@@ -216,6 +218,8 @@ function DataLoader({ match, children }: Props) {
|
||||
<ErrorOffline />
|
||||
) : error instanceof PaymentRequiredError ? (
|
||||
<Error402 />
|
||||
) : error instanceof AuthorizationError ? (
|
||||
<Error403 />
|
||||
) : (
|
||||
<Error404 />
|
||||
);
|
||||
|
||||
@@ -6,6 +6,12 @@ import { useHistory } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
import * as Y from "yjs";
|
||||
import {
|
||||
AuthenticationFailed,
|
||||
DocumentTooLarge,
|
||||
EditorUpdateError,
|
||||
} from "@shared/collaboration/CloseEvents";
|
||||
import EDITOR_VERSION from "@shared/editor/version";
|
||||
import { supportsPassiveListener } from "@shared/utils/browser";
|
||||
import Editor, { Props as EditorProps } from "~/components/Editor";
|
||||
import MultiplayerExtension from "~/editor/extensions/Multiplayer";
|
||||
@@ -65,6 +71,9 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
||||
const name = `document.${documentId}`;
|
||||
const localProvider = new IndexeddbPersistence(name, ydoc);
|
||||
const provider = new HocuspocusProvider({
|
||||
parameters: {
|
||||
editorVersion: EDITOR_VERSION,
|
||||
},
|
||||
url: `${env.COLLABORATION_URL}/collaboration`,
|
||||
name,
|
||||
document: ydoc,
|
||||
@@ -140,8 +149,14 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
||||
provider.on("close", (ev: MessageEvent) => {
|
||||
if ("code" in ev.event) {
|
||||
provider.shouldConnect =
|
||||
ev.event.code !== 1009 && ev.event.code !== 4401;
|
||||
ev.event.code !== DocumentTooLarge.code &&
|
||||
ev.event.code !== AuthenticationFailed.code &&
|
||||
ev.event.code !== EditorUpdateError.code;
|
||||
ui.setMultiplayerStatus("disconnected", ev.event.code);
|
||||
|
||||
if (ev.event.code === EditorUpdateError.code) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import Empty from "~/components/Empty";
|
||||
import Scene from "~/components/Scene";
|
||||
import { homePath } from "~/utils/routeHelpers";
|
||||
|
||||
const Error404 = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Scene title={t("Not Found")}>
|
||||
<h1>{t("Not Found")}</h1>
|
||||
<Empty>
|
||||
<Trans>
|
||||
We were unable to find the page you’re looking for. Go to the{" "}
|
||||
<Link to={homePath()}>homepage</Link>?
|
||||
</Trans>
|
||||
</Empty>
|
||||
</Scene>
|
||||
);
|
||||
};
|
||||
|
||||
export default Error404;
|
||||
@@ -1,7 +1,9 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import Flex from "@shared/components/Flex";
|
||||
import Empty from "~/components/Empty";
|
||||
import Heading from "~/components/Heading";
|
||||
import Notice from "~/components/Notice";
|
||||
import Scene from "~/components/Scene";
|
||||
|
||||
@@ -12,13 +14,15 @@ const Error402 = () => {
|
||||
|
||||
return (
|
||||
<Scene title={title}>
|
||||
<h1>{title}</h1>
|
||||
<Empty>
|
||||
<Notice>
|
||||
This document cannot be viewed with the current edition. Please
|
||||
upgrade to a paid license to restore access.
|
||||
</Notice>
|
||||
</Empty>
|
||||
<Heading>{title}</Heading>
|
||||
<Flex style={{ maxWidth: 500 }} column>
|
||||
<Empty size="large">
|
||||
<Notice>
|
||||
This document cannot be viewed with the current edition. Please
|
||||
upgrade to a paid license to restore access.
|
||||
</Notice>
|
||||
</Empty>
|
||||
</Flex>
|
||||
</Scene>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import Flex from "@shared/components/Flex";
|
||||
import Button from "~/components/Button";
|
||||
import Empty from "~/components/Empty";
|
||||
import Heading from "~/components/Heading";
|
||||
import Scene from "~/components/Scene";
|
||||
import { navigateToHome } from "~/actions/definitions/navigation";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
|
||||
const Error403 = () => {
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const context = useActionContext();
|
||||
|
||||
return (
|
||||
<Scene title={t("No access to this doc")}>
|
||||
<Heading>{t("No access to this doc")}</Heading>
|
||||
<Flex gap={20} style={{ maxWidth: 500 }} column>
|
||||
<Empty size="large">
|
||||
{t(
|
||||
"It doesn’t look like you have permission to access this document."
|
||||
)}{" "}
|
||||
{t("Please request access from the document owner.")}
|
||||
</Empty>
|
||||
<Flex gap={8}>
|
||||
<Button action={navigateToHome} context={context} hideIcon>
|
||||
{t("Home")}
|
||||
</Button>
|
||||
<Button onClick={history.goBack} neutral>
|
||||
{t("Go back")}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Scene>
|
||||
);
|
||||
};
|
||||
|
||||
export default Error403;
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import Flex from "@shared/components/Flex";
|
||||
import Button from "~/components/Button";
|
||||
import Empty from "~/components/Empty";
|
||||
import Heading from "~/components/Heading";
|
||||
import Scene from "~/components/Scene";
|
||||
import {
|
||||
navigateToHome,
|
||||
navigateToSearch,
|
||||
} from "~/actions/definitions/navigation";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
|
||||
const Error404 = () => {
|
||||
const { t } = useTranslation();
|
||||
const context = useActionContext();
|
||||
|
||||
return (
|
||||
<Scene title={t("Not found")}>
|
||||
<Heading>{t("Not found")}</Heading>
|
||||
<Flex gap={20} style={{ maxWidth: 500 }} column>
|
||||
<Empty size="large">
|
||||
<Trans>
|
||||
The page you’re looking for cannot be found. It might have been
|
||||
deleted or the link is incorrect.
|
||||
</Trans>
|
||||
</Empty>
|
||||
<Flex gap={8}>
|
||||
<Button action={navigateToHome} context={context} hideIcon>
|
||||
{t("Home")}
|
||||
</Button>
|
||||
<Button action={navigateToSearch} context={context} neutral>
|
||||
{t("Search")}…
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Scene>
|
||||
);
|
||||
};
|
||||
|
||||
export default Error404;
|
||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CenteredContent from "~/components/CenteredContent";
|
||||
import Empty from "~/components/Empty";
|
||||
import Heading from "~/components/Heading";
|
||||
import PageTitle from "~/components/PageTitle";
|
||||
|
||||
const ErrorOffline = () => {
|
||||
@@ -9,7 +10,7 @@ const ErrorOffline = () => {
|
||||
return (
|
||||
<CenteredContent>
|
||||
<PageTitle title={t("Offline")} />
|
||||
<h1>{t("Offline")}</h1>
|
||||
<Heading>{t("Offline")}</Heading>
|
||||
<Empty>{t("We were unable to load the document while offline.")}</Empty>
|
||||
</CenteredContent>
|
||||
);
|
||||
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import CenteredContent from "~/components/CenteredContent";
|
||||
import Heading from "~/components/Heading";
|
||||
import PageTitle from "~/components/PageTitle";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -12,12 +13,12 @@ const ErrorSuspended = () => {
|
||||
return (
|
||||
<CenteredContent>
|
||||
<PageTitle title={t("Your account has been suspended")} />
|
||||
<h1>
|
||||
<Heading>
|
||||
<span role="img" aria-label={t("Warning Sign")}>
|
||||
⚠️
|
||||
</span>{" "}
|
||||
{t("Your account has been suspended")}
|
||||
</h1>
|
||||
</Heading>
|
||||
|
||||
<p>
|
||||
<Trans
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from "@shared/types";
|
||||
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
|
||||
import DocumentListItem from "~/components/DocumentListItem";
|
||||
import Empty from "~/components/Empty";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
@@ -38,9 +37,7 @@ import RecentSearches from "./components/RecentSearches";
|
||||
import SearchInput from "./components/SearchInput";
|
||||
import UserFilter from "./components/UserFilter";
|
||||
|
||||
type Props = { notFound?: boolean };
|
||||
|
||||
function Search(props: Props) {
|
||||
function Search() {
|
||||
const { t } = useTranslation();
|
||||
const { documents, searches } = useStores();
|
||||
|
||||
@@ -48,7 +45,7 @@ function Search(props: Props) {
|
||||
const params = useQuery();
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const routeMatch = useRouteMatch<{ term: string }>();
|
||||
const routeMatch = useRouteMatch<{ query: string }>();
|
||||
|
||||
// refs
|
||||
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
@@ -57,7 +54,7 @@ function Search(props: Props) {
|
||||
|
||||
// filters
|
||||
const decodedQuery = decodeURIComponentSafe(
|
||||
routeMatch.params.term ?? params.get("query") ?? ""
|
||||
routeMatch.params.query ?? params.get("q") ?? params.get("query") ?? ""
|
||||
).trim();
|
||||
const query = decodedQuery !== "" ? decodedQuery : undefined;
|
||||
const collectionId = params.get("collectionId") ?? "";
|
||||
@@ -130,9 +127,9 @@ function Search(props: Props) {
|
||||
|
||||
const updateLocation = (query: string) => {
|
||||
history.replace({
|
||||
pathname: searchPath(query),
|
||||
pathname: location.pathname,
|
||||
search: queryString.stringify(
|
||||
{ ...queryString.parse(location.search), query: undefined },
|
||||
{ ...queryString.parse(location.search), q: query },
|
||||
{
|
||||
skipEmptyString: true,
|
||||
}
|
||||
@@ -153,7 +150,7 @@ function Search(props: Props) {
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: queryString.stringify(
|
||||
{ ...queryString.parse(location.search), query: undefined, ...search },
|
||||
{ ...queryString.parse(location.search), ...search },
|
||||
{
|
||||
skipEmptyString: true,
|
||||
}
|
||||
@@ -211,14 +208,6 @@ function Search(props: Props) {
|
||||
<Scene textTitle={query ? `${query} – ${t("Search")}` : t("Search")}>
|
||||
<RegisterKeyDown trigger="Escape" handler={history.goBack} />
|
||||
{loading && <LoadingIndicator />}
|
||||
{props.notFound && (
|
||||
<div>
|
||||
<h1>{t("Not Found")}</h1>
|
||||
<Empty>
|
||||
{t("We were unable to find the page you’re looking for.")}
|
||||
</Empty>
|
||||
</div>
|
||||
)}
|
||||
<ResultsWrapper column auto>
|
||||
<form
|
||||
method="GET"
|
||||
|
||||
@@ -27,7 +27,7 @@ function RecentSearchListItem({ searchQuery }: Props) {
|
||||
|
||||
return (
|
||||
<RecentSearch
|
||||
to={searchPath(searchQuery.query)}
|
||||
to={searchPath({ query: searchQuery.query })}
|
||||
ref={ref}
|
||||
{...rovingTabIndex}
|
||||
>
|
||||
|
||||
@@ -16,7 +16,7 @@ import DefaultCollectionInputSelect from "~/components/DefaultCollectionInputSel
|
||||
import Heading from "~/components/Heading";
|
||||
import Input from "~/components/Input";
|
||||
import InputColor from "~/components/InputColor";
|
||||
import InputSelect from "~/components/InputSelect";
|
||||
import { InputSelectNew, Option } from "~/components/InputSelectNew";
|
||||
import Scene from "~/components/Scene";
|
||||
import Switch from "~/components/Switch";
|
||||
import Text from "~/components/Text";
|
||||
@@ -64,6 +64,27 @@ function Details() {
|
||||
team.getPreference(TeamPreference.TocPosition) as TOCPosition
|
||||
);
|
||||
|
||||
const tocPositionOptions: Option[] = React.useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
type: "item",
|
||||
label: t("Left"),
|
||||
value: TOCPosition.Left,
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
label: t("Right"),
|
||||
value: TOCPosition.Right,
|
||||
},
|
||||
] satisfies Option[],
|
||||
[t]
|
||||
);
|
||||
|
||||
const handleTocPositionChange = React.useCallback((position: string) => {
|
||||
setTocPosition(position as TOCPosition);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (event?: React.SyntheticEvent) => {
|
||||
if (event) {
|
||||
@@ -123,9 +144,9 @@ function Details() {
|
||||
});
|
||||
};
|
||||
|
||||
const onSelectCollection = React.useCallback(async (value: string) => {
|
||||
const defaultCollectionId = value === "home" ? null : value;
|
||||
setDefaultCollectionId(defaultCollectionId);
|
||||
const onSelectCollection = React.useCallback((value: string) => {
|
||||
const selectedValue = value === "home" ? null : value;
|
||||
setDefaultCollectionId(selectedValue);
|
||||
}, []);
|
||||
|
||||
const isValid = form.current?.checkValidity();
|
||||
@@ -242,20 +263,13 @@ function Details() {
|
||||
"The side to display the table of contents in relation to the main content."
|
||||
)}
|
||||
>
|
||||
<InputSelect
|
||||
ariaLabel={t("Table of contents position")}
|
||||
options={[
|
||||
{
|
||||
label: t("Left"),
|
||||
value: TOCPosition.Left,
|
||||
},
|
||||
{
|
||||
label: t("Right"),
|
||||
value: TOCPosition.Right,
|
||||
},
|
||||
]}
|
||||
<InputSelectNew
|
||||
options={tocPositionOptions}
|
||||
value={tocPosition}
|
||||
onChange={(p: TOCPosition) => setTocPosition(p)}
|
||||
onChange={handleTocPositionChange}
|
||||
ariaLabel={t("Table of contents position")}
|
||||
label={t("Table of contents position")}
|
||||
hideLabel
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
@@ -298,7 +312,6 @@ function Details() {
|
||||
)}
|
||||
>
|
||||
<DefaultCollectionInputSelect
|
||||
id="defaultCollectionId"
|
||||
onSelectCollection={onSelectCollection}
|
||||
defaultCollectionId={defaultCollectionId}
|
||||
/>
|
||||
|
||||
+154
-88
@@ -1,10 +1,13 @@
|
||||
import orderBy from "lodash/orderBy";
|
||||
import { observer } from "mobx-react";
|
||||
import { NewDocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { Pagination } from "@shared/constants";
|
||||
import { FileOperationType } from "@shared/types";
|
||||
import { cdnPath } from "@shared/utils/urls";
|
||||
import FileOperation from "~/models/FileOperation";
|
||||
import ImportModel from "~/models/Import";
|
||||
import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
|
||||
@@ -15,16 +18,146 @@ import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import FileOperationListItem from "./components/FileOperationListItem";
|
||||
import ImportJSONDialog from "./components/ImportJSONDialog";
|
||||
import { ImportListItem } from "./components/ImportListItem";
|
||||
import ImportMarkdownDialog from "./components/ImportMarkdownDialog";
|
||||
import ImportNotionDialog from "./components/ImportNotionDialog";
|
||||
|
||||
type Config = {
|
||||
/** The title of the import. */
|
||||
title: string;
|
||||
/** The auxiliary descriptive text of the import. */
|
||||
subtitle: string;
|
||||
/** An icon to denote the kind of import. */
|
||||
icon: React.ReactElement;
|
||||
/** Trigger for the import. */
|
||||
action: React.ReactElement;
|
||||
};
|
||||
|
||||
function useImportsConfig() {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const appName = env.APP_NAME;
|
||||
|
||||
return React.useMemo(() => {
|
||||
const items: Config[] = [
|
||||
{
|
||||
title: t("Markdown"),
|
||||
subtitle: t(
|
||||
"Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)"
|
||||
),
|
||||
icon: <MarkdownIcon size={28} />,
|
||||
action: (
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
dialogs.openModal({
|
||||
title: t("Import data"),
|
||||
content: <ImportMarkdownDialog />,
|
||||
});
|
||||
}}
|
||||
neutral
|
||||
>
|
||||
{t("Import")}…
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "JSON",
|
||||
subtitle: t(
|
||||
"Import a JSON data file exported from another {{ appName }} instance",
|
||||
{
|
||||
appName,
|
||||
}
|
||||
),
|
||||
icon: <OutlineIcon size={28} cover />,
|
||||
action: (
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
dialogs.openModal({
|
||||
title: t("Import data"),
|
||||
content: <ImportJSONDialog />,
|
||||
});
|
||||
}}
|
||||
neutral
|
||||
>
|
||||
{t("Import")}…
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
PluginManager.getHooks(Hook.Imports).forEach((plugin) => {
|
||||
items.push({ ...plugin.value });
|
||||
});
|
||||
|
||||
items.push({
|
||||
title: "Confluence",
|
||||
subtitle: t("Import pages from a Confluence instance"),
|
||||
icon: <img src={cdnPath("/images/confluence.png")} width={28} />,
|
||||
action: (
|
||||
<Button type="submit" disabled neutral>
|
||||
{t("Enterprise")}
|
||||
</Button>
|
||||
),
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [t, dialogs, appName]);
|
||||
}
|
||||
|
||||
function Import() {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs, fileOperations } = useStores();
|
||||
const { fileOperations, imports } = useStores();
|
||||
const configs = useImportsConfig();
|
||||
const appName = env.APP_NAME;
|
||||
|
||||
const [, setForceRender] = React.useState(0);
|
||||
const offset = React.useMemo(() => ({ imports: 0, fileOperations: 0 }), []);
|
||||
|
||||
const fetchImports = React.useCallback(async () => {
|
||||
const [importsArr, fileOpsArr] = await Promise.all([
|
||||
imports.fetchPage({
|
||||
offset: offset.imports,
|
||||
limit: Pagination.defaultLimit,
|
||||
}),
|
||||
fileOperations.fetchPage({
|
||||
type: FileOperationType.Import,
|
||||
offset: offset.fileOperations,
|
||||
limit: Pagination.defaultLimit,
|
||||
}),
|
||||
]);
|
||||
|
||||
const pageImports = orderBy(
|
||||
[...importsArr, ...fileOpsArr],
|
||||
"createdAt",
|
||||
"desc"
|
||||
).slice(0, Pagination.defaultLimit);
|
||||
|
||||
const apiImportsCount = pageImports.filter(
|
||||
(item) => item instanceof ImportModel
|
||||
).length;
|
||||
|
||||
offset.imports += apiImportsCount;
|
||||
offset.fileOperations += pageImports.length - apiImportsCount;
|
||||
|
||||
// needed to re-render after mobx store and offset is updated
|
||||
setForceRender((s) => ++s);
|
||||
|
||||
return pageImports;
|
||||
}, [imports, fileOperations, offset]);
|
||||
|
||||
const allImports = orderBy(
|
||||
[
|
||||
...imports.orderedData,
|
||||
...fileOperations.filter({ type: FileOperationType.Import }),
|
||||
],
|
||||
"createdAt",
|
||||
"desc"
|
||||
).slice(0, offset.imports + offset.fileOperations);
|
||||
|
||||
return (
|
||||
<Scene title={t("Import")} icon={<NewDocumentIcon />}>
|
||||
<Heading>{t("Import")}</Heading>
|
||||
@@ -38,100 +171,33 @@ function Import() {
|
||||
</Text>
|
||||
|
||||
<div>
|
||||
<Item
|
||||
border={false}
|
||||
image={<MarkdownIcon size={28} />}
|
||||
title={t("Markdown")}
|
||||
subtitle={t(
|
||||
"Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)"
|
||||
)}
|
||||
actions={
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
dialogs.openModal({
|
||||
title: t("Import data"),
|
||||
content: <ImportMarkdownDialog />,
|
||||
});
|
||||
}}
|
||||
neutral
|
||||
>
|
||||
{t("Import")}…
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Item
|
||||
border={false}
|
||||
image={<OutlineIcon size={28} cover />}
|
||||
title="JSON"
|
||||
subtitle={t(
|
||||
"Import a JSON data file exported from another {{ appName }} instance",
|
||||
{
|
||||
appName,
|
||||
}
|
||||
)}
|
||||
actions={
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
dialogs.openModal({
|
||||
title: t("Import data"),
|
||||
content: <ImportJSONDialog />,
|
||||
});
|
||||
}}
|
||||
neutral
|
||||
>
|
||||
{t("Import")}…
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Item
|
||||
border={false}
|
||||
image={<img src={cdnPath("/images/notion.png")} width={28} />}
|
||||
title="Notion"
|
||||
subtitle={t("Import pages exported from Notion")}
|
||||
actions={
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
dialogs.openModal({
|
||||
title: t("Import data"),
|
||||
content: <ImportNotionDialog />,
|
||||
});
|
||||
}}
|
||||
neutral
|
||||
>
|
||||
{t("Import")}…
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Item
|
||||
border={false}
|
||||
image={<img src={cdnPath("/images/confluence.png")} width={28} />}
|
||||
title="Confluence"
|
||||
subtitle={t("Import pages from a Confluence instance")}
|
||||
actions={
|
||||
<Button type="submit" disabled neutral>
|
||||
{t("Enterprise")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{configs.map((config) => (
|
||||
<Item
|
||||
key={config.title}
|
||||
title={config.title}
|
||||
subtitle={config.subtitle}
|
||||
image={config.icon}
|
||||
actions={config.action}
|
||||
border={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<br />
|
||||
<PaginatedList
|
||||
items={fileOperations.imports}
|
||||
fetch={fileOperations.fetchPage}
|
||||
options={{
|
||||
type: FileOperationType.Import,
|
||||
}}
|
||||
items={allImports}
|
||||
fetch={fetchImports}
|
||||
heading={
|
||||
<h2>
|
||||
<Trans>Recent imports</Trans>
|
||||
</h2>
|
||||
}
|
||||
renderItem={(item: FileOperation) => (
|
||||
<FileOperationListItem key={item.id} fileOperation={item} />
|
||||
)}
|
||||
renderItem={(item: ImportModel | FileOperation) =>
|
||||
item instanceof ImportModel ? (
|
||||
<ImportListItem key={item.id} importModel={item} />
|
||||
) : (
|
||||
<FileOperationListItem key={item.id} fileOperation={item} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Scene>
|
||||
);
|
||||
|
||||
@@ -194,7 +194,7 @@ function getFilteredUsers({
|
||||
|
||||
switch (filter) {
|
||||
case "all":
|
||||
filteredUsers = users.orderedData;
|
||||
filteredUsers = users.all;
|
||||
break;
|
||||
case "suspended":
|
||||
filteredUsers = users.suspended;
|
||||
|
||||
@@ -3,12 +3,12 @@ import { SettingsIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { languageOptions } from "@shared/i18n";
|
||||
import { languageOptions as availableLanguages } from "@shared/i18n";
|
||||
import { TeamPreference, UserPreference } from "@shared/types";
|
||||
import { Theme } from "~/stores/UiStore";
|
||||
import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
import InputSelect from "~/components/InputSelect";
|
||||
import { InputSelectNew, Option } from "~/components/InputSelectNew";
|
||||
import Scene from "~/components/Scene";
|
||||
import Switch from "~/components/Switch";
|
||||
import Text from "~/components/Text";
|
||||
@@ -26,6 +26,29 @@ function Preferences() {
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(user.id);
|
||||
|
||||
const languageOptions: Option[] = React.useMemo(
|
||||
() =>
|
||||
availableLanguages.map(
|
||||
(lang) =>
|
||||
({
|
||||
type: "item",
|
||||
label: lang.label,
|
||||
value: lang.value,
|
||||
} satisfies Option)
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const themeOptions: Option[] = React.useMemo(
|
||||
() =>
|
||||
[
|
||||
{ type: "item", label: t("Light"), value: Theme.Light },
|
||||
{ type: "item", label: t("Dark"), value: Theme.Dark },
|
||||
{ type: "item", label: t("System"), value: Theme.System },
|
||||
] satisfies Option[],
|
||||
[t]
|
||||
);
|
||||
|
||||
const handlePreferenceChange =
|
||||
(inverted = false) =>
|
||||
async (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -37,10 +60,21 @@ function Preferences() {
|
||||
toast.success(t("Preferences saved"));
|
||||
};
|
||||
|
||||
const handleLanguageChange = async (language: string) => {
|
||||
await user.save({ language });
|
||||
toast.success(t("Preferences saved"));
|
||||
};
|
||||
const handleLanguageChange = React.useCallback(
|
||||
async (language: string) => {
|
||||
await user.save({ language });
|
||||
toast.success(t("Preferences saved"));
|
||||
},
|
||||
[t, user]
|
||||
);
|
||||
|
||||
const handleThemeChange = React.useCallback(
|
||||
(theme) => {
|
||||
ui.setTheme(theme as Theme);
|
||||
toast.success(t("Preferences saved"));
|
||||
},
|
||||
[t, ui]
|
||||
);
|
||||
|
||||
const showDeleteAccount = () => {
|
||||
dialogs.openModal({
|
||||
@@ -77,12 +111,13 @@ function Preferences() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<InputSelect
|
||||
id="language"
|
||||
<InputSelectNew
|
||||
options={languageOptions}
|
||||
value={user.language}
|
||||
onChange={handleLanguageChange}
|
||||
ariaLabel={t("Language")}
|
||||
label={t("Language")}
|
||||
hideLabel
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
@@ -90,18 +125,13 @@ function Preferences() {
|
||||
label={t("Appearance")}
|
||||
description={t("Choose your preferred interface color scheme.")}
|
||||
>
|
||||
<InputSelect
|
||||
<InputSelectNew
|
||||
options={themeOptions}
|
||||
value={ui.theme}
|
||||
onChange={handleThemeChange}
|
||||
ariaLabel={t("Appearance")}
|
||||
options={[
|
||||
{ label: t("Light"), value: Theme.Light },
|
||||
{ label: t("Dark"), value: Theme.Dark },
|
||||
{ label: t("System"), value: Theme.System },
|
||||
]}
|
||||
value={ui.resolvedTheme}
|
||||
onChange={(theme) => {
|
||||
ui.setTheme(theme as Theme);
|
||||
toast.success(t("Preferences saved"));
|
||||
}}
|
||||
label={t("Appearance")}
|
||||
hideLabel
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
|
||||
@@ -10,7 +10,7 @@ import { TeamPreference } from "@shared/types";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Flex from "~/components/Flex";
|
||||
import Heading from "~/components/Heading";
|
||||
import InputSelect from "~/components/InputSelect";
|
||||
import { InputSelectNew, Option } from "~/components/InputSelectNew";
|
||||
import PluginIcon from "~/components/PluginIcon";
|
||||
import Scene from "~/components/Scene";
|
||||
import Switch from "~/components/Switch";
|
||||
@@ -44,6 +44,23 @@ function Security() {
|
||||
request,
|
||||
} = useRequest(authenticationProviders.fetchPage);
|
||||
|
||||
const userRoleOptions: Option[] = React.useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
type: "item",
|
||||
label: t("Editor"),
|
||||
value: "member",
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
label: t("Viewer"),
|
||||
value: "viewer",
|
||||
},
|
||||
] satisfies Option[],
|
||||
[t]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!providers && !loading) {
|
||||
void request();
|
||||
@@ -229,21 +246,13 @@ function Security() {
|
||||
)}
|
||||
border={false}
|
||||
>
|
||||
<InputSelect
|
||||
id="defaultUserRole"
|
||||
<InputSelectNew
|
||||
value={data.defaultUserRole}
|
||||
options={[
|
||||
{
|
||||
label: t("Editor"),
|
||||
value: "member",
|
||||
},
|
||||
{
|
||||
label: t("Viewer"),
|
||||
value: "viewer",
|
||||
},
|
||||
]}
|
||||
options={userRoleOptions}
|
||||
onChange={handleDefaultRoleChange}
|
||||
ariaLabel={t("Default role")}
|
||||
label={t("Default role")}
|
||||
hideLabel
|
||||
short
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import capitalize from "lodash/capitalize";
|
||||
import { observer } from "mobx-react";
|
||||
import { CrossIcon, DoneIcon, WarningIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useTheme } from "styled-components";
|
||||
import { ImportState } from "@shared/types";
|
||||
import Import from "~/models/Import";
|
||||
import { Action } from "~/components/Actions";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Spinner from "~/components/Spinner";
|
||||
import Time from "~/components/Time";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { ImportMenu } from "~/menus/ImportMenu";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
|
||||
type Props = {
|
||||
/** Import that's displayed as list item. */
|
||||
importModel: Import;
|
||||
};
|
||||
|
||||
export const ImportListItem = observer(({ importModel }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const user = useCurrentUser();
|
||||
const theme = useTheme();
|
||||
const showProgress =
|
||||
importModel.state !== ImportState.Canceled &&
|
||||
importModel.state !== ImportState.Errored;
|
||||
const showErrorInfo =
|
||||
!isCloudHosted &&
|
||||
importModel.state === ImportState.Errored &&
|
||||
!!importModel.error;
|
||||
|
||||
const stateMap = React.useMemo(
|
||||
() => ({
|
||||
[ImportState.Created]: t("Processing"),
|
||||
[ImportState.InProgress]: t("Processing"),
|
||||
[ImportState.Processed]: t("Processing"),
|
||||
[ImportState.Completed]: t("Completed"),
|
||||
[ImportState.Errored]: t("Failed"),
|
||||
[ImportState.Canceled]: t("Canceled"),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const iconMap = React.useMemo(
|
||||
() => ({
|
||||
[ImportState.Created]: <Spinner />,
|
||||
[ImportState.InProgress]: <Spinner />,
|
||||
[ImportState.Processed]: <Spinner />,
|
||||
[ImportState.Completed]: <DoneIcon color={theme.accent} />,
|
||||
[ImportState.Errored]: <WarningIcon color={theme.danger} />,
|
||||
[ImportState.Canceled]: <CrossIcon color={theme.textTertiary} />,
|
||||
}),
|
||||
[theme]
|
||||
);
|
||||
|
||||
const handleCancel = React.useCallback(async () => {
|
||||
const onCancel = async () => {
|
||||
try {
|
||||
await importModel.cancel();
|
||||
toast.success(t("Import canceled"));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
dialogs.openModal({
|
||||
title: t("Are you sure you want to cancel this import?"),
|
||||
content: (
|
||||
<ConfirmationDialog
|
||||
onSubmit={onCancel}
|
||||
submitText={t("Cancel")}
|
||||
savingText={`${t("Canceling")}…`}
|
||||
danger
|
||||
>
|
||||
{t(
|
||||
"Canceling this import will discard any progress made. This cannot be undone."
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
),
|
||||
});
|
||||
}, [t, dialogs, importModel]);
|
||||
|
||||
const handleDelete = React.useCallback(async () => {
|
||||
const onDelete = async () => {
|
||||
try {
|
||||
await importModel.delete();
|
||||
toast.success(t("Import deleted"));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
dialogs.openModal({
|
||||
title: t("Are you sure you want to delete this import?"),
|
||||
content: (
|
||||
<ConfirmationDialog
|
||||
onSubmit={onDelete}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
danger
|
||||
>
|
||||
{t(
|
||||
"Deleting this import will also delete all collections and documents that were created from it. This cannot be undone."
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
),
|
||||
});
|
||||
}, [t, dialogs, importModel]);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
title={importModel.name}
|
||||
image={iconMap[importModel.state]}
|
||||
subtitle={
|
||||
<>
|
||||
{stateMap[importModel.state]} •
|
||||
{showErrorInfo && (
|
||||
<>
|
||||
{importModel.error}
|
||||
{`. ${t("Check server logs for more details.")}`} •
|
||||
</>
|
||||
)}
|
||||
{t(`{{userName}} requested`, {
|
||||
userName:
|
||||
user.id === importModel.createdBy.id
|
||||
? t("You")
|
||||
: importModel.createdBy.name,
|
||||
})}
|
||||
|
||||
<Time dateTime={importModel.createdAt} addSuffix shorten />
|
||||
•
|
||||
{capitalize(importModel.service)}
|
||||
{showProgress && (
|
||||
<>
|
||||
•
|
||||
{t("{{ count }} document imported", {
|
||||
count: importModel.documentCount,
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<Action>
|
||||
<ImportMenu
|
||||
importModel={importModel}
|
||||
onCancel={handleCancel}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</Action>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -8,6 +8,7 @@ type DialogDefinition = {
|
||||
isOpen: boolean;
|
||||
fullscreen?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export default class DialogsStore {
|
||||
@@ -50,6 +51,7 @@ export default class DialogsStore {
|
||||
fullscreen,
|
||||
replace,
|
||||
style,
|
||||
onClose,
|
||||
}: {
|
||||
id?: string;
|
||||
title: string;
|
||||
@@ -57,6 +59,7 @@ export default class DialogsStore {
|
||||
content: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
replace?: boolean;
|
||||
onClose?: () => void;
|
||||
}) => {
|
||||
setTimeout(
|
||||
action(() => {
|
||||
@@ -70,6 +73,7 @@ export default class DialogsStore {
|
||||
fullscreen,
|
||||
style,
|
||||
isOpen: true,
|
||||
onClose,
|
||||
});
|
||||
}),
|
||||
0
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import invariant from "invariant";
|
||||
import { action, runInAction } from "mobx";
|
||||
import Import from "~/models/Import";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import RootStore from "./RootStore";
|
||||
import Store from "./base/Store";
|
||||
|
||||
export default class ImportsStore extends Store<Import> {
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore, Import);
|
||||
}
|
||||
|
||||
@action
|
||||
cancel = async (importModel: Import) => {
|
||||
const res = await client.post("/imports.cancel", {
|
||||
id: importModel.id,
|
||||
});
|
||||
|
||||
runInAction("Import#cancel", () => {
|
||||
invariant(res?.data, "Data should be available");
|
||||
importModel.updateData(res.data);
|
||||
this.addPolicies(res.policies);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import FileOperationsStore from "./FileOperationsStore";
|
||||
import GroupMembershipsStore from "./GroupMembershipsStore";
|
||||
import GroupUsersStore from "./GroupUsersStore";
|
||||
import GroupsStore from "./GroupsStore";
|
||||
import ImportsStore from "./ImportsStore";
|
||||
import IntegrationsStore from "./IntegrationsStore";
|
||||
import MembershipsStore from "./MembershipsStore";
|
||||
import NotificationsStore from "./NotificationsStore";
|
||||
@@ -43,6 +44,7 @@ export default class RootStore {
|
||||
events: EventsStore;
|
||||
groups: GroupsStore;
|
||||
groupUsers: GroupUsersStore;
|
||||
imports: ImportsStore;
|
||||
integrations: IntegrationsStore;
|
||||
memberships: MembershipsStore;
|
||||
notifications: NotificationsStore;
|
||||
@@ -72,6 +74,7 @@ export default class RootStore {
|
||||
this.registerStore(EventsStore);
|
||||
this.registerStore(GroupsStore);
|
||||
this.registerStore(GroupUsersStore);
|
||||
this.registerStore(ImportsStore);
|
||||
this.registerStore(IntegrationsStore);
|
||||
this.registerStore(MembershipsStore);
|
||||
this.registerStore(NotificationsStore);
|
||||
|
||||
@@ -27,46 +27,44 @@ export default class UsersStore extends Store<User> {
|
||||
|
||||
@computed
|
||||
get active(): User[] {
|
||||
return this.orderedData.filter(
|
||||
(user) => !user.isSuspended && user.lastActiveAt
|
||||
);
|
||||
return this.all.filter((user) => !user.isSuspended && !user.isInvited);
|
||||
}
|
||||
|
||||
@computed
|
||||
get suspended(): User[] {
|
||||
return this.orderedData.filter((user) => user.isSuspended);
|
||||
return this.all.filter((user) => user.isSuspended);
|
||||
}
|
||||
|
||||
@computed
|
||||
get activeOrInvited(): User[] {
|
||||
return this.orderedData.filter((user) => !user.isSuspended);
|
||||
return this.all.filter((user) => !user.isSuspended);
|
||||
}
|
||||
|
||||
@computed
|
||||
get invited(): User[] {
|
||||
return this.orderedData.filter((user) => user.isInvited);
|
||||
return this.all.filter((user) => user.isInvited);
|
||||
}
|
||||
|
||||
@computed
|
||||
get admins(): User[] {
|
||||
return this.orderedData.filter((user) => user.isAdmin);
|
||||
return this.all.filter((user) => user.isAdmin && !user.isInvited);
|
||||
}
|
||||
|
||||
@computed
|
||||
get members(): User[] {
|
||||
return this.orderedData.filter(
|
||||
return this.all.filter(
|
||||
(user) => !user.isViewer && !user.isAdmin && !user.isInvited
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
get viewers(): User[] {
|
||||
return this.orderedData.filter((user) => user.isViewer);
|
||||
return this.all.filter((user) => user.isViewer && !user.isInvited);
|
||||
}
|
||||
|
||||
@computed
|
||||
get all(): User[] {
|
||||
return this.orderedData.filter((user) => user.lastActiveAt);
|
||||
return this.orderedData.filter((user) => !user.isDeleted);
|
||||
}
|
||||
|
||||
@computed
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import commandScore from "command-score";
|
||||
import invariant from "invariant";
|
||||
import { deburr, type ObjectIterateeCustom } from "lodash";
|
||||
import type { ObjectIterateeCustom } from "lodash";
|
||||
import deburr from "lodash/deburr";
|
||||
import filter from "lodash/filter";
|
||||
import find from "lodash/find";
|
||||
import flatten from "lodash/flatten";
|
||||
@@ -102,6 +103,9 @@ export default abstract class Store<T extends Model> {
|
||||
|
||||
return this.orderedData
|
||||
.filter((item: T & Searchable) => {
|
||||
if ("deletedAt" in item && item.deletedAt) {
|
||||
return false;
|
||||
}
|
||||
if ("searchContent" in item) {
|
||||
const seachables =
|
||||
typeof item.searchContent === "string"
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
RateLimitExceededError,
|
||||
RequestError,
|
||||
ServiceUnavailableError,
|
||||
UnprocessableEntityError,
|
||||
UpdateRequiredError,
|
||||
} from "./errors";
|
||||
|
||||
@@ -214,6 +215,10 @@ class ApiClient {
|
||||
throw new ServiceUnavailableError(error.message);
|
||||
}
|
||||
|
||||
if (response.status === 422) {
|
||||
throw new UnprocessableEntityError(error.message);
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
throw new RateLimitExceededError(
|
||||
`Too many requests, try again in a minute.`
|
||||
|
||||
@@ -12,6 +12,7 @@ import isCloudHosted from "./isCloudHosted";
|
||||
*/
|
||||
export enum Hook {
|
||||
Settings = "settings",
|
||||
Imports = "imports",
|
||||
Icon = "icon",
|
||||
}
|
||||
|
||||
@@ -31,6 +32,16 @@ type PluginValueMap = {
|
||||
/** Whether the plugin is enabled in the current context. */
|
||||
enabled?: (team: Team, user: User) => boolean;
|
||||
};
|
||||
[Hook.Imports]: {
|
||||
/** The title of the import. */
|
||||
title: string;
|
||||
/** The auxiliary descriptive text of the import. */
|
||||
subtitle: string;
|
||||
/** An icon to denote the kind of import. */
|
||||
icon: React.ReactElement;
|
||||
/** Trigger for the import. */
|
||||
action: React.ReactElement;
|
||||
};
|
||||
[Hook.Icon]: React.ElementType;
|
||||
};
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ export class ServiceUnavailableError extends ExtendableError {}
|
||||
|
||||
export class BadGatewayError extends ExtendableError {}
|
||||
|
||||
export class UnprocessableEntityError extends ExtendableError {}
|
||||
|
||||
export class RateLimitExceededError extends ExtendableError {}
|
||||
|
||||
export class RequestError extends ExtendableError {}
|
||||
|
||||
+18
-15
@@ -111,23 +111,26 @@ export function newNestedDocumentPath(parentDocumentId?: string): string {
|
||||
return `/doc/new?${queryString.stringify({ parentDocumentId })}`;
|
||||
}
|
||||
|
||||
export function searchPath(
|
||||
query?: string,
|
||||
params: {
|
||||
collectionId?: string;
|
||||
documentId?: string;
|
||||
ref?: string;
|
||||
} = {}
|
||||
): string {
|
||||
let search = queryString.stringify(params);
|
||||
let route = "/search";
|
||||
|
||||
if (query) {
|
||||
route += `/${encodeURIComponent(query.replace(/%/g, "%25"))}`;
|
||||
}
|
||||
export function searchPath({
|
||||
query,
|
||||
collectionId,
|
||||
documentId,
|
||||
ref,
|
||||
}: {
|
||||
query?: string;
|
||||
collectionId?: string;
|
||||
documentId?: string;
|
||||
ref?: string;
|
||||
} = {}): string {
|
||||
let search = queryString.stringify({
|
||||
q: query,
|
||||
collectionId,
|
||||
documentId,
|
||||
ref,
|
||||
});
|
||||
|
||||
search = search ? `?${search}` : "";
|
||||
return `${route}${search}`;
|
||||
return `/search${search}`;
|
||||
}
|
||||
|
||||
export function sharedDocumentPath(shareId: string, docPath?: string) {
|
||||
|
||||
+25
-19
@@ -48,16 +48,16 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.758.0",
|
||||
"@aws-sdk/lib-storage": "3.758.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.758.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.758.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.758.0",
|
||||
"@babel/core": "^7.26.9",
|
||||
"@aws-sdk/client-s3": "3.774.0",
|
||||
"@aws-sdk/lib-storage": "3.774.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.774.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.774.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.774.0",
|
||||
"@babel/core": "^7.26.10",
|
||||
"@babel/plugin-proposal-decorators": "^7.25.9",
|
||||
"@babel/plugin-transform-class-properties": "^7.25.9",
|
||||
"@babel/plugin-transform-destructuring": "^7.25.9",
|
||||
"@babel/plugin-transform-regenerator": "^7.25.9",
|
||||
"@babel/plugin-transform-regenerator": "^7.27.0",
|
||||
"@babel/preset-env": "^7.26.9",
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
"@benrbray/prosemirror-math": "^0.2.2",
|
||||
@@ -79,9 +79,12 @@
|
||||
"@hocuspocus/server": "1.1.2",
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.49",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@notionhq/client": "^2.2.16",
|
||||
"@octokit/auth-app": "^6.1.3",
|
||||
"@outlinewiki/koa-passport": "^4.2.1",
|
||||
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-visually-hidden": "^1.1.2",
|
||||
"@renderlesskit/react": "^0.11.0",
|
||||
"@sentry/node": "^7.120.3",
|
||||
"@sentry/react": "^7.120.3",
|
||||
@@ -93,6 +96,7 @@
|
||||
"@types/sanitize-filename": "^1.6.3",
|
||||
"@vitejs/plugin-react": "^3.1.0",
|
||||
"addressparser": "^1.0.1",
|
||||
"async-sema": "^3.1.1",
|
||||
"autotrack": "^2.4.1",
|
||||
"babel-plugin-styled-components": "^2.1.4",
|
||||
"babel-plugin-transform-class-properties": "^6.24.1",
|
||||
@@ -106,9 +110,9 @@
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"core-js": "^3.37.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"datadog-metrics": "^0.11.2",
|
||||
"datadog-metrics": "^0.12.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"dd-trace": "^5.41.0",
|
||||
"dd-trace": "^5.40.0",
|
||||
"diff": "^5.2.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"email-providers": "^1.14.0",
|
||||
@@ -125,11 +129,12 @@
|
||||
"fuzzy-search": "^3.2.1",
|
||||
"glob": "^8.1.0",
|
||||
"http-errors": "2.0.0",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"i18next": "^22.5.1",
|
||||
"i18next-fs-backend": "^2.6.0",
|
||||
"i18next-http-backend": "^2.7.3",
|
||||
"invariant": "^2.2.4",
|
||||
"ioredis": "^5.4.1",
|
||||
"ioredis": "^5.6.0",
|
||||
"is-printable-key-event": "^1.0.0",
|
||||
"jsdom": "^22.1.0",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
@@ -168,13 +173,13 @@
|
||||
"passport-oauth2": "^1.8.0",
|
||||
"passport-slack-oauth2": "^1.2.0",
|
||||
"patch-package": "^7.0.2",
|
||||
"pg": "^8.12.0",
|
||||
"pg": "^8.14.1",
|
||||
"pg-tsquery": "^8.4.2",
|
||||
"pluralize": "^8.0.0",
|
||||
"png-chunks-extract": "^1.0.0",
|
||||
"polished": "^4.3.1",
|
||||
"prosemirror-codemark": "^0.4.2",
|
||||
"prosemirror-commands": "^1.6.2",
|
||||
"prosemirror-commands": "^1.7.0",
|
||||
"prosemirror-dropcursor": "^1.8.1",
|
||||
"prosemirror-gapcursor": "^1.3.2",
|
||||
"prosemirror-history": "^1.4.1",
|
||||
@@ -184,7 +189,7 @@
|
||||
"prosemirror-model": "^1.24.0",
|
||||
"prosemirror-schema-list": "^1.4.1",
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-tables": "^1.4.0",
|
||||
"prosemirror-tables": "^1.6.4",
|
||||
"prosemirror-transform": "1.10.0",
|
||||
"prosemirror-view": "^1.38.1",
|
||||
"query-string": "^7.1.3",
|
||||
@@ -242,7 +247,8 @@
|
||||
"utility-types": "^3.11.0",
|
||||
"uuid": "^8.3.2",
|
||||
"validator": "13.12.0",
|
||||
"vite": "^5.4.14",
|
||||
"vaul": "^1.1.2",
|
||||
"vite": "^5.4.15",
|
||||
"vite-plugin-pwa": "^0.20.3",
|
||||
"winston": "^3.17.0",
|
||||
"ws": "^7.5.10",
|
||||
@@ -251,11 +257,11 @@
|
||||
"y-protocols": "^1.0.6",
|
||||
"yauzl": "^2.10.0",
|
||||
"yjs": "^13.6.1",
|
||||
"zod": "^3.23.8"
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.26.4",
|
||||
"@babel/preset-typescript": "^7.26.0",
|
||||
"@babel/cli": "^7.27.0",
|
||||
"@babel/preset-typescript": "^7.27.0",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@relative-ci/agent": "^4.2.14",
|
||||
"@testing-library/react": "^12.0.0",
|
||||
@@ -290,7 +296,7 @@
|
||||
"@types/markdown-it-emoji": "^2.0.4",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/natural-sort": "^0.0.24",
|
||||
"@types/node": "20.17.16",
|
||||
"@types/node": "20.17.27",
|
||||
"@types/node-fetch": "^2.6.9",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/passport-oauth2": "^1.4.17",
|
||||
@@ -354,7 +360,7 @@
|
||||
"prettier": "^2.8.8",
|
||||
"react-refresh": "^0.14.2",
|
||||
"rimraf": "^2.5.4",
|
||||
"rollup-plugin-webpack-stats": "^2.0.1",
|
||||
"rollup-plugin-webpack-stats": "^2.0.3",
|
||||
"terser": "^5.39.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite-plugin-static-copy": "^0.17.0",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Hook, PluginManager } from "@server/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import router from "./auth/email";
|
||||
|
||||
const enabled = !!env.SMTP_HOST || env.isDevelopment;
|
||||
const enabled = !!(env.SMTP_HOST || env.SMTP_SERVICE) || env.isDevelopment;
|
||||
|
||||
if (enabled) {
|
||||
PluginManager.add({
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { observer } from "mobx-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import env from "@shared/env";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import Button from "~/components/Button";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { redirectTo } from "~/utils/urls";
|
||||
import { NotionUtils } from "../shared/NotionUtils";
|
||||
import { ImportDialog } from "./components/ImportDialog";
|
||||
|
||||
export const Notion = observer(() => {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const queryParams = useQuery();
|
||||
|
||||
const appName = env.APP_NAME;
|
||||
const authUrl = NotionUtils.authUrl({ state: { teamId: team.id } });
|
||||
|
||||
const service = queryParams.get("service");
|
||||
const oauthSuccess = queryParams.get("success") === "";
|
||||
const oauthError = queryParams.get("error");
|
||||
const integrationId = queryParams.get("integrationId");
|
||||
|
||||
const clearQueryParams = React.useCallback(() => {
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: "",
|
||||
});
|
||||
}, [history, location]);
|
||||
|
||||
const handleSubmit = React.useCallback(() => {
|
||||
dialogs.closeAllModals();
|
||||
clearQueryParams();
|
||||
}, [dialogs, clearQueryParams]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
integrationId &&
|
||||
oauthSuccess &&
|
||||
service === IntegrationService.Notion
|
||||
) {
|
||||
dialogs.openModal({
|
||||
title: t("Import data"),
|
||||
content: (
|
||||
<ImportDialog integrationId={integrationId} onSubmit={handleSubmit} />
|
||||
),
|
||||
onClose: clearQueryParams,
|
||||
});
|
||||
}
|
||||
}, [t, dialogs, oauthSuccess, service, clearQueryParams]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!oauthError) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (oauthError === "access_denied") {
|
||||
toast.error(
|
||||
t(
|
||||
"Whoops, you need to accept the permissions in Notion to connect {{ appName }} to your workspace. Try again?",
|
||||
{
|
||||
appName,
|
||||
}
|
||||
)
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
t(
|
||||
"Something went wrong while authenticating your request. Please try logging in again."
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [t, appName, oauthError]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => redirectTo(authUrl)}
|
||||
disabled={!env.NOTION_CLIENT_ID}
|
||||
neutral
|
||||
>
|
||||
{t("Import")}…
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { ImportInput } from "@shared/schema";
|
||||
import { CollectionPermission, IntegrationService } from "@shared/types";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import InputSelectPermission from "~/components/InputSelectPermission";
|
||||
import Text from "~/components/Text";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { EmptySelectValue } from "~/types";
|
||||
|
||||
type Props = {
|
||||
/** The integrationId associated with this import flow. */
|
||||
integrationId: string;
|
||||
/** Callback to handle import creation. */
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export function ImportDialog({ integrationId, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { imports } = useStores();
|
||||
const [submitting, setSubmitting, resetSubmitting] = useBoolean();
|
||||
const [permission, setPermission] = React.useState<CollectionPermission>();
|
||||
|
||||
const handlePermissionChange = React.useCallback(
|
||||
(value: CollectionPermission | typeof EmptySelectValue) => {
|
||||
setPermission(value === EmptySelectValue ? undefined : value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleStartImport = React.useCallback(async () => {
|
||||
setSubmitting();
|
||||
|
||||
// TODO: This can send the page info + permission once we overcome the search timeout issues.
|
||||
const input: ImportInput<IntegrationService.Notion> = [{ permission }];
|
||||
|
||||
try {
|
||||
await imports.create(
|
||||
{ service: IntegrationService.Notion },
|
||||
{ integrationId, input }
|
||||
);
|
||||
|
||||
toast.success(
|
||||
t("Your import is being processed, you can safely leave this page")
|
||||
);
|
||||
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
resetSubmitting();
|
||||
}
|
||||
}, [permission, onSubmit]);
|
||||
|
||||
return (
|
||||
<Flex column gap={12}>
|
||||
<div>
|
||||
<InputSelectPermission
|
||||
value={permission}
|
||||
onChange={handlePermissionChange}
|
||||
/>
|
||||
<Text as="span" type="secondary">
|
||||
{t(
|
||||
"Set the default permission level for collections created from the import"
|
||||
)}
|
||||
.
|
||||
</Text>
|
||||
</div>
|
||||
<Flex justify="flex-end">
|
||||
<Button onClick={handleStartImport} disabled={submitting}>
|
||||
{t("Start import")}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { t } from "i18next";
|
||||
import * as React from "react";
|
||||
import { cdnPath } from "@shared/utils/urls";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import { Notion } from "./Imports";
|
||||
|
||||
PluginManager.add([
|
||||
{
|
||||
...config,
|
||||
type: Hook.Imports,
|
||||
value: {
|
||||
title: "Notion",
|
||||
subtitle: t("Import pages from Notion"),
|
||||
icon: <img src={cdnPath("/images/notion.png")} width={28} />,
|
||||
action: <Notion />,
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"id": "notion",
|
||||
"name": "Notion",
|
||||
"description": "Adds a Notion integration for importing data."
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import Router from "koa-router";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { Integration, IntegrationAuthentication, Team } from "@server/models";
|
||||
import { APIContext } from "@server/types";
|
||||
import { NotionClient } from "../notion";
|
||||
import * as T from "./schema";
|
||||
import { NotionUtils } from "plugins/notion/shared/NotionUtils";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.get(
|
||||
"notion.callback",
|
||||
auth({ optional: true }),
|
||||
validate(T.NotionCallbackSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.NotionCallbackReq>) => {
|
||||
const { code, state, error } = ctx.input.query;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
let parsedState;
|
||||
try {
|
||||
parsedState = NotionUtils.parseState(state);
|
||||
} catch {
|
||||
ctx.redirect(NotionUtils.errorUrl("invalid_state"));
|
||||
return;
|
||||
}
|
||||
|
||||
const { teamId } = parsedState;
|
||||
|
||||
// This code block accounts for the root domain being unable to access authentication for subdomains.
|
||||
// We must forward to the appropriate subdomain to complete the oauth flow.
|
||||
if (!user) {
|
||||
if (teamId) {
|
||||
try {
|
||||
const team = await Team.findByPk(teamId, {
|
||||
rejectOnEmpty: true,
|
||||
transaction,
|
||||
});
|
||||
|
||||
return parseDomain(ctx.host).teamSubdomain === team.subdomain
|
||||
? ctx.redirect("/")
|
||||
: ctx.redirectOnClient(
|
||||
NotionUtils.callbackUrl({
|
||||
baseUrl: team.url,
|
||||
params: ctx.request.querystring,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
Logger.error(`Error fetching team for teamId: ${teamId}!`, err);
|
||||
return ctx.redirect(NotionUtils.errorUrl("unauthenticated"));
|
||||
}
|
||||
} else {
|
||||
return ctx.redirect(NotionUtils.errorUrl("unauthenticated"));
|
||||
}
|
||||
}
|
||||
|
||||
// Check error after any sub-domain redirection. Otherwise, the user will be redirected to the root domain.
|
||||
if (error) {
|
||||
ctx.redirect(NotionUtils.errorUrl(error));
|
||||
return;
|
||||
}
|
||||
|
||||
// validation middleware ensures that code is non-null at this point.
|
||||
const data = await NotionClient.oauthAccess(code!);
|
||||
|
||||
const authentication = await IntegrationAuthentication.create(
|
||||
{
|
||||
service: IntegrationService.Notion,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
token: data.access_token,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
const integration = await Integration.create<
|
||||
Integration<IntegrationType.Import>
|
||||
>(
|
||||
{
|
||||
service: IntegrationService.Notion,
|
||||
type: IntegrationType.Import,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
authenticationId: authentication.id,
|
||||
settings: {
|
||||
externalWorkspace: {
|
||||
id: data.workspace_id,
|
||||
name: data.workspace_name ?? "Notion import",
|
||||
iconUrl: data.workspace_icon ?? undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
ctx.redirect(NotionUtils.successUrl(integration.id));
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,25 @@
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { z } from "zod";
|
||||
import { BaseSchema } from "@server/routes/api/schema";
|
||||
|
||||
export const NotionCallbackSchema = BaseSchema.extend({
|
||||
query: z
|
||||
.object({
|
||||
code: z.string().nullish(),
|
||||
state: z.string(),
|
||||
error: z.string().nullish(),
|
||||
})
|
||||
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
|
||||
message: "one of code or error is required",
|
||||
}),
|
||||
});
|
||||
|
||||
export type NotionCallbackReq = z.infer<typeof NotionCallbackSchema>;
|
||||
|
||||
export const NotionSearchSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
integrationId: z.string().uuid(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type NotionSearchReq = z.infer<typeof NotionSearchSchema>;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { IsOptional } from "class-validator";
|
||||
import { Environment } from "@server/env";
|
||||
import { Public } from "@server/utils/decorators/Public";
|
||||
import environment from "@server/utils/environment";
|
||||
import { CannotUseWithout } from "@server/utils/validators";
|
||||
|
||||
class NotionPluginEnvironment extends Environment {
|
||||
@Public
|
||||
@IsOptional()
|
||||
public NOTION_CLIENT_ID = this.toOptionalString(environment.NOTION_CLIENT_ID);
|
||||
|
||||
@IsOptional()
|
||||
@CannotUseWithout("NOTION_CLIENT_ID")
|
||||
public NOTION_CLIENT_SECRET = this.toOptionalString(
|
||||
environment.NOTION_CLIENT_SECRET
|
||||
);
|
||||
}
|
||||
|
||||
export default new NotionPluginEnvironment();
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Hook, PluginManager } from "@server/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import router from "./api/notion";
|
||||
import env from "./env";
|
||||
import { NotionImportsProcessor } from "./processors/NotionImportsProcessor";
|
||||
import NotionAPIImportTask from "./tasks/NotionAPIImportTask";
|
||||
|
||||
const enabled = !!env.NOTION_CLIENT_ID && !!env.NOTION_CLIENT_SECRET;
|
||||
|
||||
if (enabled) {
|
||||
PluginManager.add([
|
||||
{
|
||||
...config,
|
||||
type: Hook.API,
|
||||
value: router,
|
||||
},
|
||||
{
|
||||
type: Hook.Processor,
|
||||
value: NotionImportsProcessor,
|
||||
},
|
||||
{
|
||||
type: Hook.Task,
|
||||
value: NotionAPIImportTask,
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import {
|
||||
APIErrorCode,
|
||||
APIResponseError,
|
||||
Client,
|
||||
isFullPage,
|
||||
isFullPageOrDatabase,
|
||||
isFullUser,
|
||||
} from "@notionhq/client";
|
||||
import {
|
||||
BlockObjectResponse,
|
||||
DatabaseObjectResponse,
|
||||
PageObjectResponse,
|
||||
RichTextItemResponse,
|
||||
} from "@notionhq/client/build/src/api-endpoints";
|
||||
import { RateLimit } from "async-sema";
|
||||
import compact from "lodash/compact";
|
||||
import { z } from "zod";
|
||||
import { Second } from "@shared/utils/time";
|
||||
import { NotionUtils } from "../shared/NotionUtils";
|
||||
import { Block, Page, PageType } from "../shared/types";
|
||||
import env from "./env";
|
||||
|
||||
type PageInfo = {
|
||||
title: string;
|
||||
emoji?: string;
|
||||
author?: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
};
|
||||
|
||||
const Credentials = Buffer.from(
|
||||
`${env.NOTION_CLIENT_ID}:${env.NOTION_CLIENT_SECRET}`
|
||||
).toString("base64");
|
||||
|
||||
const AccessTokenResponseSchema = z.object({
|
||||
access_token: z.string(),
|
||||
bot_id: z.string(),
|
||||
workspace_id: z.string(),
|
||||
workspace_name: z.string().nullish(),
|
||||
workspace_icon: z.string().url().nullish(),
|
||||
});
|
||||
|
||||
export class NotionClient {
|
||||
private client: Client;
|
||||
private limiter: ReturnType<typeof RateLimit>;
|
||||
private pageSize = 25;
|
||||
|
||||
constructor(
|
||||
accessToken: string,
|
||||
rateLimit: { window: number; limit: number } = {
|
||||
window: Second.ms,
|
||||
limit: 3,
|
||||
}
|
||||
) {
|
||||
this.client = new Client({
|
||||
auth: accessToken,
|
||||
});
|
||||
this.limiter = RateLimit(rateLimit.limit, {
|
||||
timeUnit: rateLimit.window,
|
||||
uniformDistribution: true,
|
||||
});
|
||||
}
|
||||
|
||||
static async oauthAccess(code: string) {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
Authorization: `Basic ${Credentials}`,
|
||||
};
|
||||
const body = {
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: NotionUtils.callbackUrl(),
|
||||
};
|
||||
|
||||
const res = await fetch(NotionUtils.tokenUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
return AccessTokenResponseSchema.parse(await res.json());
|
||||
}
|
||||
|
||||
async fetchRootPages() {
|
||||
const pages: Page[] = [];
|
||||
|
||||
let cursor: string | undefined;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
await this.limiter();
|
||||
|
||||
const response = await this.client.search({
|
||||
start_cursor: cursor,
|
||||
page_size: this.pageSize,
|
||||
});
|
||||
|
||||
response.results.forEach((item) => {
|
||||
if (!isFullPageOrDatabase(item)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.parent.type === "workspace") {
|
||||
pages.push({
|
||||
type: item.object === "page" ? PageType.Page : PageType.Database,
|
||||
id: item.id,
|
||||
name: this.parseTitle(item),
|
||||
emoji: this.parseEmoji(item),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
hasMore = response.has_more;
|
||||
cursor = response.next_cursor ?? undefined;
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
async fetchPage(pageId: string) {
|
||||
const pageInfo = await this.fetchPageInfo(pageId);
|
||||
const blocks = await this.fetchBlockChildren(pageId);
|
||||
return { ...pageInfo, blocks };
|
||||
}
|
||||
|
||||
async fetchDatabase(databaseId: string) {
|
||||
const databaseInfo = await this.fetchDatabaseInfo(databaseId);
|
||||
const pages = await this.queryDatabase(databaseId);
|
||||
return { ...databaseInfo, pages };
|
||||
}
|
||||
|
||||
private async fetchBlockChildren(blockId: string) {
|
||||
const blocks: Block[] = [];
|
||||
|
||||
let cursor: string | undefined;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
await this.limiter();
|
||||
|
||||
const response = await this.client.blocks.children.list({
|
||||
block_id: blockId,
|
||||
start_cursor: cursor,
|
||||
page_size: this.pageSize,
|
||||
});
|
||||
|
||||
blocks.push(...(response.results as BlockObjectResponse[]));
|
||||
|
||||
hasMore = response.has_more;
|
||||
cursor = response.next_cursor ?? undefined;
|
||||
}
|
||||
|
||||
// Recursive fetch when direct children have their own children.
|
||||
await Promise.all(
|
||||
blocks.map(async (block) => {
|
||||
if (
|
||||
block.has_children &&
|
||||
block.type !== "child_page" &&
|
||||
block.type !== "child_database"
|
||||
) {
|
||||
block.children = await this.fetchBlockChildren(block.id);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private async queryDatabase(databaseId: string) {
|
||||
const pages: Page[] = [];
|
||||
|
||||
let cursor: string | undefined;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
await this.limiter();
|
||||
|
||||
const response = await this.client.databases.query({
|
||||
database_id: databaseId,
|
||||
filter_properties: ["title"],
|
||||
start_cursor: cursor,
|
||||
page_size: this.pageSize,
|
||||
});
|
||||
|
||||
const pagesFromRes = compact(
|
||||
response.results.map<Page | undefined>((item) => {
|
||||
if (!isFullPage(item)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
type: PageType.Page,
|
||||
id: item.id,
|
||||
name: this.parseTitle(item),
|
||||
emoji: this.parseEmoji(item),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
pages.push(...pagesFromRes);
|
||||
|
||||
hasMore = response.has_more;
|
||||
cursor = response.next_cursor ?? undefined;
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
private async fetchPageInfo(pageId: string): Promise<PageInfo> {
|
||||
await this.limiter();
|
||||
const page = (await this.client.pages.retrieve({
|
||||
page_id: pageId,
|
||||
})) as PageObjectResponse;
|
||||
|
||||
const author = await this.fetchUsername(page.created_by.id);
|
||||
|
||||
return {
|
||||
title: this.parseTitle(page),
|
||||
emoji: this.parseEmoji(page),
|
||||
author: author ?? undefined,
|
||||
createdAt: !page.created_time ? undefined : new Date(page.created_time),
|
||||
updatedAt: !page.last_edited_time
|
||||
? undefined
|
||||
: new Date(page.last_edited_time),
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchDatabaseInfo(databaseId: string): Promise<PageInfo> {
|
||||
await this.limiter();
|
||||
const database = (await this.client.databases.retrieve({
|
||||
database_id: databaseId,
|
||||
})) as DatabaseObjectResponse;
|
||||
|
||||
const author = await this.fetchUsername(database.created_by.id);
|
||||
|
||||
return {
|
||||
title: this.parseTitle(database),
|
||||
emoji: this.parseEmoji(database),
|
||||
author: author ?? undefined,
|
||||
createdAt: !database.created_time
|
||||
? undefined
|
||||
: new Date(database.created_time),
|
||||
updatedAt: !database.last_edited_time
|
||||
? undefined
|
||||
: new Date(database.last_edited_time),
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchUsername(userId: string) {
|
||||
await this.limiter();
|
||||
try {
|
||||
const user = await this.client.users.retrieve({ user_id: userId });
|
||||
|
||||
if (user.type === "person" || !user.bot.owner) {
|
||||
return user.name;
|
||||
}
|
||||
|
||||
// bot belongs to a user, get the user's name.
|
||||
if (user.bot.owner.type === "user" && isFullUser(user.bot.owner.user)) {
|
||||
return user.bot.owner.user.name;
|
||||
}
|
||||
|
||||
// bot belongs to a workspace, fallback to bot's name.
|
||||
return user.name;
|
||||
} catch (error) {
|
||||
// Handle the case where a user can't be found
|
||||
if (
|
||||
error instanceof APIResponseError &&
|
||||
error.code === APIErrorCode.ObjectNotFound
|
||||
) {
|
||||
return "Unknown";
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private parseTitle(item: PageObjectResponse | DatabaseObjectResponse) {
|
||||
let richTexts: RichTextItemResponse[];
|
||||
|
||||
if (item.object === "page") {
|
||||
const titleProp = Object.values(item.properties).find(
|
||||
(property) => property.type === "title"
|
||||
);
|
||||
richTexts = titleProp?.title ?? [];
|
||||
} else {
|
||||
richTexts = item.title;
|
||||
}
|
||||
|
||||
return richTexts.map((richText) => richText.plain_text).join("");
|
||||
}
|
||||
|
||||
private parseEmoji(item: PageObjectResponse | DatabaseObjectResponse) {
|
||||
// Other icon types return a url to download from, which we don't support.
|
||||
return item.icon?.type === "emoji" ? item.icon.emoji : undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { NotionImportInput, NotionImportTaskInput } from "@shared/schema";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import { Import, ImportTask, Integration } from "@server/models";
|
||||
import ImportsProcessor from "@server/queues/processors/ImportsProcessor";
|
||||
import { NotionClient } from "../notion";
|
||||
import NotionAPIImportTask from "../tasks/NotionAPIImportTask";
|
||||
|
||||
export class NotionImportsProcessor extends ImportsProcessor<IntegrationService.Notion> {
|
||||
/**
|
||||
* Determine whether this is a "Notion" import.
|
||||
*
|
||||
* @param importModel Import model associated with the import.
|
||||
* @returns boolean.
|
||||
*/
|
||||
protected canProcess(
|
||||
importModel: Import<IntegrationService.Notion>
|
||||
): boolean {
|
||||
return importModel.service === IntegrationService.Notion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build task inputs which will be used for `NotionAPIImportTask`s.
|
||||
*
|
||||
* @param importInput Array of root externalId and associated info which were used to create the import.
|
||||
* @returns `NotionImportTaskInput`.
|
||||
*/
|
||||
protected async buildTasksInput(
|
||||
importModel: Import<IntegrationService.Notion>,
|
||||
transaction: Transaction
|
||||
): Promise<NotionImportTaskInput> {
|
||||
const integration = await Integration.scope("withAuthentication").findByPk(
|
||||
importModel.integrationId,
|
||||
{ rejectOnEmpty: true }
|
||||
);
|
||||
|
||||
const notion = new NotionClient(integration.authentication.token);
|
||||
|
||||
const rootPages = await notion.fetchRootPages();
|
||||
|
||||
// App will send the default permission in an array with single item.
|
||||
const defaultPermission = importModel.input[0].permission;
|
||||
|
||||
// TODO: This update can be deleted when we receive the page info + permission from app.
|
||||
const importInput: NotionImportInput = rootPages.map((page) => ({
|
||||
type: page.type,
|
||||
externalId: page.id,
|
||||
permission: defaultPermission,
|
||||
}));
|
||||
|
||||
importModel.input = importInput;
|
||||
await importModel.save({ transaction });
|
||||
|
||||
return rootPages.map((page) => ({
|
||||
type: page.type,
|
||||
externalId: page.id,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule the first `NotionAPIImportTask` for the import.
|
||||
*
|
||||
* @param importTask ImportTask model associated with the `NotionAPIImportTask`.
|
||||
* @returns Promise that resolves when the task is scheduled.
|
||||
*/
|
||||
protected async scheduleTask(
|
||||
importTask: ImportTask<IntegrationService.Notion>
|
||||
): Promise<void> {
|
||||
await NotionAPIImportTask.schedule({ importTaskId: importTask.id });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { ImportTaskInput, ImportTaskOutput } from "@shared/schema";
|
||||
import { IntegrationService, ProsemirrorDoc } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { Integration } from "@server/models";
|
||||
import ImportTask from "@server/models/ImportTask";
|
||||
import APIImportTask, {
|
||||
ProcessOutput,
|
||||
} from "@server/queues/tasks/APIImportTask";
|
||||
import { Block, PageType } from "../../shared/types";
|
||||
import { NotionClient } from "../notion";
|
||||
import { NotionConverter, NotionPage } from "../utils/NotionConverter";
|
||||
|
||||
type ChildPage = { type: PageType; externalId: string };
|
||||
|
||||
type ParsePageOutput = ImportTaskOutput[number] & {
|
||||
collectionExternalId?: string;
|
||||
children: ChildPage[];
|
||||
};
|
||||
|
||||
export default class NotionAPIImportTask extends APIImportTask<IntegrationService.Notion> {
|
||||
/**
|
||||
* Process the Notion import task.
|
||||
* This fetches data from Notion and converts it to task output.
|
||||
*
|
||||
* @param importTask ImportTask model to process.
|
||||
* @returns Promise with output that resolves once processing has completed.
|
||||
*/
|
||||
protected async process(
|
||||
importTask: ImportTask<IntegrationService.Notion>
|
||||
): Promise<ProcessOutput<IntegrationService.Notion>> {
|
||||
const integration = await Integration.scope("withAuthentication").findByPk(
|
||||
importTask.import.integrationId,
|
||||
{ rejectOnEmpty: true }
|
||||
);
|
||||
|
||||
const client = new NotionClient(integration.authentication.token);
|
||||
|
||||
const parsedPages = await Promise.all(
|
||||
importTask.input.map(async (item) => this.processPage({ item, client }))
|
||||
);
|
||||
|
||||
const taskOutput: ImportTaskOutput = parsedPages.map((parsedPage) => ({
|
||||
externalId: parsedPage.externalId,
|
||||
title: parsedPage.title,
|
||||
emoji: parsedPage.emoji,
|
||||
content: parsedPage.content,
|
||||
author: parsedPage.author,
|
||||
createdAt: parsedPage.createdAt,
|
||||
updatedAt: parsedPage.updatedAt,
|
||||
}));
|
||||
|
||||
const childTasksInput: ImportTaskInput<IntegrationService.Notion> =
|
||||
parsedPages.flatMap((parsedPage) =>
|
||||
parsedPage.children.map((childPage) => ({
|
||||
type: childPage.type,
|
||||
externalId: childPage.externalId,
|
||||
parentExternalId: parsedPage.externalId,
|
||||
collectionExternalId: parsedPage.collectionExternalId,
|
||||
}))
|
||||
);
|
||||
|
||||
return { taskOutput, childTasksInput };
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule the next `NotionAPIImportTask`.
|
||||
*
|
||||
* @param importTask ImportTask model associated with the `NotionAPIImportTask`.
|
||||
* @returns Promise that resolves when the task is scheduled.
|
||||
*/
|
||||
protected async scheduleNextTask(
|
||||
importTask: ImportTask<IntegrationService.Notion>
|
||||
) {
|
||||
await NotionAPIImportTask.schedule({ importTaskId: importTask.id });
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch page data from Notion and convert it to expected output.
|
||||
*
|
||||
* @param item Object containing data about a notion page (or) database.
|
||||
* @param client Notion client.
|
||||
* @returns Promise of parsed page output that resolves when the task is scheduled.
|
||||
*/
|
||||
private async processPage({
|
||||
item,
|
||||
client,
|
||||
}: {
|
||||
item: ImportTaskInput<IntegrationService.Notion>[number];
|
||||
client: NotionClient;
|
||||
}): Promise<ParsePageOutput> {
|
||||
const collectionExternalId = item.collectionExternalId ?? item.externalId;
|
||||
|
||||
// Convert Notion database to an empty page with "pages in database" as its children.
|
||||
if (item.type === PageType.Database) {
|
||||
const { pages, ...databaseInfo } = await client.fetchDatabase(
|
||||
item.externalId
|
||||
);
|
||||
|
||||
return {
|
||||
...databaseInfo,
|
||||
externalId: item.externalId,
|
||||
content: ProsemirrorHelper.getEmptyDocument() as ProsemirrorDoc,
|
||||
collectionExternalId,
|
||||
children: pages.map((page) => ({
|
||||
type: page.type,
|
||||
externalId: page.id,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const { blocks, ...pageInfo } = await client.fetchPage(item.externalId);
|
||||
|
||||
return {
|
||||
...pageInfo,
|
||||
externalId: item.externalId,
|
||||
content: NotionConverter.page({ children: blocks } as NotionPage),
|
||||
collectionExternalId,
|
||||
children: this.parseChildPages(blocks),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Notion page blocks to get its child pages and databases.
|
||||
*
|
||||
* @param pageBlocks Array of blocks representing the page's content.
|
||||
* @returns Array containing child page and child database info.
|
||||
*/
|
||||
private parseChildPages(pageBlocks: Block[]): ChildPage[] {
|
||||
const childPages: ChildPage[] = [];
|
||||
|
||||
pageBlocks.forEach((block) => {
|
||||
if (block.type === "child_page") {
|
||||
childPages.push({ type: PageType.Page, externalId: block.id });
|
||||
} else if (block.type === "child_database") {
|
||||
childPages.push({ type: PageType.Database, externalId: block.id });
|
||||
} else if (block.children?.length) {
|
||||
childPages.push(...this.parseChildPages(block.children));
|
||||
}
|
||||
});
|
||||
|
||||
return childPages;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Node } from "prosemirror-model";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import nodesWithEmptyTextNode from "@server/test/fixtures/notion-page-with-empty-text-nodes.json";
|
||||
import allNodes from "@server/test/fixtures/notion-page.json";
|
||||
import { NotionConverter, NotionPage } from "./NotionConverter";
|
||||
|
||||
describe("NotionConverter", () => {
|
||||
it("converts a page", () => {
|
||||
const response = NotionConverter.page({
|
||||
children: allNodes,
|
||||
} as NotionPage);
|
||||
|
||||
expect(response).toMatchSnapshot();
|
||||
expect(ProsemirrorHelper.toProsemirror(response)).toBeInstanceOf(Node);
|
||||
});
|
||||
|
||||
it("converts a page with empty text nodes", () => {
|
||||
const response = NotionConverter.page({
|
||||
children: nodesWithEmptyTextNode,
|
||||
} as NotionPage);
|
||||
|
||||
expect(response).toMatchSnapshot();
|
||||
expect(ProsemirrorHelper.toProsemirror(response)).toBeInstanceOf(Node);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,587 @@
|
||||
import type {
|
||||
BookmarkBlockObjectResponse,
|
||||
BreadcrumbBlockObjectResponse,
|
||||
BulletedListItemBlockObjectResponse,
|
||||
DividerBlockObjectResponse,
|
||||
Heading1BlockObjectResponse,
|
||||
Heading2BlockObjectResponse,
|
||||
Heading3BlockObjectResponse,
|
||||
NumberedListItemBlockObjectResponse,
|
||||
ParagraphBlockObjectResponse,
|
||||
QuoteBlockObjectResponse,
|
||||
RichTextItemResponse,
|
||||
FileBlockObjectResponse,
|
||||
PdfBlockObjectResponse,
|
||||
ImageBlockObjectResponse,
|
||||
EmbedBlockObjectResponse,
|
||||
TableBlockObjectResponse,
|
||||
ToDoBlockObjectResponse,
|
||||
EquationBlockObjectResponse,
|
||||
CodeBlockObjectResponse,
|
||||
ToggleBlockObjectResponse,
|
||||
PageObjectResponse,
|
||||
VideoBlockObjectResponse,
|
||||
CalloutBlockObjectResponse,
|
||||
ColumnListBlockObjectResponse,
|
||||
ColumnBlockObjectResponse,
|
||||
LinkPreviewBlockObjectResponse,
|
||||
SyncedBlockBlockObjectResponse,
|
||||
LinkToPageBlockObjectResponse,
|
||||
} from "@notionhq/client/build/src/api-endpoints";
|
||||
import isArray from "lodash/isArray";
|
||||
import { NoticeTypes } from "@shared/editor/nodes/Notice";
|
||||
import { MentionType, ProsemirrorData, ProsemirrorDoc } from "@shared/types";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Block } from "../../shared/types";
|
||||
|
||||
export type NotionPage = PageObjectResponse & {
|
||||
children: Block[];
|
||||
};
|
||||
|
||||
/** Convert Notion blocks to Outline data. */
|
||||
export class NotionConverter {
|
||||
/**
|
||||
* Nodes which cannot contain block children in Outline, their children
|
||||
* will be flattened into the parent.
|
||||
*/
|
||||
private static nodesWithoutBlockChildren = ["paragraph", "toggle"];
|
||||
|
||||
public static page(item: NotionPage): ProsemirrorDoc {
|
||||
return {
|
||||
type: "doc",
|
||||
content: this.mapChildren(item),
|
||||
};
|
||||
}
|
||||
|
||||
private static mapChildren(item: Block | NotionPage) {
|
||||
const mapChild = (
|
||||
child: Block
|
||||
): ProsemirrorData | ProsemirrorData[] | undefined => {
|
||||
if (child.type === "child_page" || child.type === "child_database") {
|
||||
return; // this will be created as a nested page, no need to handle/convert.
|
||||
}
|
||||
|
||||
// @ts-expect-error Not all blocks have an interface
|
||||
if (this[child.type]) {
|
||||
// @ts-expect-error Not all blocks have an interface
|
||||
const response = this[child.type](child);
|
||||
if (
|
||||
response &&
|
||||
this.nodesWithoutBlockChildren.includes(response.type) &&
|
||||
"children" in child
|
||||
) {
|
||||
return [response, ...this.mapChildren(child)];
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
Logger.warn("Encountered unknown Notion block", child);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
let wrappingList;
|
||||
const children = [] as ProsemirrorData[];
|
||||
|
||||
if (!item.children) {
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const child of item.children) {
|
||||
const mapped = mapChild(child);
|
||||
if (!mapped) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure lists are wrapped correctly – we require a wrapping element
|
||||
// whereas Notion does not
|
||||
// TODO: Handle mixed list
|
||||
if (child.type === "numbered_list_item") {
|
||||
if (!wrappingList) {
|
||||
wrappingList = {
|
||||
type: "ordered_list",
|
||||
content: [] as ProsemirrorData[],
|
||||
};
|
||||
}
|
||||
|
||||
wrappingList.content.push(...(isArray(mapped) ? mapped : [mapped]));
|
||||
continue;
|
||||
}
|
||||
if (child.type === "bulleted_list_item") {
|
||||
if (!wrappingList) {
|
||||
wrappingList = {
|
||||
type: "bullet_list",
|
||||
content: [] as ProsemirrorData[],
|
||||
};
|
||||
}
|
||||
|
||||
wrappingList.content.push(...(isArray(mapped) ? mapped : [mapped]));
|
||||
continue;
|
||||
}
|
||||
if (child.type === "to_do") {
|
||||
if (!wrappingList) {
|
||||
wrappingList = {
|
||||
type: "checkbox_list",
|
||||
content: [] as ProsemirrorData[],
|
||||
};
|
||||
}
|
||||
|
||||
wrappingList.content.push(...(isArray(mapped) ? mapped : [mapped]));
|
||||
continue;
|
||||
}
|
||||
if (wrappingList) {
|
||||
children.push(wrappingList);
|
||||
wrappingList = undefined;
|
||||
}
|
||||
children.push(...(isArray(mapped) ? mapped : [mapped]));
|
||||
}
|
||||
|
||||
if (wrappingList) {
|
||||
children.push(wrappingList);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
private static callout(item: Block<CalloutBlockObjectResponse>) {
|
||||
const colorToNoticeType: Record<string, NoticeTypes> = {
|
||||
default_background: NoticeTypes.Info,
|
||||
blue_background: NoticeTypes.Info,
|
||||
purple_background: NoticeTypes.Info,
|
||||
green_background: NoticeTypes.Success,
|
||||
orange_background: NoticeTypes.Tip,
|
||||
yellow_background: NoticeTypes.Tip,
|
||||
pink_background: NoticeTypes.Warning,
|
||||
red_background: NoticeTypes.Warning,
|
||||
};
|
||||
|
||||
return {
|
||||
type: "container_notice",
|
||||
attrs: {
|
||||
style:
|
||||
colorToNoticeType[item.callout.color as string] ?? NoticeTypes.Info,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: item.callout.rich_text.map(this.rich_text).filter(Boolean),
|
||||
},
|
||||
...this.mapChildren(item),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static column_list(item: Block<ColumnListBlockObjectResponse>) {
|
||||
return this.mapChildren(item);
|
||||
}
|
||||
|
||||
private static column(item: Block<ColumnBlockObjectResponse>) {
|
||||
return this.mapChildren(item);
|
||||
}
|
||||
|
||||
private static bookmark(item: BookmarkBlockObjectResponse) {
|
||||
const caption = item.bookmark.caption
|
||||
.map(this.rich_text_to_plaintext)
|
||||
.join("");
|
||||
|
||||
return {
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: caption || item.bookmark.url,
|
||||
type: "text",
|
||||
marks: [
|
||||
{
|
||||
type: "link",
|
||||
attrs: {
|
||||
href: item.bookmark.url,
|
||||
title: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static breadcrumb(_: BreadcrumbBlockObjectResponse) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static bulleted_list_item(
|
||||
item: Block<BulletedListItemBlockObjectResponse>
|
||||
) {
|
||||
return {
|
||||
type: "list_item",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: item.bulleted_list_item.rich_text
|
||||
.map(this.rich_text)
|
||||
.filter(Boolean),
|
||||
},
|
||||
...this.mapChildren(item),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static code(item: CodeBlockObjectResponse) {
|
||||
const text = item.code.rich_text.map(this.rich_text_to_plaintext).join("");
|
||||
|
||||
return {
|
||||
type: "code_fence",
|
||||
attrs: {
|
||||
language: item.code.language,
|
||||
},
|
||||
content: text ? [{ type: "text", text }] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private static numbered_list_item(
|
||||
item: Block<NumberedListItemBlockObjectResponse>
|
||||
) {
|
||||
return {
|
||||
type: "list_item",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: item.numbered_list_item.rich_text
|
||||
.map(this.rich_text)
|
||||
.filter(Boolean),
|
||||
},
|
||||
...this.mapChildren(item),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static rich_text(item: RichTextItemResponse) {
|
||||
const annotationToMark: Record<
|
||||
keyof RichTextItemResponse["annotations"],
|
||||
string
|
||||
> = {
|
||||
bold: "strong",
|
||||
code: "code_inline",
|
||||
italic: "em",
|
||||
underline: "underline",
|
||||
strikethrough: "strikethrough",
|
||||
color: "highlight",
|
||||
};
|
||||
|
||||
const mapAttrs = () =>
|
||||
Object.entries(item.annotations)
|
||||
.filter(([key]) => key !== "color")
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([key]) => ({
|
||||
type: annotationToMark[key as keyof typeof annotationToMark],
|
||||
}));
|
||||
|
||||
if (item.type === "mention") {
|
||||
if (item.mention.type === "page") {
|
||||
return {
|
||||
type: "mention",
|
||||
attrs: {
|
||||
type: MentionType.Document,
|
||||
label: item.plain_text,
|
||||
modelId: item.mention.page.id,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (item.mention.type === "link_mention") {
|
||||
return {
|
||||
type: "text",
|
||||
text: item.plain_text,
|
||||
marks: [
|
||||
{
|
||||
type: "link",
|
||||
attrs: {
|
||||
href: item.mention.link_mention.href,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (item.mention.type === "link_preview") {
|
||||
return {
|
||||
type: "text",
|
||||
text: item.plain_text,
|
||||
marks: [
|
||||
{
|
||||
type: "link",
|
||||
attrs: {
|
||||
href: item.mention.link_preview.url,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (!item.plain_text) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "text",
|
||||
text: item.plain_text,
|
||||
};
|
||||
}
|
||||
|
||||
if (item.type === "equation") {
|
||||
return {
|
||||
type: "math_inline",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: item.equation.expression,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (!item.text.content) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "text",
|
||||
text: item.text.content,
|
||||
marks: [
|
||||
...mapAttrs(),
|
||||
...(item.text.link
|
||||
? [{ type: "link", attrs: { href: item.text.link.url } }]
|
||||
: []),
|
||||
].filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
private static rich_text_to_plaintext(item: RichTextItemResponse) {
|
||||
return item.plain_text;
|
||||
}
|
||||
|
||||
private static divider(_: DividerBlockObjectResponse) {
|
||||
return {
|
||||
type: "hr",
|
||||
};
|
||||
}
|
||||
|
||||
private static equation(item: EquationBlockObjectResponse) {
|
||||
return {
|
||||
type: "math_block",
|
||||
content: item.equation.expression
|
||||
? [
|
||||
{
|
||||
type: "text",
|
||||
text: item.equation.expression,
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private static embed(item: EmbedBlockObjectResponse) {
|
||||
return {
|
||||
type: "embed",
|
||||
attrs: {
|
||||
href: item.embed.url,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static file(item: FileBlockObjectResponse) {
|
||||
return {
|
||||
type: "attachment",
|
||||
attrs: {
|
||||
href: "file" in item.file ? item.file.file.url : item.file.external.url,
|
||||
title: item.file.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static pdf(item: PdfBlockObjectResponse) {
|
||||
return {
|
||||
type: "attachment",
|
||||
attrs: {
|
||||
href: "file" in item.pdf ? item.pdf.file.url : item.pdf.external.url,
|
||||
title: item.pdf.caption.map(this.rich_text_to_plaintext).join(""),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static heading_1(item: Heading1BlockObjectResponse) {
|
||||
return {
|
||||
type: "heading",
|
||||
attrs: {
|
||||
level: 1,
|
||||
},
|
||||
content: item.heading_1.rich_text.map(this.rich_text).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
private static heading_2(item: Heading2BlockObjectResponse) {
|
||||
return {
|
||||
type: "heading",
|
||||
attrs: {
|
||||
level: 2,
|
||||
},
|
||||
content: item.heading_2.rich_text.map(this.rich_text).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
private static heading_3(item: Heading3BlockObjectResponse) {
|
||||
return {
|
||||
type: "heading",
|
||||
attrs: {
|
||||
level: 3,
|
||||
},
|
||||
content: item.heading_3.rich_text.map(this.rich_text).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
private static image(item: ImageBlockObjectResponse) {
|
||||
return {
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "image",
|
||||
attrs: {
|
||||
src:
|
||||
"file" in item.image
|
||||
? item.image.file.url
|
||||
: item.image.external.url,
|
||||
alt: item.image.caption.map(this.rich_text_to_plaintext).join(""),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static link_preview(item: LinkPreviewBlockObjectResponse) {
|
||||
return {
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: item.link_preview.url,
|
||||
marks: [
|
||||
{
|
||||
type: "link",
|
||||
attrs: {
|
||||
href: item.link_preview.url,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static link_to_page(item: LinkToPageBlockObjectResponse) {
|
||||
if (item.link_to_page.type !== "page_id") {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
type: "mention",
|
||||
attrs: {
|
||||
modelId: item.link_to_page.page_id,
|
||||
type: MentionType.Document,
|
||||
label: "Page",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static paragraph(item: ParagraphBlockObjectResponse) {
|
||||
return {
|
||||
type: "paragraph",
|
||||
content: item.paragraph.rich_text.map(this.rich_text).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
private static quote(item: Block<QuoteBlockObjectResponse>) {
|
||||
return {
|
||||
type: "blockquote",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: item.quote.rich_text.map(this.rich_text).filter(Boolean),
|
||||
},
|
||||
...this.mapChildren(item),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static synced_block(item: Block<SyncedBlockBlockObjectResponse>) {
|
||||
return this.mapChildren(item);
|
||||
}
|
||||
|
||||
private static table(
|
||||
item: TableBlockObjectResponse & {
|
||||
children: Array<{
|
||||
table_row: {
|
||||
cells: Array<Array<RichTextItemResponse>>;
|
||||
};
|
||||
type?: "table_row";
|
||||
object?: "block";
|
||||
}>;
|
||||
}
|
||||
) {
|
||||
return {
|
||||
type: "table",
|
||||
content: item.children.map((tr, y) => ({
|
||||
type: "tr",
|
||||
content: tr.table_row.cells.map((td, x) => ({
|
||||
type:
|
||||
(item.table.has_row_header && y === 0) ||
|
||||
(item.table.has_column_header && x === 0)
|
||||
? "th"
|
||||
: "td",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: td.map(this.rich_text).filter(Boolean),
|
||||
},
|
||||
],
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private static toggle(item: ToggleBlockObjectResponse) {
|
||||
return {
|
||||
type: "paragraph",
|
||||
content: item.toggle.rich_text.map(this.rich_text).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
private static to_do(item: Block<ToDoBlockObjectResponse>) {
|
||||
return {
|
||||
type: "checkbox_item",
|
||||
attrs: {
|
||||
checked: item.to_do.checked,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: item.to_do.rich_text.map(this.rich_text).filter(Boolean),
|
||||
},
|
||||
...this.mapChildren(item),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static video(item: VideoBlockObjectResponse) {
|
||||
if (item.video.type === "file") {
|
||||
return {
|
||||
type: "video",
|
||||
attrs: {
|
||||
src: item.video.file.url,
|
||||
title: item.video.caption.map(this.rich_text_to_plaintext).join(""),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "embed",
|
||||
attrs: {
|
||||
href: item.video.external.url,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user