Compare commits

..

10 Commits

Author SHA1 Message Date
Tom Moor f8cd5f3e4b fix: Address PR feedback 2025-01-28 19:38:46 -05:00
Tom Moor 39852470cc Update API key list UI 2025-01-27 22:13:47 -05:00
Tom Moor 9a03e1c947 Update API key UI 2025-01-27 22:08:53 -05:00
Tom Moor cfaa08403a Store scopes with full url 2025-01-27 21:35:42 -05:00
Tom Moor 99bc586f34 Switch to storing array 2025-01-27 21:22:54 -05:00
Tom Moor 75838bb311 Merge branch 'main' into tom/api-scopes 2025-01-27 20:30:30 -05:00
Tom Moor 8b3115be9a test 2025-01-25 00:30:00 -05:00
Tom Moor 7782292500 Allow creation 2025-01-25 00:24:13 -05:00
Tom Moor a7da968499 Add scope restriction 2025-01-24 23:24:49 -05:00
Tom Moor a95005776f scope storage 2025-01-24 22:45:32 -05:00
123 changed files with 2062 additions and 2633 deletions
-3
View File
@@ -1,8 +1,5 @@
URL=https://local.outline.dev:3000
DATABASE_URL=postgres://user:pass@127.0.0.1:5432/outline
REDIS_URL=redis://127.0.0.1:6379
SMTP_FROM_EMAIL=hello@example.com
# Enable unsafe-inline in script-src CSP directive
+2 -2
View File
@@ -12,14 +12,14 @@ UTILS_SECRET=generate_a_new_key
# For production point these at your databases, in development the default
# should work out of the box.
DATABASE_URL=postgres://user:pass@postgres:5432/outline
DATABASE_URL=postgres://user:pass@localhost:5432/outline
DATABASE_CONNECTION_POOL_MIN=
DATABASE_CONNECTION_POOL_MAX=
# Uncomment this to disable SSL for connecting to Postgres
# PGSSLMODE=disable
# For redis you can either specify an ioredis compatible url like this
REDIS_URL=redis://redis:6379
REDIS_URL=redis://localhost:6379
# or alternatively, if you would like to provide additional connection options,
# use a base64 encoded JSON connection option object. Refer to the ioredis documentation
# for a list of available options.
+18 -16
View File
@@ -7,10 +7,9 @@ export enum AvatarSize {
Small = 16,
Toast = 18,
Medium = 24,
Large = 28,
XLarge = 32,
XXLarge = 48,
Upload = 64,
Large = 32,
XLarge = 48,
XXLarge = 64,
}
export interface IAvatar {
@@ -21,37 +20,36 @@ export interface IAvatar {
}
type Props = {
/** The size of the avatar */
size: AvatarSize;
/** The source of the avatar image, if not passing a model. */
src?: string;
/** The avatar model, if not passing a source. */
model?: IAvatar;
/** The alt text for the image */
alt?: string;
/** Optional click handler */
showBorder?: boolean;
onClick?: React.MouseEventHandler<HTMLImageElement>;
/** Optional class name */
className?: string;
/** Optional style */
style?: React.CSSProperties;
};
function Avatar(props: Props) {
const { model, style, ...rest } = props;
const { showBorder, model, style, ...rest } = props;
const src = props.src || model?.avatarUrl;
const [error, handleError] = useBoolean(false);
return (
<Relative style={style}>
{src && !error ? (
<CircleImg onError={handleError} src={src} {...rest} />
<CircleImg
onError={handleError}
src={src}
$showBorder={showBorder}
{...rest}
/>
) : model ? (
<Initials color={model.color} {...rest}>
<Initials color={model.color} $showBorder={showBorder} {...rest}>
{model.initial}
</Initials>
) : (
<Initials {...rest} />
<Initials $showBorder={showBorder} {...rest} />
)}
</Relative>
);
@@ -67,11 +65,15 @@ const Relative = styled.div`
flex-shrink: 0;
`;
const CircleImg = styled.img<{ size: number }>`
const CircleImg = styled.img<{ size: number; $showBorder?: boolean }>`
display: block;
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 50%;
border: ${(props) =>
props.$showBorder === false
? "none"
: `2px solid ${props.theme.background}`};
flex-shrink: 0;
overflow: hidden;
`;
+5 -10
View File
@@ -5,7 +5,7 @@ import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import User from "~/models/User";
import Tooltip from "~/components/Tooltip";
import Avatar, { AvatarSize } from "./Avatar";
import Avatar from "./Avatar";
type Props = {
user: User;
@@ -14,8 +14,6 @@ type Props = {
isObserving: boolean;
isCurrentUser: boolean;
onClick?: React.MouseEventHandler<HTMLImageElement>;
size?: AvatarSize;
style?: React.CSSProperties;
};
function AvatarWithPresence({
@@ -25,8 +23,6 @@ function AvatarWithPresence({
isEditing,
isObserving,
isCurrentUser,
size = AvatarSize.Large,
style,
}: Props) {
const { t } = useTranslation();
const status = isPresent
@@ -51,14 +47,13 @@ function AvatarWithPresence({
}
placement="bottom"
>
<AvatarPresence
<AvatarWrapper
$isPresent={isPresent}
$isObserving={isObserving}
$color={user.color}
style={style}
>
<Avatar model={user} onClick={onClick} size={size} />
</AvatarPresence>
<Avatar model={user} onClick={onClick} size={32} />
</AvatarWrapper>
</Tooltip>
</>
);
@@ -74,7 +69,7 @@ type AvatarWrapperProps = {
$color: string;
};
const AvatarPresence = styled.div<AvatarWrapperProps>`
const AvatarWrapper = styled.div<AvatarWrapperProps>`
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
transition: opacity 250ms ease-in-out;
border-radius: 50%;
+6 -8
View File
@@ -3,12 +3,9 @@ import { s } from "@shared/styles";
import Flex from "~/components/Flex";
const Initials = styled(Flex)<{
/** The color of the background, defaults to textTertiary. */
color?: string;
/** Content is only used to calculate font size, use children to render. */
content?: string;
/** The size of the avatar */
size: number;
$showBorder?: boolean;
}>`
align-items: center;
justify-content: center;
@@ -16,14 +13,15 @@ const Initials = styled(Flex)<{
width: 100%;
height: 100%;
color: ${s("white75")};
background-color: ${(props) => props.color ?? props.theme.textTertiary};
background-color: ${(props) => props.color};
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 50%;
border: 2px solid
${(props) =>
props.$showBorder === false ? "transparent" : props.theme.background};
flex-shrink: 0;
// adjust font size down for each additional character
font-size: ${(props) => props.size / 2 - (props.content?.length ?? 0)}px;
font-size: ${(props) => props.size / 2}px;
font-weight: 500;
`;
+34 -41
View File
@@ -7,7 +7,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import Document from "~/models/Document";
import { AvatarSize, AvatarWithPresence } from "~/components/Avatar";
import { AvatarWithPresence } from "~/components/Avatar";
import DocumentViews from "~/components/DocumentViews";
import Facepile from "~/components/Facepile";
import NudeButton from "~/components/NudeButton";
@@ -78,56 +78,49 @@ function Collaborators(props: Props) {
placement: "bottom-end",
});
const renderAvatar = React.useCallback(
({ model: collaborator, ...rest }) => {
const isPresent = presentIds.includes(collaborator.id);
const isEditing = editingIds.includes(collaborator.id);
const isObserving = ui.observingUserId === collaborator.id;
const isObservable = collaborator.id !== currentUserId;
return (
<AvatarWithPresence
{...rest}
key={collaborator.id}
user={collaborator}
isPresent={isPresent}
isEditing={isEditing}
isObserving={isObserving}
isCurrentUser={currentUserId === collaborator.id}
onClick={
isObservable
? (ev) => {
if (isPresent) {
ev.preventDefault();
ev.stopPropagation();
ui.setObservingUser(
isObserving ? undefined : collaborator.id
);
}
}
: undefined
}
/>
);
},
[presentIds, ui, currentUserId, editingIds]
);
return (
<>
<PopoverDisclosure {...popover}>
{(popoverProps) => (
<NudeButton
width={Math.min(collaborators.length, limit) * AvatarSize.Large}
height={AvatarSize.Large}
width={Math.min(collaborators.length, limit) * 32}
height={32}
{...popoverProps}
>
<Facepile
size={AvatarSize.Large}
limit={limit}
overflow={Math.max(0, collaborators.length - limit)}
overflow={collaborators.length - limit}
users={collaborators}
renderAvatar={renderAvatar}
renderAvatar={(collaborator) => {
const isPresent = presentIds.includes(collaborator.id);
const isEditing = editingIds.includes(collaborator.id);
const isObserving = ui.observingUserId === collaborator.id;
const isObservable = collaborator.id !== user.id;
return (
<AvatarWithPresence
key={collaborator.id}
user={collaborator}
isPresent={isPresent}
isEditing={isEditing}
isObserving={isObserving}
isCurrentUser={currentUserId === collaborator.id}
onClick={
isObservable
? (ev) => {
if (isPresent) {
ev.preventDefault();
ev.stopPropagation();
ui.setObservingUser(
isObserving ? undefined : collaborator.id
);
}
}
: undefined
}
/>
);
}}
/>
</NudeButton>
)}
+185 -31
View File
@@ -1,5 +1,6 @@
import debounce from "lodash/debounce";
import { observer } from "mobx-react";
import { transparentize } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -7,14 +8,23 @@ import styled from "styled-components";
import { richExtensions } from "@shared/editor/nodes";
import { s } from "@shared/styles";
import Collection from "~/models/Collection";
import Arrow from "~/components/Arrow";
import ButtonLink from "~/components/ButtonLink";
import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator";
import { withUIExtensions } from "~/editor/extensions";
import NudeButton from "~/components/NudeButton";
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import Text from "./Text";
const extensions = withUIExtensions(richExtensions);
const extensions = [
...richExtensions,
BlockMenuExtension,
EmojiMenuExtension,
HoverPreviewsExtension,
];
type Props = {
collection: Collection;
@@ -23,8 +33,33 @@ type Props = {
function CollectionDescription({ collection }: Props) {
const { collections } = useStores();
const { t } = useTranslation();
const [isExpanded, setExpanded] = React.useState(false);
const [isEditing, setEditing] = React.useState(false);
const [isDirty, setDirty] = React.useState(false);
const can = usePolicy(collection);
const handleStartEditing = React.useCallback(() => {
setEditing(true);
}, []);
const handleStopEditing = React.useCallback(() => {
setEditing(false);
}, []);
const handleClickDisclosure = React.useCallback(
(event) => {
event.preventDefault();
if (isExpanded && document.activeElement) {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'blur' does not exist on type 'Element'.
document.activeElement.blur();
}
setExpanded(!isExpanded);
},
[isExpanded]
);
const handleSave = React.useMemo(
() =>
debounce(async (getValue) => {
@@ -32,6 +67,7 @@ function CollectionDescription({ collection }: Props) {
await collection.save({
data: getValue(false),
});
setDirty(false);
} catch (err) {
toast.error(t("Sorry, an error occurred saving the collection"));
throw err;
@@ -40,44 +76,162 @@ function CollectionDescription({ collection }: Props) {
[collection, t]
);
const childRef = React.useRef<HTMLDivElement>(null);
const childOffsetHeight = childRef.current?.offsetHeight || 0;
const editorStyle = React.useMemo(
() => ({
padding: "0 32px",
margin: "0 -32px",
paddingBottom: `calc(50vh - ${childOffsetHeight}px)`,
}),
[childOffsetHeight]
const handleChange = React.useCallback(
async (getValue) => {
setDirty(true);
await handleSave(getValue);
},
[handleSave]
);
React.useEffect(() => {
setEditing(false);
}, [collection.id]);
const placeholder = `${t("Add a description")}`;
const key = isEditing || isDirty ? "draft" : collection.updatedAt;
return (
<>
{collections.isSaving && <LoadingIndicator />}
{(collection.hasDescription || can.update) && (
<React.Suspense fallback={<Placeholder>Loading</Placeholder>}>
<Editor
defaultValue={collection.data}
onChange={handleSave}
placeholder={`${t("Add a description")}`}
extensions={extensions}
maxLength={1000}
canUpdate={can.update}
readOnly={!can.update}
editorStyle={editorStyle}
embedsDisabled
/>
<div ref={childRef} />
</React.Suspense>
<MaxHeight data-editing={isEditing} data-expanded={isExpanded}>
<Input data-editing={isEditing} data-expanded={isExpanded}>
<span onClick={can.update ? handleStartEditing : undefined}>
{collections.isSaving && <LoadingIndicator />}
{collection.hasDescription || isEditing || isDirty ? (
<React.Suspense
fallback={
<Placeholder
onClick={() => {
//
}}
>
Loading
</Placeholder>
}
>
<Editor
key={key}
defaultValue={collection.data}
onChange={handleChange}
placeholder={placeholder}
readOnly={!isEditing}
autoFocus={isEditing}
onBlur={handleStopEditing}
extensions={extensions}
maxLength={1000}
embedsDisabled
canUpdate
/>
</React.Suspense>
) : (
can.update && (
<Placeholder
onClick={() => {
//
}}
>
{placeholder}
</Placeholder>
)
)}
</span>
</Input>
{!isEditing && (
<Disclosure
onClick={handleClickDisclosure}
aria-label={isExpanded ? t("Collapse") : t("Expand")}
size={30}
>
<Arrow />
</Disclosure>
)}
</>
</MaxHeight>
);
}
const Placeholder = styled(Text)`
const Disclosure = styled(NudeButton)`
opacity: 0;
color: ${s("divider")};
position: absolute;
top: calc(25vh - 50px);
left: 50%;
z-index: 1;
transform: rotate(-90deg) translateX(-50%);
transition: opacity 100ms ease-in-out;
&:focus,
&:hover {
opacity: 1;
}
&:active {
color: ${s("sidebarText")};
}
`;
const Placeholder = styled(ButtonLink)`
color: ${s("placeholder")};
cursor: text;
min-height: 27px;
`;
const MaxHeight = styled.div`
position: relative;
max-height: 25vh;
overflow: hidden;
margin: 8px -8px -8px;
padding: 8px;
&[data-editing="true"],
&[data-expanded="true"] {
max-height: initial;
overflow: initial;
${Disclosure} {
top: initial;
bottom: 0;
transform: rotate(90deg) translateX(-50%);
}
}
&:hover ${Disclosure} {
opacity: 1;
}
`;
const Input = styled.div`
margin: -8px;
padding: 8px;
border-radius: 8px;
&:after {
content: "";
position: absolute;
top: calc(25vh - 50px);
left: 0;
right: 0;
height: 50px;
pointer-events: none;
background: linear-gradient(
180deg,
${(props) => transparentize(1, props.theme.background)} 0%,
${s("background")} 100%
);
}
&[data-editing="true"],
&[data-expanded="true"] {
&:after {
background: transparent;
}
}
&[data-editing="true"] {
background: ${s("backgroundSecondary")};
}
.block-menu-trigger,
.heading-anchor {
display: none !important;
}
`;
export default observer(CollectionDescription);
+6 -25
View File
@@ -1,12 +1,10 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { CollectionPermission, NavigationNode } from "@shared/types";
import type Collection from "~/models/Collection";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import useStores from "~/hooks/useStores";
import { AuthorizationError } from "~/utils/errors";
type Props = {
/** The navigation node to move, must represent a document. */
@@ -32,29 +30,12 @@ function ConfirmMoveDialog({ collection, item, ...rest }: Props) {
};
const handleSubmit = async () => {
try {
await documents.move({
documentId: item.id,
collectionId: collection.id,
...rest,
});
} catch (err) {
if (err instanceof AuthorizationError) {
toast.error(
t(
"You do not have permission to move {{ documentName }} to the {{ collectionName }} collection",
{
documentName: item.title,
collectionName: collection.name,
}
)
);
} else {
toast.error(err.message);
}
} finally {
dialogs.closeAllModals();
}
await documents.move({
documentId: item.id,
collectionId: collection.id,
...rest,
});
dialogs.closeAllModals();
};
return (
+4 -5
View File
@@ -105,15 +105,14 @@ function DocumentBreadcrumb(
}
path.slice(0, -1).forEach((node: NavigationNode) => {
const title = node.title || t("Untitled");
output.push({
type: "route",
title: node.icon ? (
<>
<StyledIcon value={node.icon} color={node.color} /> {title}
<StyledIcon value={node.icon} color={node.color} /> {node.title}
</>
) : (
title
node.title
),
to: {
pathname: node.url,
@@ -122,7 +121,7 @@ function DocumentBreadcrumb(
});
});
return output;
}, [t, path, category, sidebarContext, collectionNode]);
}, [path, category, sidebarContext, collectionNode]);
if (!collections.isLoaded) {
return null;
@@ -135,7 +134,7 @@ function DocumentBreadcrumb(
{path.slice(0, -1).map((node: NavigationNode) => (
<React.Fragment key={node.id}>
<SmallSlash />
{node.title || t("Untitled")}
{node.title}
</React.Fragment>
))}
</>
+2 -8
View File
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import { dateLocale, dateToRelative } from "@shared/utils/date";
import Document from "~/models/Document";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { Avatar } from "~/components/Avatar";
import ListItem from "~/components/List/Item";
import PaginatedList from "~/components/PaginatedList";
import useCurrentUser from "~/hooks/useCurrentUser";
@@ -71,13 +71,7 @@ function DocumentViews({ document, isOpen }: Props) {
key={model.id}
title={model.name}
subtitle={subtitle}
image={
<Avatar
key={model.id}
model={model}
size={AvatarSize.Large}
/>
}
image={<Avatar key={model.id} model={model} size={32} />}
border={false}
small
/>
+4 -8
View File
@@ -17,7 +17,7 @@ import useDictionary from "~/hooks/useDictionary";
import useEditorClickHandlers from "~/hooks/useEditorClickHandlers";
import useEmbeds from "~/hooks/useEmbeds";
import useStores from "~/hooks/useStores";
import { uploadFile, uploadFileFromUrl } from "~/utils/files";
import { uploadFile } from "~/utils/files";
import lazyWithRetry from "~/utils/lazyWithRetry";
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
@@ -49,15 +49,11 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const previousCommentIds = React.useRef<string[]>();
const handleUploadFile = React.useCallback(
async (file: File | string) => {
const options = {
async (file: File) => {
const result = await uploadFile(file, {
documentId: id,
preset: AttachmentPreset.DocumentAttachment,
};
const result =
file instanceof File
? await uploadFile(file, options)
: await uploadFileFromUrl(file, options);
});
return result.url;
},
[id]
+2 -2
View File
@@ -16,7 +16,7 @@ import EventBoundary from "@shared/components/EventBoundary";
import { s, hover } from "@shared/styles";
import Document from "~/models/Document";
import Event from "~/models/Event";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { Avatar } from "~/components/Avatar";
import Item, { Actions, Props as ItemProps } from "~/components/List/Item";
import Time from "~/components/Time";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
@@ -153,7 +153,7 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
onClick={handleTimeClick}
/>
}
image={<Avatar model={event.actor} size={AvatarSize.Large} />}
image={<Avatar model={event.actor} size={32} />}
subtitle={
<Subtitle>
{icon}
+38 -62
View File
@@ -1,26 +1,17 @@
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Initials from "./Avatar/Initials";
type Props = {
/** The users to display */
users: User[];
/** The size of the avatars, defaults to AvatarSize.Large */
size?: number;
/** A number to show as the number of additional users */
overflow?: number;
/** The maximum number of users to display, defaults to 8 */
limit?: number;
/** A component to render the avatar, defaults to Avatar. */
renderAvatar?: React.ComponentType<
React.ComponentProps<typeof Avatar> & {
model: User;
}
>;
renderAvatar?: (user: User) => React.ReactNode;
};
function Facepile({
@@ -28,70 +19,55 @@ function Facepile({
overflow = 0,
size = AvatarSize.Large,
limit = 8,
renderAvatar = Avatar,
renderAvatar = DefaultAvatar,
...rest
}: Props) {
const filtered = users.filter(Boolean).slice(-limit);
const Component = renderAvatar;
return (
<Avatars {...rest}>
{overflow > 0 && (
<Initials size={size} content={String(overflow)}>
{users.length ? "+" : ""}
{overflow}
</Initials>
<More size={size}>
<span>
{users.length ? "+" : ""}
{overflow}
</span>
</More>
)}
{filtered.map((model, index) => {
const lastChild = index === 0 && overflow <= 0;
return (
<Component
key={model.id}
{...{
model,
size,
style: {
marginRight: lastChild ? 0 : -4,
...(lastChild || filtered.length === 1
? {}
: { clipPath: `url(#${clipPathId(size)})` }),
},
}}
/>
);
})}
<FacepileClip size={size} />
{users
.filter(Boolean)
.slice(0, limit)
.map((user) => (
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
))}
</Avatars>
);
}
function FacepileClip({ size }: { size: number }) {
return (
<SVG
width="25"
height="28"
viewBox="0 0 25 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<clipPath id={clipPathId(size)}>
<path
transform={size !== 28 ? `scale(${size / 28})` : ""}
d="M14.0633 0.5C18.1978 0.5 21.8994 2.34071 24.3876 5.24462C22.8709 7.81315 22.0012 10.8061 22.0012 14C22.0012 17.1939 22.8709 20.1868 24.3876 22.7554C21.8994 25.6593 18.1978 27.5 14.0633 27.5C6.57035 27.5 0.5 21.4537 0.5 14C0.5 6.54628 6.57035 0.5 14.0633 0.5Z"
/>
</clipPath>
</SVG>
);
function DefaultAvatar(user: User) {
return <Avatar model={user} size={AvatarSize.Large} />;
}
function clipPathId(size: number) {
return `facepile-${size}`;
}
const AvatarWrapper = styled.div`
margin-right: -8px;
const SVG = styled.svg`
position: absolute;
top: 0;
left: 0;
&:first-child {
margin-right: 0;
}
`;
const More = styled.div<{ size: number }>`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 100%;
background: ${(props) => props.theme.textTertiary};
color: ${s("white")};
border: 2px solid ${s("background")};
text-align: center;
font-size: 12px;
font-weight: 600;
`;
const Avatars = styled(Flex)`
+1 -1
View File
@@ -6,12 +6,12 @@ import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { useComponentSize } from "@shared/hooks/useComponentSize";
import { depths, s } from "@shared/styles";
import { supportsPassiveListener } from "@shared/utils/browser";
import Button from "~/components/Button";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import useComponentSize from "~/hooks/useComponentSize";
import useEventListener from "~/hooks/useEventListener";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
+1 -1
View File
@@ -1,7 +1,7 @@
import { m, TargetAndTransition } from "framer-motion";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import { useComponentSize } from "@shared/hooks/useComponentSize";
import useComponentSize from "~/hooks/useComponentSize";
type Props = {
/** The children to render */
@@ -201,7 +201,11 @@ export const AccessControlList = observer(
<ListItem
key={membership.id}
image={
<Avatar model={membership.user} size={AvatarSize.Medium} />
<Avatar
model={membership.user}
size={AvatarSize.Medium}
showBorder={false}
/>
}
title={membership.user.name}
subtitle={membership.user.email}
@@ -146,7 +146,7 @@ export const AccessControlList = observer(
/>
) : (
<ListItem
image={<Avatar model={user} />}
image={<Avatar model={user} showBorder={false} />}
title={user.name}
subtitle={t("You have full access")}
actions={<AccessTooltip>{t("Can edit")}</AccessTooltip>}
@@ -160,7 +160,9 @@ export const AccessControlList = observer(
) : document.isDraft ? (
<>
<ListItem
image={<Avatar model={document.createdBy} />}
image={
<Avatar model={document.createdBy} showBorder={false} />
}
title={document.createdBy?.name}
actions={
<AccessTooltip content={t("Created the document")}>
@@ -73,7 +73,9 @@ const DocumentMemberListItem = ({
return (
<ListItem
title={user.name}
image={<Avatar model={user} size={AvatarSize.Medium} />}
image={
<Avatar model={user} size={AvatarSize.Medium} showBorder={false} />
}
subtitle={
membership?.sourceId ? (
<Trans>
@@ -158,7 +158,13 @@ export const Suggestions = observer(
: suggestion.isViewer
? t("Viewer")
: t("Editor"),
image: <Avatar model={suggestion} size={AvatarSize.Medium} />,
image: (
<Avatar
model={suggestion}
size={AvatarSize.Medium}
showBorder={false}
/>
),
};
}
+1 -4
View File
@@ -14,7 +14,6 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import { homePath, sharedDocumentPath } from "~/utils/routeHelpers";
import { AvatarSize } from "../Avatar";
import { useTeamContext } from "../TeamContext";
import TeamLogo from "../TeamLogo";
import Sidebar from "./Sidebar";
@@ -41,9 +40,7 @@ function SharedSidebar({ rootNode, shareId }: Props) {
{teamAvailable && (
<SidebarButton
title={team.name}
image={
<TeamLogo model={team} size={AvatarSize.XLarge} alt={t("Logo")} />
}
image={<TeamLogo model={team} size={32} alt={t("Logo")} />}
onClick={() =>
history.push(
user ? homePath() : sharedDocumentPath(shareId, rootNode.url)
+1
View File
@@ -228,6 +228,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
alt={user.name}
model={user}
size={24}
showBorder={false}
style={{ marginLeft: 4 }}
/>
}
@@ -2,29 +2,26 @@ import { Location } from "history";
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import { mergeRefs } from "react-merge-refs";
import { useHistory } from "react-router-dom";
import { UserPreference } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { CollectionValidation, DocumentValidation } from "@shared/validations";
import { CollectionValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
import Fade from "~/components/Fade";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import NudeButton from "~/components/NudeButton";
import { createDocument } from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import CollectionMenu from "~/menus/CollectionMenu";
import { documentEditPath } from "~/utils/routeHelpers";
import { useDropToChangeCollection } from "../hooks/useDragAndDrop";
import DropToImport from "./DropToImport";
import EditableTitle, { RefHandle } from "./EditableTitle";
import Relative from "./Relative";
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import SidebarLink, { DragObject } from "./SidebarLink";
type Props = {
collection: Collection;
@@ -44,14 +41,12 @@ const CollectionLink: React.FC<Props> = ({
depth,
onClick,
}: Props) => {
const { dialogs, documents, collections } = useStores();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [isEditing, setIsEditing] = React.useState(false);
const { documents } = useStores();
const history = useHistory();
const can = usePolicy(collection);
const { t } = useTranslation();
const sidebarContext = useSidebarContext();
const user = useCurrentUser();
const editableTitleRef = React.useRef<RefHandle>(null);
const handleTitleChange = React.useCallback(
@@ -63,127 +58,119 @@ const CollectionLink: React.FC<Props> = ({
[collection]
);
const handleExpand = React.useCallback(() => {
if (!expanded) {
onDisclosureClick();
}
}, [expanded, onDisclosureClick]);
// Drop to re-parent document
const [{ isOver, canDrop }, drop] = useDrop({
accept: "document",
drop: async (item: DragObject, monitor) => {
const { id, collectionId } = item;
if (monitor.didDrop()) {
return;
}
if (!collection) {
return;
}
const parentRef = React.useRef<HTMLDivElement>(null);
const [{ isOver, canDrop }, dropRef] = useDropToChangeCollection(
collection,
handleExpand,
parentRef
);
const document = documents.get(id);
if (collection.id === collectionId && !document?.parentDocumentId) {
return;
}
const prevCollection = collections.get(collectionId);
if (
prevCollection &&
prevCollection.permission !== collection.permission &&
!document?.isDraft
) {
dialogs.openModal({
title: t("Change permissions?"),
content: <ConfirmMoveDialog item={item} collection={collection} />,
});
} else {
await documents.move({ documentId: id, collectionId: collection.id });
if (!expanded) {
onDisclosureClick();
}
}
},
canDrop: () => can.createDocument,
collect: (monitor) => ({
isOver: !!monitor.isOver({
shallow: true,
}),
canDrop: monitor.canDrop(),
}),
});
const handlePrefetch = React.useCallback(() => {
void collection.fetchDocuments();
}, [collection]);
const context = useActionContext({
activeCollectionId: collection.id,
sidebarContext,
});
const handleRename = React.useCallback(() => {
editableTitleRef.current?.setIsEditing(true);
}, [editableTitleRef]);
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
useBoolean();
const handleNewDoc = React.useCallback(
async (input) => {
const newDocument = await documents.create(
{
collectionId: collection.id,
title: input,
fullWidth: user.getPreference(UserPreference.FullWidthDocuments),
data: ProsemirrorHelper.getEmptyDocument(),
},
{ publish: true }
);
collection?.addDocument(newDocument);
closeAddingNewChild();
history.replace(documentEditPath(newDocument));
},
[user, closeAddingNewChild, history, collection, documents]
);
return (
<>
<Relative ref={mergeRefs([parentRef, dropRef])}>
<DropToImport collectionId={collection.id}>
<SidebarLink
onClick={onClick}
to={{
pathname: collection.path,
state: { sidebarContext },
}}
expanded={expanded}
onDisclosureClick={onDisclosureClick}
onClickIntent={handlePrefetch}
icon={
<CollectionIcon collection={collection} expanded={expanded} />
}
showActions={menuOpen}
isActiveDrop={isOver && canDrop}
isActive={(
match,
location: Location<{ sidebarContext?: SidebarContextType }>
) => !!match && location.state?.sidebarContext === sidebarContext}
label={
<EditableTitle
title={collection.name}
onSubmit={handleTitleChange}
onEditing={setIsEditing}
canUpdate={can.update}
maxLength={CollectionValidation.maxNameLength}
ref={editableTitleRef}
/>
}
exact={false}
depth={depth ? depth : 0}
menu={
!isEditing &&
!isDraggingAnyCollection && (
<Fade>
<NudeButton
tooltip={{ content: t("New doc"), delay: 500 }}
onClick={(ev) => {
ev.preventDefault();
setIsAddingNewChild();
handleExpand();
}}
>
<PlusIcon />
</NudeButton>
<CollectionMenu
collection={collection}
onRename={handleRename}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
)
}
/>
</DropToImport>
</Relative>
{isAddingNewChild && (
<Relative ref={drop}>
<DropToImport collectionId={collection.id}>
<SidebarLink
depth={2}
isActive={() => true}
onClick={onClick}
to={{
pathname: collection.path,
state: { sidebarContext },
}}
expanded={expanded}
onDisclosureClick={onDisclosureClick}
onClickIntent={handlePrefetch}
icon={<CollectionIcon collection={collection} expanded={expanded} />}
showActions={menuOpen}
isActiveDrop={isOver && canDrop}
isActive={(
match,
location: Location<{ sidebarContext?: SidebarContextType }>
) => !!match && location.state?.sidebarContext === sidebarContext}
label={
<EditableTitle
title=""
canUpdate
isEditing
placeholder={`${t("New doc")}`}
onCancel={closeAddingNewChild}
onSubmit={handleNewDoc}
maxLength={DocumentValidation.maxTitleLength}
title={collection.name}
onSubmit={handleTitleChange}
onEditing={setIsEditing}
canUpdate={can.update}
maxLength={CollectionValidation.maxNameLength}
ref={editableTitleRef}
/>
}
exact={false}
depth={depth ? depth : 0}
menu={
!isEditing &&
!isDraggingAnyCollection && (
<Fade>
<NudeButton
tooltip={{ content: t("New doc"), delay: 500 }}
action={createDocument}
context={context}
hideOnActionDisabled
>
<PlusIcon />
</NudeButton>
<CollectionMenu
collection={collection}
onRename={handleRename}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
)
}
/>
)}
</>
</DropToImport>
</Relative>
);
};
@@ -1,23 +1,25 @@
import noop from "lodash/noop";
import { observer } from "mobx-react";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import { Waypoint } from "react-waypoint";
import { toast } from "sonner";
import styled from "styled-components";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
import DocumentsLoader from "~/components/DocumentsLoader";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import Text from "~/components/Text";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import useCollectionDocuments from "../hooks/useCollectionDocuments";
import { useDropToChangeCollection } from "../hooks/useDragAndDrop";
import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor";
import Folder from "./Folder";
import PlaceholderCollections from "./PlaceholderCollections";
import SidebarLink from "./SidebarLink";
import SidebarLink, { DragObject } from "./SidebarLink";
type Props = {
/** The collection to render the children of. */
@@ -34,17 +36,55 @@ function CollectionLinkChildren({
prefetchDocument,
}: Props) {
const pageSize = 250;
const { documents } = useStores();
const can = usePolicy(collection);
const manualSort = collection.sort.field === "index";
const { documents, dialogs, collections } = useStores();
const { t } = useTranslation();
const childDocuments = useCollectionDocuments(collection, documents.active);
const [showing, setShowing] = React.useState(pageSize);
const dummyRef = React.useRef<HTMLDivElement>(null);
const [{ isOver, canDrop }, dropRef] = useDropToChangeCollection(
collection,
noop,
dummyRef
);
// Drop to reorder document
const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] = useDrop({
accept: "document",
drop: (item: DragObject) => {
if (!manualSort && item.collectionId === collection?.id) {
toast.message(
t(
"You can't reorder documents in an alphabetically sorted collection"
)
);
return;
}
if (!collection) {
return;
}
const prevCollection = collections.get(item.collectionId);
if (
prevCollection &&
prevCollection.permission !== collection.permission
) {
dialogs.openModal({
title: t("Change permissions?"),
content: (
<ConfirmMoveDialog item={item} collection={collection} index={0} />
),
});
} else {
void documents.move({
documentId: item.id,
collectionId: collection.id,
index: 0,
});
}
},
collect: (monitor) => ({
isOverReorder: !!monitor.isOver(),
isDraggingAnyDocument: !!monitor.canDrop(),
}),
});
React.useEffect(() => {
if (!expanded) {
@@ -60,8 +100,12 @@ function CollectionLinkChildren({
return (
<Folder expanded={expanded}>
{canDrop && collection.isManualSort && (
<DropCursor isActiveDrop={isOver} innerRef={dropRef} position="top" />
{isDraggingAnyDocument && can.createDocument && manualSort && (
<DropCursor
isActiveDrop={isOverReorder}
innerRef={dropToReorder}
position="top"
/>
)}
<DocumentsLoader collection={collection} enabled={expanded}>
{!childDocuments && (
@@ -10,7 +10,6 @@ import Error from "~/components/List/Error";
import PaginatedList from "~/components/PaginatedList";
import { createCollection } from "~/actions/definitions/collections";
import useStores from "~/hooks/useStores";
import { DragObject } from "../hooks/useDragAndDrop";
import DraggableCollectionLink from "./DraggableCollectionLink";
import DropCursor from "./DropCursor";
import Header from "./Header";
@@ -18,6 +17,7 @@ import PlaceholderCollections from "./PlaceholderCollections";
import Relative from "./Relative";
import SidebarAction from "./SidebarAction";
import SidebarContext from "./SidebarContext";
import { DragObject } from "./SidebarLink";
function Collections() {
const { documents, collections } = useStores();
@@ -3,11 +3,10 @@ import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { Link } from "react-router-dom";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { NavigationNode, UserPreference } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { NavigationNode } from "@shared/types";
import { sortNavigationNodes } from "@shared/utils/collections";
import { DocumentValidation } from "@shared/validations";
import Collection from "~/models/Collection";
@@ -16,11 +15,10 @@ import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu";
import { documentEditPath } from "~/utils/routeHelpers";
import { newNestedDocumentPath } from "~/utils/routeHelpers";
import {
useDragDocument,
useDropToReorderDocument,
@@ -60,7 +58,6 @@ function InnerDocumentLink(
) {
const { documents, policies } = useStores();
const { t } = useTranslation();
const history = useHistory();
const canUpdate = usePolicy(node.id).update;
const isActiveDocument = activeDocument && activeDocument.id === node.id;
const hasChildDocuments =
@@ -70,7 +67,6 @@ function InnerDocumentLink(
const [isEditing, setIsEditing] = React.useState(false);
const editableTitleRef = React.useRef<RefHandle>(null);
const sidebarContext = useSidebarContext();
const user = useCurrentUser();
React.useEffect(() => {
if (
@@ -220,31 +216,6 @@ function InnerDocumentLink(
[setExpanded, setCollapsed, hasChildren, expanded]
);
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
useBoolean();
const handleNewDoc = React.useCallback(
async (input) => {
const newDocument = await documents.create(
{
collectionId: collection?.id,
parentDocumentId: node.id,
fullWidth:
doc?.fullWidth ??
user.getPreference(UserPreference.FullWidthDocuments),
title: input,
data: ProsemirrorHelper.getEmptyDocument(),
},
{ publish: true }
);
collection?.addDocument(newDocument, node.id);
closeAddingNewChild();
history.replace(documentEditPath(newDocument));
},
[documents, collection, user, node, doc, history, closeAddingNewChild]
);
return (
<>
<Relative ref={parentRef}>
@@ -311,11 +282,8 @@ function InnerDocumentLink(
<NudeButton
type={undefined}
aria-label={t("New nested document")}
onClick={(ev) => {
ev.preventDefault();
setIsAddingNewChild();
setExpanded();
}}
as={Link}
to={newNestedDocumentPath(document.id)}
>
<PlusIcon />
</NudeButton>
@@ -340,25 +308,8 @@ function InnerDocumentLink(
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
</Relative>
{isAddingNewChild && (
<SidebarLink
isActive={() => true}
depth={depth + 1}
label={
<EditableTitle
title=""
canUpdate
isEditing
placeholder={`${t("New doc")}`}
onCancel={closeAddingNewChild}
onSubmit={handleNewDoc}
maxLength={DocumentValidation.maxTitleLength}
/>
}
/>
)}
<Folder expanded={expanded && !isDragging}>
{nodeChildren.map((childNode, childIndex) => (
{nodeChildren.map((childNode, index) => (
<DocumentLink
key={childNode.id}
collection={collection}
@@ -367,7 +318,7 @@ function InnerDocumentLink(
prefetchDocument={prefetchDocument}
isDraft={childNode.isDraft}
depth={depth + 1}
index={childIndex}
index={index}
parentId={node.id}
/>
))}
@@ -9,12 +9,12 @@ import Document from "~/models/Document";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import { DragObject } from "../hooks/useDragAndDrop";
import CollectionLink from "./CollectionLink";
import CollectionLinkChildren from "./CollectionLinkChildren";
import DropCursor from "./DropCursor";
import Relative from "./Relative";
import { useSidebarContext } from "./SidebarContext";
import { DragObject } from "./SidebarLink";
type Props = {
collection: Collection;
@@ -3,33 +3,23 @@ import { toast } from "sonner";
import styled from "styled-components";
import { s } from "@shared/styles";
type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onSubmit"> & {
/** A callback when the title is submitted. */
onSubmit: (title: string) => Promise<void> | void;
/** A callback when the editing status changes. */
type Props = {
onSubmit: (title: string) => Promise<void>;
onEditing?: (isEditing: boolean) => void;
/** A callback when editing is canceled. */
onCancel?: () => void;
/** The default title. */
title: string;
/** Whether the user can update the title. */
canUpdate: boolean;
/** The maximum length of the title. */
maxLength?: number;
/** The default editing state. */
isEditing?: boolean;
};
export type RefHandle = {
/** A function to set the editing state. */
setIsEditing: (isEditing: boolean) => void;
};
function EditableTitle(
{ title, onSubmit, canUpdate, onEditing, onCancel, ...rest }: Props,
{ title, onSubmit, canUpdate, onEditing, ...rest }: Props,
ref: React.RefObject<RefHandle>
) {
const [isEditing, setIsEditing] = React.useState(rest.isEditing || false);
const [isEditing, setIsEditing] = React.useState(false);
const [originalValue, setOriginalValue] = React.useState(title);
const [value, setValue] = React.useState(title);
@@ -69,7 +59,6 @@ function EditableTitle(
if (trimmedValue === originalValue || trimmedValue.length === 0) {
setValue(originalValue);
onCancel?.();
return;
}
@@ -84,7 +73,7 @@ function EditableTitle(
}
}
},
[originalValue, value, onCancel, onSubmit]
[originalValue, value, onSubmit]
);
const handleKeyDown = React.useCallback(
@@ -94,14 +83,13 @@ function EditableTitle(
}
if (ev.key === "Escape") {
setIsEditing(false);
onCancel?.();
setValue(originalValue);
}
if (ev.key === "Enter") {
await handleSave(ev);
}
},
[handleSave, onCancel, originalValue]
[handleSave, originalValue]
);
React.useEffect(() => {
@@ -4,6 +4,7 @@ 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 NudeButton from "~/components/NudeButton";
import { UnreadBadge } from "~/components/UnreadBadge";
import useUnmount from "~/hooks/useUnmount";
@@ -11,6 +12,11 @@ import { undraggableOnDesktop } from "~/styles";
import Disclosure from "./Disclosure";
import NavLink, { Props as NavLinkProps } from "./NavLink";
export type DragObject = NavigationNode & {
depth: number;
collectionId: string;
};
type Props = Omit<NavLinkProps, "to"> & {
to?: LocationDescriptor;
innerRef?: (ref: HTMLElement | null | undefined) => void;
@@ -6,8 +6,7 @@ import { useTranslation } from "react-i18next";
import DocumentDelete from "~/scenes/DocumentDelete";
import useStores from "~/hooks/useStores";
import { trashPath } from "~/utils/routeHelpers";
import { DragObject } from "../hooks/useDragAndDrop";
import SidebarLink from "./SidebarLink";
import SidebarLink, { DragObject } from "./SidebarLink";
function TrashLink() {
const { policies, dialogs, documents } = useStores();
+60 -170
View File
@@ -15,49 +15,10 @@ import Star from "~/models/Star";
import UserMembership from "~/models/UserMembership";
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { AuthorizationError } from "~/utils/errors";
import { DragObject } from "../components/SidebarLink";
import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon";
export type DragObject = NavigationNode & {
depth: number;
collectionId: string;
};
function useHover(
elementRef: React.RefObject<HTMLDivElement>,
callback: () => void
) {
const hoverTimeoutRef = React.useRef<ReturnType<typeof setTimeout>>();
const startHover = React.useCallback(() => {
if (!hoverTimeoutRef.current) {
hoverTimeoutRef.current = setTimeout(() => {
hoverTimeoutRef.current = undefined;
callback();
}, 500);
}
}, [callback]);
const unsetHover = React.useCallback(() => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = undefined;
}
}, []);
// We set a timeout when the user first starts hovering over the document link,
// to trigger expansion of children. Clear this timeout when they stop hovering.
React.useEffect(() => {
const element = elementRef.current;
element?.addEventListener("dragleave", unsetHover);
return () => element?.removeEventListener("dragleave", unsetHover);
}, [elementRef, unsetHover]);
return startHover;
}
/**
* Hook for shared logic that allows dragging a Starred item
*
@@ -201,84 +162,6 @@ export function useDragDocument(
return [{ isDragging }, draggableRef] as const;
}
export function useDropToChangeCollection(
collection: Collection,
expandNode: () => void,
parentRef: React.RefObject<HTMLDivElement>
) {
const { t } = useTranslation();
const { documents, collections, dialogs } = useStores();
const can = usePolicy(collection);
const startHover = useHover(parentRef, expandNode);
return useDrop<
DragObject,
Promise<void>,
{ isOver: boolean; canDrop: boolean }
>({
accept: "document",
drop: async (item, monitor) => {
if (monitor.didDrop()) {
return;
}
const { id, collectionId } = item;
const prevCollection = collections.get(collectionId);
const document = documents.get(id);
if (
prevCollection &&
prevCollection.permission !== collection.permission &&
!document?.isDraft
) {
dialogs.openModal({
title: t("Change permissions?"),
content: (
<ConfirmMoveDialog item={item} collection={collection} index={0} />
),
});
} else {
try {
await documents.move({
documentId: id,
collectionId: collection.id,
index: 0,
});
expandNode();
} catch (err) {
if (err instanceof AuthorizationError) {
toast.error(
t(
"You do not have permission to move {{ documentName }} to the {{ collectionName }} collection",
{
documentName: item.title,
collectionName: collection.name,
}
)
);
} else {
toast.error(err.message);
}
}
}
},
canDrop: () => can.createDocument,
hover: (_, monitor) => {
if (
collection.hasDocuments &&
monitor.canDrop() &&
monitor.isOver({ shallow: true })
) {
startHover();
}
},
collect: (monitor) => ({
isOver: monitor.isOver({ shallow: true }),
canDrop: monitor.canDrop(),
}),
});
}
/**
* Hook for shared logic that allows dropping documents to reparent
*
@@ -292,7 +175,7 @@ export function useDropToReparentDocument(
parentRef: React.RefObject<HTMLDivElement>
) {
const { t } = useTranslation();
const { documents, collections, dialogs } = useStores();
const { documents, collections, dialogs, policies } = useStores();
const hasChildDocuments = !!node?.children.length;
const document = node ? documents.get(node.id) : undefined;
const pathToNode = React.useMemo(
@@ -300,7 +183,25 @@ export function useDropToReparentDocument(
[document]
);
const startHover = useHover(parentRef, setExpanded);
const hoverExpanding = React.useRef<ReturnType<typeof setTimeout>>();
// We set a timeout when the user first starts hovering over the document link,
// to trigger expansion of children. Clear this timeout when they stop hovering.
React.useEffect(() => {
const resetHoverExpanding = () => {
if (hoverExpanding.current) {
clearTimeout(hoverExpanding.current);
hoverExpanding.current = undefined;
}
};
const element = parentRef.current;
element?.addEventListener("dragleave", resetHoverExpanding);
return () => {
element?.removeEventListener("dragleave", resetHoverExpanding);
};
}, [parentRef]);
return useDrop<
DragObject,
@@ -313,9 +214,7 @@ export function useDropToReparentDocument(
return;
}
const collection = node.collectionId
? collections.get(node.collectionId)
: undefined;
const collection = documents.get(node.id)?.collection;
const prevCollection = collections.get(item.collectionId);
if (
@@ -334,40 +233,22 @@ export function useDropToReparentDocument(
),
});
} else {
try {
await documents.move({
documentId: item.id,
parentDocumentId: node.id,
});
setExpanded();
} catch (err) {
if (err instanceof AuthorizationError) {
toast.error(
t(
"{{ documentName }} cannot be moved within {{ parentDocumentName }}",
{
documentName: item.title,
parentDocumentName: node.title,
}
)
);
} else {
toast.error(err.message);
}
}
}
},
canDrop: (item) => {
if (!node || item.id === node.id) {
return false;
await documents.move({
documentId: item.id,
parentDocumentId: node.id,
});
}
if (!document) {
return true; // optimistic, in case the document is not loaded yet; server will check for permissions before performing the move.
}
return document.isActive && !!pathToNode && !pathToNode.includes(item.id);
setExpanded();
},
canDrop: (item, monitor) =>
!!node &&
!!pathToNode &&
!pathToNode.includes(monitor.getItem().id) &&
item.id !== node.id &&
!!document?.isActive &&
policies.abilities(node.id).update &&
policies.abilities(item.id).move,
hover: (_item, monitor) => {
// Enables expansion of document children when hovering over the document
// for more than half a second.
@@ -378,7 +259,15 @@ export function useDropToReparentDocument(
shallow: true,
})
) {
startHover();
if (!hoverExpanding.current) {
hoverExpanding.current = setTimeout(() => {
hoverExpanding.current = undefined;
if (monitor.isOver({ shallow: true })) {
setExpanded();
}
}, 500);
}
}
},
collect: (monitor) => ({
@@ -408,7 +297,7 @@ export function useDropToReorderDocument(
}
) {
const { t } = useTranslation();
const { documents, collections, dialogs } = useStores();
const { documents, collections, dialogs, policies } = useStores();
const document = documents.get(node.id);
@@ -419,9 +308,22 @@ export function useDropToReorderDocument(
>({
accept: "document",
canDrop: (item: DragObject) => {
if (item.id === node.id || (document && !document.isActive)) {
if (
item.id === node.id ||
!policies.abilities(item.id)?.move ||
!document?.isActive
) {
return false;
}
const params = getMoveParams(item);
if (params?.collectionId) {
return policies.abilities(params.collectionId)?.updateDocument;
}
if (params?.parentDocumentId) {
return policies.abilities(params.parentDocumentId)?.update;
}
return true;
},
drop: async (item) => {
@@ -455,19 +357,7 @@ export function useDropToReorderDocument(
),
});
} else {
try {
await documents.move(params);
} catch (err) {
if (err instanceof AuthorizationError) {
toast.error(
t("The {{ documentName }} cannot be moved here", {
documentName: item.title,
})
);
} else {
toast.error(err.message);
}
}
void documents.move(params);
}
}
},
+1 -1
View File
@@ -190,7 +190,7 @@ class WebsocketProvider extends React.Component<Props> {
if (collection?.updatedAt === collectionDescriptor.updatedAt) {
continue;
}
if (!collection?.documents && !event.fetchIfMissing) {
if (!collection?.documents?.length && !event.fetchIfMissing) {
continue;
}
+3 -3
View File
@@ -6,10 +6,10 @@ import styled, { css } from "styled-components";
import { isCode } from "@shared/editor/lib/isCode";
import { findParentNode } from "@shared/editor/queries/findParentNode";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { useComponentSize } from "@shared/hooks/useComponentSize";
import { depths, s } from "@shared/styles";
import { HEADER_HEIGHT } from "~/components/Header";
import { Portal } from "~/components/Portal";
import useComponentSize from "~/hooks/useComponentSize";
import useEventListener from "~/hooks/useEventListener";
import useMobile from "~/hooks/useMobile";
import useWindowSize from "~/hooks/useWindowSize";
@@ -184,10 +184,10 @@ function usePosition({
// of the selection still
const offset = left - (centerOfSelection - menuWidth / 2);
return {
left: Math.max(margin, Math.round(left - offsetParent.left)),
left: Math.round(left - offsetParent.left),
top: Math.round(top - offsetParent.top),
offset: Math.round(offset),
maxWidth: Math.min(window.innerWidth, offsetParent.width) - margin * 2,
maxWidth: Math.min(window.innerWidth - margin * 2, offsetParent.width),
blockSelection: codeBlock || isColSelection || isRowSelection,
visible: true,
};
+6 -9
View File
@@ -187,7 +187,7 @@ class LinkEditor extends React.Component<Props, State> {
};
render() {
const { view, dictionary } = this.props;
const { dictionary } = this.props;
const { value } = this.state;
const isInternal = isInternalUrl(value);
@@ -202,7 +202,6 @@ class LinkEditor extends React.Component<Props, State> {
onChange={this.handleSearch}
onFocus={this.handleSearch}
autoFocus={this.href === ""}
readOnly={!view.editable}
/>
<Tooltip
@@ -212,13 +211,11 @@ class LinkEditor extends React.Component<Props, State> {
{isInternal ? <ArrowIcon /> : <OpenIcon />}
</ToolbarButton>
</Tooltip>
{view.editable && (
<Tooltip content={dictionary.removeLink}>
<ToolbarButton onClick={this.handleRemoveLink}>
<CloseIcon />
</ToolbarButton>
</Tooltip>
)}
<Tooltip content={dictionary.removeLink}>
<ToolbarButton onClick={this.handleRemoveLink}>
<CloseIcon />
</ToolbarButton>
</Tooltip>
</Wrapper>
);
}
+1 -3
View File
@@ -84,6 +84,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
>
<Avatar
model={user}
showBorder={false}
alt={t("Profile picture")}
size={AvatarSize.Small}
/>
@@ -157,9 +158,6 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
if (item.attrs.type === MentionType.Document) {
return;
}
if (!documentId) {
return;
}
// Check if the mentioned user has access to the document
const res = await client.post("/documents.users", {
id: documentId,
@@ -1,9 +1,12 @@
import { Plugin, PluginKey } from "prosemirror-state";
import Extension from "@shared/editor/lib/Extension";
import textBetween from "@shared/editor/lib/textBetween";
import { getTextSerializers } from "@shared/editor/lib/textSerializers";
/**
* A plugin that allows overriding the default behavior of the editor to allow
* copying text including the markdown formatting.
* copying text for nodes that do not inherently have text children by defining
* a `toPlainText` method in the node spec.
*/
export default class ClipboardTextSerializer extends Extension {
get name() {
@@ -11,16 +14,20 @@ export default class ClipboardTextSerializer extends Extension {
}
get plugins() {
const serializer = this.editor.extensions.serializer();
const textSerializers = getTextSerializers(this.editor.schema);
return [
new Plugin({
key: new PluginKey("clipboardTextSerializer"),
props: {
clipboardTextSerializer: (slice) =>
serializer.serialize(slice.content, {
softBreak: true,
}),
clipboardTextSerializer: () => {
const { doc, selection } = this.editor.view.state;
const { ranges } = selection;
const from = Math.min(...ranges.map((range) => range.$from.pos));
const to = Math.max(...ranges.map((range) => range.$to.pos));
return textBetween(doc, from, to, textSerializers);
},
},
}),
];
-7
View File
@@ -1,5 +1,4 @@
import isEqual from "lodash/isEqual";
import { Plugin } from "prosemirror-state";
import {
ySyncPlugin,
yCursorPlugin,
@@ -9,7 +8,6 @@ import {
} from "y-prosemirror";
import * as Y from "yjs";
import Extension from "@shared/editor/lib/Extension";
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
import { Second } from "@shared/utils/time";
type UserAwareness = {
@@ -105,11 +103,6 @@ export default class Multiplayer extends Extension {
selectionBuilder,
}),
yUndoPlugin(),
new Plugin({
props: {
handleScrollToSelection: (view) => isRemoteTransaction(view.state.tr),
},
}),
];
}
+7 -7
View File
@@ -148,13 +148,6 @@ export default class PasteHandler extends Extension {
const supportsCodeMark = !!state.schema.marks.code_inline;
if (!this.shiftKey) {
// If the HTML on the clipboard is from Prosemirror then the best
// compatability is to just use the HTML parser, regardless of
// whether it "looks" like Markdown, see: outline/outline#2416
if (html?.includes("data-pm-slice")) {
return false;
}
// Check if the clipboard contents can be parsed as a single url
if (isUrl(text)) {
// If there is selected text then we want to wrap it in a link to the url
@@ -256,6 +249,13 @@ export default class PasteHandler extends Extension {
return true;
}
}
// If the HTML on the clipboard is from Prosemirror then the best
// compatability is to just use the HTML parser, regardless of
// whether it "looks" like Markdown, see: outline/outline#2416
if (html?.includes("data-pm-slice")) {
return false;
}
}
// If the text on the clipboard looks like Markdown OR there is no
-30
View File
@@ -1,30 +0,0 @@
import Extension from "@shared/editor/lib/Extension";
import Mark from "@shared/editor/marks/Mark";
import Node from "@shared/editor/nodes/Node";
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
import ClipboardTextSerializer from "~/editor/extensions/ClipboardTextSerializer";
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
import FindAndReplaceExtension from "~/editor/extensions/FindAndReplace";
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
import Keys from "~/editor/extensions/Keys";
import MentionMenuExtension from "~/editor/extensions/MentionMenu";
import PasteHandler from "~/editor/extensions/PasteHandler";
import PreventTab from "~/editor/extensions/PreventTab";
import SmartText from "~/editor/extensions/SmartText";
type Nodes = (typeof Node | typeof Mark | typeof Extension)[];
export const withUIExtensions = (nodes: Nodes) => [
...nodes,
SmartText,
PasteHandler,
ClipboardTextSerializer,
BlockMenuExtension,
EmojiMenuExtension,
MentionMenuExtension,
FindAndReplaceExtension,
HoverPreviewsExtension,
// Order these default key handlers last
PreventTab,
Keys,
];
+35
View File
@@ -0,0 +1,35 @@
import { useState, useLayoutEffect } from "react";
export default function useComponentSize(
ref: React.RefObject<HTMLElement | null>
): {
width: number;
height: number;
} {
const [size, setSize] = useState({ width: 0, height: 0 });
useLayoutEffect(() => {
const sizeObserver = new ResizeObserver((entries) => {
entries.forEach(({ target }) => {
if (
size.width !== target.clientWidth ||
size.height !== target.clientHeight
) {
setSize({ width: target.clientWidth, height: target.clientHeight });
}
});
});
if (ref.current) {
setSize({
width: ref.current?.clientWidth,
height: ref.current?.clientHeight,
});
sizeObserver.observe(ref.current);
}
return () => sizeObserver.disconnect();
}, [ref, size.height, size.width]);
return size;
}
-35
View File
@@ -181,11 +181,6 @@ export default class Collection extends ParanoidModel {
return !this.isArchived && !this.isDeleted;
}
@computed
get hasDocuments() {
return !!this.documents?.length;
}
fetchDocuments = async (options?: { force: boolean }) => {
if (this.isFetching) {
return;
@@ -263,36 +258,6 @@ export default class Collection extends ParanoidModel {
});
}
/**
* Adds the document identified by the given id to the collection in
* memory. Does not add the document to the database or store.
*
* @param document The document to add.
* @param parentDocumentId The id of the document to add the new document to.
*/
@action
addDocument(document: Document, parentDocumentId?: string) {
if (!this.documents) {
return;
}
if (!parentDocumentId) {
this.documents.unshift(document.asNavigationNode);
return;
}
const travelNodes = (nodes: NavigationNode[]) =>
nodes.forEach((node) => {
if (node.id === parentDocumentId) {
node.children = [document.asNavigationNode, ...(node.children ?? [])];
} else {
travelNodes(node.children);
}
});
travelNodes(this.documents);
}
@action
updateIndex(index: string) {
this.index = index;
+2 -1
View File
@@ -72,7 +72,8 @@ function AuthenticatedRoutes() {
<Redirect exact from="/templates" to={settingsPath("templates")} />
<Redirect exact from="/collections/*" to="/collection/*" />
<Route exact path="/collection/:id/new" component={DocumentNew} />
<Route exact path="/collection/:id/:tab?" component={Collection} />
<Route exact path="/collection/:id/:tab" component={Collection} />
<Route exact path="/collection/:id" component={Collection} />
<Route exact path="/doc/new" component={DocumentNew} />
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
<Route
@@ -4,7 +4,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { PAGINATION_SYMBOL } from "~/stores/base/Store";
import Collection from "~/models/Collection";
import { AvatarSize } from "~/components/Avatar";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Facepile from "~/components/Facepile";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
@@ -44,10 +44,10 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
groupMemberships.fetchPage(options),
]);
if (users[PAGINATION_SYMBOL]) {
setUsersCount(users[PAGINATION_SYMBOL].total ?? 0);
setUsersCount(users[PAGINATION_SYMBOL].total);
}
if (groups[PAGINATION_SYMBOL]) {
setGroupsCount(groups[PAGINATION_SYMBOL].total ?? 0);
setGroupsCount(groups[PAGINATION_SYMBOL].total);
}
} finally {
setIsLoading(false);
@@ -101,10 +101,12 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
>
<Fade>
<Facepile
size={AvatarSize.Large}
users={sortBy(collectionUsers, "lastActiveAt")}
overflow={overflow}
limit={limit}
renderAvatar={(item) => (
<Avatar model={item} size={AvatarSize.Large} />
)}
/>
</Fade>
</NudeButton>
+101 -187
View File
@@ -3,12 +3,12 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import {
useParams,
Redirect,
Switch,
Route,
useHistory,
useRouteMatch,
useLocation,
Redirect,
} from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -30,13 +30,12 @@ import PaginatedDocumentList from "~/components/PaginatedDocumentList";
import PinnedDocuments from "~/components/PinnedDocuments";
import PlaceholderText from "~/components/PlaceholderText";
import Scene from "~/components/Scene";
import Subheading from "~/components/Subheading";
import Tab from "~/components/Tab";
import Tabs from "~/components/Tabs";
import { editCollection } from "~/actions/definitions/collections";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import { useLastVisitedPath } from "~/hooks/useLastVisitedPath";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePersistedState from "~/hooks/usePersistedState";
import { usePinnedDocuments } from "~/hooks/usePinnedDocuments";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -50,16 +49,7 @@ import ShareButton from "./components/ShareButton";
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
enum CollectionPath {
Overview = "overview",
Recent = "recent",
Updated = "updated",
Published = "published",
Old = "old",
Alphabetical = "alphabetical",
}
const CollectionScene = observer(function _CollectionScene() {
function CollectionScene() {
const params = useParams<{ id?: string }>();
const history = useHistory();
const match = useRouteMatch();
@@ -70,26 +60,17 @@ const CollectionScene = observer(function _CollectionScene() {
const [error, setError] = React.useState<Error | undefined>();
const currentPath = location.pathname;
const [, setLastVisitedPath] = useLastVisitedPath();
const sidebarContext = useLocationSidebarContext();
const id = params.id || "";
const collection: Collection | null | undefined =
collections.getByUrl(id) || collections.get(id);
const can = usePolicy(collection);
const { pins, count } = usePinnedDocuments(id, collection?.id);
const [collectionTab, setCollectionTab] = usePersistedState<CollectionPath>(
`collection-tab:${collection?.id}`,
collection?.hasDescription
? CollectionPath.Overview
: CollectionPath.Recent,
{
listen: false,
}
);
const handleIconChange = React.useCallback(
(icon: string | null, color: string | null) =>
collection?.save({ icon, color }),
async (icon: string | null, color: string | null) => {
await collection?.save({ icon, color });
},
[collection]
);
@@ -139,8 +120,6 @@ const CollectionScene = observer(function _CollectionScene() {
return <Search notFound />;
}
const hasOverview = can.update || collection?.hasDescription;
const fallbackIcon = collection ? (
<Icon
value={collection.icon ?? "collection"}
@@ -149,17 +128,11 @@ const CollectionScene = observer(function _CollectionScene() {
/>
) : null;
const tabProps = (path: CollectionPath) => ({
exact: true,
onClick: () => setCollectionTab(path),
to: {
pathname: collectionPath(collection!.path, path),
state: { sidebarContext },
},
});
return collection ? (
<Scene
// Forced mount prevents animation of pinned documents when navigating
// _between_ collections, speeds up perceived performance.
key={collection.id}
centered={false}
textTitle={collection.name}
left={
@@ -223,156 +196,105 @@ const CollectionScene = observer(function _CollectionScene() {
canUpdate={can.update}
placeholderCount={count}
/>
<CollectionDescription collection={collection} />
<Documents>
<Tabs>
{hasOverview && (
<Tab {...tabProps(CollectionPath.Overview)}>
{t("Overview")}
{!collection.isArchived && (
<Tabs>
<Tab to={collectionPath(collection.path)} exact>
{t("Documents")}
</Tab>
)}
<Tab {...tabProps(CollectionPath.Recent)}>{t("Documents")}</Tab>
{!collection.isArchived && (
<>
<Tab {...tabProps(CollectionPath.Updated)}>
{t("Recently updated")}
</Tab>
<Tab {...tabProps(CollectionPath.Published)}>
{t("Recently published")}
</Tab>
<Tab {...tabProps(CollectionPath.Old)}>
{t("Least recently updated")}
</Tab>
<Tab {...tabProps(CollectionPath.Alphabetical)}>
{t("AZ")}
</Tab>
</>
)}
</Tabs>
<Switch>
<Route path={collectionPath(collection.path)} exact>
<Redirect
to={{
pathname: collectionPath(collection!.path, collectionTab),
state: { sidebarContext },
}}
/>
</Route>
<Route
path={collectionPath(collection.path, CollectionPath.Overview)}
>
{hasOverview ? (
<CollectionDescription collection={collection} />
) : (
<Redirect
to={{
pathname: collectionPath(
collection.path,
CollectionPath.Recent
),
state: { sidebarContext },
<Tab to={collectionPath(collection.path, "updated")} exact>
{t("Recently updated")}
</Tab>
<Tab to={collectionPath(collection.path, "published")} exact>
{t("Recently published")}
</Tab>
<Tab to={collectionPath(collection.path, "old")} exact>
{t("Least recently updated")}
</Tab>
<Tab to={collectionPath(collection.path, "alphabetical")} exact>
{t("AZ")}
</Tab>
</Tabs>
)}
{collection.isEmpty ? (
<Empty collection={collection} />
) : !collection.isArchived ? (
<Switch>
<Route path={collectionPath(collection.path, "alphabetical")}>
<PaginatedDocumentList
key="alphabetical"
documents={documents.alphabeticalInCollection(
collection.id
)}
fetch={documents.fetchAlphabetical}
options={{
collectionId: collection.id,
}}
/>
)}
</Route>
{collection.isEmpty ? (
<Empty collection={collection} />
) : !collection.isArchived ? (
<>
<Route
path={collectionPath(
collection.path,
CollectionPath.Alphabetical
</Route>
<Route path={collectionPath(collection.path, "old")}>
<PaginatedDocumentList
key="old"
documents={documents.leastRecentlyUpdatedInCollection(
collection.id
)}
>
<PaginatedDocumentList
key="alphabetical"
documents={documents.alphabeticalInCollection(
collection.id
)}
fetch={documents.fetchAlphabetical}
options={{
collectionId: collection.id,
}}
/>
</Route>
<Route
path={collectionPath(collection.path, CollectionPath.Old)}
>
<PaginatedDocumentList
key="old"
documents={documents.leastRecentlyUpdatedInCollection(
collection.id
)}
fetch={documents.fetchLeastRecentlyUpdated}
options={{
collectionId: collection.id,
}}
/>
</Route>
<Route
path={collectionPath(
collection.path,
CollectionPath.Published
fetch={documents.fetchLeastRecentlyUpdated}
options={{
collectionId: collection.id,
}}
/>
</Route>
<Route path={collectionPath(collection.path, "recent")}>
<Redirect to={collectionPath(collection.path, "published")} />
</Route>
<Route path={collectionPath(collection.path, "published")}>
<PaginatedDocumentList
key="published"
documents={documents.recentlyPublishedInCollection(
collection.id
)}
>
<PaginatedDocumentList
key="published"
documents={documents.recentlyPublishedInCollection(
collection.id
)}
fetch={documents.fetchRecentlyPublished}
options={{
collectionId: collection.id,
}}
showPublished
/>
</Route>
<Route
path={collectionPath(
collection.path,
CollectionPath.Updated
fetch={documents.fetchRecentlyPublished}
options={{
collectionId: collection.id,
}}
showPublished
/>
</Route>
<Route path={collectionPath(collection.path, "updated")}>
<PaginatedDocumentList
key="updated"
documents={documents.recentlyUpdatedInCollection(
collection.id
)}
>
<PaginatedDocumentList
key="updated"
documents={documents.recentlyUpdatedInCollection(
collection.id
)}
fetch={documents.fetchRecentlyUpdated}
options={{
collectionId: collection.id,
}}
/>
</Route>
<Route
path={collectionPath(
collection.path,
CollectionPath.Recent
)}
exact
>
<PaginatedDocumentList
documents={documents.rootInCollection(collection.id)}
fetch={documents.fetchPage}
options={{
collectionId: collection.id,
parentDocumentId: null,
sort: collection.sort.field,
direction: collection.sort.direction,
}}
showParentDocuments
/>
</Route>
</>
) : (
<Route
path={collectionPath(collection.path, CollectionPath.Recent)}
exact
>
fetch={documents.fetchRecentlyUpdated}
options={{
collectionId: collection.id,
}}
/>
</Route>
<Route path={collectionPath(collection.path)} exact>
<PaginatedDocumentList
documents={documents.rootInCollection(collection.id)}
fetch={documents.fetchPage}
options={{
collectionId: collection.id,
parentDocumentId: null,
sort: collection.sort.field,
direction: collection.sort.direction,
}}
showParentDocuments
/>
</Route>
</Switch>
) : (
<Switch>
<Route path={collectionPath(collection.path)} exact>
<PaginatedDocumentList
documents={documents.archivedInCollection(collection.id)}
fetch={documents.fetchPage}
heading={<Subheading sticky>{t("Documents")}</Subheading>}
options={{
collectionId: collection.id,
parentDocumentId: null,
@@ -383,8 +305,8 @@ const CollectionScene = observer(function _CollectionScene() {
showParentDocuments
/>
</Route>
)}
</Switch>
</Switch>
)}
</Documents>
</CenteredContent>
</DropToImport>
@@ -397,15 +319,7 @@ const CollectionScene = observer(function _CollectionScene() {
<PlaceholderList count={5} />
</CenteredContent>
);
});
const KeyedCollection = () => {
const params = useParams<{ id?: string }>();
// Forced mount prevents animation of pinned documents when navigating
// _between_ collections, speeds up perceived performance.
return <CollectionScene key={params.id} />;
};
}
const Documents = styled.div`
position: relative;
@@ -423,4 +337,4 @@ const CollectionHeading = styled(Heading)`
`}
`;
export default KeyedCollection;
export default observer(CollectionScene);
@@ -11,7 +11,7 @@ import { ProsemirrorData } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import Comment from "~/models/Comment";
import Document from "~/models/Document";
import { AvatarSize } from "~/components/Avatar";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { useDocumentContext } from "~/components/DocumentContext";
import Facepile from "~/components/Facepile";
import Fade from "~/components/Fade";
@@ -149,6 +149,9 @@ function CommentThread({
limit={limit}
overflow={overflow}
size={AvatarSize.Medium}
renderAvatar={(item) => (
<Avatar size={AvatarSize.Medium} model={item} />
)}
/>
</ShowMore>
);
+8 -8
View File
@@ -380,23 +380,24 @@ class DocumentScene extends React.Component<Props> {
AUTOSAVE_DELAY
);
updateIsDirty = action(() => {
updateIsDirty = () => {
const { document } = this.props;
const doc = this.editor.current?.view.state.doc;
this.isEditorDirty = !isEqual(doc?.toJSON(), document.data);
// a single hash is a doc with just an empty title
this.isEmpty = (!doc || ProsemirrorHelper.isEmpty(doc)) && !this.title;
});
};
updateIsDirtyDebounced = debounce(this.updateIsDirty, 500);
onFileUploadStart = action(() => {
onFileUploadStart = () => {
this.isUploading = true;
});
};
onFileUploadStop = action(() => {
onFileUploadStop = () => {
this.isUploading = false;
});
};
handleChangeTitle = action((value: string) => {
this.title = value;
@@ -583,7 +584,6 @@ class DocumentScene extends React.Component<Props> {
readOnly={readOnly}
canUpdate={abilities.update}
canComment={abilities.comment}
autoFocus={document.createdAt === document.updatedAt}
>
{shareId ? (
<ReferencesWrapper>
+24 -2
View File
@@ -13,7 +13,16 @@ import { RefHandle } from "~/components/ContentEditable";
import { useDocumentContext } from "~/components/DocumentContext";
import Editor, { Props as EditorProps } from "~/components/Editor";
import Flex from "~/components/Flex";
import { withUIExtensions } from "~/editor/extensions";
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
import ClipboardTextSerializer from "~/editor/extensions/ClipboardTextSerializer";
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
import FindAndReplaceExtension from "~/editor/extensions/FindAndReplace";
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
import Keys from "~/editor/extensions/Keys";
import MentionMenuExtension from "~/editor/extensions/MentionMenu";
import PasteHandler from "~/editor/extensions/PasteHandler";
import PreventTab from "~/editor/extensions/PreventTab";
import SmartText from "~/editor/extensions/SmartText";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import useFocusedComment from "~/hooks/useFocusedComment";
@@ -31,7 +40,20 @@ import MultiplayerEditor from "./AsyncMultiplayerEditor";
import DocumentMeta from "./DocumentMeta";
import DocumentTitle from "./DocumentTitle";
const extensions = withUIExtensions(withComments(richExtensions));
const extensions = [
...withComments(richExtensions),
SmartText,
PasteHandler,
ClipboardTextSerializer,
BlockMenuExtension,
EmojiMenuExtension,
MentionMenuExtension,
FindAndReplaceExtension,
HoverPreviewsExtension,
// Order these default key handlers last
PreventTab,
Keys,
];
type Props = Omit<EditorProps, "editorStyle"> & {
onChangeTitle: (title: string) => void;
+1 -1
View File
@@ -12,7 +12,6 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import Icon from "@shared/components/Icon";
import { useComponentSize } from "@shared/hooks/useComponentSize";
import { NavigationNode } from "@shared/types";
import { altDisplay, metaDisplay } from "@shared/utils/keyboard";
import { Theme } from "~/stores/UiStore";
@@ -32,6 +31,7 @@ import { publishDocument } from "~/actions/definitions/documents";
import { navigateToTemplateSettings } from "~/actions/definitions/navigation";
import { restoreRevision } from "~/actions/definitions/revisions";
import useActionContext from "~/hooks/useActionContext";
import useComponentSize from "~/hooks/useComponentSize";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import useEditingFocus from "~/hooks/useEditingFocus";
+2 -2
View File
@@ -6,7 +6,7 @@ import styled from "styled-components";
import { s } from "@shared/styles";
import { stringToColor } from "@shared/utils/color";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { Avatar } from "~/components/Avatar";
import { useDocumentContext } from "~/components/DocumentContext";
import DocumentViews from "~/components/DocumentViews";
import Flex from "~/components/Flex";
@@ -136,7 +136,7 @@ function Insights() {
avatarUrl: null,
initial: document.sourceMetadata.createdByName[0],
}}
size={AvatarSize.Large}
size={32}
/>
}
subtitle={t("Creator")}
@@ -1,5 +1,5 @@
import * as React from "react";
import { useComponentSize } from "@shared/hooks/useComponentSize";
import useComponentSize from "@shared/hooks/useComponentSize";
export const MeasuredContainer = <T extends React.ElementType>({
as: As,
+1 -1
View File
@@ -50,7 +50,7 @@ function DocumentNew({ template }: Props) {
user.getPreference(UserPreference.FullWidthDocuments),
templateId: query.get("templateId") ?? undefined,
template,
title: query.get("title") ?? "",
title: "",
data: ProsemirrorHelper.getEmptyDocument(),
},
{ publish: collection?.id || parentDocumentId ? true : undefined }
+2 -3
View File
@@ -10,7 +10,6 @@ import { s } from "@shared/styles";
import { UserPreference } from "@shared/types";
import { parseDomain } from "@shared/utils/domains";
import { Config } from "~/stores/AuthStore";
import { AvatarSize } from "~/components/Avatar";
import ButtonLarge from "~/components/ButtonLarge";
import ChangeLanguage from "~/components/ChangeLanguage";
import Fade from "~/components/Fade";
@@ -250,9 +249,9 @@ function Login({ children }: Props) {
/>
<Logo>
{config.logo && !isCreate ? (
<TeamLogo size={AvatarSize.XXLarge} src={config.logo} />
<TeamLogo size={48} src={config.logo} />
) : (
<OutlineIcon size={AvatarSize.XXLarge} />
<OutlineIcon size={48} />
)}
</Logo>
{isCreate ? (
+1 -1
View File
@@ -25,7 +25,7 @@ function UserFilter(props: Props) {
const userOptions = users.all.map((user) => ({
key: user.id,
label: user.name,
icon: <Avatar model={user} size={AvatarSize.Small} />,
icon: <Avatar model={user} showBorder={false} size={AvatarSize.Small} />,
}));
return [
{
@@ -434,7 +434,7 @@ const GroupMemberListItem = observer(function ({
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
</>
}
image={<Avatar model={user} size={AvatarSize.Large} />}
image={<Avatar model={user} size={32} />}
actions={
<Flex align="center">
{onRemove && <GroupMemberMenu onRemove={onRemove} />}
@@ -18,7 +18,7 @@ export default function ImageInput({ model, onSuccess, ...rest }: Props) {
<Flex gap={8} justify="space-between">
<ImageBox>
<ImageUpload onSuccess={onSuccess} {...rest}>
<StyledAvatar model={model} size={AvatarSize.Upload} />
<StyledAvatar model={model} size={AvatarSize.XXLarge} />
<Flex auto align="center" justify="center" className="upload">
{t("Upload")}
</Flex>
@@ -34,8 +34,8 @@ export default function ImageInput({ model, onSuccess, ...rest }: Props) {
}
const avatarStyles = `
width: ${AvatarSize.Upload}px;
height: ${AvatarSize.Upload}px;
width: ${AvatarSize.XXLarge}px;
height: ${AvatarSize.XXLarge}px;
`;
const StyledAvatar = styled(Avatar)`
@@ -3,7 +3,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { Avatar } from "~/components/Avatar";
import Badge from "~/components/Badge";
import Flex from "~/components/Flex";
import { HEADER_HEIGHT } from "~/components/Header";
@@ -38,7 +38,7 @@ export function PeopleTable({ canManage, ...rest }: Props) {
accessor: (user) => user.name,
component: (user) => (
<Flex align="center" gap={8}>
<Avatar model={user} size={AvatarSize.Large} /> {user.name}{" "}
<Avatar model={user} size={32} /> {user.name}{" "}
{currentUser.id === user.id && `(${t("You")})`}
</Flex>
),
+1 -8
View File
@@ -1,6 +1,5 @@
import { observable, action } from "mobx";
import { AwarenessChangeEvent } from "~/types";
import RootStore from "./RootStore";
type DocumentPresence = Map<
string,
@@ -18,12 +17,6 @@ export default class PresenceStore {
offlineTimeout = 30000;
private rootStore: RootStore;
constructor(rootStore: RootStore) {
this.rootStore = rootStore;
}
// called when a user leaves the document
@action
public leave(documentId: string, userId: string) {
@@ -45,7 +38,7 @@ export default class PresenceStore {
event.states.forEach((state) => {
const { user, cursor } = state;
if (user && this.rootStore.auth.currentUserId !== user.id) {
if (user) {
this.update(documentId, user.id, !!cursor);
existingUserIds = existingUserIds.filter((id) => id !== user.id);
}
-31
View File
@@ -14,31 +14,6 @@ type UploadOptions = {
onProgress?: (fractionComplete: number) => void;
};
/**
* Upload a file from a URL
*
* @param url The remote URL to download the file from
* @param options The upload options
* @returns The attachment object
*/
export const uploadFileFromUrl = async (
url: string,
options: UploadOptions
) => {
const response = await client.post("/attachments.createFromUrl", {
documentId: options.documentId,
url,
});
return response.data;
};
/**
* Upload a file
*
* @param file The file to upload
* @param options The upload options
* @returns The attachment object
*/
export const uploadFile = async (
file: File | Blob,
options: UploadOptions = {
@@ -99,12 +74,6 @@ export const uploadFile = async (
return attachment;
};
/**
* Convert a data URL to a Blob
*
* @param dataURL The data URL to convert
* @returns The Blob
*/
export const dataUrlToBlob = (dataURL: string) => {
const blobBin = atob(dataURL.split(",")[1]);
const array = [];
+9 -9
View File
@@ -48,11 +48,11 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.740.0",
"@aws-sdk/lib-storage": "3.740.0",
"@aws-sdk/s3-presigned-post": "3.740.0",
"@aws-sdk/s3-request-presigner": "3.740.0",
"@aws-sdk/signature-v4-crt": "^3.740.0",
"@aws-sdk/client-s3": "3.693.0",
"@aws-sdk/lib-storage": "3.693.0",
"@aws-sdk/s3-presigned-post": "3.693.0",
"@aws-sdk/s3-request-presigner": "3.693.0",
"@aws-sdk/signature-v4-crt": "^3.693.0",
"@babel/core": "^7.26.7",
"@babel/plugin-proposal-decorators": "^7.25.9",
"@babel/plugin-transform-class-properties": "^7.25.9",
@@ -127,7 +127,7 @@
"http-errors": "2.0.0",
"i18next": "^22.5.1",
"i18next-fs-backend": "^2.6.0",
"i18next-http-backend": "^2.7.3",
"i18next-http-backend": "^2.7.1",
"invariant": "^2.2.4",
"ioredis": "^5.4.1",
"is-printable-key-event": "^1.0.0",
@@ -243,7 +243,7 @@
"validator": "13.12.0",
"vite": "^5.4.12",
"vite-plugin-pwa": "^0.20.3",
"winston": "^3.17.0",
"winston": "^3.13.0",
"ws": "^7.5.10",
"y-indexeddb": "^9.0.11",
"y-prosemirror": "^1.2.12",
@@ -265,7 +265,7 @@
"@types/dotenv": "^8.2.3",
"@types/emoji-regex": "^9.2.0",
"@types/escape-html": "^1.0.4",
"@types/express-useragent": "^1.0.5",
"@types/express-useragent": "^1.0.2",
"@types/formidable": "^2.0.6",
"@types/fs-extra": "^11.0.4",
"@types/fuzzy-search": "^2.1.5",
@@ -354,7 +354,7 @@
"react-refresh": "^0.14.2",
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "^2.0.1",
"terser": "^5.37.0",
"terser": "^5.36.0",
"typescript": "^5.7.2",
"vite-plugin-static-copy": "^0.17.0",
"yarn-deduplicate": "^6.0.2"
+2 -6
View File
@@ -57,14 +57,10 @@ if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) {
const [profileResponse, organizationResponse] = await Promise.all([
// Load the users profile from the Microsoft Graph API
// https://docs.microsoft.com/en-us/graph/api/resources/users?view=graph-rest-1.0
request("GET", `https://graph.microsoft.com/v1.0/me`, accessToken),
request(`https://graph.microsoft.com/v1.0/me`, accessToken),
// Load the organization profile from the Microsoft Graph API
// https://docs.microsoft.com/en-us/graph/api/organization-get?view=graph-rest-1.0
request(
"GET",
`https://graph.microsoft.com/v1.0/organization`,
accessToken
),
request(`https://graph.microsoft.com/v1.0/organization`, accessToken),
]);
if (!profileResponse) {
-3
View File
@@ -70,7 +70,6 @@ if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
const client = getClientFromContext(ctx);
/** Fetch the user's profile */
const profile: RESTGetAPICurrentUserResult = await request(
"GET",
"https://discord.com/api/users/@me",
accessToken
);
@@ -106,7 +105,6 @@ if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
if (env.DISCORD_SERVER_ID) {
/** Fetch the guilds a user is in */
const guilds: RESTGetAPICurrentUserGuildsResult = await request(
"GET",
"https://discord.com/api/users/@me/guilds",
accessToken
);
@@ -148,7 +146,6 @@ if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
/** Fetch the user's member object in the server for nickname and roles */
const guildMember: RESTGetCurrentUserGuildMemberResult =
await request(
"GET",
`https://discord.com/api/users/@me/guilds/${env.DISCORD_SERVER_ID}/member`,
accessToken
);
+1
View File
@@ -116,6 +116,7 @@ function GitHub() {
<TeamLogo
src={githubAccount?.avatarUrl}
size={AvatarSize.Large}
showBorder={false}
/>
}
actions={
+1 -7
View File
@@ -81,14 +81,8 @@ if (
) => void
) {
try {
// Some providers require a POST request to the userinfo endpoint, add them as exceptions here.
const usePostMethod = [
"https://api.dropboxapi.com/2/openid/userinfo",
];
const profile = await request(
usePostMethod.includes(env.OIDC_USERINFO_URI!) ? "POST" : "GET",
env.OIDC_USERINFO_URI!,
env.OIDC_USERINFO_URI ?? "",
accessToken
);
@@ -102,7 +102,6 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
case "api_keys.create":
case "api_keys.delete":
case "attachments.create":
case "attachments.update":
case "attachments.delete":
case "subscriptions.create":
case "subscriptions.delete":
@@ -210,7 +210,6 @@ describe("subscriptionCreator", () => {
where: {
teamId: user.teamId,
},
order: [["createdAt", "ASC"]],
});
expect(events.length).toEqual(3);
-1
View File
@@ -338,7 +338,6 @@ class Collection extends ParanoidModel<
createdById: model.createdById,
},
transaction: options.transaction,
hooks: false,
});
}
-49
View File
@@ -18,15 +18,12 @@ import {
AfterCreate,
AfterUpdate,
Length,
AfterDestroy,
} from "sequelize-typescript";
import { CollectionPermission, DocumentPermission } from "@shared/types";
import { APIContext } from "@server/types";
import Collection from "./Collection";
import Document from "./Document";
import User from "./User";
import IdModel from "./base/IdModel";
import { HookContext } from "./base/Model";
import Fix from "./decorators/Fix";
/**
@@ -212,16 +209,6 @@ class UserMembership extends IdModel<
return this.recreateSourcedMemberships(model, options);
}
@AfterCreate
static async publishAddUserEventAfterCreate(
model: UserMembership,
context: APIContext["context"]
) {
await model.insertEvent(context, "add_user", {
isNew: true,
});
}
@AfterUpdate
static async updateSourcedMemberships(
model: UserMembership,
@@ -249,24 +236,6 @@ class UserMembership extends IdModel<
}
}
@AfterUpdate
static async publishAddUserEventAfterUpdate(
model: UserMembership,
context: APIContext["context"]
) {
await model.insertEvent(context, "add_user", {
isNew: false,
});
}
@AfterDestroy
static async publishRemoveUserEvent(
model: UserMembership,
context: APIContext["context"]
) {
await model.insertEvent(context, "remove_user");
}
/**
* Recreate all sourced permissions for a given permission.
*/
@@ -324,28 +293,10 @@ class UserMembership extends IdModel<
},
{
transaction,
hooks: false,
}
);
}
}
private async insertEvent(
ctx: APIContext["context"],
name: string,
data?: Record<string, unknown>
) {
const hookContext = {
...ctx,
event: { name, data, create: true },
} as HookContext;
if (this.collectionId) {
await Collection.insertEvent(name, this, hookContext);
} else {
await Document.insertEvent(name, this, hookContext);
}
}
}
export default UserMembership;
@@ -1,72 +0,0 @@
import { Transaction } from "sequelize";
import subscriptionCreator from "@server/commands/subscriptionCreator";
import { createContext } from "@server/context";
import { Subscription, User } from "@server/models";
import { sequelize } from "@server/storage/database";
import { DocumentUserEvent, Event } from "@server/types";
import BaseProcessor from "./BaseProcessor";
export default class DocumentSubscriptionProcessor extends BaseProcessor {
static applicableEvents: Event["name"][] = [
"documents.add_user",
"documents.remove_user",
];
async perform(event: DocumentUserEvent) {
const user = await User.findByPk(event.userId);
if (!user) {
return;
}
switch (event.name) {
case "documents.add_user": {
return this.addUser(event, user);
}
case "documents.remove_user": {
return this.removeUser(event, user);
}
default:
}
}
private async addUser(event: DocumentUserEvent, user: User) {
await sequelize.transaction(async (transaction) => {
await subscriptionCreator({
ctx: createContext({
user,
authType: event.authType,
ip: event.ip,
transaction,
}),
documentId: event.documentId,
event: "documents.update",
resubscribe: false,
});
});
}
private async removeUser(event: DocumentUserEvent, user: User) {
await sequelize.transaction(async (transaction) => {
const subscription = await Subscription.findOne({
where: {
userId: user.id,
documentId: event.documentId,
event: "documents.update",
},
transaction,
lock: Transaction.LOCK.UPDATE,
});
await subscription?.destroyWithCtx(
createContext({
user,
authType: event.authType,
ip: event.ip,
transaction,
})
);
});
}
}
@@ -0,0 +1,31 @@
import subscriptionCreator from "@server/commands/subscriptionCreator";
import { createContext } from "@server/context";
import { User } from "@server/models";
import { sequelize } from "@server/storage/database";
import { DocumentUserEvent, Event } from "@server/types";
import BaseProcessor from "./BaseProcessor";
export default class DocumentUserAddedProcessor extends BaseProcessor {
static applicableEvents: Event["name"][] = ["documents.add_user"];
async perform(event: DocumentUserEvent) {
const user = await User.findByPk(event.userId);
if (!user) {
return;
}
await sequelize.transaction(async (transaction) => {
await subscriptionCreator({
ctx: createContext({
user,
authType: event.authType,
ip: event.ip,
transaction,
}),
documentId: event.documentId,
event: "documents.update",
resubscribe: false,
});
});
}
}
@@ -1,51 +0,0 @@
import { createContext } from "@server/context";
import { Attachment } from "@server/models";
import FileStorage from "@server/storage/files";
import BaseTask, { TaskPriority } from "./BaseTask";
type Props = {
/** The ID of the attachment */
attachmentId: string;
/** The remote URL to upload */
url: string;
};
/**
* A task that uploads the provided url to a known attachment.
*/
export default class UploadAttachmentFromUrlTask extends BaseTask<Props> {
public async perform(props: Props) {
const attachment = await Attachment.findByPk(props.attachmentId, {
rejectOnEmpty: true,
include: [{ association: "user" }],
});
try {
const res = await FileStorage.storeFromUrl(
props.url,
attachment.key,
attachment.acl
);
if (res?.url) {
const ctx = createContext({ user: attachment.user });
await attachment.updateWithCtx(ctx, {
url: res.url,
size: res.contentLength,
contentType: res.contentType,
});
}
} catch (err) {
return { error: err.message };
}
return {};
}
public get options() {
return {
attempts: 3,
priority: TaskPriority.Normal,
};
}
}
+2 -79
View File
@@ -1,14 +1,9 @@
import Router from "koa-router";
import { v4 as uuidv4 } from "uuid";
import { AttachmentPreset } from "@shared/types";
import { bytesToHumanReadable, getFileNameFromUrl } from "@shared/utils/files";
import { bytesToHumanReadable } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import { createContext } from "@server/context";
import {
AuthorizationError,
InvalidRequestError,
ValidationError,
} from "@server/errors";
import { AuthorizationError, ValidationError } from "@server/errors";
import auth from "@server/middlewares/authentication";
import { rateLimiter } from "@server/middlewares/rateLimiter";
import { transaction } from "@server/middlewares/transaction";
@@ -17,8 +12,6 @@ import { Attachment, Document } from "@server/models";
import AttachmentHelper from "@server/models/helpers/AttachmentHelper";
import { authorize } from "@server/policies";
import { presentAttachment } from "@server/presenters";
import UploadAttachmentFromUrlTask from "@server/queues/tasks/UploadAttachmentFromUrlTask";
import { sequelize } from "@server/storage/database";
import FileStorage from "@server/storage/files";
import BaseStorage from "@server/storage/files/BaseStorage";
import { APIContext } from "@server/types";
@@ -112,76 +105,6 @@ router.post(
}
);
router.post(
"attachments.createFromUrl",
rateLimiter(RateLimiterStrategy.TwentyFivePerMinute),
auth(),
validate(T.AttachmentsCreateFromUrlSchema),
async (ctx: APIContext<T.AttachmentCreateFromUrlReq>) => {
const { url, documentId, preset } = ctx.input.body;
const { user, type } = ctx.state.auth;
if (preset !== AttachmentPreset.DocumentAttachment || !documentId) {
throw ValidationError(
"Only document attachments can be created from a URL"
);
}
const document = await Document.findByPk(documentId, {
userId: user.id,
});
authorize(user, "update", document);
const name = getFileNameFromUrl(url) ?? "file";
const modelId = uuidv4();
const acl = AttachmentHelper.presetToAcl(preset);
const key = AttachmentHelper.getKey({
acl,
id: modelId,
name,
userId: user.id,
});
// Does not use transaction middleware, as attachment must be persisted
// before the job is scheduled.
const attachment = await sequelize.transaction(async (transaction) =>
Attachment.createWithCtx(
createContext({
authType: type,
user,
ip: ctx.ip,
transaction,
}),
{
id: modelId,
key,
acl,
size: 0,
expiresAt: AttachmentHelper.presetToExpiry(preset),
contentType: "application/octet-stream",
documentId,
teamId: user.teamId,
userId: user.id,
}
)
);
const job = await UploadAttachmentFromUrlTask.schedule({
attachmentId: attachment.id,
url,
});
const response = await job.finished();
if ("error" in response) {
throw InvalidRequestError(response.error);
}
ctx.body = {
data: presentAttachment(attachment),
};
}
);
router.post(
"attachments.delete",
auth(),
-19
View File
@@ -26,25 +26,6 @@ export const AttachmentsCreateSchema = BaseSchema.extend({
export type AttachmentCreateReq = z.infer<typeof AttachmentsCreateSchema>;
export const AttachmentsCreateFromUrlSchema = BaseSchema.extend({
body: z.object({
/** Attachment url */
url: z.string(),
/** Id of the document to which the Attachment belongs */
documentId: z.string().uuid().optional(),
/** Attachment type */
preset: z
.nativeEnum(AttachmentPreset)
.default(AttachmentPreset.DocumentAttachment),
}),
});
export type AttachmentCreateFromUrlReq = z.infer<
typeof AttachmentsCreateFromUrlSchema
>;
export const AttachmentDeleteSchema = BaseSchema.extend({
body: z.object({
/** Id of the attachment to be deleted */
+29 -8
View File
@@ -353,8 +353,8 @@ router.post(
validate(T.CollectionsAddUserSchema),
transaction(),
async (ctx: APIContext<T.CollectionsAddUserReq>) => {
const { transaction } = ctx.state;
const { user: actor } = ctx.state.auth;
const { auth, transaction } = ctx.state;
const actor = auth.user;
const { id, userId, permission } = ctx.input.body;
const [collection, user] = await Promise.all([
@@ -375,15 +375,26 @@ router.post(
permission: permission || user.defaultCollectionPermission,
createdById: actor.id,
},
transaction,
lock: transaction.LOCK.UPDATE,
...ctx.context,
});
if (!isNew && permission) {
if (permission) {
membership.permission = permission;
await membership.save(ctx.context);
await membership.save({ transaction });
}
await Event.createFromContext(ctx, {
name: "collections.add_user",
userId,
modelId: membership.id,
collectionId: collection.id,
data: {
isNew,
permission: membership.permission,
},
});
ctx.body = {
data: {
users: [presentUser(user)],
@@ -399,8 +410,8 @@ router.post(
validate(T.CollectionsRemoveUserSchema),
transaction(),
async (ctx: APIContext<T.CollectionsRemoveUserReq>) => {
const { transaction } = ctx.state;
const { user: actor } = ctx.state.auth;
const { auth, transaction } = ctx.state;
const actor = auth.user;
const { id, userId } = ctx.input.body;
const [collection, user] = await Promise.all([
@@ -420,7 +431,17 @@ router.post(
ctx.throw(400, "User is not a collection member");
}
await membership.destroy(ctx.context);
await collection.$remove("user", user, { transaction });
await Event.createFromContext(ctx, {
name: "collections.remove_user",
userId,
modelId: membership.id,
collectionId: collection.id,
data: {
name: user.name,
},
});
ctx.body = {
success: true,
+25 -6
View File
@@ -1680,8 +1680,8 @@ router.post(
rateLimiter(RateLimiterStrategy.OneHundredPerHour),
transaction(),
async (ctx: APIContext<T.DocumentsAddUserReq>) => {
const { transaction } = ctx.state;
const { user: actor } = ctx.state.auth;
const { auth, transaction } = ctx.state;
const actor = auth.user;
const { id, userId, permission } = ctx.input.body;
if (userId === actor.id) {
@@ -1734,19 +1734,31 @@ router.post(
permission: permission || user.defaultDocumentPermission,
createdById: actor.id,
},
transaction,
lock: transaction.LOCK.UPDATE,
...ctx.context,
});
if (!isNew && permission) {
if (permission) {
membership.permission = permission;
// disconnect from the source if the permission is manually updated
membership.sourceId = null;
await membership.save(ctx.context);
await membership.save({ transaction });
}
await Event.createFromContext(ctx, {
name: "documents.add_user",
userId,
modelId: membership.id,
documentId: document.id,
data: {
title: document.title,
isNew,
permission: membership.permission,
},
});
ctx.body = {
data: {
users: [presentUser(user)],
@@ -1793,7 +1805,14 @@ router.post(
rejectOnEmpty: true,
});
await membership.destroy(ctx.context);
await membership.destroy({ transaction });
await Event.createFromContext(ctx, {
name: "documents.remove_user",
userId,
modelId: membership.id,
documentId: document.id,
});
ctx.body = {
success: true,
@@ -12,7 +12,6 @@ export default async function main(exit = false, limit = 100) {
let apiKeys: ApiKey[] = [];
await sequelize.transaction(async (transaction) => {
apiKeys = await ApiKey.unscoped().findAll({
attributes: ["id", "secret", "value", "hash"],
limit,
offset: page * limit,
order: [["createdAt", "ASC"]],
+2 -7
View File
@@ -129,15 +129,13 @@ export default abstract class BaseStorage {
* @param key The path to store the file at
* @param acl The ACL to use
* @param init Optional fetch options to use
* @param options Optional upload options
* @returns A promise that resolves when the file is uploaded
*/
public async storeFromUrl(
url: string,
key: string,
acl: string,
init?: RequestInit,
options?: { maxUploadSize?: number }
init?: RequestInit
): Promise<
| {
url: string;
@@ -164,10 +162,7 @@ export default abstract class BaseStorage {
const res = await fetch(url, {
follow: 3,
redirect: "follow",
size: Math.min(
options?.maxUploadSize ?? Infinity,
env.FILE_STORAGE_UPLOAD_MAX_SIZE
),
size: env.FILE_STORAGE_UPLOAD_MAX_SIZE,
timeout: 10000,
...init,
});
+4 -4
View File
@@ -7,6 +7,7 @@ import {
NavigationNode,
Client,
CollectionPermission,
DocumentPermission,
JSONValue,
UnfurlResourceType,
ProsemirrorData,
@@ -116,10 +117,6 @@ export type AttachmentEvent = BaseEvent<Attachment> &
source?: "import";
};
}
| {
name: "attachments.update";
modelId: string;
}
| {
name: "attachments.delete";
modelId: string;
@@ -268,6 +265,7 @@ export type CollectionUserEvent = BaseEvent<UserMembership> & {
collectionId: string;
data: {
isNew?: boolean;
permission?: CollectionPermission;
};
};
@@ -284,7 +282,9 @@ export type DocumentUserEvent = BaseEvent<UserMembership> & {
modelId: string;
documentId: string;
data: {
title: string;
isNew?: boolean;
permission?: DocumentPermission;
};
};
+2 -6
View File
@@ -68,13 +68,9 @@ export class StateStore {
};
}
export async function request(
method: "GET" | "POST",
endpoint: string,
accessToken: string
) {
export async function request(endpoint: string, accessToken: string) {
const response = await fetch(endpoint, {
method,
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
@@ -0,0 +1,88 @@
import { Node } from "prosemirror-model";
import { EditorView } from "prosemirror-view";
import { toast } from "sonner";
import type { Dictionary } from "~/hooks/useDictionary";
function findPlaceholderLink(doc: Node, href: string) {
let result: { pos: number; node: Node } | undefined;
doc.descendants((node: Node, pos = 0) => {
// get text nodes
if (node.type.name === "text") {
// get marks for text nodes
node.marks.forEach((mark) => {
// any of the marks links?
if (mark.type.name === "link") {
// any of the links to other docs?
if (mark.attrs.href === href) {
result = { node, pos };
}
}
});
return false;
}
if (!node.content.size) {
return false;
}
return true;
});
return result;
}
const createAndInsertLink = async function (
view: EditorView,
title: string,
href: string,
options: {
dictionary: Dictionary;
nested?: boolean;
onCreateLink: (title: string, nested?: boolean) => Promise<string>;
}
) {
const { dispatch, state } = view;
const { onCreateLink } = options;
try {
const url = await onCreateLink(title, options.nested);
const result = findPlaceholderLink(view.state.doc, href);
if (!result) {
return;
}
dispatch(
view.state.tr
.removeMark(
result.pos,
result.pos + result.node.nodeSize,
state.schema.marks.link
)
.addMark(
result.pos,
result.pos + result.node.nodeSize,
state.schema.marks.link.create({ href: url })
)
);
} catch (err) {
const result = findPlaceholderLink(view.state.doc, href);
if (!result) {
return;
}
dispatch(
view.state.tr.removeMark(
result.pos,
result.pos + result.node.nodeSize,
state.schema.marks.link
)
);
toast.error(options.dictionary.createLinkError);
}
};
export default createAndInsertLink;
+1 -1
View File
@@ -16,7 +16,7 @@ export type Options = {
/** Set to true to replace any existing image at the users selection */
replaceExisting?: boolean;
/** Callback fired to upload a file */
uploadFile?: (file: File | string) => Promise<string>;
uploadFile?: (file: File) => Promise<string>;
/** Callback fired when the user starts a file upload */
onFileUploadStart?: () => void;
/** Callback fired when the user completes a file upload */
+1 -1
View File
@@ -18,7 +18,7 @@ type Props = ComponentProps & {
const Embed = (props: Props) => {
const ref = React.useRef<HTMLIFrameElement>(null);
const { node, isEditable, onChangeSize } = props;
const naturalWidth = 0;
const naturalWidth = 10000;
const naturalHeight = 400;
const isResizable = !!onChangeSize;
+1 -1
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import { useComponentSize } from "../../hooks/useComponentSize";
import useComponentSize from "../../hooks/useComponentSize";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
+1 -2
View File
@@ -13,8 +13,7 @@ import { Decoration, DecorationSet } from "prosemirror-view";
import { v4 as uuidv4 } from "uuid";
import { isCode } from "../lib/isCode";
import { isRemoteTransaction } from "../lib/multiplayer";
import { findBlockNodes } from "../queries/findChildren";
import { NodeWithPos } from "../types";
import { findBlockNodes, NodeWithPos } from "../queries/findChildren";
type MermaidState = {
decorationSet: DecorationSet;
-17
View File
@@ -21,23 +21,6 @@ export default class FileHelper {
return file.type.startsWith("video/");
}
/**
* Download a file from a URL and return it as a File object.
*
* @param url The URL to download the file from
* @returns The downloaded file
*/
static async getFileForUrl(url: string): Promise<File> {
const response = await fetch(url);
const blob = await response.blob();
const fileName = (response.headers.get("content-disposition") || "").split(
"filename="
)[1];
return new File([blob], fileName || "file", {
type: blob.type,
});
}
/**
* Loads the dimensions of a video file.
*
+1 -1
View File
@@ -65,7 +65,7 @@ export default class Link extends Mark {
inclusive: false,
parseDOM: [
{
tag: "a[href]:not(.embed)",
tag: "a[href]",
getAttrs: (dom: HTMLElement) => ({
href: dom.getAttribute("href"),
title: dom.getAttribute("title"),
+2 -18
View File
@@ -16,7 +16,7 @@ import splitHeading from "../commands/splitHeading";
import toggleBlockType from "../commands/toggleBlockType";
import headingToSlug, { headingToPersistenceKey } from "../lib/headingToSlug";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import { findCollapsedNodes } from "../queries/findCollapsedNodes";
import { FoldingHeadersPlugin } from "../plugins/FoldingHeaders";
import Node from "./Node";
export default class Heading extends Node {
@@ -274,23 +274,7 @@ export default class Heading extends Node {
},
});
const foldPlugin: Plugin = new Plugin({
props: {
decorations: (state) => {
const { doc } = state;
const decorations: Decoration[] = findCollapsedNodes(doc).map(
(block) =>
Decoration.node(block.pos, block.pos + block.node.nodeSize, {
class: "folded-content",
})
);
return DecorationSet.create(doc, decorations);
},
},
});
return [foldPlugin, plugin];
return [new FoldingHeadersPlugin(this.editor.props.id), plugin];
}
inputRules({ type }: { type: NodeType }) {
-4
View File
@@ -17,10 +17,6 @@ export default class TableRow extends Node {
};
}
toMarkdown() {
// see: renderTable
}
parseMarkdown() {
return { block: "tr" };
}
+1 -6
View File
@@ -107,7 +107,6 @@ export const richExtensions: Nodes = [
TemplatePlaceholder,
Math,
MathBlock,
Mention,
// Container type nodes should be last so that key handlers are registered for content inside
// the container nodes first.
...listExtensions,
@@ -117,8 +116,4 @@ export const richExtensions: Nodes = [
/**
* Add commenting and mentions to a set of nodes
*/
export const withComments = (nodes: Nodes) => [
...nodes.filter((node) => node !== Mention),
Mention,
Comment,
];
export const withComments = (nodes: Nodes) => [...nodes, Mention, Comment];
+67
View File
@@ -0,0 +1,67 @@
import { Plugin, PluginKey } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import Storage from "../../utils/Storage";
import { headingToPersistenceKey } from "../lib/headingToSlug";
import { findBlockNodes } from "../queries/findChildren";
import { findCollapsedNodes } from "../queries/findCollapsedNodes";
export class FoldingHeadersPlugin extends Plugin {
constructor(documentId: string | undefined) {
const plugin = new PluginKey("folding");
let loaded = false;
super({
key: plugin,
view: (view) => {
loaded = false;
view.dispatch(view.state.tr.setMeta("folding", { loaded: true }));
return {};
},
appendTransaction: (transactions, oldState, newState) => {
if (loaded) {
return;
}
if (
!transactions.some((transaction) => transaction.getMeta("folding"))
) {
return;
}
let modified = false;
const tr = newState.tr;
const blocks = findBlockNodes(newState.doc);
for (const block of blocks) {
if (block.node.type.name === "heading") {
const persistKey = headingToPersistenceKey(block.node, documentId);
const persistedState = Storage.get(persistKey);
if (persistedState === "collapsed") {
tr.setNodeMarkup(block.pos, undefined, {
...block.node.attrs,
collapsed: true,
});
modified = true;
}
}
}
loaded = true;
return modified ? tr : null;
},
props: {
decorations: (state) => {
const { doc } = state;
const decorations: Decoration[] = findCollapsedNodes(doc).map(
(block) =>
Decoration.node(block.pos, block.pos + block.node.nodeSize, {
class: "folded-content",
})
);
return DecorationSet.create(doc, decorations);
},
},
});
}
}
-44
View File
@@ -1,10 +1,8 @@
import { extension } from "mime-types";
import { Node } from "prosemirror-model";
import { Plugin } from "prosemirror-state";
import { getDataTransferFiles, getDataTransferImage } from "../../utils/files";
import { fileNameFromUrl, isInternalUrl } from "../../utils/urls";
import insertFiles, { Options } from "../commands/insertFiles";
import FileHelper from "../lib/FileHelper";
export class UploadPlugin extends Plugin {
constructor(options: Options) {
@@ -98,48 +96,6 @@ export class UploadPlugin extends Plugin {
return false;
},
},
transformPasted: (slice, view) => {
// find any remote images in pasted slice, but leave it alone.
const images: Node[] = [];
slice.content.descendants((node) => {
if (
node.type.name === "image" &&
node.attrs.src &&
!isInternalUrl(node.attrs.src)
) {
images.push(node);
}
});
// Upload each remote image to our storage and replace the src
// with the new url and dimensions.
void images.map(async (image) => {
const url = await options.uploadFile?.(image.attrs.src);
if (url) {
const file = await FileHelper.getFileForUrl(url);
const dimensions = await FileHelper.getImageDimensions(file);
const { tr } = view.state;
tr.doc.nodesBetween(0, tr.doc.nodeSize - 2, (node, pos) => {
if (
node.type.name === "image" &&
node.attrs.src === image.attrs.src
) {
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
...dimensions,
src: url,
});
}
});
view.dispatch(tr);
}
});
return slice;
},
},
});
}
+5 -1
View File
@@ -1,8 +1,12 @@
import { Node } from "prosemirror-model";
import { NodeWithPos } from "../types";
type Predicate = (node: Node) => boolean;
export type NodeWithPos = {
pos: number;
node: Node;
};
export function flatten(node: Node, descend = true): NodeWithPos[] {
if (!node) {
throw new Error('Invalid "node" parameter');
+1 -2
View File
@@ -1,6 +1,5 @@
import { Node } from "prosemirror-model";
import { NodeWithPos } from "../types";
import { findBlockNodes } from "./findChildren";
import { findBlockNodes, NodeWithPos } from "./findChildren";
export function findCollapsedNodes(doc: Node): NodeWithPos[] {
const blocks = findBlockNodes(doc);
-5
View File
@@ -6,11 +6,6 @@ import * as React from "react";
import { DefaultTheme } from "styled-components";
import { Primitive } from "utility-types";
export type NodeWithPos = {
pos: number;
node: ProsemirrorNode;
};
export type PlainTextSerializer = (node: ProsemirrorNode) => string;
export enum TableLayout {
+5 -12
View File
@@ -1,4 +1,4 @@
import { useState, useLayoutEffect } from "react";
import { useState, useEffect } from "react";
const defaultRect = {
top: 0,
@@ -11,19 +11,12 @@ const defaultRect = {
height: 0,
};
/**
* A hook that returns the size of an element or ref.
*
* @param input The element or ref to observe
* @returns The size and position of the element
*/
export function useComponentSize(
input: HTMLElement | null | React.RefObject<HTMLElement | null>
export default function useComponentSize(
element: HTMLElement | null
): DOMRect | typeof defaultRect {
const element = input instanceof HTMLElement ? input : input?.current;
const [size, setSize] = useState(() => element?.getBoundingClientRect());
useLayoutEffect(() => {
useEffect(() => {
const sizeObserver = new ResizeObserver(() => {
element?.dispatchEvent(new CustomEvent("resize"));
});
@@ -33,7 +26,7 @@ export function useComponentSize(
return () => sizeObserver.disconnect();
}, [element]);
useLayoutEffect(() => {
useEffect(() => {
const handleResize = () => {
setSize((state) => {
const rect = element?.getBoundingClientRect();
+3 -13
View File
@@ -167,6 +167,8 @@
"Also, <em>{{collectionName}}</em> is being used as the start view deleting it will reset the start view to the Home page.": "<em>{{collectionName}}</em> se také používá jako domovská stránka odstraněním se obnoví výchozí nastavení.",
"Sorry, an error occurred saving the collection": "Omlouváme se, při ukládání sbírky došlo k chybě",
"Add a description": "Přidat popis",
"Collapse": "Sbalit",
"Expand": "Rozbalit",
"Type a command or search": "Zadejte příkaz nebo začněte vyhledávat",
"Choose a template": "Vybrat šablonu",
"Are you sure you want to permanently delete this entire comment thread?": "Jste si jisti, že chcete natrvalo odstranit vlákno komentářů?",
@@ -195,7 +197,6 @@
"Install now": "Nainstalovat",
"Deleted Collection": "Odstraněná sbírka",
"Unpin": "Zrušit připnutí",
"{{ minutes }}m read": "{{ minutes }}m read",
"Select a location to copy": "Select a location to copy",
"Document copied": "Document copied",
"Couldnt copy the document, try again?": "Couldnt copy the document, try again?",
@@ -369,8 +370,6 @@
"You can't reorder documents in an alphabetically sorted collection": "Nemůžete změnit pořadí dokumentů v abecedně seřazené sbírce",
"Empty": "Prázdné",
"Collections": "Sbírky",
"Collapse": "Sbalit",
"Expand": "Rozbalit",
"Document not supported try Markdown, Plain text, HTML, or Word": "Dokument není podporován zkuste Markdown, Plain text, HTML nebo Word",
"Go back": "Jít zpět",
"Go forward": "Jít vpřed",
@@ -420,8 +419,6 @@
"Profile picture": "Profilový obrázek",
"Create a new doc": "Vytvořit nový dokument",
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} won't be notified, as they do not have access to this document",
"Keep as link": "Keep as link",
"Embed": "Embed",
"Add column after": "Přidat sloupec za",
"Add column before": "Přidat sloupec před",
"Add row after": "Přidat řádek za",
@@ -570,8 +567,7 @@
"invited you to": "vás pozval/a do",
"Choose a date": "Vybrat datum",
"API key created. Please copy the value now as it will not be shown again.": "API key created. Please copy the value now as it will not be shown again.",
"Scopes": "Scopes",
"Space-separated scopes restrict the access of this API key to specific parts of the API. Leave blank for full access": "Space-separated scopes restrict the access of this API key to specific parts of the API. Leave blank for full access",
"Name your key something that will help you to remember it's use in the future, for example \"local development\" or \"continuous integration\".": "Pojmenujte svůj klíč tak, abyste si ho snadno zapamatovali pro jeho použití v budoucnu, například \"místní vývoj\" nebo \"průběžná integrace\".",
"Expiration": "Vypršení platnosti",
"Never expires": "Nikdy nevyprší",
"7 days": "7 dní",
@@ -594,7 +590,6 @@
"{{ groupsCount }} groups with access_plural": "{{ groupsCount }} skupin s přístupem",
"Archived by {{userName}}": "Archivoval {{userName}}",
"Share": "Sdílet",
"Overview": "Overview",
"Recently updated": "Nedávno aktualizováno",
"Recently published": "Nedávno zveřejněné",
"Least recently updated": "Naposledy aktualizováno",
@@ -764,10 +759,6 @@
"LaTeX block": "Blok LaTeX",
"Inline code": "Vložený kód",
"Inline LaTeX": "Vložený LaTeX",
"Triggers": "Triggers",
"Mention user or document": "Mention user or document",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Sign In": "Přihlásit se",
"Continue with Email": "Pokračovat pomocí e-mailu",
"Continue with {{ authProviderName }}": "Pokračovat s {{ authProviderName }}",
@@ -830,7 +821,6 @@
"by {{ name }}": "by {{ name }}",
"Last used": "Naposledy použito",
"No expiry": "Bez vypršení platnosti",
"Restricted scope": "Restricted scope",
"API key copied to clipboard": "Klíč API byl zkopírován do schránky",
"Copied": "Zkopírováno",
"Revoking": "Odvolávání",
+3 -13
View File
@@ -167,6 +167,8 @@
"Also, <em>{{collectionName}}</em> is being used as the start view deleting it will reset the start view to the Home page.": "Also, <em>{{collectionName}}</em> is being used as the start view deleting it will reset the start view to the Home page.",
"Sorry, an error occurred saving the collection": "Sorry, an error occurred saving the collection",
"Add a description": "Add a description",
"Collapse": "Collapse",
"Expand": "Expand",
"Type a command or search": "Type a command or search",
"Choose a template": "Choose a template",
"Are you sure you want to permanently delete this entire comment thread?": "Are you sure you want to permanently delete this entire comment thread?",
@@ -195,7 +197,6 @@
"Install now": "Install now",
"Deleted Collection": "Deleted Collection",
"Unpin": "Unpin",
"{{ minutes }}m read": "{{ minutes }}m read",
"Select a location to copy": "Select a location to copy",
"Document copied": "Document copied",
"Couldnt copy the document, try again?": "Couldnt copy the document, try again?",
@@ -369,8 +370,6 @@
"You can't reorder documents in an alphabetically sorted collection": "Du kan ikke omarrangere dokumenter i en alfabetisk sorteret samling",
"Empty": "Tom",
"Collections": "Samlinger",
"Collapse": "Collapse",
"Expand": "Expand",
"Document not supported try Markdown, Plain text, HTML, or Word": "Dokumentet understøttes ikke prøv Markdown, Almindelig tekst, HTML, eller Word",
"Go back": "Gå tilbage",
"Go forward": "Gå fremad",
@@ -420,8 +419,6 @@
"Profile picture": "Profile picture",
"Create a new doc": "Create a new doc",
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} won't be notified, as they do not have access to this document",
"Keep as link": "Keep as link",
"Embed": "Embed",
"Add column after": "Add column after",
"Add column before": "Add column before",
"Add row after": "Add row after",
@@ -570,8 +567,7 @@
"invited you to": "invited you to",
"Choose a date": "Choose a date",
"API key created. Please copy the value now as it will not be shown again.": "API key created. Please copy the value now as it will not be shown again.",
"Scopes": "Scopes",
"Space-separated scopes restrict the access of this API key to specific parts of the API. Leave blank for full access": "Space-separated scopes restrict the access of this API key to specific parts of the API. Leave blank for full access",
"Name your key something that will help you to remember it's use in the future, for example \"local development\" or \"continuous integration\".": "Name your key something that will help you to remember it's use in the future, for example \"local development\" or \"continuous integration\".",
"Expiration": "Expiration",
"Never expires": "Never expires",
"7 days": "7 days",
@@ -594,7 +590,6 @@
"{{ groupsCount }} groups with access_plural": "{{ groupsCount }} groups with access",
"Archived by {{userName}}": "Archived by {{userName}}",
"Share": "Del",
"Overview": "Overview",
"Recently updated": "Recently updated",
"Recently published": "Recently published",
"Least recently updated": "Least recently updated",
@@ -764,10 +759,6 @@
"LaTeX block": "LaTeX block",
"Inline code": "Inline code",
"Inline LaTeX": "Inline LaTeX",
"Triggers": "Triggers",
"Mention user or document": "Mention user or document",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Sign In": "Sign In",
"Continue with Email": "Continue with Email",
"Continue with {{ authProviderName }}": "Continue with {{ authProviderName }}",
@@ -830,7 +821,6 @@
"by {{ name }}": "by {{ name }}",
"Last used": "Last used",
"No expiry": "No expiry",
"Restricted scope": "Restricted scope",
"API key copied to clipboard": "API key copied to clipboard",
"Copied": "Copied",
"Revoking": "Revoking",
+12 -22
View File
@@ -25,8 +25,8 @@
"Mark as resolved": "Als gelöst markieren",
"Thread resolved": "Thread gelöst",
"Mark as unresolved": "Als ungelöst markieren",
"View reactions": "Reaktionen anzeigen",
"Reactions": "Reaktionen",
"View reactions": "View reactions",
"Reactions": "Reactions",
"Copy ID": "ID kopieren",
"Clear IndexedDB cache": "IndexedDB Cache löschen",
"IndexedDB cache cleared": "IndexedDB Cache gelöscht",
@@ -167,6 +167,8 @@
"Also, <em>{{collectionName}}</em> is being used as the start view deleting it will reset the start view to the Home page.": "Die Sammlung <em>{{collectionName}}</em> wird außerdem als Startseite genutzt sollte diese Sammlung gelöscht werden, wird die Startseite auf die Standardeinstellung zurückgesetzt.",
"Sorry, an error occurred saving the collection": "Beim Speichern der Sammlung ist leider ein Fehler aufgetreten",
"Add a description": "Beschreibung hinzufügen",
"Collapse": "Zusammenklappen",
"Expand": "Ausklappen",
"Type a command or search": "Gib einen Befehl oder eine Suche ein",
"Choose a template": "Wähle eine Vorlage",
"Are you sure you want to permanently delete this entire comment thread?": "Bist du sicher, dass du diesen Kommentarverlauf löschen möchtest?",
@@ -195,7 +197,6 @@
"Install now": "Jetzt installieren",
"Deleted Collection": "Gelöschte Sammlung",
"Unpin": "Lospinnen",
"{{ minutes }}m read": "{{ minutes }}m read",
"Select a location to copy": "Select a location to copy",
"Document copied": "Document copied",
"Couldnt copy the document, try again?": "Couldnt copy the document, try again?",
@@ -309,7 +310,7 @@
"Add reaction": "Add reaction",
"Reaction picker": "Reaction picker",
"Could not load reactions": "Could not load reactions",
"Reaction": "Reaktion",
"Reaction": "Reaction",
"Results": "Ergebnisse",
"No results for {{query}}": "Keine Ergebnisse für {{query}}",
"Manage": "Verwalten",
@@ -369,8 +370,6 @@
"You can't reorder documents in an alphabetically sorted collection": "Du kannst Dokumente in einer alphabetisch sortierten Sammlung nicht neu anordnen",
"Empty": "Leer",
"Collections": "Sammlungen",
"Collapse": "Zusammenklappen",
"Expand": "Ausklappen",
"Document not supported try Markdown, Plain text, HTML, or Word": "Dokument nicht unterstützt - versuche Markdown, Klartext, HTML oder Word",
"Go back": "Zurück",
"Go forward": "Vor",
@@ -420,8 +419,6 @@
"Profile picture": "Profilbild",
"Create a new doc": "Neues Dokument erstellen",
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} won't be notified, as they do not have access to this document",
"Keep as link": "Keep as link",
"Embed": "Embed",
"Add column after": "Spalte dahinter hinzufügen",
"Add column before": "Spalte davor hinzufügen",
"Add row after": "Zeile danach einfügen",
@@ -483,7 +480,7 @@
"Sort ascending": "Aufsteigend sortieren",
"Sort descending": "Absteigend sortieren",
"Table": "Tabelle",
"Export as CSV": "Exportiere als CSV",
"Export as CSV": "Export as CSV",
"Toggle header": "Kopfzeile umschalten",
"Math inline (LaTeX)": "Mathe-Inline (LaTeX)",
"Math block (LaTeX)": "Mathe-Block (LaTeX)",
@@ -525,7 +522,7 @@
"Z-A sort": "Z-A sort",
"Manual sort": "Manuelle Sortierung",
"Comment options": "Kommentar Optionen",
"Show document menu": "Dokumentmenü anzeigen",
"Show document menu": "Show document menu",
"{{ documentName }} restored": "{{ documentName }} wiederhergestellt",
"Document options": "Dokument-Einstellungen",
"Choose a collection": "Sammlung auswählen",
@@ -550,7 +547,7 @@
"Headings you add to the document will appear here": "Überschriften, die du dem Dokument hinzufügst, werden hier angezeigt",
"Table of contents": "Inhaltsverzeichnis",
"Change name": "Namen ändern",
"Change email": "E-Mail-Adresse ändern",
"Change email": "Change email",
"Suspend user": "Benutzer sperren",
"An error occurred while sending the invite": "Beim Versand der Einladung trat ein Fehler auf",
"User options": "Nutzer-Einstellungen",
@@ -569,9 +566,8 @@
"shared": "geteilt",
"invited you to": "hat dich eingeladen zu",
"Choose a date": "Datum auswählen",
"API key created. Please copy the value now as it will not be shown again.": "API-Schlüssel erstellt. Bitte kopieren Sie den Wert jetzt, da er nicht wieder angezeigt werden kann.",
"Scopes": "Scopes",
"Space-separated scopes restrict the access of this API key to specific parts of the API. Leave blank for full access": "Space-separated scopes restrict the access of this API key to specific parts of the API. Leave blank for full access",
"API key created. Please copy the value now as it will not be shown again.": "API key created. Please copy the value now as it will not be shown again.",
"Name your key something that will help you to remember it's use in the future, for example \"local development\" or \"continuous integration\".": "Benenne deinen Token so, dass du dich leicht daran erinnern kannst, z. B. \"Entwicklung\" \"Produktion\" oder \"durchgängige Integration\".",
"Expiration": "Ablaufdatum",
"Never expires": "Läuft nie ab",
"7 days": "7 Tage",
@@ -594,7 +590,6 @@
"{{ groupsCount }} groups with access_plural": "{{ groupsCount }} Gruppen mit Zugriff",
"Archived by {{userName}}": "Archiviert durch {{userName}}",
"Share": "Teilen",
"Overview": "Overview",
"Recently updated": "Vor Kurzem aktualisiert",
"Recently published": "Vor Kurzem veröffentlicht",
"Least recently updated": "Am längsten nicht aktualisiert",
@@ -610,8 +605,8 @@
"Upload image": "Bild hochladen",
"No resolved comments": "Keine gelösten Kommentare",
"No comments yet": "Bisher keine Kommentare",
"New comments": "Neue Kommentare",
"Sort comments": "Kommentare sortieren",
"New comments": "New comments",
"Sort comments": "Sort comments",
"Most recent": "Most recent",
"Order in doc": "Order in doc",
"Resolved": "Resolved",
@@ -764,10 +759,6 @@
"LaTeX block": "LaTeX-Block",
"Inline code": "Inline-Code",
"Inline LaTeX": "Inline-LaTeX",
"Triggers": "Triggers",
"Mention user or document": "Mention user or document",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Sign In": "Anmelden",
"Continue with Email": "Weiter mit E-Mail",
"Continue with {{ authProviderName }}": "Weiter mit {{ authProviderName }}",
@@ -830,7 +821,6 @@
"by {{ name }}": "by {{ name }}",
"Last used": "Zuletzt verwendet",
"No expiry": "Kein Verfall",
"Restricted scope": "Restricted scope",
"API key copied to clipboard": "API-Schlüssel wurde in die Zwischenablage kopiert",
"Copied": "Kopiert",
"Revoking": "Wird widerrufen",
+5 -9
View File
@@ -167,6 +167,8 @@
"Also, <em>{{collectionName}}</em> is being used as the start view deleting it will reset the start view to the Home page.": "Also, <em>{{collectionName}}</em> is being used as the start view deleting it will reset the start view to the Home page.",
"Sorry, an error occurred saving the collection": "Sorry, an error occurred saving the collection",
"Add a description": "Add a description",
"Collapse": "Collapse",
"Expand": "Expand",
"Type a command or search": "Type a command or search",
"Choose a template": "Choose a template",
"Are you sure you want to permanently delete this entire comment thread?": "Are you sure you want to permanently delete this entire comment thread?",
@@ -176,7 +178,6 @@
"view and edit access": "view and edit access",
"view only access": "view only access",
"no access": "no access",
"You do not have permission to move {{ documentName }} to the {{ collectionName }} collection": "You do not have permission to move {{ documentName }} to the {{ collectionName }} collection",
"Move document": "Move document",
"Moving": "Moving",
"Moving the document <em>{{ title }}</em> to the {{ newCollectionName }} collection will change permission for all workspace members from <em>{{ prevPermission }}</em> to <em>{{ newPermission }}</em>.": "Moving the document <em>{{ title }}</em> to the {{ newCollectionName }} collection will change permission for all workspace members from <em>{{ prevPermission }}</em> to <em>{{ newPermission }}</em>.",
@@ -195,7 +196,6 @@
"Default collection": "Default collection",
"Install now": "Install now",
"Deleted Collection": "Deleted Collection",
"Untitled": "Untitled",
"Unpin": "Unpin",
"{{ minutes }}m read": "{{ minutes }}m read",
"Select a location to copy": "Select a location to copy",
@@ -205,6 +205,7 @@
"Copy to <em>{{ location }}</em>": "Copy to <em>{{ location }}</em>",
"Search collections & documents": "Search collections & documents",
"No results found": "No results found",
"Untitled": "Untitled",
"New": "New",
"Only visible to you": "Only visible to you",
"Draft": "Draft",
@@ -365,11 +366,11 @@
"{{ count }} groups added to the document_plural": "{{ count }} groups added to the document",
"Logo": "Logo",
"Archived collections": "Archived collections",
"Change permissions?": "Change permissions?",
"New doc": "New doc",
"You can't reorder documents in an alphabetically sorted collection": "You can't reorder documents in an alphabetically sorted collection",
"Empty": "Empty",
"Collections": "Collections",
"Collapse": "Collapse",
"Expand": "Expand",
"Document not supported try Markdown, Plain text, HTML, or Word": "Document not supported try Markdown, Plain text, HTML, or Word",
"Go back": "Go back",
"Go forward": "Go forward",
@@ -381,10 +382,6 @@
"Up to date": "Up to date",
"{{ releasesBehind }} versions behind": "{{ releasesBehind }} version behind",
"{{ releasesBehind }} versions behind_plural": "{{ releasesBehind }} versions behind",
"Change permissions?": "Change permissions?",
"{{ documentName }} cannot be moved within {{ parentDocumentName }}": "{{ documentName }} cannot be moved within {{ parentDocumentName }}",
"You can't reorder documents in an alphabetically sorted collection": "You can't reorder documents in an alphabetically sorted collection",
"The {{ documentName }} cannot be moved here": "The {{ documentName }} cannot be moved here",
"Return to App": "Back to App",
"Installation": "Installation",
"Unstar document": "Unstar document",
@@ -597,7 +594,6 @@
"{{ groupsCount }} groups with access_plural": "{{ groupsCount }} groups with access",
"Archived by {{userName}}": "Archived by {{userName}}",
"Share": "Share",
"Overview": "Overview",
"Recently updated": "Recently updated",
"Recently published": "Recently published",
"Least recently updated": "Least recently updated",
+3 -13
View File
@@ -167,6 +167,8 @@
"Also, <em>{{collectionName}}</em> is being used as the start view deleting it will reset the start view to the Home page.": "Además, la colección <em>{{collectionName}}</em> está siendo utilizada como vista inicial eliminarla restablecerá la vista inicial a la página de Inicio.",
"Sorry, an error occurred saving the collection": "Lo sentimos, se ha producido un error al guardar la colección",
"Add a description": "Añadir una descripción",
"Collapse": "Colapsar",
"Expand": "Expandir",
"Type a command or search": "Escribe un comando o busca",
"Choose a template": "Elige una plantilla",
"Are you sure you want to permanently delete this entire comment thread?": "¿Estás seguro de que quieres eliminar permanentemente todo este hilo de comentarios?",
@@ -195,7 +197,6 @@
"Install now": "Instalar ahora",
"Deleted Collection": "Colección Eliminada",
"Unpin": "Desfijar",
"{{ minutes }}m read": "{{ minutes }}m read",
"Select a location to copy": "Select a location to copy",
"Document copied": "Document copied",
"Couldnt copy the document, try again?": "No se pudo copiar el documento, ¿intentar de nuevo?",
@@ -369,8 +370,6 @@
"You can't reorder documents in an alphabetically sorted collection": "No puedes reordenar documentos en una colección ordenada alfabéticamente",
"Empty": "Vacío",
"Collections": "Colecciones",
"Collapse": "Colapsar",
"Expand": "Expandir",
"Document not supported try Markdown, Plain text, HTML, or Word": "Documento no compatible intenta Markdown, Texto sin formato, HTML o Word",
"Go back": "Volver",
"Go forward": "Avanzar",
@@ -420,8 +419,6 @@
"Profile picture": "Foto de perfil",
"Create a new doc": "Crea un nuevo documento",
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} won't be notified, as they do not have access to this document",
"Keep as link": "Keep as link",
"Embed": "Embed",
"Add column after": "Agregar columna después",
"Add column before": "Agregar columna antes",
"Add row after": "Añadir fila después",
@@ -570,8 +567,7 @@
"invited you to": "te invitó a",
"Choose a date": "Elige una fecha",
"API key created. Please copy the value now as it will not be shown again.": "API key created. Please copy the value now as it will not be shown again.",
"Scopes": "Scopes",
"Space-separated scopes restrict the access of this API key to specific parts of the API. Leave blank for full access": "Space-separated scopes restrict the access of this API key to specific parts of the API. Leave blank for full access",
"Name your key something that will help you to remember it's use in the future, for example \"local development\" or \"continuous integration\".": "Dale a tu token un nombre que te permita recordar su uso en el futuro, por ejemplo \"desarrollo local\", \"producción\" o \"integración continua\".",
"Expiration": "Expiración",
"Never expires": "Nunca expira",
"7 days": "7 días",
@@ -594,7 +590,6 @@
"{{ groupsCount }} groups with access_plural": "{{ groupsCount }} grupos con acceso",
"Archived by {{userName}}": "Archivado por {{userName}}",
"Share": "Compartir",
"Overview": "Overview",
"Recently updated": "Actualizado recientemente",
"Recently published": "Publicado recientemente",
"Least recently updated": "Actualizado menos recientemente",
@@ -764,10 +759,6 @@
"LaTeX block": "Bloque de LaTeX",
"Inline code": "Código en línea",
"Inline LaTeX": "Línea de LaTeX",
"Triggers": "Triggers",
"Mention user or document": "Mention user or document",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Sign In": "Iniciar sesión",
"Continue with Email": "Continuar con el correo electrónico",
"Continue with {{ authProviderName }}": "Continuar con {{ authProviderName }}",
@@ -830,7 +821,6 @@
"by {{ name }}": "by {{ name }}",
"Last used": "Utilizado por última vez",
"No expiry": "No expira",
"Restricted scope": "Restricted scope",
"API key copied to clipboard": "Clave API copiada al portapapeles",
"Copied": "Copiado",
"Revoking": "Revocando",
+8 -18
View File
@@ -7,21 +7,21 @@
"Edit collection": "ویرایش مجموعه",
"Permissions": "دسترسی ها",
"Collection permissions": "دسترسی‌های مجموعه",
"Share this collection": "جستجو در مجموعه",
"Share this collection": "Share this collection",
"Search in collection": "جستجو در مجموعه",
"Star": "ستاره‌گذاری",
"Unstar": "برداشتن ستاره",
"Archive": "آرشیو",
"Archive collection": "آرشیو مجموعه",
"Collection archived": "مجموعه آرشیو شد",
"Archive collection": "Archive collection",
"Collection archived": "Collection archived",
"Archiving": "در حال بایگانی",
"Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results.": "Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results.",
"Restore": "بازیابی",
"Collection restored": "ایجاد مجموعه",
"Collection restored": "Collection restored",
"Delete": "حذف",
"Delete collection": "حذف مجموعه",
"New template": "قالب جدید",
"Delete comment": "حذف نظر",
"Delete comment": "Delete comment",
"Mark as resolved": "",
"Thread resolved": "Thread resolved",
"Mark as unresolved": "Mark as unresolved",
@@ -167,6 +167,8 @@
"Also, <em>{{collectionName}}</em> is being used as the start view deleting it will reset the start view to the Home page.": "همچنین، <em>{{collectionName}}</em> به عنوان نمای شروع استفاده می شود - با حذف آن، نمای شروع به صفحه اصلی بازگردانده می شود.",
"Sorry, an error occurred saving the collection": "متاسفانه خطایی در ذخیره‌سازی مجموعه رخ داد",
"Add a description": "توضیحاتی اضافه کنید",
"Collapse": "جمع کردن",
"Expand": "باز کردن",
"Type a command or search": "دستوری تایپ و یا جستجو کنید",
"Choose a template": "Choose a template",
"Are you sure you want to permanently delete this entire comment thread?": "Are you sure you want to permanently delete this entire comment thread?",
@@ -195,7 +197,6 @@
"Install now": "Install now",
"Deleted Collection": "مجموعه‌های حذف شده",
"Unpin": "برداشتن سنجاق",
"{{ minutes }}m read": "{{ minutes }}m read",
"Select a location to copy": "Select a location to copy",
"Document copied": "Document copied",
"Couldnt copy the document, try again?": "Couldnt copy the document, try again?",
@@ -369,8 +370,6 @@
"You can't reorder documents in an alphabetically sorted collection": "You can't reorder documents in an alphabetically sorted collection",
"Empty": "خالی",
"Collections": "مجموعه‌ها",
"Collapse": "جمع کردن",
"Expand": "باز کردن",
"Document not supported try Markdown, Plain text, HTML, or Word": "نوع سند پشتیبانی نمی‌شود - از Markdown، متن ساده، HTML، یا Word استفاده کنید",
"Go back": "Go back",
"Go forward": "Go forward",
@@ -420,8 +419,6 @@
"Profile picture": "تصویر پروفایل",
"Create a new doc": "ایجاد سند جدید",
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} won't be notified, as they do not have access to this document",
"Keep as link": "Keep as link",
"Embed": "Embed",
"Add column after": "Add column after",
"Add column before": "Add column before",
"Add row after": "Add row after",
@@ -570,8 +567,7 @@
"invited you to": "invited you to",
"Choose a date": "Choose a date",
"API key created. Please copy the value now as it will not be shown again.": "API key created. Please copy the value now as it will not be shown again.",
"Scopes": "Scopes",
"Space-separated scopes restrict the access of this API key to specific parts of the API. Leave blank for full access": "Space-separated scopes restrict the access of this API key to specific parts of the API. Leave blank for full access",
"Name your key something that will help you to remember it's use in the future, for example \"local development\" or \"continuous integration\".": "Name your key something that will help you to remember it's use in the future, for example \"local development\" or \"continuous integration\".",
"Expiration": "Expiration",
"Never expires": "Never expires",
"7 days": "7 days",
@@ -594,7 +590,6 @@
"{{ groupsCount }} groups with access_plural": "{{ groupsCount }} groups with access",
"Archived by {{userName}}": "بایگانی شده توسط {{userName}}",
"Share": "اشتراک‌گذاری",
"Overview": "Overview",
"Recently updated": "آخرین به‌روزرسانی",
"Recently published": "آخرین انتشار",
"Least recently updated": "اولین به‌روزرسانی",
@@ -764,10 +759,6 @@
"LaTeX block": "LaTeX block",
"Inline code": "کد درون خطی",
"Inline LaTeX": "Inline LaTeX",
"Triggers": "Triggers",
"Mention user or document": "Mention user or document",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Sign In": "ورود",
"Continue with Email": "ادامه با ایمیل",
"Continue with {{ authProviderName }}": "با {{ authProviderName }} ادامه دهید",
@@ -830,7 +821,6 @@
"by {{ name }}": "by {{ name }}",
"Last used": "Last used",
"No expiry": "No expiry",
"Restricted scope": "Restricted scope",
"API key copied to clipboard": "API key copied to clipboard",
"Copied": "Copied",
"Revoking": "Revoking",
+3 -13
View File
@@ -167,6 +167,8 @@
"Also, <em>{{collectionName}}</em> is being used as the start view deleting it will reset the start view to the Home page.": "De plus, la collection <em>{{collectionName}}</em> est affichée par défaut au démarrage - sa suppression réinitialisera la vue de démarrage sur la page d'accueil.",
"Sorry, an error occurred saving the collection": "Désolé, une erreur s'est produite lors de l'enregistrement de la collection",
"Add a description": "Ajouter une description",
"Collapse": "Réduire",
"Expand": "Développer",
"Type a command or search": "Entrez une commande ou faites une recherche",
"Choose a template": "Choisir un modèle",
"Are you sure you want to permanently delete this entire comment thread?": "Êtes-vous sûr de vouloir supprimer définitivement ce fil de commentaires ?",
@@ -195,7 +197,6 @@
"Install now": "Installer maintenant",
"Deleted Collection": "Collection supprimée",
"Unpin": "Désépingler",
"{{ minutes }}m read": "{{ minutes }}m read",
"Select a location to copy": "Select a location to copy",
"Document copied": "Document copied",
"Couldnt copy the document, try again?": "Couldnt copy the document, try again?",
@@ -369,8 +370,6 @@
"You can't reorder documents in an alphabetically sorted collection": "Vous ne pouvez pas réorganiser les documents dans une collection triée par ordre alphabétique",
"Empty": "Vide",
"Collections": "Collections",
"Collapse": "Réduire",
"Expand": "Développer",
"Document not supported try Markdown, Plain text, HTML, or Word": "Document non pris en charge - essayez un format Markdown, Text, HTML ou Word",
"Go back": "Revenir en arrière",
"Go forward": "Continuer",
@@ -420,8 +419,6 @@
"Profile picture": "Photo de profil",
"Create a new doc": "Créer un nouveau doc",
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} ne sera pas notifié, car il n'a pas accès à ce document",
"Keep as link": "Keep as link",
"Embed": "Embed",
"Add column after": "Ajouter une colonne après",
"Add column before": "Ajouter une colonne avant",
"Add row after": "Ajouter une ligne après",
@@ -570,8 +567,7 @@
"invited you to": "vous a invité à",
"Choose a date": "Choisissez une date",
"API key created. Please copy the value now as it will not be shown again.": "Clé d'API créée. Veuillez copier la valeur maintenant car elle ne sera plus affichée.",
"Scopes": "Scopes",
"Space-separated scopes restrict the access of this API key to specific parts of the API. Leave blank for full access": "Space-separated scopes restrict the access of this API key to specific parts of the API. Leave blank for full access",
"Name your key something that will help you to remember it's use in the future, for example \"local development\" or \"continuous integration\".": "Nommez votre clé par quelque chose qui vous aidera à vous souvenir de son utilisation à l'avenir, par exemple \"développement local\" ou \"intégration continue\".",
"Expiration": "Expiration",
"Never expires": "N'expire jamais",
"7 days": "7 jours",
@@ -594,7 +590,6 @@
"{{ groupsCount }} groups with access_plural": "{{ groupsCount }} groupes ont accès",
"Archived by {{userName}}": "Archivé par {{userName}}",
"Share": "Partager",
"Overview": "Overview",
"Recently updated": "Récemment mis à jour",
"Recently published": "Récemment publiés",
"Least recently updated": "Anciennement modifiés",
@@ -764,10 +759,6 @@
"LaTeX block": "Bloc LaTeX",
"Inline code": "Ligne de Code",
"Inline LaTeX": "LaTeX en ligne",
"Triggers": "Triggers",
"Mention user or document": "Mention user or document",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Sign In": "Se connecter",
"Continue with Email": "Continuer avec un e-mail",
"Continue with {{ authProviderName }}": "Continuer avec {{ authProviderName }}",
@@ -830,7 +821,6 @@
"by {{ name }}": "by {{ name }}",
"Last used": "Dernier utilisé",
"No expiry": "Pas d'expiration",
"Restricted scope": "Restricted scope",
"API key copied to clipboard": "Clé API copiée dans le presse-papier",
"Copied": "Copié",
"Revoking": "Supression en cours",

Some files were not shown because too many files have changed in this diff Show More