mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 682fbeb10a | |||
| 05c1bee412 | |||
| 3d0160463c | |||
| 391f72aeb4 | |||
| 1b95838a16 | |||
| fc8a491133 | |||
| b219e42cc8 | |||
| f4e2c2de77 | |||
| a9f1086422 | |||
| 379d2cb788 | |||
| eb1882eb96 | |||
| 6318714aee | |||
| 9415a35795 | |||
| da9ea9f82c | |||
| e733fd27e4 | |||
| 63cfa6e25a | |||
| f8a9c18650 | |||
| f35676f347 | |||
| bf130f9915 | |||
| dfe36fcbf5 | |||
| e1c44ba1a8 | |||
| e69c0e62fa | |||
| fd17364ebf | |||
| 23c8adc5d1 | |||
| 20b1766e8d | |||
| 076d564aa3 | |||
| 5b866a7451 | |||
| 4ef3615516 | |||
| b907d1887a | |||
| 8a4555f565 | |||
| df3cd22aee | |||
| 0bf66cc560 | |||
| b769da2626 | |||
| 7bf5c4e533 | |||
| 1ad7c7409a | |||
| 428908b2df | |||
| 1df1b0c110 | |||
| 9d95c673d1 | |||
| 203cd3c2a3 | |||
| f663c5a7ef | |||
| be0a0f4e40 | |||
| f439293a7b | |||
| 2e466aefc3 | |||
| ed59b3e350 | |||
| 808415b906 | |||
| 2f495f0add |
@@ -126,7 +126,7 @@ jobs:
|
||||
docker buildx install
|
||||
docker context create docker-multiarch
|
||||
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
|
||||
docker buildx create --name docker-multiarch --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x docker-multiarch
|
||||
docker buildx create --name docker-multiarch --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x docker-multiarch
|
||||
docker buildx inspect --builder docker-multiarch --bootstrap
|
||||
docker buildx use docker-multiarch
|
||||
- run:
|
||||
@@ -142,9 +142,9 @@ jobs:
|
||||
name: Build and push Docker image
|
||||
command: |
|
||||
if [[ "$CIRCLE_TAG" == *"-"* ]]; then
|
||||
docker buildx build -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
|
||||
docker buildx build -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
|
||||
else
|
||||
docker buildx build -t $IMAGE_NAME:latest -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
|
||||
docker buildx build -t $IMAGE_NAME:latest -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
|
||||
fi
|
||||
|
||||
workflows:
|
||||
|
||||
+20
@@ -127,6 +127,26 @@ GITHUB_APP_NAME=
|
||||
GITHUB_APP_ID=
|
||||
GITHUB_APP_PRIVATE_KEY=
|
||||
|
||||
# To configure Discord auth, you'll need to create a Discord Application at
|
||||
# => https://discord.com/developers/applications/
|
||||
#
|
||||
# When configuring the Client ID, add a redirect URL under "OAuth2":
|
||||
# https://<URL>/api/discord.callback
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_CLIENT_SECRET=
|
||||
|
||||
# DISCORD_SERVER_ID should be the ID of the Discord server that Outline is
|
||||
# integrated with.
|
||||
# Used to verify that the user is a member of the server as well as server
|
||||
# metadata such as nicknames, server icon and name.
|
||||
DISCORD_SERVER_ID=
|
||||
|
||||
# DISCORD_SERVER_ROLES should be a comma separated list of role IDs that are
|
||||
# allowed to access Outline. If this is not set, all members of the server
|
||||
# will be allowed to access Outline.
|
||||
# DISCORD_SERVER_ID and DISCORD_SERVER_ROLES must be set together.
|
||||
DISCORD_SERVER_ROLES=
|
||||
|
||||
# –––––––––––––––– OPTIONAL ––––––––––––––––
|
||||
|
||||
# Base64 encoded private key and certificate for HTTPS termination. This is only
|
||||
|
||||
+4
-5
@@ -5,9 +5,7 @@ ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
|
||||
# ---
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
RUN apk update && apk add --no-cache curl && apk add --no-cache ca-certificates
|
||||
FROM node:20-slim AS runner
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
|
||||
|
||||
@@ -22,8 +20,9 @@ COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
|
||||
COPY --from=base $APP_PATH/node_modules ./node_modules
|
||||
COPY --from=base $APP_PATH/package.json ./package.json
|
||||
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nodejs -u 1001 && \
|
||||
# Create a non-root user compatible with Debian and BusyBox based images
|
||||
RUN addgroup --gid 1001 nodejs && \
|
||||
adduser --uid 1001 --ingroup nodejs nodejs && \
|
||||
chown -R nodejs:nodejs $APP_PATH/build && \
|
||||
mkdir -p /var/lib/outline && \
|
||||
chown -R nodejs:nodejs /var/lib/outline
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
ARG APP_PATH=/opt/outline
|
||||
FROM node:20-alpine AS deps
|
||||
FROM node:20-slim AS deps
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
/* global ga */
|
||||
import escape from "lodash/escape";
|
||||
import * as React from "react";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import { IntegrationService, PublicEnv } from "@shared/types";
|
||||
import env from "~/env";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
// TODO: Refactor this component to allow injection from plugins
|
||||
const Analytics: React.FC = ({ children }: Props) => {
|
||||
// Google Analytics 3
|
||||
React.useEffect(() => {
|
||||
@@ -43,12 +44,16 @@ const Analytics: React.FC = ({ children }: Props) => {
|
||||
React.useEffect(() => {
|
||||
const measurementIds = [];
|
||||
|
||||
if (env.analytics.service === IntegrationService.GoogleAnalytics) {
|
||||
measurementIds.push(escape(env.analytics.settings?.measurementId));
|
||||
}
|
||||
if (env.GOOGLE_ANALYTICS_ID?.startsWith("G-")) {
|
||||
measurementIds.push(env.GOOGLE_ANALYTICS_ID);
|
||||
}
|
||||
|
||||
(env.analytics as PublicEnv["analytics"]).forEach((integration) => {
|
||||
if (integration.service === IntegrationService.GoogleAnalytics) {
|
||||
measurementIds.push(escape(integration.settings?.measurementId));
|
||||
}
|
||||
});
|
||||
|
||||
if (measurementIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -75,6 +80,32 @@ const Analytics: React.FC = ({ children }: Props) => {
|
||||
document.getElementsByTagName("head")[0]?.appendChild(script);
|
||||
}, []);
|
||||
|
||||
// Matomo
|
||||
React.useEffect(() => {
|
||||
(env.analytics as PublicEnv["analytics"]).forEach((integration) => {
|
||||
if (integration.service !== IntegrationService.Matomo) {
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-expect-error - Matomo global variable
|
||||
const _paq = (window._paq = window._paq || []);
|
||||
_paq.push(["trackPageView"]);
|
||||
_paq.push(["enableLinkTracking"]);
|
||||
(function () {
|
||||
const u = integration.settings?.instanceUrl;
|
||||
_paq.push(["setTrackerUrl", u + "matomo.php"]);
|
||||
_paq.push(["setSiteId", integration.settings?.measurementId]);
|
||||
const d = document,
|
||||
g = d.createElement("script"),
|
||||
s = d.getElementsByTagName("script")[0];
|
||||
g.type = "text/javascript";
|
||||
g.async = true;
|
||||
g.src = u + "matomo.js";
|
||||
s.parentNode?.insertBefore(g, s);
|
||||
})();
|
||||
});
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { RovingTabIndexProvider } from "@getoutline/react-roving-tabindex";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
useCompositeState,
|
||||
Composite,
|
||||
CompositeStateReturn,
|
||||
} from "reakit/Composite";
|
||||
|
||||
type Props = React.HTMLAttributes<HTMLDivElement> & {
|
||||
children: (composite: CompositeStateReturn) => React.ReactNode;
|
||||
children: () => React.ReactNode;
|
||||
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
};
|
||||
|
||||
@@ -15,40 +11,36 @@ function ArrowKeyNavigation(
|
||||
{ children, onEscape, ...rest }: Props,
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const composite = useCompositeState();
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev) => {
|
||||
(ev: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (onEscape) {
|
||||
if (ev.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "Escape") {
|
||||
ev.preventDefault();
|
||||
onEscape(ev);
|
||||
}
|
||||
|
||||
if (
|
||||
ev.key === "ArrowUp" &&
|
||||
composite.currentId === composite.items[0].id
|
||||
// If the first item is focused and the user presses ArrowUp
|
||||
ev.currentTarget.firstElementChild === document.activeElement
|
||||
) {
|
||||
onEscape(ev);
|
||||
}
|
||||
}
|
||||
},
|
||||
[composite.currentId, composite.items, onEscape]
|
||||
[onEscape]
|
||||
);
|
||||
|
||||
return (
|
||||
<Composite
|
||||
{...rest}
|
||||
{...composite}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="menu"
|
||||
ref={ref}
|
||||
>
|
||||
{children(composite)}
|
||||
</Composite>
|
||||
<RovingTabIndexProvider options={{ focusOnClick: true, direction: "both" }}>
|
||||
<div {...rest} onKeyDown={handleKeyDown} ref={ref}>
|
||||
{children()}
|
||||
</div>
|
||||
</RovingTabIndexProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import {
|
||||
useFocusEffect,
|
||||
useRovingTabIndex,
|
||||
} from "@getoutline/react-roving-tabindex";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { CompositeStateReturn, CompositeItem } from "reakit/Composite";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { s } from "@shared/styles";
|
||||
import Document from "~/models/Document";
|
||||
import Badge from "~/components/Badge";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import EventBoundary from "~/components/EventBoundary";
|
||||
import Flex from "~/components/Flex";
|
||||
import Highlight from "~/components/Highlight";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
@@ -32,7 +35,7 @@ type Props = {
|
||||
showPin?: boolean;
|
||||
showDraft?: boolean;
|
||||
showTemplate?: boolean;
|
||||
} & CompositeStateReturn;
|
||||
};
|
||||
|
||||
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
||||
|
||||
@@ -49,6 +52,15 @@ function DocumentListItem(
|
||||
const user = useCurrentUser();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
|
||||
let itemRef: React.Ref<HTMLAnchorElement> =
|
||||
React.useRef<HTMLAnchorElement>(null);
|
||||
if (ref) {
|
||||
itemRef = ref;
|
||||
}
|
||||
|
||||
const { focused, ...rovingTabIndex } = useRovingTabIndex(itemRef, false);
|
||||
useFocusEffect(focused, itemRef);
|
||||
|
||||
const {
|
||||
document,
|
||||
showParentDocuments,
|
||||
@@ -68,9 +80,8 @@ function DocumentListItem(
|
||||
!document.isDraft && !document.isArchived && !document.isTemplate;
|
||||
|
||||
return (
|
||||
<CompositeItem
|
||||
as={DocumentLink}
|
||||
ref={ref}
|
||||
<DocumentLink
|
||||
ref={itemRef}
|
||||
dir={document.dir}
|
||||
role="menuitem"
|
||||
$isStarred={document.isStarred}
|
||||
@@ -82,6 +93,7 @@ function DocumentListItem(
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
{...rovingTabIndex}
|
||||
>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
@@ -142,7 +154,7 @@ function DocumentListItem(
|
||||
modal={false}
|
||||
/>
|
||||
</Actions>
|
||||
</CompositeItem>
|
||||
</DocumentLink>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,16 +11,12 @@ import {
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { CompositeStateReturn } from "reakit/Composite";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Document from "~/models/Document";
|
||||
import Event from "~/models/Event";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import CompositeItem, {
|
||||
Props as ItemProps,
|
||||
} from "~/components/List/CompositeItem";
|
||||
import Item, { Actions } from "~/components/List/Item";
|
||||
import Item, { Actions, Props as ItemProps } from "~/components/List/Item";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import RevisionMenu from "~/menus/RevisionMenu";
|
||||
@@ -32,7 +28,7 @@ type Props = {
|
||||
document: Document;
|
||||
event: Event;
|
||||
latest?: boolean;
|
||||
} & CompositeStateReturn;
|
||||
};
|
||||
|
||||
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -176,11 +172,7 @@ const BaseItem = React.forwardRef(function _BaseItem(
|
||||
{ to, ...rest }: ItemProps,
|
||||
ref?: React.Ref<HTMLAnchorElement>
|
||||
) {
|
||||
if (to) {
|
||||
return <CompositeListItem to={to} ref={ref} {...rest} />;
|
||||
}
|
||||
|
||||
return <ListItem ref={ref} {...rest} />;
|
||||
return <ListItem to={to} ref={ref} {...rest} />;
|
||||
});
|
||||
|
||||
const Subtitle = styled.span`
|
||||
@@ -240,8 +232,4 @@ const ListItem = styled(Item)`
|
||||
${ItemStyle}
|
||||
`;
|
||||
|
||||
const CompositeListItem = styled(CompositeItem)`
|
||||
${ItemStyle}
|
||||
`;
|
||||
|
||||
export default observer(EventListItem);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
/** The size of the icon, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
/** The color of the icon, defaults to the current text color */
|
||||
color?: string;
|
||||
/** If true, the icon will retain its color in selected menus and other places that attempt to override it */
|
||||
retainColor?: boolean;
|
||||
};
|
||||
|
||||
export default function CircleIcon({
|
||||
size = 24,
|
||||
color = "currentColor",
|
||||
retainColor,
|
||||
...rest
|
||||
}: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={color}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
version="1.1"
|
||||
style={retainColor ? { fill: color } : undefined}
|
||||
{...rest}
|
||||
>
|
||||
<circle xmlns="http://www.w3.org/2000/svg" cx="12" cy="12" r="8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -23,15 +23,16 @@ function InputSelectPermission(
|
||||
ref={ref}
|
||||
label={t("Permission")}
|
||||
options={[
|
||||
{
|
||||
label: t("Can edit"),
|
||||
value: CollectionPermission.ReadWrite,
|
||||
},
|
||||
{
|
||||
label: t("View only"),
|
||||
value: CollectionPermission.Read,
|
||||
},
|
||||
{
|
||||
label: t("Can edit"),
|
||||
value: CollectionPermission.ReadWrite,
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
label: t("No access"),
|
||||
value: EmptySelectValue,
|
||||
},
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
CompositeStateReturn,
|
||||
CompositeItem as BaseCompositeItem,
|
||||
} from "reakit/Composite";
|
||||
import Item, { Props as ItemProps } from "./Item";
|
||||
|
||||
export type Props = ItemProps & CompositeStateReturn;
|
||||
|
||||
function CompositeItem(
|
||||
{ to, ...rest }: Props,
|
||||
ref?: React.Ref<HTMLAnchorElement>
|
||||
) {
|
||||
return <BaseCompositeItem as={Item} to={to} {...rest} ref={ref} />;
|
||||
}
|
||||
|
||||
export default React.forwardRef(CompositeItem);
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
useFocusEffect,
|
||||
useRovingTabIndex,
|
||||
} from "@getoutline/react-roving-tabindex";
|
||||
import { LocationDescriptor } from "history";
|
||||
import * as React from "react";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
@@ -33,6 +37,18 @@ const ListItem = (
|
||||
const theme = useTheme();
|
||||
const compact = !subtitle;
|
||||
|
||||
let itemRef: React.Ref<HTMLAnchorElement> =
|
||||
React.useRef<HTMLAnchorElement>(null);
|
||||
if (ref) {
|
||||
itemRef = ref;
|
||||
}
|
||||
|
||||
const { focused, ...rovingTabIndex } = useRovingTabIndex(
|
||||
itemRef as React.RefObject<HTMLAnchorElement>,
|
||||
to ? false : true
|
||||
);
|
||||
useFocusEffect(focused, itemRef as React.RefObject<HTMLAnchorElement>);
|
||||
|
||||
const content = (selected: boolean) => (
|
||||
<>
|
||||
{image && <Image>{image}</Image>}
|
||||
@@ -59,13 +75,20 @@ const ListItem = (
|
||||
if (to) {
|
||||
return (
|
||||
<Wrapper
|
||||
ref={ref}
|
||||
ref={itemRef}
|
||||
$border={border}
|
||||
$small={small}
|
||||
activeStyle={{
|
||||
background: theme.accent,
|
||||
}}
|
||||
{...rest}
|
||||
{...rovingTabIndex}
|
||||
onClick={(ev) => {
|
||||
if (rest.onClick) {
|
||||
rest.onClick(ev);
|
||||
}
|
||||
rovingTabIndex.onClick(ev);
|
||||
}}
|
||||
as={NavLink}
|
||||
to={to}
|
||||
>
|
||||
@@ -75,7 +98,7 @@ const ListItem = (
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper ref={ref} $border={border} $small={small} {...rest}>
|
||||
<Wrapper ref={itemRef} $border={border} $small={small} {...rest}>
|
||||
{content(false)}
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const MobileWrapper = styled.div`
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
`;
|
||||
|
||||
const MobileScrollWrapper = ({ children }: Props) => {
|
||||
const isMobile = useMobile();
|
||||
const isPrinting = useMediaQuery("print");
|
||||
|
||||
return isMobile && !isPrinting ? (
|
||||
<MobileWrapper>{children}</MobileWrapper>
|
||||
) : (
|
||||
<>{children}</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileScrollWrapper;
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import ScrollContext from "./ScrollContext";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const MobileWrapper = styled.div`
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
`;
|
||||
|
||||
/**
|
||||
* A component that wraps its children in a scrollable container on mobile devices.
|
||||
* This allows us to place a fixed toolbar at the bottom of the page in the document
|
||||
* editor, which would otherwise be obscured by the on-screen keyboard.
|
||||
*
|
||||
* On desktop devices, the children are rendered directly without any wrapping.
|
||||
*/
|
||||
const PageScroll = ({ children }: Props) => {
|
||||
const isMobile = useMobile();
|
||||
const isPrinting = useMediaQuery("print");
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
return isMobile && !isPrinting ? (
|
||||
<ScrollContext.Provider value={ref}>
|
||||
<MobileWrapper ref={ref}>{children}</MobileWrapper>
|
||||
</ScrollContext.Provider>
|
||||
) : (
|
||||
<>{children}</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageScroll;
|
||||
@@ -42,7 +42,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderError={(props) => <Error {...props} />}
|
||||
renderItem={(item: Document, _index, compositeProps) => (
|
||||
renderItem={(item: Document, _index) => (
|
||||
<DocumentListItem
|
||||
key={item.id}
|
||||
document={item}
|
||||
@@ -52,7 +52,6 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
|
||||
showPublished={showPublished}
|
||||
showTemplate={showTemplate}
|
||||
showDraft={showDraft}
|
||||
{...compositeProps}
|
||||
/>
|
||||
)}
|
||||
{...rest}
|
||||
|
||||
@@ -30,13 +30,12 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
|
||||
heading={heading}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={(item: Event, index, compositeProps) => (
|
||||
renderItem={(item: Event, index) => (
|
||||
<EventListItem
|
||||
key={item.id}
|
||||
event={item}
|
||||
document={document}
|
||||
latest={index === 0}
|
||||
{...compositeProps}
|
||||
/>
|
||||
)}
|
||||
renderHeading={(name) => <Heading>{name}</Heading>}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, WithTranslation } from "react-i18next";
|
||||
import { Waypoint } from "react-waypoint";
|
||||
import { CompositeStateReturn } from "reakit/Composite";
|
||||
import { Pagination } from "@shared/constants";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
|
||||
@@ -30,11 +29,7 @@ type Props<T> = WithTranslation &
|
||||
loading?: React.ReactElement;
|
||||
items?: T[];
|
||||
className?: string;
|
||||
renderItem: (
|
||||
item: T,
|
||||
index: number,
|
||||
compositeProps: CompositeStateReturn
|
||||
) => React.ReactNode;
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
renderError?: (options: {
|
||||
error: Error;
|
||||
retry: () => void;
|
||||
@@ -194,10 +189,10 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
onEscape={onEscape}
|
||||
className={this.props.className}
|
||||
>
|
||||
{(composite: CompositeStateReturn) => {
|
||||
{() => {
|
||||
let previousHeading = "";
|
||||
return items.slice(0, this.renderCount).map((item, index) => {
|
||||
const children = this.props.renderItem(item, index, composite);
|
||||
const children = this.props.renderItem(item, index);
|
||||
|
||||
// If there is no renderHeading method passed then no date
|
||||
// headings are rendered
|
||||
|
||||
@@ -5,13 +5,17 @@ import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
|
||||
type Props = {
|
||||
/** Whether to include a title placeholder. */
|
||||
includeTitle?: boolean;
|
||||
/** Delay before mounting the component. Defaults to 500ms */
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
export default function PlaceholderDocument({
|
||||
includeTitle,
|
||||
delay,
|
||||
}: {
|
||||
includeTitle?: boolean;
|
||||
delay?: number;
|
||||
}) {
|
||||
delay = 500,
|
||||
}: Props) {
|
||||
const content = (
|
||||
<>
|
||||
<PlaceholderText delay={0.2} />
|
||||
|
||||
@@ -1,29 +1,37 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import PluginLoader from "~/utils/PluginLoader";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { Hook, usePluginValue } from "~/utils/PluginManager";
|
||||
|
||||
type Props = {
|
||||
/** The ID of the plugin to render an Icon for. */
|
||||
id: string;
|
||||
/** The size of the icon. */
|
||||
size?: number;
|
||||
/** The color of the icon. */
|
||||
color?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders an icon defined in a plugin (Hook.Icon).
|
||||
*/
|
||||
function PluginIcon({ id, color, size = 24 }: Props) {
|
||||
const plugin = PluginLoader.plugins[id];
|
||||
const Icon = plugin?.icon;
|
||||
const Icon = usePluginValue(Hook.Icon, id);
|
||||
|
||||
if (Icon) {
|
||||
return (
|
||||
<Wrapper>
|
||||
<IconPosition>
|
||||
<Icon size={size} fill={color} />
|
||||
</Wrapper>
|
||||
</IconPosition>
|
||||
);
|
||||
}
|
||||
|
||||
Logger.warn("No Icon registered for plugin", { id });
|
||||
return null;
|
||||
}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
const IconPosition = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -32,4 +40,4 @@ const Wrapper = styled.div`
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
export default PluginIcon;
|
||||
export default observer(PluginIcon);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Context to provide a reference to the scrollable container
|
||||
*/
|
||||
const ScrollContext = React.createContext<
|
||||
React.RefObject<HTMLDivElement> | undefined
|
||||
>(undefined);
|
||||
|
||||
/**
|
||||
* Hook to get the scrollable container reference
|
||||
*/
|
||||
export const useScrollContext = () => React.useContext(ScrollContext);
|
||||
|
||||
export default ScrollContext;
|
||||
@@ -2,6 +2,7 @@
|
||||
import * as React from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import { useScrollContext } from "./ScrollContext";
|
||||
|
||||
type Props = {
|
||||
children: JSX.Element;
|
||||
@@ -10,6 +11,7 @@ type Props = {
|
||||
export default function ScrollToTop({ children }: Props) {
|
||||
const location = useLocation<{ retainScrollPosition?: boolean }>();
|
||||
const previousLocationPathname = usePrevious(location.pathname);
|
||||
const scrollContainerRef = useScrollContext();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
@@ -25,8 +27,9 @@ export default function ScrollToTop({ children }: Props) {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
window.scrollTo(0, 0);
|
||||
(scrollContainerRef?.current || window).scrollTo(0, 0);
|
||||
}, [
|
||||
scrollContainerRef,
|
||||
location.pathname,
|
||||
previousLocationPathname,
|
||||
location.state?.retainScrollPosition,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import {
|
||||
useFocusEffect,
|
||||
useRovingTabIndex,
|
||||
} from "@getoutline/react-roving-tabindex";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { CompositeItem } from "reakit/Composite";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
@@ -34,10 +37,18 @@ function DocumentListItem(
|
||||
) {
|
||||
const { document, highlight, context, shareId, ...rest } = props;
|
||||
|
||||
let itemRef: React.Ref<HTMLAnchorElement> =
|
||||
React.useRef<HTMLAnchorElement>(null);
|
||||
if (ref) {
|
||||
itemRef = ref;
|
||||
}
|
||||
|
||||
const { focused, ...rovingTabIndex } = useRovingTabIndex(itemRef, false);
|
||||
useFocusEffect(focused, itemRef);
|
||||
|
||||
return (
|
||||
<CompositeItem
|
||||
as={DocumentLink}
|
||||
ref={ref}
|
||||
<DocumentLink
|
||||
ref={itemRef}
|
||||
dir={document.dir}
|
||||
to={{
|
||||
pathname: shareId
|
||||
@@ -48,6 +59,13 @@ function DocumentListItem(
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
{...rovingTabIndex}
|
||||
onClick={(ev) => {
|
||||
if (rest.onClick) {
|
||||
rest.onClick(ev);
|
||||
}
|
||||
rovingTabIndex.onClick(ev);
|
||||
}}
|
||||
>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
@@ -66,7 +84,7 @@ function DocumentListItem(
|
||||
/>
|
||||
}
|
||||
</Content>
|
||||
</CompositeItem>
|
||||
</DocumentLink>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ function SearchPopover({ shareId }: Props) {
|
||||
<NoResults>{t("No results for {{query}}", { query })}</NoResults>
|
||||
}
|
||||
loading={<PlaceholderList count={3} header={{ height: 20 }} />}
|
||||
renderItem={(item: SearchResult, index, compositeProps) => (
|
||||
renderItem={(item: SearchResult, index) => (
|
||||
<SearchListItem
|
||||
key={item.document.id}
|
||||
shareId={shareId}
|
||||
@@ -215,7 +215,6 @@ function SearchPopover({ shareId }: Props) {
|
||||
context={item.context}
|
||||
highlight={cachedQuery}
|
||||
onClick={handleSearchItemClick}
|
||||
{...compositeProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -53,16 +53,16 @@ function CollectionMemberList({ collection, invitedInSession }: Props) {
|
||||
() =>
|
||||
[
|
||||
{
|
||||
label: t("Admin"),
|
||||
value: CollectionPermission.Admin,
|
||||
label: t("View only"),
|
||||
value: CollectionPermission.Read,
|
||||
},
|
||||
{
|
||||
label: t("Can edit"),
|
||||
value: CollectionPermission.ReadWrite,
|
||||
},
|
||||
{
|
||||
label: t("View only"),
|
||||
value: CollectionPermission.Read,
|
||||
label: t("Manage"),
|
||||
value: CollectionPermission.Admin,
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
|
||||
@@ -59,6 +59,9 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
|
||||
useKeyDown(
|
||||
"Escape",
|
||||
(ev) => {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
ev.stopImmediatePropagation();
|
||||
|
||||
@@ -229,16 +232,16 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
|
||||
() =>
|
||||
[
|
||||
{
|
||||
label: t("Admin"),
|
||||
value: CollectionPermission.Admin,
|
||||
label: t("View only"),
|
||||
value: CollectionPermission.Read,
|
||||
},
|
||||
{
|
||||
label: t("Can edit"),
|
||||
value: CollectionPermission.ReadWrite,
|
||||
},
|
||||
{
|
||||
label: t("View only"),
|
||||
value: CollectionPermission.Read,
|
||||
label: t("Manage"),
|
||||
value: CollectionPermission.Admin,
|
||||
},
|
||||
] as Permission[],
|
||||
[t]
|
||||
|
||||
@@ -51,6 +51,10 @@ const DocumentMemberListItem = ({
|
||||
label: t("Can edit"),
|
||||
value: DocumentPermission.ReadWrite,
|
||||
},
|
||||
{
|
||||
label: t("Manage"),
|
||||
value: DocumentPermission.Admin,
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
label: t("Remove"),
|
||||
|
||||
@@ -67,6 +67,9 @@ function SharePopover({
|
||||
useKeyDown(
|
||||
"Escape",
|
||||
(ev) => {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
ev.stopImmediatePropagation();
|
||||
|
||||
@@ -202,13 +205,17 @@ function SharePopover({
|
||||
const permissions = React.useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
label: t("View only"),
|
||||
value: DocumentPermission.Read,
|
||||
},
|
||||
{
|
||||
label: t("Can edit"),
|
||||
value: DocumentPermission.ReadWrite,
|
||||
},
|
||||
{
|
||||
label: t("View only"),
|
||||
value: DocumentPermission.Read,
|
||||
label: t("Manage"),
|
||||
value: DocumentPermission.Admin,
|
||||
},
|
||||
] as Permission[],
|
||||
[t]
|
||||
|
||||
@@ -55,7 +55,7 @@ function AppSidebar() {
|
||||
);
|
||||
|
||||
return (
|
||||
<Sidebar ref={handleSidebarRef}>
|
||||
<Sidebar hidden={!ui.readyToShow} ref={handleSidebarRef}>
|
||||
<HistoryNavigation />
|
||||
{dndArea && (
|
||||
<DndProvider backend={HTML5Backend} options={html5Options}>
|
||||
|
||||
@@ -24,11 +24,12 @@ const ANIMATION_MS = 250;
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
hidden?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
{ children, className }: Props,
|
||||
{ children, hidden = false, className }: Props,
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const [isCollapsing, setCollapsing] = React.useState(false);
|
||||
@@ -178,6 +179,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
<Container
|
||||
ref={ref}
|
||||
style={style}
|
||||
$hidden={hidden}
|
||||
$isHovering={isHovering}
|
||||
$isAnimating={isAnimating}
|
||||
$isSmallerThanMinimum={isSmallerThanMinimum}
|
||||
@@ -249,6 +251,7 @@ type ContainerProps = {
|
||||
$isSmallerThanMinimum: boolean;
|
||||
$isHovering: boolean;
|
||||
$collapsed: boolean;
|
||||
$hidden: boolean;
|
||||
};
|
||||
|
||||
const hoverStyles = (props: ContainerProps) => `
|
||||
@@ -267,13 +270,14 @@ const hoverStyles = (props: ContainerProps) => `
|
||||
`;
|
||||
|
||||
const Container = styled(Flex)<ContainerProps>`
|
||||
opacity: ${(props) => (props.$hidden ? 0 : 1)};
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
background: ${s("sidebarBackground")};
|
||||
transition: box-shadow 100ms ease-in-out, opacity 100ms ease-in-out,
|
||||
transform 100ms ease-out,
|
||||
transition: box-shadow 150ms ease-in-out, opacity 150ms ease-in-out,
|
||||
transform 150ms ease-out,
|
||||
${s("backgroundTransition")}
|
||||
${(props: ContainerProps) =>
|
||||
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
|
||||
@@ -316,7 +320,7 @@ const Container = styled(Flex)<ContainerProps>`
|
||||
|
||||
& > div {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -2,9 +2,9 @@ import { LocationDescriptor } from "history";
|
||||
import * as React from "react";
|
||||
import styled, { useTheme, css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { s } from "@shared/styles";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import EventBoundary from "~/components/EventBoundary";
|
||||
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { UnreadBadge } from "~/components/UnreadBadge";
|
||||
|
||||
@@ -3,10 +3,10 @@ import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import createAndInsertLink from "@shared/editor/commands/createAndInsertLink";
|
||||
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
|
||||
import getMarkRange from "@shared/editor/queries/getMarkRange";
|
||||
import isInCode from "@shared/editor/queries/isInCode";
|
||||
import isMarkActive from "@shared/editor/queries/isMarkActive";
|
||||
import isNodeActive from "@shared/editor/queries/isNodeActive";
|
||||
import { getMarkRange } from "@shared/editor/queries/getMarkRange";
|
||||
import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import { getColumnIndex, getRowIndex } from "@shared/editor/queries/table";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { creatingUrlPrefix } from "@shared/utils/urls";
|
||||
|
||||
@@ -20,7 +20,7 @@ type Props = {
|
||||
/*
|
||||
* Renders a dropdown menu in the floating toolbar.
|
||||
*/
|
||||
function ToolbarDropdown(props: { item: MenuItem }) {
|
||||
function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
|
||||
const menu = useMenuState();
|
||||
const { commands, view } = useEditor();
|
||||
const { item } = props;
|
||||
@@ -102,7 +102,7 @@ function ToolbarMenu(props: Props) {
|
||||
key={index}
|
||||
>
|
||||
{item.children ? (
|
||||
<ToolbarDropdown item={item} />
|
||||
<ToolbarDropdown active={isActive && !item.label} item={item} />
|
||||
) : (
|
||||
<ToolbarButton
|
||||
onClick={handleClick(item)}
|
||||
|
||||
@@ -12,8 +12,9 @@ import BlockMenu from "../components/BlockMenu";
|
||||
export default class BlockMenuExtension extends Suggestion {
|
||||
get defaultOptions() {
|
||||
return {
|
||||
openRegex: /^\/(\w+)?$/,
|
||||
closeRegex: /(^(?!\/(\w+)?)(.*)$|^\/(([\w\W]+)\s.*|\s)$|^\/((\W)+)$)/,
|
||||
// ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w
|
||||
openRegex: /(?:^|\s|\()\/([\p{L}\p{M}\d]+)?$/u,
|
||||
closeRegex: /(?:^|\s|\()\/(([\p{L}\p{M}\d]*\s+)|(\s+[\p{L}\p{M}\d]+))$/u,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ export default class EmojiMenuExtension extends Suggestion {
|
||||
),
|
||||
closeRegex:
|
||||
/(?:^|\s|\():(([0-9a-zA-Z_+-]*\s+)|(\s+[0-9a-zA-Z_+-]+)|[^0-9a-zA-Z_+-]+)$/,
|
||||
enabledInTable: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Command,
|
||||
} from "prosemirror-state";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import isInCode from "@shared/editor/queries/isInCode";
|
||||
import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
|
||||
export default class Keys extends Extension {
|
||||
get name() {
|
||||
|
||||
@@ -10,7 +10,6 @@ export default class MentionMenuExtension extends Suggestion {
|
||||
// ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w
|
||||
openRegex: /(?:^|\s|\()@([\p{L}\p{M}\d]+)?$/u,
|
||||
closeRegex: /(?:^|\s|\()@(([\p{L}\p{M}\d]*\s+)|(\s+[\p{L}\p{M}\d]+))$/u,
|
||||
enabledInTable: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import { LANGUAGES } from "@shared/editor/extensions/Prism";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import isMarkdown from "@shared/editor/lib/isMarkdown";
|
||||
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
|
||||
import isInCode from "@shared/editor/queries/isInCode";
|
||||
import isInList from "@shared/editor/queries/isInList";
|
||||
import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
import { isInList } from "@shared/editor/queries/isInList";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isDocumentUrl, isUrl } from "@shared/utils/urls";
|
||||
import stores from "~/stores";
|
||||
|
||||
@@ -2,10 +2,9 @@ import { action, observable } from "mobx";
|
||||
import { InputRule } from "prosemirror-inputrules";
|
||||
import { NodeType, Schema } from "prosemirror-model";
|
||||
import { EditorState, Plugin } from "prosemirror-state";
|
||||
import { isInTable } from "prosemirror-tables";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import { SuggestionsMenuPlugin } from "@shared/editor/plugins/Suggestions";
|
||||
import isInCode from "@shared/editor/queries/isInCode";
|
||||
import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
|
||||
export default class Suggestion extends Extension {
|
||||
state: {
|
||||
@@ -50,8 +49,7 @@ export default class Suggestion extends Extension {
|
||||
match &&
|
||||
(parent.type.name === "paragraph" ||
|
||||
parent.type.name === "heading") &&
|
||||
(!isInCode(state) || this.options.enabledInCode) &&
|
||||
(!isInTable(state) || this.options.enabledInTable)
|
||||
(!isInCode(state) || this.options.enabledInCode)
|
||||
) {
|
||||
this.state.open = true;
|
||||
this.state.query = match[1];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PageBreakIcon, HorizontalRuleIcon } from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import isNodeActive from "@shared/editor/queries/isNodeActive";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
|
||||
@@ -20,11 +20,14 @@ import {
|
||||
} from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import isInCode from "@shared/editor/queries/isInCode";
|
||||
import isInList from "@shared/editor/queries/isInList";
|
||||
import isMarkActive from "@shared/editor/queries/isMarkActive";
|
||||
import isNodeActive from "@shared/editor/queries/isNodeActive";
|
||||
import Highlight from "@shared/editor/marks/Highlight";
|
||||
import { getMarksBetween } from "@shared/editor/queries/getMarksBetween";
|
||||
import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
import { isInList } from "@shared/editor/queries/isInList";
|
||||
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import CircleIcon from "~/components/Icons/CircleIcon";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
export default function formattingMenuItems(
|
||||
@@ -34,11 +37,16 @@ export default function formattingMenuItems(
|
||||
dictionary: Dictionary
|
||||
): MenuItem[] {
|
||||
const { schema } = state;
|
||||
const isList = isInList(state);
|
||||
const isCode = isInCode(state);
|
||||
const isCodeBlock = isInCode(state, { onlyBlock: true });
|
||||
const isEmpty = state.selection.empty;
|
||||
|
||||
const highlight = getMarksBetween(
|
||||
state.selection.from,
|
||||
state.selection.to,
|
||||
state
|
||||
).find(({ mark }) => mark.type.name === "highlight");
|
||||
|
||||
return [
|
||||
{
|
||||
name: "placeholder",
|
||||
@@ -73,11 +81,21 @@ export default function formattingMenuItems(
|
||||
visible: !isCode && (!isMobile || !isEmpty),
|
||||
},
|
||||
{
|
||||
name: "highlight",
|
||||
tooltip: dictionary.mark,
|
||||
icon: <HighlightIcon />,
|
||||
active: isMarkActive(schema.marks.highlight),
|
||||
icon: highlight ? (
|
||||
<CircleIcon color={highlight.mark.attrs.color} />
|
||||
) : (
|
||||
<HighlightIcon />
|
||||
),
|
||||
active: () => !!highlight,
|
||||
visible: !isCode && (!isMobile || !isEmpty),
|
||||
children: Highlight.colors.map((color, index) => ({
|
||||
name: "highlight",
|
||||
label: Highlight.colorNames[index],
|
||||
icon: <CircleIcon retainColor color={color} />,
|
||||
active: isMarkActive(schema.marks.highlight, { color }),
|
||||
attrs: { color },
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "code_inline",
|
||||
@@ -152,13 +170,27 @@ export default function formattingMenuItems(
|
||||
name: "outdentList",
|
||||
tooltip: dictionary.outdent,
|
||||
icon: <OutdentIcon />,
|
||||
visible: isList && isMobile,
|
||||
visible:
|
||||
isMobile && isInList(state, { types: ["ordered_list", "bullet_list"] }),
|
||||
},
|
||||
{
|
||||
name: "indentList",
|
||||
tooltip: dictionary.indent,
|
||||
icon: <IndentIcon />,
|
||||
visible: isList && isMobile,
|
||||
visible:
|
||||
isMobile && isInList(state, { types: ["ordered_list", "bullet_list"] }),
|
||||
},
|
||||
{
|
||||
name: "outdentCheckboxList",
|
||||
tooltip: dictionary.outdent,
|
||||
icon: <OutdentIcon />,
|
||||
visible: isMobile && isInList(state, { types: ["checkbox_list"] }),
|
||||
},
|
||||
{
|
||||
name: "indentCheckboxList",
|
||||
tooltip: dictionary.indent,
|
||||
icon: <IndentIcon />,
|
||||
visible: isMobile && isInList(state, { types: ["checkbox_list"] }),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import isNodeActive from "@shared/editor/queries/isNodeActive";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CommentIcon } from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import isMarkActive from "@shared/editor/queries/isMarkActive";
|
||||
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AlignFullWidthIcon, TrashIcon } from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import isNodeActive from "@shared/editor/queries/isNodeActive";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import { MenuItem, TableLayout } from "@shared/editor/types";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import isNodeActive from "@shared/editor/queries/isNodeActive";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { computed } from "mobx";
|
||||
import { type DependencyList, useMemo } from "react";
|
||||
|
||||
/**
|
||||
* Hook around MobX computed function that runs computation whenever observable values change.
|
||||
*
|
||||
* @param callback Function which returns a memorized value.
|
||||
* @param inputs Dependency list for useMemo.
|
||||
*/
|
||||
export function useComputed<T>(
|
||||
callback: () => T,
|
||||
inputs: DependencyList = []
|
||||
): T {
|
||||
const value = useMemo(() => computed(callback), inputs);
|
||||
return value.get();
|
||||
}
|
||||
@@ -18,12 +18,12 @@ import {
|
||||
import React, { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
|
||||
import GoogleIcon from "~/components/Icons/GoogleIcon";
|
||||
import ZapierIcon from "~/components/Icons/ZapierIcon";
|
||||
import PluginLoader from "~/utils/PluginLoader";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import lazy from "~/utils/lazyWithRetry";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import { useComputed } from "./useComputed";
|
||||
import useCurrentTeam from "./useCurrentTeam";
|
||||
import useCurrentUser from "./useCurrentUser";
|
||||
import usePolicy from "./usePolicy";
|
||||
@@ -32,7 +32,6 @@ const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys"));
|
||||
const Details = lazy(() => import("~/scenes/Settings/Details"));
|
||||
const Export = lazy(() => import("~/scenes/Settings/Export"));
|
||||
const Features = lazy(() => import("~/scenes/Settings/Features"));
|
||||
const GoogleAnalytics = lazy(() => import("~/scenes/Settings/GoogleAnalytics"));
|
||||
const Groups = lazy(() => import("~/scenes/Settings/Groups"));
|
||||
const Import = lazy(() => import("~/scenes/Settings/Import"));
|
||||
const Members = lazy(() => import("~/scenes/Settings/Members"));
|
||||
@@ -60,7 +59,7 @@ const useSettingsConfig = () => {
|
||||
const can = usePolicy(team);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const config = React.useMemo(() => {
|
||||
const config = useComputed(() => {
|
||||
const items: ConfigItem[] = [
|
||||
// Account
|
||||
{
|
||||
@@ -177,14 +176,6 @@ const useSettingsConfig = () => {
|
||||
group: t("Integrations"),
|
||||
icon: BuildingBlocksIcon,
|
||||
},
|
||||
{
|
||||
name: t("Google Analytics"),
|
||||
path: integrationSettingsPath("google-analytics"),
|
||||
component: GoogleAnalytics,
|
||||
enabled: can.update,
|
||||
group: t("Integrations"),
|
||||
icon: GoogleIcon,
|
||||
},
|
||||
{
|
||||
name: "Zapier",
|
||||
path: integrationSettingsPath("zapier"),
|
||||
@@ -196,29 +187,20 @@ const useSettingsConfig = () => {
|
||||
];
|
||||
|
||||
// Plugins
|
||||
Object.values(PluginLoader.plugins).map((plugin) => {
|
||||
const hasSettings = !!plugin.settings;
|
||||
const enabledInDeployment =
|
||||
!plugin.config?.deployments ||
|
||||
plugin.config.deployments.length === 0 ||
|
||||
(plugin.config.deployments.includes("cloud") && isCloudHosted) ||
|
||||
(plugin.config.deployments.includes("enterprise") && !isCloudHosted);
|
||||
|
||||
const item = {
|
||||
name: t(plugin.config.name),
|
||||
PluginManager.getHooks(Hook.Settings).forEach((plugin) => {
|
||||
const insertIndex = plugin.value.after
|
||||
? items.findIndex((i) => i.name === t(plugin.value.after!)) + 1
|
||||
: items.findIndex(
|
||||
(i) => i.group === t(plugin.value.group ?? "Integrations")
|
||||
);
|
||||
items.splice(insertIndex, 0, {
|
||||
name: t(plugin.name),
|
||||
path: integrationSettingsPath(plugin.id),
|
||||
// TODO: Remove hardcoding of plugin id here
|
||||
group: plugin.id === "collections" ? t("Workspace") : t("Integrations"),
|
||||
component: plugin.settings,
|
||||
enabled:
|
||||
enabledInDeployment &&
|
||||
hasSettings &&
|
||||
(plugin.config.roles?.includes(user.role) || can.update),
|
||||
icon: plugin.icon,
|
||||
} as ConfigItem;
|
||||
|
||||
const insertIndex = items.findIndex((i) => i.group === t("Integrations"));
|
||||
items.splice(insertIndex, 0, item);
|
||||
group: t(plugin.value.group),
|
||||
component: plugin.value.component,
|
||||
enabled: plugin.roles?.includes(user.role) || can.update,
|
||||
icon: plugin.value.icon,
|
||||
} as ConfigItem);
|
||||
});
|
||||
|
||||
return items;
|
||||
|
||||
+7
-3
@@ -20,12 +20,16 @@ import env from "~/env";
|
||||
import { initI18n } from "~/utils/i18n";
|
||||
import Desktop from "./components/DesktopEventHandler";
|
||||
import LazyPolyfill from "./components/LazyPolyfills";
|
||||
import MobileScrollWrapper from "./components/MobileScrollWrapper";
|
||||
import PageScroll from "./components/PageScroll";
|
||||
import Routes from "./routes";
|
||||
import Logger from "./utils/Logger";
|
||||
import { PluginManager } from "./utils/PluginManager";
|
||||
import history from "./utils/history";
|
||||
import { initSentry } from "./utils/sentry";
|
||||
|
||||
// Load plugins as soon as possible
|
||||
void PluginManager.loadPlugins();
|
||||
|
||||
initI18n(env.DEFAULT_LANGUAGE);
|
||||
const element = window.document.getElementById("root");
|
||||
|
||||
@@ -61,7 +65,7 @@ if (element) {
|
||||
<LazyPolyfill>
|
||||
<LazyMotion features={loadFeatures}>
|
||||
<Router history={history}>
|
||||
<MobileScrollWrapper>
|
||||
<PageScroll>
|
||||
<PageTheme />
|
||||
<ScrollToTop>
|
||||
<Routes />
|
||||
@@ -69,7 +73,7 @@ if (element) {
|
||||
<Toasts />
|
||||
<Dialogs />
|
||||
<Desktop />
|
||||
</MobileScrollWrapper>
|
||||
</PageScroll>
|
||||
</Router>
|
||||
</LazyMotion>
|
||||
</LazyPolyfill>
|
||||
|
||||
@@ -4,13 +4,13 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import { toast } from "sonner";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import Comment from "~/models/Comment";
|
||||
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import MenuItem from "~/components/ContextMenu/MenuItem";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Separator from "~/components/ContextMenu/Separator";
|
||||
import EventBoundary from "~/components/EventBoundary";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { commentPath, urlify } from "~/utils/routeHelpers";
|
||||
|
||||
@@ -3,15 +3,14 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import Text from "~/components/Text";
|
||||
import useWindowScrollPosition from "~/hooks/useWindowScrollPosition";
|
||||
|
||||
const HEADING_OFFSET = 20;
|
||||
|
||||
type Props = {
|
||||
/** Whether the document is rendering full width or not. */
|
||||
isFullWidth: boolean;
|
||||
/** The headings to render in the contents. */
|
||||
headings: {
|
||||
title: string;
|
||||
@@ -20,9 +19,9 @@ type Props = {
|
||||
}[];
|
||||
};
|
||||
|
||||
export default function Contents({ headings, isFullWidth }: Props) {
|
||||
export default function Contents({ headings }: Props) {
|
||||
const [activeSlug, setActiveSlug] = React.useState<string>();
|
||||
const position = useWindowScrollPosition({
|
||||
const scrollPosition = useWindowScrollPosition({
|
||||
throttle: 100,
|
||||
});
|
||||
|
||||
@@ -43,7 +42,7 @@ export default function Contents({ headings, isFullWidth }: Props) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [position, headings]);
|
||||
}, [scrollPosition, headings]);
|
||||
|
||||
// calculate the minimum heading level and adjust all the headings to make
|
||||
// that the top-most. This prevents the contents from being weirdly indented
|
||||
@@ -56,70 +55,53 @@ export default function Contents({ headings, isFullWidth }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Wrapper isFullWidth={isFullWidth}>
|
||||
<Sticky>
|
||||
<Heading>{t("Contents")}</Heading>
|
||||
{headings.length ? (
|
||||
<List>
|
||||
{headings
|
||||
.filter((heading) => heading.level < 4)
|
||||
.map((heading) => (
|
||||
<ListItem
|
||||
key={heading.id}
|
||||
level={heading.level - headingAdjustment}
|
||||
active={activeSlug === heading.id}
|
||||
>
|
||||
<Link href={`#${heading.id}`}>{heading.title}</Link>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<Empty>
|
||||
{t("Headings you add to the document will appear here")}
|
||||
</Empty>
|
||||
)}
|
||||
</Sticky>
|
||||
</Wrapper>
|
||||
<StickyWrapper>
|
||||
<Heading>{t("Contents")}</Heading>
|
||||
{headings.length ? (
|
||||
<List>
|
||||
{headings
|
||||
.filter((heading) => heading.level < 4)
|
||||
.map((heading) => (
|
||||
<ListItem
|
||||
key={heading.id}
|
||||
level={heading.level - headingAdjustment}
|
||||
active={activeSlug === heading.id}
|
||||
>
|
||||
<Link href={`#${heading.id}`}>{heading.title}</Link>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<Empty>{t("Headings you add to the document will appear here")}</Empty>
|
||||
)}
|
||||
</StickyWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled.div<{ isFullWidth: boolean }>`
|
||||
width: 256px;
|
||||
const StickyWrapper = styled.div`
|
||||
display: none;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: block;
|
||||
`};
|
||||
|
||||
${(props) =>
|
||||
!props.isFullWidth &&
|
||||
breakpoint("desktopLarge")`
|
||||
transform: translateX(-256px);
|
||||
width: 0;
|
||||
`}
|
||||
`;
|
||||
|
||||
const Sticky = styled.div`
|
||||
position: sticky;
|
||||
top: 80px;
|
||||
max-height: calc(100vh - 80px);
|
||||
top: 90px;
|
||||
max-height: calc(100vh - 90px);
|
||||
width: ${EditorStyleHelper.tocWidth}px;
|
||||
|
||||
padding: 0 16px;
|
||||
overflow-y: auto;
|
||||
border-radius: 8px;
|
||||
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
|
||||
margin-top: 80px;
|
||||
margin-right: 52px;
|
||||
min-width: 204px;
|
||||
width: 228px;
|
||||
min-height: 40px;
|
||||
overflow-y: auto;
|
||||
padding: 0 16px;
|
||||
border-radius: 8px;
|
||||
|
||||
@supports (backdrop-filter: blur(20px)) {
|
||||
backdrop-filter: blur(20px);
|
||||
background: ${(props) => transparentize(0.2, props.theme.background)};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: block;
|
||||
z-index: ${depths.toc};
|
||||
`};
|
||||
`;
|
||||
|
||||
const Heading = styled.h3`
|
||||
@@ -131,15 +113,12 @@ const Heading = styled.h3`
|
||||
`;
|
||||
|
||||
const Empty = styled(Text)`
|
||||
margin: 1em 0 4em;
|
||||
padding-right: 2em;
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const ListItem = styled.li<{ level: number; active?: boolean }>`
|
||||
margin-left: ${(props) => (props.level - 1) * 10}px;
|
||||
margin-bottom: 8px;
|
||||
padding-right: 2em;
|
||||
line-height: 1.3;
|
||||
word-break: break-word;
|
||||
|
||||
|
||||
@@ -17,8 +17,9 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
import { s } from "@shared/styles";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { NavigationNode, TOCPosition, TeamPreference } from "@shared/types";
|
||||
import { ProsemirrorHelper, Heading } from "@shared/utils/ProsemirrorHelper";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
@@ -403,6 +404,9 @@ class DocumentScene extends React.Component<Props> {
|
||||
const hasHeadings = this.headings.length > 0;
|
||||
const showContents =
|
||||
ui.tocVisible && ((readOnly && hasHeadings) || !readOnly);
|
||||
const tocPosition =
|
||||
(team?.getPreference(TeamPreference.TocPosition) as TOCPosition) ||
|
||||
TOCPosition.Left;
|
||||
const multiplayerEditor =
|
||||
!document.isArchived && !document.isDeleted && !revision && !isShare;
|
||||
|
||||
@@ -449,7 +453,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
favicon={document.emoji ? emojiToUrl(document.emoji) : undefined}
|
||||
/>
|
||||
{(this.isUploading || this.isSaving) && <LoadingIndicator />}
|
||||
<Container justify="center" column auto>
|
||||
<Container column>
|
||||
{!readOnly && (
|
||||
<Prompt
|
||||
when={this.isUploading && !this.isEditorDirty}
|
||||
@@ -476,27 +480,39 @@ class DocumentScene extends React.Component<Props> {
|
||||
onSave={this.onSave}
|
||||
headings={this.headings}
|
||||
/>
|
||||
<MeasuredContainer
|
||||
as={MaxWidth}
|
||||
name="document"
|
||||
archived={document.isArchived}
|
||||
showContents={showContents}
|
||||
isEditing={!readOnly}
|
||||
isFullWidth={document.fullWidth}
|
||||
column
|
||||
auto
|
||||
>
|
||||
<Flex justify="center">
|
||||
<Notices document={document} readOnly={readOnly} />
|
||||
</Flex>
|
||||
<MeasuredContainer
|
||||
as={Main}
|
||||
name="document"
|
||||
fullWidth={document.fullWidth}
|
||||
tocPosition={tocPosition}
|
||||
>
|
||||
<React.Suspense fallback={<PlaceholderDocument />}>
|
||||
<Flex auto={!readOnly} reverse>
|
||||
{revision ? (
|
||||
{revision ? (
|
||||
<RevisionContainer docFullWidth={document.fullWidth}>
|
||||
<RevisionViewer
|
||||
document={document}
|
||||
revision={revision}
|
||||
id={revision.id}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
</RevisionContainer>
|
||||
) : (
|
||||
<>
|
||||
{showContents && (
|
||||
<ContentsContainer
|
||||
docFullWidth={document.fullWidth}
|
||||
position={tocPosition}
|
||||
>
|
||||
<Contents headings={this.headings} />
|
||||
</ContentsContainer>
|
||||
)}
|
||||
<EditorContainer
|
||||
docFullWidth={document.fullWidth}
|
||||
showContents={showContents}
|
||||
tocPosition={tocPosition}
|
||||
>
|
||||
<Editor
|
||||
id={document.id}
|
||||
key={embedsDisabled ? "disabled" : "enabled"}
|
||||
@@ -543,16 +559,9 @@ class DocumentScene extends React.Component<Props> {
|
||||
</>
|
||||
)}
|
||||
</Editor>
|
||||
|
||||
{showContents && (
|
||||
<Contents
|
||||
headings={this.headings}
|
||||
isFullWidth={document.fullWidth}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</EditorContainer>
|
||||
</>
|
||||
)}
|
||||
</React.Suspense>
|
||||
</MeasuredContainer>
|
||||
{isShare &&
|
||||
@@ -573,6 +582,95 @@ class DocumentScene extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
type MainProps = {
|
||||
fullWidth: boolean;
|
||||
tocPosition: TOCPosition;
|
||||
};
|
||||
|
||||
const Main = styled.div<MainProps>`
|
||||
margin-top: 4px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: grid;
|
||||
grid-template-columns: ${({ fullWidth, tocPosition }: MainProps) =>
|
||||
fullWidth
|
||||
? tocPosition === TOCPosition.Left
|
||||
? `${EditorStyleHelper.tocWidth}px minmax(0, 1fr)`
|
||||
: `minmax(0, 1fr) ${EditorStyleHelper.tocWidth}px`
|
||||
: `1fr minmax(0, ${`calc(46em + 76px)`}) 1fr`};
|
||||
`};
|
||||
|
||||
${breakpoint("desktopLarge")`
|
||||
grid-template-columns: ${({ fullWidth, tocPosition }: MainProps) =>
|
||||
fullWidth
|
||||
? tocPosition === TOCPosition.Left
|
||||
? `${EditorStyleHelper.tocWidth}px minmax(0, 1fr)`
|
||||
: `minmax(0, 1fr) ${EditorStyleHelper.tocWidth}px`
|
||||
: `1fr minmax(0, ${`calc(52em + 76px)`}) 1fr`};
|
||||
`};
|
||||
`;
|
||||
|
||||
type ContentsContainerProps = {
|
||||
docFullWidth: boolean;
|
||||
position: TOCPosition;
|
||||
};
|
||||
|
||||
const ContentsContainer = styled.div<ContentsContainerProps>`
|
||||
margin-top: calc(44px + 6vh);
|
||||
|
||||
${breakpoint("tablet")`
|
||||
grid-row: 1;
|
||||
grid-column: ${({ docFullWidth, position }: ContentsContainerProps) =>
|
||||
position === TOCPosition.Left ? 1 : docFullWidth ? 2 : 3};
|
||||
justify-self: ${({ position }: ContentsContainerProps) =>
|
||||
position === TOCPosition.Left ? "end" : "start"};
|
||||
`};
|
||||
`;
|
||||
|
||||
type EditorContainerProps = {
|
||||
docFullWidth: boolean;
|
||||
showContents: boolean;
|
||||
tocPosition: TOCPosition;
|
||||
};
|
||||
|
||||
const EditorContainer = styled.div<EditorContainerProps>`
|
||||
// Adds space to the gutter to make room for icon & heading annotations
|
||||
padding: 0 44px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
grid-row: 1;
|
||||
|
||||
// Decides the editor column position & span
|
||||
grid-column: ${({
|
||||
docFullWidth,
|
||||
showContents,
|
||||
tocPosition,
|
||||
}: EditorContainerProps) =>
|
||||
docFullWidth
|
||||
? showContents
|
||||
? tocPosition === TOCPosition.Left
|
||||
? 2
|
||||
: 1
|
||||
: "1 / -1"
|
||||
: 2};
|
||||
`};
|
||||
`;
|
||||
|
||||
type RevisionContainerProps = {
|
||||
docFullWidth: boolean;
|
||||
};
|
||||
|
||||
const RevisionContainer = styled.div<RevisionContainerProps>`
|
||||
// Adds space to the gutter to make room for icon
|
||||
padding: 0 44px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
grid-row: 1;
|
||||
grid-column: ${({ docFullWidth }: RevisionContainerProps) =>
|
||||
docFullWidth ? "1 / -1" : 2};
|
||||
`}
|
||||
`;
|
||||
|
||||
const Footer = styled.div`
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
@@ -595,34 +693,4 @@ const ReferencesWrapper = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
type MaxWidthProps = {
|
||||
isEditing?: boolean;
|
||||
isFullWidth?: boolean;
|
||||
archived?: boolean;
|
||||
showContents?: boolean;
|
||||
};
|
||||
|
||||
const MaxWidth = styled(Flex)<MaxWidthProps>`
|
||||
// Adds space to the gutter to make room for heading annotations
|
||||
padding: 0 32px;
|
||||
transition: padding 100ms;
|
||||
max-width: 100vw;
|
||||
width: 100%;
|
||||
|
||||
padding-bottom: 16px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
margin: 4px auto 12px;
|
||||
max-width: ${(props: MaxWidthProps) =>
|
||||
props.isFullWidth
|
||||
? "100vw"
|
||||
: `calc(64px + 46em + ${props.showContents ? "256px" : "0px"});`}
|
||||
`};
|
||||
|
||||
${breakpoint("desktopLarge")`
|
||||
max-width: ${(props: MaxWidthProps) =>
|
||||
props.isFullWidth ? "100vw" : `calc(64px + 52em);`}
|
||||
`};
|
||||
`;
|
||||
|
||||
export default withTranslation()(withStores(withRouter(DocumentScene)));
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Selection } from "prosemirror-state";
|
||||
import { __parseFromClipboard } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import styled, { css } from "styled-components";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import isMarkdown from "@shared/editor/lib/isMarkdown";
|
||||
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
|
||||
@@ -33,8 +33,6 @@ type Props = {
|
||||
title: string;
|
||||
/** Emoji to display */
|
||||
emoji?: string | null;
|
||||
/** Position of the emoji relative to text */
|
||||
emojiPosition: "side" | "top";
|
||||
/** Placeholder to display when the document has no title */
|
||||
placeholder?: string;
|
||||
/** Should the title be editable, policies will also be considered separately */
|
||||
@@ -59,7 +57,6 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
|
||||
documentId,
|
||||
title,
|
||||
emoji,
|
||||
emojiPosition,
|
||||
readOnly,
|
||||
onChangeTitle,
|
||||
onChangeEmoji,
|
||||
@@ -247,12 +244,7 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
|
||||
ref={mergeRefs([ref, externalRef])}
|
||||
>
|
||||
{can.update && !readOnly ? (
|
||||
<EmojiWrapper
|
||||
align="center"
|
||||
justify="center"
|
||||
$position={emojiPosition}
|
||||
dir={dir}
|
||||
>
|
||||
<EmojiWrapper align="center" justify="center" dir={dir}>
|
||||
<React.Suspense fallback={emojiIcon}>
|
||||
<StyledEmojiPicker
|
||||
value={emoji}
|
||||
@@ -265,12 +257,7 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
|
||||
</React.Suspense>
|
||||
</EmojiWrapper>
|
||||
) : emoji ? (
|
||||
<EmojiWrapper
|
||||
align="center"
|
||||
justify="center"
|
||||
$position={emojiPosition}
|
||||
dir={dir}
|
||||
>
|
||||
<EmojiWrapper align="center" justify="center" dir={dir}>
|
||||
{emojiIcon}
|
||||
</EmojiWrapper>
|
||||
) : null}
|
||||
@@ -282,25 +269,17 @@ const StyledEmojiPicker = styled(EmojiPicker)`
|
||||
${extraArea(8)}
|
||||
`;
|
||||
|
||||
const EmojiWrapper = styled(Flex)<{ $position: "top" | "side"; dir?: string }>`
|
||||
const EmojiWrapper = styled(Flex)<{ dir?: string }>`
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
|
||||
// Always move above TOC
|
||||
z-index: 1;
|
||||
|
||||
${(props) =>
|
||||
props.$position === "top"
|
||||
? css`
|
||||
position: relative;
|
||||
top: -8px;
|
||||
`
|
||||
: css`
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
${(props: { dir?: string }) =>
|
||||
props.dir === "rtl" ? "right: -40px" : "left: -40px"};
|
||||
`}
|
||||
${(props: { dir?: string }) =>
|
||||
props.dir === "rtl" ? "right: -40px" : "left: -40px"};
|
||||
`;
|
||||
|
||||
type TitleProps = {
|
||||
|
||||
@@ -187,7 +187,6 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
: document.title
|
||||
}
|
||||
emoji={document.emoji}
|
||||
emojiPosition={document.fullWidth ? "top" : "side"}
|
||||
onChangeTitle={onChangeTitle}
|
||||
onChangeEmoji={onChangeEmoji}
|
||||
onGoToNextInput={handleGoToNextInput}
|
||||
|
||||
@@ -31,7 +31,6 @@ function RevisionViewer(props: Props) {
|
||||
documentId={revision.documentId}
|
||||
title={revision.title}
|
||||
emoji={revision.emoji}
|
||||
emojiPosition={document.fullWidth ? "top" : "side"}
|
||||
readOnly
|
||||
/>
|
||||
<DocumentMeta
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import User from "~/models/User";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Badge from "~/components/Badge";
|
||||
import Button from "~/components/Button";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Time from "~/components/Time";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
canEdit: boolean;
|
||||
onAdd: () => void;
|
||||
};
|
||||
|
||||
const UserListItem = ({ user, onAdd, canEdit }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
title={user.name}
|
||||
image={<Avatar model={user} size={32} />}
|
||||
subtitle={
|
||||
<>
|
||||
{user.lastActiveAt ? (
|
||||
<Trans>
|
||||
Active <Time dateTime={user.lastActiveAt} /> ago
|
||||
</Trans>
|
||||
) : (
|
||||
t("Never signed in")
|
||||
)}
|
||||
{user.isInvited && <Badge>{t("Invited")}</Badge>}
|
||||
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
canEdit ? (
|
||||
<Button type="button" onClick={onAdd} icon={<PlusIcon />} neutral>
|
||||
{t("Add")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(UserListItem);
|
||||
@@ -93,17 +93,15 @@ function AuthenticationProvider(props: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<ButtonLarge
|
||||
onClick={() => (window.location.href = href)}
|
||||
icon={<PluginIcon id={id} />}
|
||||
fullwidth
|
||||
>
|
||||
{t("Continue with {{ authProviderName }}", {
|
||||
authProviderName: name,
|
||||
})}
|
||||
</ButtonLarge>
|
||||
</Wrapper>
|
||||
<ButtonLarge
|
||||
onClick={() => (window.location.href = href)}
|
||||
icon={<PluginIcon id={id} />}
|
||||
fullwidth
|
||||
>
|
||||
{t("Continue with {{ authProviderName }}", {
|
||||
authProviderName: name,
|
||||
})}
|
||||
</ButtonLarge>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,8 +53,8 @@ function Search(props: Props) {
|
||||
|
||||
// refs
|
||||
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const resultListCompositeRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const recentSearchesCompositeRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const resultListRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const recentSearchesRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// filters
|
||||
const query = decodeURIComponentSafe(routeMatch.params.term ?? "");
|
||||
@@ -178,19 +178,9 @@ function Search(props: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
const firstResultItem = (
|
||||
resultListCompositeRef.current?.querySelectorAll(
|
||||
"[href]"
|
||||
) as NodeListOf<HTMLAnchorElement>
|
||||
)?.[0];
|
||||
const firstItem = (resultListRef.current?.firstElementChild ??
|
||||
recentSearchesRef.current?.firstElementChild) as HTMLAnchorElement;
|
||||
|
||||
const firstRecentSearchItem = (
|
||||
recentSearchesCompositeRef.current?.querySelectorAll(
|
||||
"li > [href]"
|
||||
) as NodeListOf<HTMLAnchorElement>
|
||||
)?.[0];
|
||||
|
||||
const firstItem = firstResultItem ?? firstRecentSearchItem;
|
||||
firstItem?.focus();
|
||||
}
|
||||
};
|
||||
@@ -277,11 +267,11 @@ function Search(props: Props) {
|
||||
)}
|
||||
<ResultList column>
|
||||
<StyledArrowKeyNavigation
|
||||
ref={resultListCompositeRef}
|
||||
ref={resultListRef}
|
||||
onEscape={handleEscape}
|
||||
aria-label={t("Search Results")}
|
||||
>
|
||||
{(compositeProps) =>
|
||||
{() =>
|
||||
data?.length
|
||||
? data.map((result) => (
|
||||
<DocumentListItem
|
||||
@@ -291,7 +281,6 @@ function Search(props: Props) {
|
||||
context={result.context}
|
||||
showCollection
|
||||
showTemplate
|
||||
{...compositeProps}
|
||||
/>
|
||||
))
|
||||
: null
|
||||
@@ -305,10 +294,7 @@ function Search(props: Props) {
|
||||
</ResultList>
|
||||
</>
|
||||
) : documentId || collectionId ? null : (
|
||||
<RecentSearches
|
||||
ref={recentSearchesCompositeRef}
|
||||
onEscape={handleEscape}
|
||||
/>
|
||||
<RecentSearches ref={recentSearchesRef} onEscape={handleEscape} />
|
||||
)}
|
||||
</ResultsWrapper>
|
||||
</Scene>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
useFocusEffect,
|
||||
useRovingTabIndex,
|
||||
} from "@getoutline/react-roving-tabindex";
|
||||
import { CloseIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import type SearchQuery from "~/models/SearchQuery";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { hover } from "~/styles";
|
||||
import { searchPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
searchQuery: SearchQuery;
|
||||
};
|
||||
|
||||
function RecentSearchListItem({ searchQuery }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const ref = React.useRef<HTMLAnchorElement>(null);
|
||||
|
||||
const { focused, ...rovingTabIndex } = useRovingTabIndex(ref, false);
|
||||
useFocusEffect(focused, ref);
|
||||
|
||||
return (
|
||||
<RecentSearch
|
||||
to={searchPath(searchQuery.query)}
|
||||
ref={ref}
|
||||
{...rovingTabIndex}
|
||||
>
|
||||
{searchQuery.query}
|
||||
<Tooltip content={t("Remove search")} delay={150}>
|
||||
<RemoveButton
|
||||
aria-label={t("Remove search")}
|
||||
onClick={async (ev) => {
|
||||
ev.preventDefault();
|
||||
await searchQuery.delete();
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</RemoveButton>
|
||||
</Tooltip>
|
||||
</RecentSearch>
|
||||
);
|
||||
}
|
||||
|
||||
const RemoveButton = styled(NudeButton)`
|
||||
opacity: 0;
|
||||
color: ${s("textTertiary")};
|
||||
|
||||
&:hover {
|
||||
color: ${s("text")};
|
||||
}
|
||||
`;
|
||||
|
||||
const RecentSearch = styled(Link)`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: ${s("textSecondary")};
|
||||
cursor: var(--pointer);
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
font-size: 14px;
|
||||
|
||||
&:before {
|
||||
content: "·";
|
||||
color: ${s("textTertiary")};
|
||||
position: absolute;
|
||||
left: -8px;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:${hover} {
|
||||
color: ${s("text")};
|
||||
background: ${s("secondaryBackground")};
|
||||
|
||||
${RemoveButton} {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default RecentSearchListItem;
|
||||
@@ -1,18 +1,12 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { CloseIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { CompositeItem } from "reakit/Composite";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
|
||||
import Fade from "~/components/Fade";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover } from "~/styles";
|
||||
import { searchPath } from "~/utils/routeHelpers";
|
||||
import RecentSearchListItem from "./RecentSearchListItem";
|
||||
|
||||
type Props = {
|
||||
/** Callback when the Escape key is pressed while navigating the list */
|
||||
@@ -36,39 +30,20 @@ function RecentSearches(
|
||||
const content = searches.recent.length ? (
|
||||
<>
|
||||
<Heading>{t("Recent searches")}</Heading>
|
||||
<List>
|
||||
<ArrowKeyNavigation
|
||||
ref={ref}
|
||||
onEscape={onEscape}
|
||||
aria-label={t("Search Results")}
|
||||
>
|
||||
{(compositeProps) =>
|
||||
searches.recent.map((searchQuery) => (
|
||||
<ListItem key={searchQuery.id}>
|
||||
<CompositeItem
|
||||
as={RecentSearch}
|
||||
to={searchPath(searchQuery.query)}
|
||||
role="menuitem"
|
||||
{...compositeProps}
|
||||
>
|
||||
{searchQuery.query}
|
||||
<Tooltip content={t("Remove search")} delay={150}>
|
||||
<RemoveButton
|
||||
aria-label={t("Remove search")}
|
||||
onClick={async (ev) => {
|
||||
ev.preventDefault();
|
||||
await searchQuery.delete();
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</RemoveButton>
|
||||
</Tooltip>
|
||||
</CompositeItem>
|
||||
</ListItem>
|
||||
))
|
||||
}
|
||||
</ArrowKeyNavigation>
|
||||
</List>
|
||||
<StyledArrowKeyNavigation
|
||||
ref={ref}
|
||||
onEscape={onEscape}
|
||||
aria-label={t("Recent searches")}
|
||||
>
|
||||
{() =>
|
||||
searches.recent.map((searchQuery) => (
|
||||
<RecentSearchListItem
|
||||
key={searchQuery.id}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</StyledArrowKeyNavigation>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
@@ -83,55 +58,9 @@ const Heading = styled.h2`
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
const List = styled.ol`
|
||||
const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)`
|
||||
padding: 0;
|
||||
margin-top: 8px;
|
||||
`;
|
||||
|
||||
const ListItem = styled.li`
|
||||
font-size: 14px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
position: relative;
|
||||
|
||||
&:before {
|
||||
content: "·";
|
||||
color: ${s("textTertiary")};
|
||||
position: absolute;
|
||||
left: -8px;
|
||||
}
|
||||
`;
|
||||
|
||||
const RemoveButton = styled(NudeButton)`
|
||||
opacity: 0;
|
||||
color: ${s("textTertiary")};
|
||||
|
||||
&:hover {
|
||||
color: ${s("text")};
|
||||
}
|
||||
`;
|
||||
|
||||
const RecentSearch = styled(Link)`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: ${s("textSecondary")};
|
||||
cursor: var(--pointer);
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&: ${hover} {
|
||||
color: ${s("text")};
|
||||
background: ${s("secondaryBackground")};
|
||||
|
||||
${RemoveButton} {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(React.forwardRef(RecentSearches));
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { ThemeProvider, useTheme } from "styled-components";
|
||||
import { buildDarkTheme, buildLightTheme } from "@shared/styles/theme";
|
||||
import { CustomTheme, TeamPreference } from "@shared/types";
|
||||
import { CustomTheme, TOCPosition, TeamPreference } from "@shared/types";
|
||||
import { getBaseDomain } from "@shared/utils/domains";
|
||||
import Button from "~/components/Button";
|
||||
import ButtonLink from "~/components/ButtonLink";
|
||||
@@ -16,6 +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 Scene from "~/components/Scene";
|
||||
import Switch from "~/components/Switch";
|
||||
import Text from "~/components/Text";
|
||||
@@ -58,6 +59,10 @@ function Details() {
|
||||
isHexColor
|
||||
);
|
||||
|
||||
const [tocPosition, setTocPosition] = useState(
|
||||
team.getPreference(TeamPreference.TocPosition) as TOCPosition
|
||||
);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (event?: React.SyntheticEvent) => {
|
||||
if (event) {
|
||||
@@ -73,6 +78,7 @@ function Details() {
|
||||
...team.preferences,
|
||||
publicBranding,
|
||||
customTheme,
|
||||
tocPosition,
|
||||
},
|
||||
});
|
||||
toast.success(t("Settings saved"));
|
||||
@@ -174,7 +180,6 @@ function Details() {
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
border={false}
|
||||
label={t("Theme")}
|
||||
name="accent"
|
||||
description={
|
||||
@@ -212,7 +217,6 @@ function Details() {
|
||||
</SettingRow>
|
||||
{team.avatarUrl && (
|
||||
<SettingRow
|
||||
border={false}
|
||||
name={TeamPreference.PublicBranding}
|
||||
label={t("Public branding")}
|
||||
description={t(
|
||||
@@ -229,6 +233,30 @@ function Details() {
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
<SettingRow
|
||||
border={false}
|
||||
label={t("Table of contents position")}
|
||||
name="tocPosition"
|
||||
description={t(
|
||||
"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,
|
||||
},
|
||||
]}
|
||||
value={tocPosition}
|
||||
onChange={(p: TOCPosition) => setTocPosition(p)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<Heading as="h2">{t("Behavior")}</Heading>
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Share from "~/models/Share";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Time from "~/components/Time";
|
||||
import ShareMenu from "~/menus/ShareMenu";
|
||||
|
||||
type Props = {
|
||||
share: Share;
|
||||
};
|
||||
|
||||
const ShareListItem = ({ share }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { lastAccessedAt } = share;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
title={share.documentTitle}
|
||||
subtitle={
|
||||
<>
|
||||
{t("Shared")} <Time dateTime={share.createdAt} addSuffix />{" "}
|
||||
{t("by {{ name }}", {
|
||||
name: share.createdBy.name,
|
||||
})}{" "}
|
||||
{lastAccessedAt && (
|
||||
<>
|
||||
{" "}
|
||||
· {t("Last accessed")}{" "}
|
||||
<Time dateTime={lastAccessedAt} addSuffix />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
actions={<ShareMenu share={share} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShareListItem;
|
||||
@@ -1,50 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import User from "~/models/User";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Badge from "~/components/Badge";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Time from "~/components/Time";
|
||||
import UserMenu from "~/menus/UserMenu";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
showMenu: boolean;
|
||||
};
|
||||
|
||||
const UserListItem = ({ user, showMenu }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
title={<Title>{user.name}</Title>}
|
||||
image={<Avatar model={user} size={32} />}
|
||||
subtitle={
|
||||
<>
|
||||
{user.email ? `${user.email} · ` : undefined}
|
||||
{user.lastActiveAt ? (
|
||||
<Trans>
|
||||
Active <Time dateTime={user.lastActiveAt} /> ago
|
||||
</Trans>
|
||||
) : (
|
||||
t("Invited")
|
||||
)}
|
||||
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
|
||||
{user.isSuspended && <Badge>{t("Suspended")}</Badge>}
|
||||
</>
|
||||
}
|
||||
actions={showMenu ? <UserMenu user={user} /> : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Title = styled.span`
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
cursor: var(--pointer);
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(UserListItem);
|
||||
+14
-1
@@ -3,6 +3,7 @@ import { light as defaultTheme } from "@shared/styles/theme";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
import Document from "~/models/Document";
|
||||
import type { ConnectionStatus } from "~/scenes/Document/components/MultiplayerEditor";
|
||||
import type RootStore from "./RootStore";
|
||||
|
||||
const UI_STORE = "UI_STORE";
|
||||
|
||||
@@ -82,7 +83,11 @@ class UiStore {
|
||||
@observable
|
||||
multiplayerErrorCode?: number;
|
||||
|
||||
constructor() {
|
||||
rootStore: RootStore;
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
this.rootStore = rootStore;
|
||||
|
||||
// Rehydrate
|
||||
const data: PersistedData = Storage.get(UI_STORE) || {};
|
||||
this.languagePromptDismissed = data.languagePromptDismissed;
|
||||
@@ -270,6 +275,14 @@ class UiStore {
|
||||
this.mobileSidebarVisible = false;
|
||||
};
|
||||
|
||||
@computed
|
||||
get readyToShow() {
|
||||
return (
|
||||
!this.rootStore.auth.user ||
|
||||
(this.rootStore.collections.isLoaded && this.rootStore.documents.isLoaded)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current state of the sidebar taking into account user preference
|
||||
* and whether the sidebar has been hidden as part of launching in a new
|
||||
|
||||
+2
-1
@@ -8,7 +8,8 @@ type LogCategory =
|
||||
| "editor"
|
||||
| "router"
|
||||
| "collaboration"
|
||||
| "misc";
|
||||
| "misc"
|
||||
| "plugins";
|
||||
|
||||
type Extra = Record<string, any>;
|
||||
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import React from "react";
|
||||
import { UserRole } from "@shared/types";
|
||||
|
||||
interface Plugin {
|
||||
id: string;
|
||||
config: {
|
||||
name: string;
|
||||
description: string;
|
||||
roles?: UserRole[];
|
||||
deployments?: string[];
|
||||
};
|
||||
settings: React.FC;
|
||||
icon: React.FC<{ size?: number; fill?: string }>;
|
||||
}
|
||||
|
||||
export default class PluginLoader {
|
||||
private static pluginsCache: { [id: string]: Plugin };
|
||||
|
||||
public static get plugins(): { [id: string]: Plugin } {
|
||||
if (this.pluginsCache) {
|
||||
return this.pluginsCache;
|
||||
}
|
||||
const plugins = {};
|
||||
|
||||
function importAll(r: any, property: string) {
|
||||
Object.keys(r).forEach((key: string) => {
|
||||
const id = key.split("/")[3];
|
||||
plugins[id] = plugins[id] || {
|
||||
id,
|
||||
};
|
||||
plugins[id][property] = r[key].default ?? React.lazy(r[key]);
|
||||
});
|
||||
}
|
||||
|
||||
importAll(
|
||||
import.meta.glob("../../plugins/*/client/Settings.{ts,js,tsx,jsx}"),
|
||||
"settings"
|
||||
);
|
||||
importAll(
|
||||
import.meta.glob("../../plugins/*/client/Icon.{ts,js,tsx,jsx}", {
|
||||
eager: true,
|
||||
}),
|
||||
"icon"
|
||||
);
|
||||
importAll(
|
||||
import.meta.glob("../../plugins/*/plugin.json", { eager: true }),
|
||||
"config"
|
||||
);
|
||||
|
||||
this.pluginsCache = plugins;
|
||||
return plugins;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import isArray from "lodash/isArray";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import { action, observable } from "mobx";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
import Logger from "./Logger";
|
||||
import isCloudHosted from "./isCloudHosted";
|
||||
|
||||
/**
|
||||
* The different types of client plugins that can be registered.
|
||||
*/
|
||||
export enum Hook {
|
||||
Settings = "settings",
|
||||
Icon = "icon",
|
||||
}
|
||||
|
||||
/**
|
||||
* A map of plugin types to their values, each plugin type has a different shape of value.
|
||||
*/
|
||||
type PluginValueMap = {
|
||||
[Hook.Settings]: {
|
||||
/** The group in settings sidebar this plugin belongs to. */
|
||||
group: string;
|
||||
/** An optional settings item to display this after. */
|
||||
after?: string;
|
||||
/** The displayed icon of the plugin. */
|
||||
icon: React.ElementType;
|
||||
/** The settings screen somponent, should be lazy loaded. */
|
||||
component: React.LazyExoticComponent<React.ComponentType>;
|
||||
};
|
||||
[Hook.Icon]: React.ElementType;
|
||||
};
|
||||
|
||||
export type Plugin<T extends Hook> = {
|
||||
/** A unique identifier for the plugin */
|
||||
id: string;
|
||||
/** Plugin type */
|
||||
type: T;
|
||||
/** The plugin's display name */
|
||||
name: string;
|
||||
/** A brief description of the plugin */
|
||||
description?: string;
|
||||
/** The plugin content */
|
||||
value: PluginValueMap[T];
|
||||
/** Priority will affect order in menus and execution. Lower is earlier. */
|
||||
priority?: number;
|
||||
/** The deployments this plugin is enabled for (default: all) */
|
||||
deployments?: string[];
|
||||
/** The roles this plugin is enabled for. (default: admin) */
|
||||
roles?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Client plugin manager.
|
||||
*/
|
||||
export class PluginManager {
|
||||
/**
|
||||
* Add plugins to the manager.
|
||||
*
|
||||
* @param plugins
|
||||
*/
|
||||
public static add(plugins: Array<Plugin<Hook>> | Plugin<Hook>) {
|
||||
if (isArray(plugins)) {
|
||||
return plugins.forEach((plugin) => this.register(plugin));
|
||||
}
|
||||
|
||||
this.register(plugins);
|
||||
}
|
||||
|
||||
@action
|
||||
private static register<T extends Hook>(plugin: Plugin<T>) {
|
||||
const enabledInDeployment =
|
||||
!plugin?.deployments ||
|
||||
plugin.deployments.length === 0 ||
|
||||
(plugin.deployments.includes("cloud") && isCloudHosted) ||
|
||||
(plugin.deployments.includes("community") && !isCloudHosted) ||
|
||||
(plugin.deployments.includes("enterprise") && !isCloudHosted);
|
||||
if (!enabledInDeployment) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.plugins.has(plugin.type)) {
|
||||
this.plugins.set(plugin.type, observable.array([]));
|
||||
}
|
||||
|
||||
this.plugins
|
||||
.get(plugin.type)!
|
||||
.push({ ...plugin, priority: plugin.priority ?? 0 });
|
||||
|
||||
Logger.debug(
|
||||
"plugins",
|
||||
`Plugin(type=${plugin.type}) registered ${plugin.name} ${
|
||||
plugin.description ? `(${plugin.description})` : ""
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the plugins of a given type in order of priority.
|
||||
*
|
||||
* @param type The type of plugin to filter by
|
||||
* @returns A list of plugins
|
||||
*/
|
||||
public static getHooks<T extends Hook>(type: T) {
|
||||
return sortBy(this.plugins.get(type) || [], "priority") as Plugin<T>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a plugin of a given type by its id.
|
||||
*
|
||||
* @param type The type of plugin to filter by
|
||||
* @param id The id of the plugin
|
||||
* @returns A plugin
|
||||
*/
|
||||
public static getHook<T extends Hook>(type: T, id: string) {
|
||||
return this.plugins.get(type)?.find((hook) => hook.id === id) as
|
||||
| Plugin<T>
|
||||
| undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load plugin client components, must be in `/<plugin>/client/index.ts(x)`
|
||||
*/
|
||||
public static async loadPlugins() {
|
||||
if (this.loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const r = import.meta.glob("../../plugins/*/client/index.{ts,js,tsx,jsx}");
|
||||
await Promise.all(Object.keys(r).map((key: string) => r[key]()));
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
private static plugins = observable.map<Hook, Plugin<Hook>[]>();
|
||||
|
||||
@observable
|
||||
private static loaded = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience hook to get the value for a specific plugin and type.
|
||||
*/
|
||||
export function usePluginValue<T extends Hook>(type: T, id: string) {
|
||||
return useComputed(
|
||||
() => PluginManager.getHook<T>(type, id)?.value,
|
||||
[type, id]
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -6,4 +6,4 @@ The Outline team takes security bugs seriously. We appreciate your efforts to re
|
||||
|
||||
If you discover a security vulnerability in outline, please disclose it via [GitHub](https://github.com/outline/outline/security/advisories/new). The Outline maintainers will send a response indicating the next steps in handling your report. After the initial reply to your report you will be kept informed of the progress towards a fix and full announcement.
|
||||
|
||||
Report security bugs in third-party dependencies to the person or team maintaining the module. You can also report a vulnerability through the [Node Security Project](https://nodesecurity.io/report).
|
||||
Report security bugs in third-party dependencies to the person or team maintaining the module.
|
||||
|
||||
+8
-6
@@ -50,7 +50,7 @@
|
||||
"@aws-sdk/client-s3": "3.577.0",
|
||||
"@aws-sdk/lib-storage": "3.577.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.588.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.577.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.592.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.577.0",
|
||||
"@babel/core": "^7.23.7",
|
||||
"@babel/plugin-proposal-decorators": "^7.23.2",
|
||||
@@ -70,6 +70,7 @@
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@getoutline/react-roving-tabindex": "^3.2.2",
|
||||
"@getoutline/y-prosemirror": "^1.0.18",
|
||||
"@hocuspocus/extension-throttle": "1.1.2",
|
||||
"@hocuspocus/provider": "1.1.2",
|
||||
@@ -178,7 +179,7 @@
|
||||
"prosemirror-inputrules": "^1.3.0",
|
||||
"prosemirror-keymap": "^1.2.2",
|
||||
"prosemirror-markdown": "^1.13.0",
|
||||
"prosemirror-model": "^1.21.0",
|
||||
"prosemirror-model": "^1.21.1",
|
||||
"prosemirror-schema-list": "^1.3.0",
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-tables": "^1.3.7",
|
||||
@@ -232,7 +233,7 @@
|
||||
"throng": "^5.0.0",
|
||||
"tiny-cookie": "^2.5.1",
|
||||
"tmp": "^0.2.3",
|
||||
"turndown": "^7.1.3",
|
||||
"turndown": "^7.2.0",
|
||||
"umzug": "^3.2.1",
|
||||
"utility-types": "^3.10.0",
|
||||
"uuid": "^8.3.2",
|
||||
@@ -285,7 +286,7 @@
|
||||
"@types/mermaid": "^9.2.0",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/natural-sort": "^0.0.24",
|
||||
"@types/node": "20.10.0",
|
||||
"@types/node": "20.14.2",
|
||||
"@types/node-fetch": "^2.6.9",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/passport-oauth2": "^1.4.17",
|
||||
@@ -327,6 +328,7 @@
|
||||
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.4",
|
||||
"browserslist-to-esbuild": "^1.2.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"discord-api-types": "^0.37.87",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^8.10.0",
|
||||
"eslint-import-resolver-typescript": "^3.6.1",
|
||||
@@ -350,7 +352,7 @@
|
||||
"react-refresh": "^0.14.0",
|
||||
"rimraf": "^2.5.4",
|
||||
"rollup-plugin-webpack-stats": "^0.2.4",
|
||||
"terser": "^5.19.2",
|
||||
"terser": "^5.31.1",
|
||||
"typescript": "^5.4.5",
|
||||
"vite-plugin-static-copy": "^0.17.0",
|
||||
"yarn-deduplicate": "^6.0.2"
|
||||
@@ -366,5 +368,5 @@
|
||||
"qs": "6.9.7",
|
||||
"rollup": "^4.5.1"
|
||||
},
|
||||
"version": "0.76.2-0"
|
||||
"version": "0.77.1"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
|
||||
PluginManager.add([
|
||||
{
|
||||
...config,
|
||||
type: Hook.Icon,
|
||||
value: Icon,
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
/** The size of the icon, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
/** The color of the icon, defaults to the current text color */
|
||||
fill?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function DiscordLogo({ size = 24, fill = "currentColor", className }: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={fill}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M17.5535 7.01557C16.5023 6.5343 15.3925 6.19287 14.2526 6C14.0966 6.27886 13.9555 6.56577 13.8298 6.85952C12.6155 6.67655 11.3807 6.67655 10.1664 6.85952C10.0406 6.5658 9.89949 6.27889 9.74357 6C8.60289 6.1945 7.4924 6.53674 6.44013 7.01809C4.3511 10.1088 3.78479 13.1228 4.06794 16.0941C5.29133 16.9979 6.66066 17.6854 8.11639 18.1265C8.44417 17.6856 8.73422 17.2179 8.98346 16.7283C8.51007 16.5515 8.05317 16.3334 7.61804 16.0764C7.73256 15.9934 7.84456 15.9078 7.95279 15.8247C9.21891 16.4202 10.6008 16.7289 12 16.7289C13.3991 16.7289 14.781 16.4202 16.0472 15.8247C16.1566 15.9141 16.2686 15.9997 16.3819 16.0764C15.9459 16.3338 15.4882 16.5523 15.014 16.7296C15.2629 17.2189 15.553 17.6862 15.881 18.1265C17.338 17.6871 18.7084 17 19.932 16.0953C20.2642 12.6497 19.3644 9.66336 17.5535 7.01557ZM9.34212 14.2668C8.55307 14.2668 7.90119 13.5507 7.90119 12.6698C7.90119 11.7889 8.53042 11.0665 9.3396 11.0665C10.1488 11.0665 10.7956 11.7889 10.7818 12.6698C10.7679 13.5507 10.1463 14.2668 9.34212 14.2668ZM14.6578 14.2668C13.8675 14.2668 13.2182 13.5507 13.2182 12.6698C13.2182 11.7889 13.8474 11.0665 14.6578 11.0665C15.4683 11.0665 16.1101 11.7889 16.0962 12.6698C16.0824 13.5507 15.462 14.2668 14.6578 14.2668Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default DiscordLogo;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
|
||||
PluginManager.add([
|
||||
{
|
||||
...config,
|
||||
type: Hook.Icon,
|
||||
value: Icon,
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "discord",
|
||||
"name": "Discord",
|
||||
"priority": 10,
|
||||
"description": "Adds a Discord authentication provider."
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import passport from "@outlinewiki/koa-passport";
|
||||
import type {
|
||||
RESTGetAPICurrentUserGuildsResult,
|
||||
RESTGetAPICurrentUserResult,
|
||||
RESTGetCurrentUserGuildMemberResult,
|
||||
} from "discord-api-types/v10";
|
||||
import type { Context } from "koa";
|
||||
import Router from "koa-router";
|
||||
|
||||
import { Strategy } from "passport-oauth2";
|
||||
import { languages } from "@shared/i18n";
|
||||
import { slugifyDomain } from "@shared/utils/domains";
|
||||
import slugify from "@shared/utils/slugify";
|
||||
import accountProvisioner from "@server/commands/accountProvisioner";
|
||||
import { InvalidRequestError, TeamDomainRequiredError } from "@server/errors";
|
||||
import passportMiddleware from "@server/middlewares/passport";
|
||||
import { User } from "@server/models";
|
||||
import { AuthenticationResult } from "@server/types";
|
||||
import {
|
||||
StateStore,
|
||||
getTeamFromContext,
|
||||
getClientFromContext,
|
||||
request,
|
||||
} from "@server/utils/passport";
|
||||
import config from "../../plugin.json";
|
||||
import env from "../env";
|
||||
import { DiscordGuildError, DiscordGuildRoleError } from "../errors";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
const scope = ["identify", "email"];
|
||||
|
||||
if (env.DISCORD_SERVER_ID) {
|
||||
scope.push("guilds", "guilds.members.read");
|
||||
}
|
||||
|
||||
if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
|
||||
passport.use(
|
||||
config.id,
|
||||
new Strategy(
|
||||
{
|
||||
clientID: env.DISCORD_CLIENT_ID,
|
||||
clientSecret: env.DISCORD_CLIENT_SECRET,
|
||||
passReqToCallback: true,
|
||||
scope,
|
||||
// @ts-expect-error custom state store
|
||||
store: new StateStore(),
|
||||
state: true,
|
||||
callbackURL: `${env.URL}/auth/${config.id}.callback`,
|
||||
authorizationURL: "https://discord.com/api/oauth2/authorize",
|
||||
tokenURL: "https://discord.com/api/oauth2/token",
|
||||
pkce: false,
|
||||
},
|
||||
async function (
|
||||
ctx: Context,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
params: { expires_in: number },
|
||||
_profile: unknown,
|
||||
done: (
|
||||
err: Error | null,
|
||||
user: User | null,
|
||||
result?: AuthenticationResult
|
||||
) => void
|
||||
) {
|
||||
try {
|
||||
const team = await getTeamFromContext(ctx);
|
||||
const client = getClientFromContext(ctx);
|
||||
/** Fetch the user's profile */
|
||||
const profile: RESTGetAPICurrentUserResult = await request(
|
||||
"https://discord.com/api/users/@me",
|
||||
accessToken
|
||||
);
|
||||
|
||||
const email = profile.email;
|
||||
if (!email) {
|
||||
/** We have the email scope, so this should never happen */
|
||||
throw InvalidRequestError("Discord profile email is missing");
|
||||
}
|
||||
const parts = email.toLowerCase().split("@");
|
||||
const domain = parts.length && parts[1];
|
||||
|
||||
if (!domain) {
|
||||
throw TeamDomainRequiredError();
|
||||
}
|
||||
|
||||
/** Determine the user's language from the locale */
|
||||
const { locale } = profile;
|
||||
const language = locale
|
||||
? languages.find((l) => l.startsWith(locale))
|
||||
: undefined;
|
||||
|
||||
/** Default user and team names metadata */
|
||||
let userName = profile.username;
|
||||
let teamName = "Wiki";
|
||||
let userAvatarUrl: string = `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png`;
|
||||
let teamAvatarUrl: string | undefined = undefined;
|
||||
let subdomain = slugifyDomain(domain);
|
||||
|
||||
/**
|
||||
* If a Discord server is configured, we will check if the user is a member of the server
|
||||
* Additionally, we can get the user's nickname in the server if it exists
|
||||
*/
|
||||
if (env.DISCORD_SERVER_ID) {
|
||||
/** Fetch the guilds a user is in */
|
||||
const guilds: RESTGetAPICurrentUserGuildsResult = await request(
|
||||
"https://discord.com/api/users/@me/guilds",
|
||||
accessToken
|
||||
);
|
||||
|
||||
/** Find the guild that matches the configured server ID */
|
||||
const guild = guilds?.find((g) => g.id === env.DISCORD_SERVER_ID);
|
||||
|
||||
/** If the user is not in the server, throw an error */
|
||||
if (!guild) {
|
||||
throw DiscordGuildError();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the guild's icon
|
||||
* https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints
|
||||
**/
|
||||
if (guild.icon) {
|
||||
const isGif = guild.icon.startsWith("a_");
|
||||
if (isGif) {
|
||||
teamAvatarUrl = `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.gif`;
|
||||
} else {
|
||||
teamAvatarUrl = `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Guild Name */
|
||||
teamName = guild.name;
|
||||
subdomain = slugify(guild.name);
|
||||
|
||||
/** Fetch the user's member object in the server for nickname and roles */
|
||||
const guildMember: RESTGetCurrentUserGuildMemberResult =
|
||||
await request(
|
||||
`https://discord.com/api/users/@me/guilds/${env.DISCORD_SERVER_ID}/member`,
|
||||
accessToken
|
||||
);
|
||||
|
||||
/** If the user has a nickname in the server, use that as the name */
|
||||
if (guildMember.nick) {
|
||||
userName = guildMember.nick;
|
||||
}
|
||||
|
||||
/** If the user has a custom avatar in the server, use that as the avatar */
|
||||
if (guildMember.avatar) {
|
||||
userAvatarUrl = `https://cdn.discordapp.com/guilds/${guild.id}/users/${profile.id}/avatars/${guildMember.avatar}.png`;
|
||||
}
|
||||
|
||||
/** If server roles are configured, check if the user has any of the roles */
|
||||
if (env.DISCORD_SERVER_ROLES) {
|
||||
const { roles } = guildMember;
|
||||
const hasRole = roles?.some((role) =>
|
||||
env.DISCORD_SERVER_ROLES?.includes(role)
|
||||
);
|
||||
|
||||
/** If the user does not have any of the roles, throw an error */
|
||||
if (!hasRole) {
|
||||
throw DiscordGuildRoleError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if a team can be inferred, we assume the user is only interested in signing into
|
||||
// that team in particular; otherwise, we will do a best effort at finding their account
|
||||
// or provisioning a new one (within AccountProvisioner)
|
||||
const result = await accountProvisioner({
|
||||
ip: ctx.ip,
|
||||
team: {
|
||||
teamId: team?.id,
|
||||
name: teamName,
|
||||
domain,
|
||||
subdomain,
|
||||
avatarUrl: teamAvatarUrl,
|
||||
},
|
||||
user: {
|
||||
email,
|
||||
name: userName,
|
||||
language,
|
||||
avatarUrl: userAvatarUrl,
|
||||
},
|
||||
authenticationProvider: {
|
||||
name: config.id,
|
||||
providerId: env.DISCORD_SERVER_ID ?? "",
|
||||
},
|
||||
authentication: {
|
||||
providerId: profile.id,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: params.expires_in,
|
||||
scopes: scope,
|
||||
},
|
||||
});
|
||||
return done(null, result.user, { ...result, client });
|
||||
} catch (err) {
|
||||
return done(err, null);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.get(
|
||||
config.id,
|
||||
passport.authenticate(config.id, {
|
||||
scope,
|
||||
prompt: "consent",
|
||||
})
|
||||
);
|
||||
router.get(`${config.id}.callback`, passportMiddleware(config.id));
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,18 @@
|
||||
import invariant from "invariant";
|
||||
import OAuthClient from "@server/utils/oauth";
|
||||
import env from "./env";
|
||||
|
||||
export default class DiscordClient extends OAuthClient {
|
||||
endpoints = {
|
||||
authorize: "https://discord.com/oauth2/authorize",
|
||||
token: "https://discord.com/api/oauth2/token",
|
||||
userinfo: "https://discord.com/api/users/@me",
|
||||
};
|
||||
|
||||
constructor() {
|
||||
invariant(env.DISCORD_CLIENT_ID, "DISCORD_CLIENT_ID is required");
|
||||
invariant(env.DISCORD_CLIENT_SECRET, "DISCORD_CLIENT_SECRET is required");
|
||||
|
||||
super(env.DISCORD_CLIENT_ID, env.DISCORD_CLIENT_SECRET);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { IsOptional } from "class-validator";
|
||||
import { Environment } from "@server/env";
|
||||
import environment from "@server/utils/environment";
|
||||
import { CannotUseWithout } from "@server/utils/validators";
|
||||
|
||||
class DiscordPluginEnvironment extends Environment {
|
||||
/**
|
||||
* Discord OAuth2 client credentials. To enable authentication with Discord.
|
||||
*/
|
||||
@IsOptional()
|
||||
@CannotUseWithout("DISCORD_CLIENT_ID")
|
||||
public DISCORD_CLIENT_ID = this.toOptionalString(
|
||||
environment.DISCORD_CLIENT_ID
|
||||
);
|
||||
|
||||
@IsOptional()
|
||||
@CannotUseWithout("DISCORD_CLIENT_SECRET")
|
||||
public DISCORD_CLIENT_SECRET = this.toOptionalString(
|
||||
environment.DISCORD_CLIENT_SECRET
|
||||
);
|
||||
|
||||
@IsOptional()
|
||||
@CannotUseWithout("DISCORD_CLIENT_SECRET")
|
||||
public DISCORD_SERVER_ID = this.toOptionalString(
|
||||
environment.DISCORD_SERVER_ID
|
||||
);
|
||||
|
||||
@CannotUseWithout("DISCORD_SERVER_ID")
|
||||
@IsOptional()
|
||||
public DISCORD_SERVER_ROLES = this.toOptionalCommaList(
|
||||
environment.DISCORD_SERVER_ROLES
|
||||
);
|
||||
}
|
||||
|
||||
export default new DiscordPluginEnvironment();
|
||||
@@ -0,0 +1,17 @@
|
||||
import httpErrors from "http-errors";
|
||||
|
||||
export function DiscordGuildError(
|
||||
message = "User is not a member of the required Discord server"
|
||||
) {
|
||||
return httpErrors(400, message, {
|
||||
id: "discord_guild_error",
|
||||
});
|
||||
}
|
||||
|
||||
export function DiscordGuildRoleError(
|
||||
message = "User does not have the required role from the Discord server"
|
||||
) {
|
||||
return httpErrors(400, message, {
|
||||
id: "discord_guild_role_error",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { PluginManager, Hook } from "@server/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import router from "./auth/discord";
|
||||
import env from "./env";
|
||||
|
||||
const enabled = !!env.DISCORD_CLIENT_ID && !!env.DISCORD_CLIENT_SECRET;
|
||||
|
||||
if (enabled) {
|
||||
PluginManager.add({
|
||||
...config,
|
||||
type: Hook.AuthProvider,
|
||||
value: { router, id: config.id },
|
||||
});
|
||||
}
|
||||
@@ -90,7 +90,15 @@ router.get(
|
||||
"email.callback",
|
||||
validate(T.EmailCallbackSchema),
|
||||
async (ctx: APIContext<T.EmailCallbackReq>) => {
|
||||
const { token, client } = ctx.input.query;
|
||||
const { token, client, follow } = ctx.input.query;
|
||||
|
||||
// The link in the email does not include the follow query param, this
|
||||
// is to help prevent anti-virus, and email clients from pre-fetching the link
|
||||
// and spending the token before the user clicks on it. Instead we redirect
|
||||
// to the same URL with the follow query param added from the client side.
|
||||
if (!follow) {
|
||||
return ctx.redirectOnClient(ctx.request.href + "&follow=true");
|
||||
}
|
||||
|
||||
let user!: User;
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export const EmailCallbackSchema = BaseSchema.extend({
|
||||
query: z.object({
|
||||
token: z.string(),
|
||||
client: z.nativeEnum(Client).default(Client.Web),
|
||||
follow: z.string().default(""),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import * as React from "react";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
|
||||
PluginManager.add([
|
||||
{
|
||||
...config,
|
||||
type: Hook.Settings,
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
|
||||
PluginManager.add([
|
||||
{
|
||||
...config,
|
||||
type: Hook.Icon,
|
||||
value: Icon,
|
||||
},
|
||||
]);
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "google",
|
||||
"name": "Google",
|
||||
"priority": 10,
|
||||
"priority": 0,
|
||||
"description": "Adds a Google authentication provider."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
/** The size of the icon, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
/** The color of the icon, defaults to the current text color */
|
||||
fill?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function GoogleLogo({ size = 24, fill = "currentColor", className }: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={fill}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M19.2312 10.5455H11.8276V13.6364H16.0892C15.6919 15.6 14.0306 16.7273 11.8276 16.7273C9.22733 16.7273 7.13267 14.6182 7.13267 12C7.13267 9.38182 9.22733 7.27273 11.8276 7.27273C12.9472 7.27273 13.9584 7.67273 14.7529 8.32727L17.0643 6C15.6558 4.76364 13.85 4 11.8276 4C7.42159 4 3.88232 7.56364 3.88232 12C3.88232 16.4364 7.42159 20 11.8276 20C15.8002 20 19.4117 17.0909 19.4117 12C19.4117 11.5273 19.3395 11.0182 19.2312 10.5455Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default GoogleLogo;
|
||||
+1
-1
@@ -6,6 +6,7 @@ import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { IntegrationType, IntegrationService } from "@shared/types";
|
||||
import Integration from "~/models/Integration";
|
||||
import SettingRow from "~/scenes/Settings/components/SettingRow";
|
||||
import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
import GoogleIcon from "~/components/Icons/GoogleIcon";
|
||||
@@ -13,7 +14,6 @@ import Input from "~/components/Input";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
|
||||
type FormData = {
|
||||
measurementId: string;
|
||||
@@ -0,0 +1,16 @@
|
||||
import * as React from "react";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
|
||||
PluginManager.add([
|
||||
{
|
||||
...config,
|
||||
type: Hook.Settings,
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "googleanalytics",
|
||||
"name": "Google Analytics",
|
||||
"priority": 30,
|
||||
"description": "Adds support for reporting analytics to a Google."
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
/** The size of the icon, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
/** The color of the icon, defaults to the current text color */
|
||||
fill?: string;
|
||||
};
|
||||
|
||||
export default function Icon({ size = 24, fill = "currentColor" }: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={fill}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
version="1.1"
|
||||
>
|
||||
<path d="M15.7624 15.309L18.2171 11.588C18.5688 11.0797 18.7702 10.4684 18.7702 9.81395C18.7702 9.72425 18.7668 9.63787 18.76 9.5515L21.4981 13.6113C21.5084 13.6246 21.5118 13.6346 21.522 13.6478L21.5732 13.7243L21.5698 13.7276C21.8395 14.1561 21.9966 14.6578 22 15.1927C22 16.7409 20.7095 17.9967 19.1185 17.9967C18.142 17.9967 17.2851 17.5249 16.7627 16.804L16.7593 16.8073L16.7422 16.7807C16.7286 16.7608 16.7149 16.7375 16.6979 16.7176M15.5336 14.9635L17.1041 12.5814C15.1767 13.6317 12.5053 12.4636 12.2253 10.1993C12.2253 10.1927 12.2253 10.186 12.2219 10.1794L10.8938 8.35216H10.8904C10.3817 7.5515 9.47695 7.01661 8.43564 7.01661C8.43223 7.01661 8.43223 7.01661 8.42882 7.01661C8.4254 7.01661 8.4254 7.01661 8.42199 7.01661C7.38409 7.01661 6.47593 7.5515 5.97064 8.35216H5.96722L3.29737 12.4086C3.76852 12.1528 4.30795 12.01 4.88153 12.01C6.55446 12.01 7.93718 13.2359 8.13179 14.8106L9.63059 16.8571H9.63401C10.1564 17.5482 10.9997 18 11.9522 18H11.959H11.9659C12.9184 18 13.7583 17.5515 14.2841 16.8571H14.2875L14.3114 16.8206C14.3694 16.7409 14.424 16.6578 14.4787 16.5681L15.5336 14.9668V14.9635ZM12.6009 9.80731C12.6009 11.3588 13.8914 12.6146 15.4858 12.6146C17.0802 12.6146 18.3708 11.3588 18.3708 9.80731C18.3708 8.25581 17.0802 7 15.4858 7C13.8914 7 12.6009 8.25581 12.6009 9.80731ZM2 15.196C2 16.7442 3.29054 18 4.88153 18C6.47252 18 7.76306 16.7442 7.76306 15.196C7.76306 13.6478 6.47252 12.392 4.88153 12.392C3.29054 12.392 2 13.6478 2 15.196Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import find from "lodash/find";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { IntegrationType, IntegrationService } from "@shared/types";
|
||||
import Integration from "~/models/Integration";
|
||||
import SettingRow from "~/scenes/Settings/components/SettingRow";
|
||||
import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
import Input from "~/components/Input";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Icon from "./Icon";
|
||||
|
||||
type FormData = {
|
||||
instanceUrl: string;
|
||||
measurementId: string;
|
||||
};
|
||||
|
||||
function Matomo() {
|
||||
const { integrations } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const integration = find(integrations.orderedData, {
|
||||
type: IntegrationType.Analytics,
|
||||
service: IntegrationService.Matomo,
|
||||
}) as Integration<IntegrationType.Analytics> | undefined;
|
||||
|
||||
const {
|
||||
register,
|
||||
reset,
|
||||
handleSubmit: formHandleSubmit,
|
||||
formState,
|
||||
} = useForm<FormData>({
|
||||
mode: "all",
|
||||
defaultValues: {
|
||||
instanceUrl: integration?.settings.instanceUrl,
|
||||
measurementId: integration?.settings.measurementId,
|
||||
},
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
void integrations.fetchPage({
|
||||
type: IntegrationType.Analytics,
|
||||
});
|
||||
}, [integrations]);
|
||||
|
||||
React.useEffect(() => {
|
||||
reset({
|
||||
measurementId: integration?.settings.measurementId,
|
||||
instanceUrl: integration?.settings.instanceUrl,
|
||||
});
|
||||
}, [integration, reset]);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (data: FormData) => {
|
||||
try {
|
||||
if (data.instanceUrl && data.measurementId) {
|
||||
await integrations.save({
|
||||
id: integration?.id,
|
||||
type: IntegrationType.Analytics,
|
||||
service: IntegrationService.Matomo,
|
||||
settings: {
|
||||
measurementId: data.measurementId,
|
||||
// Ensure the URL ends with a trailing slash
|
||||
instanceUrl: data.instanceUrl.replace(/\/?$/, "/"),
|
||||
} as Integration<IntegrationType.Analytics>["settings"],
|
||||
});
|
||||
} else {
|
||||
await integration?.delete();
|
||||
}
|
||||
|
||||
toast.success(t("Settings saved"));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[integrations, integration, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene title="Matomo" icon={<Icon />}>
|
||||
<Heading>Matomo</Heading>
|
||||
|
||||
<Text as="p" type="secondary">
|
||||
<Trans>
|
||||
Configure a Matomo installation to send views and analytics from the
|
||||
workspace to your own Matomo instance.
|
||||
</Trans>
|
||||
</Text>
|
||||
<form onSubmit={formHandleSubmit(handleSubmit)}>
|
||||
<SettingRow
|
||||
label={t("Instance URL")}
|
||||
name="instanceUrl"
|
||||
description={t(
|
||||
"The URL of your Matomo instance. If you are using Matomo Cloud it will end in matomo.cloud/"
|
||||
)}
|
||||
border={false}
|
||||
>
|
||||
<Input
|
||||
required
|
||||
placeholder="https://instance.matomo.cloud/"
|
||||
{...register("instanceUrl")}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("Site ID")}
|
||||
name="measurementId"
|
||||
description={t(
|
||||
"An ID that uniquely identifies the website in your Matomo instance."
|
||||
)}
|
||||
border={false}
|
||||
>
|
||||
<Input required placeholder="1" {...register("measurementId")} />
|
||||
</SettingRow>
|
||||
|
||||
<Button type="submit" disabled={formState.isSubmitting}>
|
||||
{formState.isSubmitting ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
</form>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Matomo);
|
||||
@@ -0,0 +1,16 @@
|
||||
import * as React from "react";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
|
||||
PluginManager.add([
|
||||
{
|
||||
...config,
|
||||
type: Hook.Settings,
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "matomo",
|
||||
"name": "Matomo",
|
||||
"roles": ["admin"],
|
||||
"priority": 40,
|
||||
"description": "Adds support for reporting analytics to a Matomo server.",
|
||||
"deployments": ["community", "enterprise"]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "oidc",
|
||||
"name": "OIDC",
|
||||
"priority": 30,
|
||||
"priority": 10,
|
||||
"description": "Adds an OpenID compatible authentication provider."
|
||||
}
|
||||
|
||||
@@ -14,9 +14,9 @@ import { User } from "@server/models";
|
||||
import { AuthenticationResult } from "@server/types";
|
||||
import {
|
||||
StateStore,
|
||||
request,
|
||||
getTeamFromContext,
|
||||
getClientFromContext,
|
||||
request,
|
||||
} from "@server/utils/passport";
|
||||
import config from "../../plugin.json";
|
||||
import env from "../env";
|
||||
@@ -24,15 +24,6 @@ import env from "../env";
|
||||
const router = new Router();
|
||||
const scopes = env.OIDC_SCOPES.split(" ");
|
||||
|
||||
Strategy.prototype.userProfile = async function (accessToken, done) {
|
||||
try {
|
||||
const response = await request(env.OIDC_USERINFO_URI ?? "", accessToken);
|
||||
return done(null, response);
|
||||
} catch (err) {
|
||||
return done(err);
|
||||
}
|
||||
};
|
||||
|
||||
const authorizationParams = Strategy.prototype.authorizationParams;
|
||||
Strategy.prototype.authorizationParams = function (options) {
|
||||
return {
|
||||
@@ -81,7 +72,7 @@ if (
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
params: { expires_in: number },
|
||||
profile: Record<string, string>,
|
||||
_profile: unknown,
|
||||
done: (
|
||||
err: Error | null,
|
||||
user: User | null,
|
||||
@@ -89,6 +80,11 @@ if (
|
||||
) => void
|
||||
) {
|
||||
try {
|
||||
const profile = await request(
|
||||
env.OIDC_USERINFO_URI ?? "",
|
||||
accessToken
|
||||
);
|
||||
|
||||
if (!profile.email) {
|
||||
throw AuthenticationError(
|
||||
`An email field was not returned in the profile parameter, but is required.`
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
|
||||
PluginManager.add([
|
||||
{
|
||||
...config,
|
||||
type: Hook.Settings,
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
},
|
||||
},
|
||||
{
|
||||
...config,
|
||||
type: Hook.Icon,
|
||||
value: Icon,
|
||||
},
|
||||
]);
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "slack",
|
||||
"name": "Slack",
|
||||
"priority": 40,
|
||||
"priority": 20,
|
||||
"roles": ["admin", "member"],
|
||||
"description": "Adds a Slack authentication provider, support for the /outline slash command, and link unfurling."
|
||||
}
|
||||
|
||||
@@ -72,34 +72,32 @@ router.get(
|
||||
async (ctx: APIContext<T.FilesGetReq>) => {
|
||||
const actor = ctx.state.auth.user;
|
||||
const key = getKeyFromContext(ctx);
|
||||
const forceDownload = !!ctx.input.query.download;
|
||||
const isSignedRequest = !!ctx.input.query.sig;
|
||||
const { isPublicBucket, fileName } = AttachmentHelper.parseKey(key);
|
||||
const skipAuthorize = isPublicBucket || isSignedRequest;
|
||||
const cacheHeader = "max-age=604800, immutable";
|
||||
let contentType =
|
||||
(fileName ? mime.lookup(fileName) : undefined) ||
|
||||
"application/octet-stream";
|
||||
|
||||
if (skipAuthorize) {
|
||||
ctx.set("Cache-Control", cacheHeader);
|
||||
ctx.set(
|
||||
"Content-Type",
|
||||
(fileName ? mime.lookup(fileName) : undefined) ||
|
||||
"application/octet-stream"
|
||||
);
|
||||
ctx.attachment(fileName);
|
||||
} else {
|
||||
if (!skipAuthorize) {
|
||||
const attachment = await Attachment.findOne({
|
||||
where: { key },
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
|
||||
authorize(actor, "read", attachment);
|
||||
|
||||
ctx.set("Cache-Control", cacheHeader);
|
||||
ctx.set("Content-Type", attachment.contentType);
|
||||
ctx.attachment(attachment.name, {
|
||||
type: FileStorage.getContentDisposition(attachment.contentType),
|
||||
});
|
||||
contentType = attachment.contentType;
|
||||
}
|
||||
|
||||
ctx.set("Cache-Control", cacheHeader);
|
||||
ctx.set("Content-Type", contentType);
|
||||
ctx.attachment(fileName, {
|
||||
type: forceDownload
|
||||
? "attachment"
|
||||
: FileStorage.getContentDisposition(contentType),
|
||||
});
|
||||
|
||||
// Handle byte range requests
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests
|
||||
const stats = await (FileStorage as LocalStorage).stat(key);
|
||||
|
||||
@@ -24,6 +24,7 @@ export const FilesGetSchema = z.object({
|
||||
.optional()
|
||||
.transform((val) => (val ? ValidateKey.sanitize(val) : undefined)),
|
||||
sig: z.string().optional(),
|
||||
download: z.string().optional(),
|
||||
})
|
||||
.refine((obj) => !(isEmpty(obj.key) && isEmpty(obj.sig)), {
|
||||
message: "One of key or sig is required",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import * as React from "react";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
|
||||
PluginManager.add([
|
||||
{
|
||||
...config,
|
||||
type: Hook.Settings,
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "webhooks",
|
||||
"name": "Webhooks",
|
||||
"priority": 200,
|
||||
"priority": 0,
|
||||
"description": "Adds HTTP webhooks for various events."
|
||||
}
|
||||
|
||||
@@ -520,7 +520,12 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
|
||||
subscription,
|
||||
payload: {
|
||||
id: event.documentId,
|
||||
model: model && (await presentDocument(undefined, model)),
|
||||
model:
|
||||
model &&
|
||||
(await presentDocument(undefined, model, {
|
||||
includeData: true,
|
||||
includeText: true,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -546,7 +551,12 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
|
||||
payload: {
|
||||
id: event.modelId,
|
||||
model: model && presentMembership(model),
|
||||
document: model && (await presentDocument(undefined, model.document!)),
|
||||
document:
|
||||
model &&
|
||||
(await presentDocument(undefined, model.document!, {
|
||||
includeData: true,
|
||||
includeText: true,
|
||||
})),
|
||||
user: model && presentUser(model.user),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { Optional } from "utility-types";
|
||||
import { Document, Event, User } from "@server/models";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import { TextHelper } from "@server/models/helpers/TextHelper";
|
||||
|
||||
type Props = Optional<
|
||||
@@ -58,6 +59,12 @@ export default async function documentCreator({
|
||||
}: Props): Promise<Document> {
|
||||
const templateId = templateDocument ? templateDocument.id : undefined;
|
||||
|
||||
if (state && templateDocument) {
|
||||
throw new Error(
|
||||
"State cannot be set when creating a document from a template"
|
||||
);
|
||||
}
|
||||
|
||||
if (urlId) {
|
||||
const existing = await Document.unscoped().findOne({
|
||||
attributes: ["id"],
|
||||
@@ -103,6 +110,12 @@ export default async function documentCreator({
|
||||
ip,
|
||||
transaction
|
||||
),
|
||||
content: templateDocument
|
||||
? ProsemirrorHelper.replaceTemplateVariables(
|
||||
templateDocument.content,
|
||||
user
|
||||
)
|
||||
: undefined,
|
||||
state,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -73,7 +73,7 @@ View Document: ${teamUrl}${document.path}
|
||||
const documentUrl = `${teamUrl}${document.path}?ref=notification-email`;
|
||||
|
||||
const permission =
|
||||
membership.permission === DocumentPermission.ReadWrite ? "edit" : "view";
|
||||
membership.permission === DocumentPermission.Read ? "view" : "edit";
|
||||
|
||||
return (
|
||||
<EmailTemplate
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user