+
+
+ }
+ title={membership.group.name}
+ subtitle={t("{{ count }} member", {
+ count: membership.group.memberCount,
+ })}
+ actions={
+
+ {
+ if (permission) {
+ await collectionGroupMemberships.create({
+ collectionId: collection.id,
+ groupId: membership.groupId,
+ permission,
+ });
+ } else {
+ await collectionGroupMemberships.delete({
+ collectionId: collection.id,
+ groupId: membership.groupId,
+ });
+ }
+ }}
+ disabled={!can.update}
+ value={membership.permission}
+ labelHidden
+ nude
+ />
+
+ }
+ />
+ ))}
+ {memberships
+ .inCollection(collection.id)
+ .sort((a, b) =>
+ (
+ (invitedInSession.includes(a.user.id) ? "_" : "") + a.user.name
+ ).localeCompare(b.user.name)
+ )
+ .map((membership) => (
+
+ }
+ title={membership.user.name}
+ subtitle={membership.user.email}
+ actions={
+
+ {
+ if (permission) {
+ await memberships.create({
+ collectionId: collection.id,
+ userId: membership.userId,
+ permission,
+ });
+ } else {
+ await memberships.delete({
+ collectionId: collection.id,
+ userId: membership.userId,
+ });
+ }
+ }}
+ disabled={!can.update}
+ value={membership.permission}
+ labelHidden
+ nude
+ />
+
+ }
+ />
+ ))}
+ >
+ );
+}
+
+export default observer(CollectionMemberList);
diff --git a/app/components/Sharing/Collection/SharePopover.tsx b/app/components/Sharing/Collection/SharePopover.tsx
new file mode 100644
index 000000000..86d00af9d
--- /dev/null
+++ b/app/components/Sharing/Collection/SharePopover.tsx
@@ -0,0 +1,340 @@
+import { isEmail } from "class-validator";
+import { m } from "framer-motion";
+import { observer } from "mobx-react";
+import { BackIcon, LinkIcon, UserIcon } from "outline-icons";
+import * as React from "react";
+import { useTranslation } from "react-i18next";
+import { toast } from "sonner";
+import { useTheme } from "styled-components";
+import Squircle from "@shared/components/Squircle";
+import { CollectionPermission, UserRole } from "@shared/types";
+import Collection from "~/models/Collection";
+import Group from "~/models/Group";
+import Share from "~/models/Share";
+import User from "~/models/User";
+import Avatar, { AvatarSize } from "~/components/Avatar/Avatar";
+import ButtonSmall from "~/components/ButtonSmall";
+import CopyToClipboard from "~/components/CopyToClipboard";
+import InputSelectPermission from "~/components/InputSelectPermission";
+import NudeButton from "~/components/NudeButton";
+import Tooltip from "~/components/Tooltip";
+import { createAction } from "~/actions";
+import { UserSection } from "~/actions/sections";
+import useActionContext from "~/hooks/useActionContext";
+import useBoolean from "~/hooks/useBoolean";
+import useCurrentTeam from "~/hooks/useCurrentTeam";
+import useKeyDown from "~/hooks/useKeyDown";
+import usePolicy from "~/hooks/usePolicy";
+import useStores from "~/hooks/useStores";
+import { collectionPath, urlify } from "~/utils/routeHelpers";
+import { Wrapper, presence } from "../components";
+import { ListItem } from "../components/ListItem";
+import { SearchInput } from "../components/SearchInput";
+import { Suggestions } from "../components/Suggestions";
+import CollectionMemberList from "./CollectionMemberList";
+
+type Props = {
+ collection: Collection;
+ /** The existing share model, if any. */
+ share: Share | null | undefined;
+ /** Callback fired when the popover requests to be closed. */
+ onRequestClose: () => void;
+ /** Whether the popover is visible. */
+ visible: boolean;
+};
+
+function SharePopover({ collection, visible, onRequestClose }: Props) {
+ const theme = useTheme();
+ const team = useCurrentTeam();
+ const { collectionGroupMemberships, users, groups, memberships } =
+ useStores();
+ const { t } = useTranslation();
+ const can = usePolicy(collection);
+ const [query, setQuery] = React.useState("");
+ const [picker, showPicker, hidePicker] = useBoolean();
+ const [hasRendered, setHasRendered] = React.useState(visible);
+ const [pendingIds, setPendingIds] = React.useState([]);
+ const [invitedInSession, setInvitedInSession] = React.useState([]);
+ const timeout = React.useRef>();
+ const context = useActionContext();
+
+ useKeyDown(
+ "Escape",
+ (ev) => {
+ ev.preventDefault();
+ ev.stopImmediatePropagation();
+
+ if (picker) {
+ hidePicker();
+ } else {
+ onRequestClose();
+ }
+ },
+ {
+ allowInInput: true,
+ }
+ );
+
+ // Clear the query when picker is closed
+ React.useEffect(() => {
+ if (!picker) {
+ setQuery("");
+ }
+ }, [picker]);
+
+ React.useEffect(() => {
+ if (visible) {
+ setHasRendered(true);
+ }
+ }, [visible]);
+
+ const handleCopied = React.useCallback(() => {
+ onRequestClose();
+
+ timeout.current = setTimeout(() => {
+ toast.message(t("Link copied to clipboard"));
+ }, 100);
+
+ return () => {
+ if (timeout.current) {
+ clearTimeout(timeout.current);
+ }
+ };
+ }, [onRequestClose, t]);
+
+ const handleQuery = React.useCallback(
+ (event) => {
+ showPicker();
+ setQuery(event.target.value);
+ },
+ [showPicker, setQuery]
+ );
+
+ const handleAddPendingId = React.useCallback(
+ (id: string) => {
+ setPendingIds((prev) => [...prev, id]);
+ },
+ [setPendingIds]
+ );
+
+ const handleRemovePendingId = React.useCallback(
+ (id: string) => {
+ setPendingIds((prev) => prev.filter((i) => i !== id));
+ },
+ [setPendingIds]
+ );
+
+ const inviteAction = React.useMemo(
+ () =>
+ createAction({
+ name: t("Invite"),
+ section: UserSection,
+ perform: async () => {
+ const invited = await Promise.all(
+ pendingIds.map(async (idOrEmail) => {
+ let user, group;
+
+ // convert email to user
+ if (isEmail(idOrEmail)) {
+ const response = await users.invite([
+ {
+ email: idOrEmail,
+ name: idOrEmail,
+ role: team.defaultUserRole,
+ },
+ ]);
+ user = response.users[0];
+ } else {
+ user = users.get(idOrEmail);
+ group = groups.get(idOrEmail);
+ }
+
+ if (user) {
+ await memberships.create({
+ collectionId: collection.id,
+ userId: user.id,
+ permission:
+ user?.role === UserRole.Viewer ||
+ user?.role === UserRole.Guest
+ ? CollectionPermission.Read
+ : CollectionPermission.ReadWrite,
+ });
+ return user;
+ }
+
+ if (group) {
+ await collectionGroupMemberships.create({
+ collectionId: collection.id,
+ groupId: group.id,
+ permission:
+ user?.role === UserRole.Viewer ||
+ user?.role === UserRole.Guest
+ ? CollectionPermission.Read
+ : CollectionPermission.ReadWrite,
+ });
+ return group;
+ }
+ })
+ );
+
+ const invitedUsers = invited.filter((item) => item instanceof User);
+ const invitedGroups = invited.filter((item) => item instanceof Group);
+
+ // Special case for the common action of adding a single user.
+ if (invitedUsers.length === 1 && invited.length === 1) {
+ const user = invitedUsers[0];
+ toast.message(
+ t("{{ userName }} was added to the collection", {
+ userName: user.name,
+ }),
+ {
+ icon: ,
+ }
+ );
+ } else if (invitedGroups.length === 1 && invited.length === 1) {
+ const group = invitedGroups[0];
+ toast.success(
+ t("{{ userName }} was added to the collection", {
+ userName: group.name,
+ })
+ );
+ } else if (invitedGroups.length === 0) {
+ toast.success(
+ t("{{ count }} people added to the collection", {
+ count: invitedUsers.length,
+ })
+ );
+ } else {
+ toast.success(
+ t(
+ "{{ count }} people and {{ count2 }} groups added to the collection",
+ {
+ count: invitedUsers.length,
+ count2: invitedGroups.length,
+ }
+ )
+ );
+ }
+
+ setInvitedInSession((prev) => [...prev, ...pendingIds]);
+ setPendingIds([]);
+ hidePicker();
+ },
+ }),
+ [
+ collection.id,
+ hidePicker,
+ memberships,
+ pendingIds,
+ t,
+ team.defaultUserRole,
+ users,
+ ]
+ );
+
+ if (!hasRendered) {
+ return null;
+ }
+
+ const backButton = (
+ <>
+ {picker && (
+ {
+ event.preventDefault();
+ event.stopPropagation();
+ hidePicker();
+ }}
+ >
+
+
+ )}
+ >
+ );
+
+ const rightButton = picker ? (
+ pendingIds.length ? (
+
+ {t("Add")}
+
+ ) : null
+ ) : (
+
+
+
+
+
+
+
+ );
+
+ return (
+
+ {can.update && (
+
+ )}
+
+ {picker && (
+
+
+
+ )}
+
+
+
+
+
+ }
+ title={t("All members")}
+ subtitle={t("Everyone in the workspace")}
+ actions={
+
+ {
+ void collection.save({ permission });
+ }}
+ disabled={!can.update}
+ value={collection?.permission}
+ labelHidden
+ nude
+ />
+
+ }
+ />
+
+
+
+
+ );
+}
+
+export default observer(SharePopover);
diff --git a/app/components/Sharing/DocumentMemberList.tsx b/app/components/Sharing/Document/DocumentMemberList.tsx
similarity index 86%
rename from app/components/Sharing/DocumentMemberList.tsx
rename to app/components/Sharing/Document/DocumentMemberList.tsx
index 0b125dc8d..4fd747058 100644
--- a/app/components/Sharing/DocumentMemberList.tsx
+++ b/app/components/Sharing/Document/DocumentMemberList.tsx
@@ -1,7 +1,7 @@
-import { t } from "i18next";
import orderBy from "lodash/orderBy";
import { observer } from "mobx-react";
import * as React from "react";
+import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import { Pagination } from "@shared/constants";
@@ -13,29 +13,23 @@ import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { homePath } from "~/utils/routeHelpers";
-import MemberListItem from "./MemberListItem";
+import MemberListItem from "./DocumentMemberListItem";
type Props = {
/** Document to which team members are supposed to be invited */
document: Document;
/** Children to be rendered before the list of members */
children?: React.ReactNode;
- /** List of users that have been invited to the document during the current editing session */
+ /** List of users that have been invited during the current editing session */
invitedInSession: string[];
};
function DocumentMembersList({ document, invitedInSession }: Props) {
- const { users, userMemberships } = useStores();
+ const { userMemberships } = useStores();
const user = useCurrentUser();
const history = useHistory();
const can = usePolicy(document);
-
- const { loading: loadingTeamMembers, request: fetchTeamMembers } = useRequest(
- React.useCallback(
- () => users.fetchPage({ limit: Pagination.defaultLimit }),
- [users]
- )
- );
+ const { t } = useTranslation();
const { loading: loadingDocumentMembers, request: fetchDocumentMembers } =
useRequest(
@@ -50,9 +44,8 @@ function DocumentMembersList({ document, invitedInSession }: Props) {
);
React.useEffect(() => {
- void fetchTeamMembers();
void fetchDocumentMembers();
- }, [fetchTeamMembers, fetchDocumentMembers]);
+ }, [fetchDocumentMembers]);
const handleRemoveUser = React.useCallback(
async (item) => {
@@ -112,7 +105,7 @@ function DocumentMembersList({ document, invitedInSession }: Props) {
[document.members, invitedInSession]
);
- if (loadingTeamMembers || loadingDocumentMembers) {
+ if (loadingDocumentMembers) {
return ;
}
diff --git a/app/components/Sharing/MemberListItem.tsx b/app/components/Sharing/Document/DocumentMemberListItem.tsx
similarity index 86%
rename from app/components/Sharing/MemberListItem.tsx
rename to app/components/Sharing/Document/DocumentMemberListItem.tsx
index 9f875284f..f62a0addd 100644
--- a/app/components/Sharing/MemberListItem.tsx
+++ b/app/components/Sharing/Document/DocumentMemberListItem.tsx
@@ -1,5 +1,4 @@
import { observer } from "mobx-react";
-import { PlusIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
@@ -11,9 +10,8 @@ import UserMembership from "~/models/UserMembership";
import Avatar from "~/components/Avatar";
import { AvatarSize } from "~/components/Avatar/Avatar";
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
-import ListItem from "~/components/List/Item";
-import { hover } from "~/styles";
import { EmptySelectValue, Permission } from "~/types";
+import { ListItem } from "../components/ListItem";
type Props = {
user: User;
@@ -24,7 +22,7 @@ type Props = {
onUpdate?: (permission: DocumentPermission) => void;
};
-const MemberListItem = ({
+const DocumentMemberListItem = ({
user,
membership,
onRemove,
@@ -54,7 +52,7 @@ const MemberListItem = ({
value: DocumentPermission.ReadWrite,
},
{
- label: t("No access"),
+ label: t("Remove"),
value: EmptySelectValue,
},
];
@@ -69,7 +67,7 @@ const MemberListItem = ({
const MaybeLink = membership?.source ? StyledLink : React.Fragment;
return (
-
@@ -122,26 +120,9 @@ const MemberListItem = ({
);
};
-export const InviteIcon = styled(PlusIcon)`
- opacity: 0;
-`;
-
-export const StyledListItem = styled(ListItem).attrs({
- small: true,
- border: false,
-})`
- margin: 0 -16px;
- padding: 6px 16px;
- border-radius: 8px;
-
- &: ${hover} ${InviteIcon} {
- opacity: 1;
- }
-`;
-
const StyledLink = styled(Link)`
color: ${s("textTertiary")};
text-decoration: underline;
`;
-export default observer(MemberListItem);
+export default observer(DocumentMemberListItem);
diff --git a/app/components/Sharing/OtherAccess.tsx b/app/components/Sharing/Document/OtherAccess.tsx
similarity index 92%
rename from app/components/Sharing/OtherAccess.tsx
rename to app/components/Sharing/Document/OtherAccess.tsx
index 97346b573..1eadd30af 100644
--- a/app/components/Sharing/OtherAccess.tsx
+++ b/app/components/Sharing/Document/OtherAccess.tsx
@@ -12,11 +12,11 @@ import Text from "~/components/Text";
import useCurrentUser from "~/hooks/useCurrentUser";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
-import Avatar from "../Avatar";
-import { AvatarSize } from "../Avatar/Avatar";
-import CollectionIcon from "../Icons/CollectionIcon";
-import Tooltip from "../Tooltip";
-import { StyledListItem } from "./MemberListItem";
+import Avatar from "../../Avatar";
+import { AvatarSize } from "../../Avatar/Avatar";
+import CollectionIcon from "../../Icons/CollectionIcon";
+import Tooltip from "../../Tooltip";
+import { ListItem } from "../components/ListItem";
type Props = {
/** The document being shared. */
@@ -36,7 +36,7 @@ export const OtherAccess = observer(({ document, children }: Props) => {
{collection ? (
<>
{collection.permission ? (
-
@@ -53,7 +53,7 @@ export const OtherAccess = observer(({ document, children }: Props) => {
}
/>
) : usersInCollection ? (
-
{
actions={{t("Can view")}}
/>
) : (
- }
title={user.name}
subtitle={t("You have full access")}
@@ -79,7 +79,7 @@ export const OtherAccess = observer(({ document, children }: Props) => {
>
) : document.isDraft ? (
<>
- }
title={document.createdBy?.name}
actions={
@@ -93,7 +93,7 @@ export const OtherAccess = observer(({ document, children }: Props) => {
) : (
<>
{children}
-
diff --git a/app/components/Sharing/PublicAccess.tsx b/app/components/Sharing/Document/PublicAccess.tsx
similarity index 94%
rename from app/components/Sharing/PublicAccess.tsx
rename to app/components/Sharing/Document/PublicAccess.tsx
index dd848ca0e..a9fea8f10 100644
--- a/app/components/Sharing/PublicAccess.tsx
+++ b/app/components/Sharing/Document/PublicAccess.tsx
@@ -18,13 +18,13 @@ import Switch from "~/components/Switch";
import env from "~/env";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
-import { AvatarSize } from "../Avatar/Avatar";
-import CopyToClipboard from "../CopyToClipboard";
-import NudeButton from "../NudeButton";
-import { ResizingHeightContainer } from "../ResizingHeightContainer";
-import Text from "../Text";
-import Tooltip from "../Tooltip";
-import { StyledListItem } from "./MemberListItem";
+import { AvatarSize } from "../../Avatar/Avatar";
+import CopyToClipboard from "../../CopyToClipboard";
+import NudeButton from "../../NudeButton";
+import { ResizingHeightContainer } from "../../ResizingHeightContainer";
+import Text from "../../Text";
+import Tooltip from "../../Tooltip";
+import { ListItem } from "../components/ListItem";
type Props = {
/** The document to share. */
@@ -122,7 +122,7 @@ function PublicAccess({ document, share, sharedParent }: Props) {
return (
-
diff --git a/app/components/Sharing/SharePopover.tsx b/app/components/Sharing/Document/SharePopover.tsx
similarity index 69%
rename from app/components/Sharing/SharePopover.tsx
rename to app/components/Sharing/Document/SharePopover.tsx
index d31223a93..a51a688e0 100644
--- a/app/components/Sharing/SharePopover.tsx
+++ b/app/components/Sharing/Document/SharePopover.tsx
@@ -1,37 +1,34 @@
import { isEmail } from "class-validator";
-import { AnimatePresence, m } from "framer-motion";
+import { m } from "framer-motion";
import { observer } from "mobx-react";
import { BackIcon, LinkIcon } from "outline-icons";
-import { darken } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
-import styled from "styled-components";
-import { s } from "@shared/styles";
import { DocumentPermission, UserRole } from "@shared/types";
import Document from "~/models/Document";
import Share from "~/models/Share";
+import Avatar from "~/components/Avatar";
+import { AvatarSize } from "~/components/Avatar/Avatar";
+import ButtonSmall from "~/components/ButtonSmall";
import CopyToClipboard from "~/components/CopyToClipboard";
-import Flex from "~/components/Flex";
+import NudeButton from "~/components/NudeButton";
+import Tooltip from "~/components/Tooltip";
import { createAction } from "~/actions";
import { UserSection } from "~/actions/sections";
import useActionContext from "~/hooks/useActionContext";
import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useKeyDown from "~/hooks/useKeyDown";
-import useMobile from "~/hooks/useMobile";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
-import { hover } from "~/styles";
import { documentPath, urlify } from "~/utils/routeHelpers";
-import ButtonSmall from "../ButtonSmall";
-import Input, { NativeInput } from "../Input";
-import NudeButton from "../NudeButton";
-import Tooltip from "../Tooltip";
+import { Separator, Wrapper, presence } from "../components";
+import { SearchInput } from "../components/SearchInput";
+import { Suggestions } from "../components/Suggestions";
import DocumentMembersList from "./DocumentMemberList";
import { OtherAccess } from "./OtherAccess";
import PublicAccess from "./PublicAccess";
-import { UserSuggestions } from "./UserSuggestions";
type Props = {
/** The document to share. */
@@ -46,29 +43,6 @@ type Props = {
visible: boolean;
};
-const presence = {
- initial: {
- opacity: 0,
- width: 0,
- marginRight: 0,
- },
- animate: {
- opacity: 1,
- width: "auto",
- marginRight: 8,
- transition: {
- type: "spring",
- duration: 0.2,
- bounce: 0,
- },
- },
- exit: {
- opacity: 0,
- width: 0,
- marginRight: 0,
- },
-};
-
function SharePopover({
document,
share,
@@ -79,13 +53,13 @@ function SharePopover({
const team = useCurrentTeam();
const { t } = useTranslation();
const can = usePolicy(document);
- const inputRef = React.useRef(null);
+ const linkButtonRef = React.useRef(null);
+ const context = useActionContext();
+ const [hasRendered, setHasRendered] = React.useState(visible);
const { users, userMemberships } = useStores();
- const isMobile = useMobile();
const [query, setQuery] = React.useState("");
const [picker, showPicker, hidePicker] = useBoolean();
const timeout = React.useRef>();
- const linkButtonRef = React.useRef(null);
const [invitedInSession, setInvitedInSession] = React.useState([]);
const [pendingIds, setPendingIds] = React.useState([]);
const collectionSharingDisabled = document.collection?.sharing === false;
@@ -111,6 +85,7 @@ function SharePopover({
React.useEffect(() => {
if (visible) {
void document.share();
+ setHasRendered(true);
}
}, [document, hidePicker, visible]);
@@ -143,8 +118,6 @@ function SharePopover({
};
}, [onRequestClose, t]);
- const context = useActionContext();
-
const inviteAction = React.useMemo(
() =>
createAction({
@@ -188,13 +161,17 @@ function SharePopover({
);
if (usersInvited.length === 1) {
+ const user = usersInvited[0];
toast.message(
t("{{ userName }} was invited to the document", {
- userName: usersInvited[0].name,
- })
+ userName: user.name,
+ }),
+ {
+ icon: ,
+ }
);
} else {
- toast.message(
+ toast.success(
t("{{ count }} people invited to the document", {
count: pendingIds.length,
})
@@ -225,13 +202,6 @@ function SharePopover({
[showPicker, setQuery]
);
- const focusInput = React.useCallback(() => {
- if (!picker) {
- inputRef.current?.focus();
- showPicker();
- }
- }, [picker, showPicker]);
-
const handleAddPendingId = React.useCallback(
(id: string) => {
setPendingIds((prev) => [...prev, id]);
@@ -246,10 +216,23 @@ function SharePopover({
[setPendingIds]
);
+ if (!hasRendered) {
+ return null;
+ }
+
const backButton = (
<>
{picker && (
-
+ {
+ event.preventDefault();
+ event.stopPropagation();
+ hidePicker();
+ }}
+ >
)}
@@ -282,44 +265,19 @@ function SharePopover({
return (
- {can.manageUsers &&
- (isMobile ? (
-
- {backButton}
-
- {rightButton}
-
-
- ) : (
-
-
- {backButton}
-
- {rightButton}
-
-
- ))}
+ {can.manageUsers && (
+
+ )}
{picker && (
-
darken(0.05, props.theme.buttonNeutralBackground)};
- }
-`;
-
-const Separator = styled.div`
- border-top: 1px dashed ${s("divider")};
- margin: 12px 0;
-`;
-
-const HeaderInput = styled(Flex)`
- position: sticky;
- z-index: 1;
- top: 0;
- background: ${s("menuBackground")};
- color: ${s("textTertiary")};
- border-bottom: 1px solid ${s("inputBorder")};
- padding: 0 24px 12px;
- margin-top: 0;
- margin-left: -24px;
- margin-right: -24px;
- margin-bottom: 12px;
- cursor: text;
-
- &:before {
- content: "";
- position: absolute;
- left: 0;
- right: 0;
- top: -20px;
- height: 20px;
- background: ${s("menuBackground")};
- }
-`;
-
export default observer(SharePopover);
diff --git a/app/components/Sharing/index.tsx b/app/components/Sharing/Document/index.tsx
similarity index 100%
rename from app/components/Sharing/index.tsx
rename to app/components/Sharing/Document/index.tsx
diff --git a/app/components/Sharing/components/ListItem.tsx b/app/components/Sharing/components/ListItem.tsx
new file mode 100644
index 000000000..635570645
--- /dev/null
+++ b/app/components/Sharing/components/ListItem.tsx
@@ -0,0 +1,21 @@
+import { PlusIcon } from "outline-icons";
+import styled from "styled-components";
+import BaseListItem from "~/components/List/Item";
+import { hover } from "~/styles";
+
+export const InviteIcon = styled(PlusIcon)`
+ opacity: 0;
+`;
+
+export const ListItem = styled(BaseListItem).attrs({
+ small: true,
+ border: false,
+})`
+ margin: 0 -16px;
+ padding: 6px 16px;
+ border-radius: 8px;
+
+ &: ${hover} ${InviteIcon} {
+ opacity: 1;
+ }
+`;
diff --git a/app/components/Sharing/components/SearchInput.tsx b/app/components/Sharing/components/SearchInput.tsx
new file mode 100644
index 000000000..b62eefd6f
--- /dev/null
+++ b/app/components/Sharing/components/SearchInput.tsx
@@ -0,0 +1,63 @@
+import { AnimatePresence } from "framer-motion";
+import * as React from "react";
+import { useTranslation } from "react-i18next";
+import Flex from "~/components/Flex";
+import useMobile from "~/hooks/useMobile";
+import Input, { NativeInput } from "../../Input";
+import { HeaderInput } from "../components";
+
+type Props = {
+ query: string;
+ onChange: React.ChangeEventHandler;
+ onClick: React.MouseEventHandler;
+ back: React.ReactNode;
+ action: React.ReactNode;
+};
+
+export function SearchInput({ onChange, onClick, query, back, action }: Props) {
+ const { t } = useTranslation();
+ const inputRef = React.useRef(null);
+ const isMobile = useMobile();
+
+ const focusInput = React.useCallback(
+ (event) => {
+ inputRef.current?.focus();
+ onClick(event);
+ },
+ [onClick]
+ );
+
+ return isMobile ? (
+
+ {back}
+
+ {action}
+
+
+ ) : (
+
+
+ {back}
+
+ {action}
+
+
+ );
+}
diff --git a/app/components/Sharing/UserSuggestions.tsx b/app/components/Sharing/components/Suggestions.tsx
similarity index 62%
rename from app/components/Sharing/UserSuggestions.tsx
rename to app/components/Sharing/components/Suggestions.tsx
index 73a274c08..945f35267 100644
--- a/app/components/Sharing/UserSuggestions.tsx
+++ b/app/components/Sharing/components/Suggestions.tsx
@@ -1,21 +1,24 @@
import { isEmail } from "class-validator";
import { observer } from "mobx-react";
-import { CheckmarkIcon, CloseIcon } from "outline-icons";
+import { CheckmarkIcon, CloseIcon, GroupIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
-import styled from "styled-components";
+import styled, { useTheme } from "styled-components";
+import Squircle from "@shared/components/Squircle";
import { s } from "@shared/styles";
import { stringToColor } from "@shared/utils/color";
+import Collection from "~/models/Collection";
import Document from "~/models/Document";
+import Group from "~/models/Group";
import User from "~/models/User";
+import Avatar from "~/components/Avatar";
+import { AvatarSize, IAvatar } from "~/components/Avatar/Avatar";
+import Empty from "~/components/Empty";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import useThrottledCallback from "~/hooks/useThrottledCallback";
import { hover } from "~/styles";
-import Avatar from "../Avatar";
-import { AvatarSize, IAvatar } from "../Avatar/Avatar";
-import Empty from "../Empty";
-import { InviteIcon, StyledListItem } from "./MemberListItem";
+import { InviteIcon, ListItem } from "./ListItem";
type Suggestion = IAvatar & {
id: string;
@@ -23,7 +26,9 @@ type Suggestion = IAvatar & {
type Props = {
/** The document being shared. */
- document: Document;
+ document?: Document;
+ /** The collection being shared. */
+ collection?: Collection;
/** The search query to filter users by. */
query: string;
/** A list of pending user ids that have not yet been invited. */
@@ -32,18 +37,32 @@ type Props = {
addPendingId: (id: string) => void;
/** Callback to remove a user from the pending list. */
removePendingId: (id: string) => void;
+ /** Show group suggestions. */
+ showGroups?: boolean;
};
-export const UserSuggestions = observer(
- ({ document, query, pendingIds, addPendingId, removePendingId }: Props) => {
- const { users } = useStores();
+export const Suggestions = observer(
+ ({
+ document,
+ collection,
+ query,
+ pendingIds,
+ addPendingId,
+ removePendingId,
+ showGroups,
+ }: Props) => {
+ const { users, groups } = useStores();
const { t } = useTranslation();
const user = useCurrentUser();
+ const theme = useTheme();
- const fetchUsersByQuery = useThrottledCallback(
- (params) => users.fetchPage({ query: params.query }),
- 250
- );
+ const fetchUsersByQuery = useThrottledCallback((params) => {
+ void users.fetchPage({ query: params.query });
+
+ if (showGroups) {
+ void groups.fetchPage({ query: params.query });
+ }
+ }, 250);
const getSuggestionForEmail = React.useCallback(
(email: string) => ({
@@ -58,21 +77,30 @@ export const UserSuggestions = observer(
);
const suggestions = React.useMemo(() => {
- const filtered: Suggestion[] = users
- .notInDocument(document.id, query)
- .filter((u) => u.id !== user.id && !u.isSuspended);
+ const filtered: Suggestion[] = (
+ document
+ ? users.notInDocument(document.id, query)
+ : collection
+ ? users.notInCollection(collection.id, query)
+ : users.orderedData
+ ).filter((u) => u.id !== user.id && !u.isSuspended);
if (isEmail(query)) {
filtered.push(getSuggestionForEmail(query));
}
+ if (collection?.id) {
+ return [...groups.notInCollection(collection.id, query), ...filtered];
+ }
+
return filtered;
}, [
getSuggestionForEmail,
users,
users.orderedData,
- document.id,
- document.members,
+ document?.id,
+ document?.members,
+ collection?.id,
user.id,
query,
t,
@@ -82,19 +110,32 @@ export const UserSuggestions = observer(
() =>
pendingIds
.map((id) =>
- isEmail(id) ? getSuggestionForEmail(id) : users.get(id)
+ isEmail(id)
+ ? getSuggestionForEmail(id)
+ : users.get(id) ?? groups.get(id)
)
.filter(Boolean) as User[],
[users, getSuggestionForEmail, pendingIds]
);
React.useEffect(() => {
- if (query) {
- void fetchUsersByQuery(query);
- }
+ void fetchUsersByQuery(query);
}, [query, fetchUsersByQuery]);
- function getListItemProps(suggestion: User) {
+ function getListItemProps(suggestion: User | Group) {
+ if (suggestion instanceof Group) {
+ return {
+ title: suggestion.name,
+ subtitle: t("{{ count }} member", {
+ count: suggestion.memberCount,
+ }),
+ image: (
+
+
+
+ ),
+ };
+ }
return {
title: suggestion.name,
subtitle: suggestion.email
@@ -135,7 +176,7 @@ export const UserSuggestions = observer(
{pending.length > 0 &&
(suggestionsWithPending.length > 0 || isEmpty) && }
{suggestionsWithPending.map((suggestion) => (
- addPendingId(suggestion.id)}
@@ -156,7 +197,7 @@ const RemoveIcon = styled(CloseIcon)`
display: none;
`;
-const PendingListItem = styled(StyledListItem)`
+const PendingListItem = styled(ListItem)`
&: ${hover} {
${InvitedIcon} {
display: none;
diff --git a/app/components/Sharing/components/index.tsx b/app/components/Sharing/components/index.tsx
new file mode 100644
index 000000000..51087ea1f
--- /dev/null
+++ b/app/components/Sharing/components/index.tsx
@@ -0,0 +1,67 @@
+import { darken } from "polished";
+import styled from "styled-components";
+import Flex from "@shared/components/Flex";
+import { s } from "@shared/styles";
+import NudeButton from "~/components/NudeButton";
+import { hover } from "~/styles";
+
+// TODO: Temp until Button/NudeButton styles are normalized
+export const Wrapper = styled.div`
+ ${NudeButton}:${hover},
+ ${NudeButton}[aria-expanded="true"] {
+ background: ${(props) => darken(0.05, props.theme.buttonNeutralBackground)};
+ }
+`;
+
+export const Separator = styled.div`
+ border-top: 1px dashed ${s("divider")};
+ margin: 12px 0;
+`;
+
+export const HeaderInput = styled(Flex)`
+ position: sticky;
+ z-index: 1;
+ top: 0;
+ background: ${s("menuBackground")};
+ color: ${s("textTertiary")};
+ border-bottom: 1px solid ${s("inputBorder")};
+ padding: 0 24px 12px;
+ margin-top: 0;
+ margin-left: -24px;
+ margin-right: -24px;
+ margin-bottom: 12px;
+ cursor: text;
+
+ &:before {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: -20px;
+ height: 20px;
+ background: ${s("menuBackground")};
+ }
+`;
+
+export const presence = {
+ initial: {
+ opacity: 0,
+ width: 0,
+ marginRight: 0,
+ },
+ animate: {
+ opacity: 1,
+ width: "auto",
+ marginRight: 8,
+ transition: {
+ type: "spring",
+ duration: 0.2,
+ bounce: 0,
+ },
+ },
+ exit: {
+ opacity: 0,
+ width: 0,
+ marginRight: 0,
+ },
+};
diff --git a/app/models/Membership.ts b/app/models/Membership.ts
index 5b49edd05..b0bec953b 100644
--- a/app/models/Membership.ts
+++ b/app/models/Membership.ts
@@ -1,6 +1,9 @@
import { observable } from "mobx";
import { CollectionPermission } from "@shared/types";
+import Collection from "./Collection";
+import User from "./User";
import Model from "./base/Model";
+import Relation from "./decorators/Relation";
class Membership extends Model {
static modelName = "Membership";
@@ -9,8 +12,14 @@ class Membership extends Model {
userId: string;
+ @Relation(() => User, { onDelete: "cascade" })
+ user: User;
+
collectionId: string;
+ @Relation(() => Collection, { onDelete: "cascade" })
+ collection: Collection;
+
@observable
permission: CollectionPermission;
}
diff --git a/app/models/Share.ts b/app/models/Share.ts
index 4b065d032..6a1e945c7 100644
--- a/app/models/Share.ts
+++ b/app/models/Share.ts
@@ -1,4 +1,5 @@
import { observable } from "mobx";
+import Collection from "./Collection";
import Document from "./Document";
import User from "./User";
import Model from "./base/Model";
@@ -25,6 +26,15 @@ class Share extends Model {
@Relation(() => Document, { onDelete: "cascade" })
document: Document;
+ /** The collection ID that is shared. */
+ @Field
+ @observable
+ collectionId: string;
+
+ /** The collection that is shared. */
+ @Relation(() => Collection, { onDelete: "cascade" })
+ collection: Collection;
+
@Field
@observable
urlId: string;
diff --git a/app/scenes/Collection/Actions.tsx b/app/scenes/Collection/components/Actions.tsx
similarity index 100%
rename from app/scenes/Collection/Actions.tsx
rename to app/scenes/Collection/components/Actions.tsx
diff --git a/app/scenes/Collection/DropToImport.tsx b/app/scenes/Collection/components/DropToImport.tsx
similarity index 100%
rename from app/scenes/Collection/DropToImport.tsx
rename to app/scenes/Collection/components/DropToImport.tsx
diff --git a/app/scenes/Collection/Empty.tsx b/app/scenes/Collection/components/Empty.tsx
similarity index 83%
rename from app/scenes/Collection/Empty.tsx
rename to app/scenes/Collection/components/Empty.tsx
index 3656e17b8..fb0667eec 100644
--- a/app/scenes/Collection/Empty.tsx
+++ b/app/scenes/Collection/components/Empty.tsx
@@ -11,6 +11,7 @@ import Text from "~/components/Text";
import { editCollectionPermissions } from "~/actions/definitions/collections";
import useActionContext from "~/hooks/useActionContext";
import usePolicy from "~/hooks/usePolicy";
+import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
import { newDocumentPath } from "~/utils/routeHelpers";
type Props = {
@@ -48,14 +49,16 @@ function EmptyCollection({ collection }: Props) {
{t("Create a document")}
-
+ {FeatureFlags.isEnabled(Feature.newCollectionSharing) ? null : (
+
+ )}
)}
diff --git a/app/scenes/Collection/MembershipPreview.tsx b/app/scenes/Collection/components/MembershipPreview.tsx
similarity index 90%
rename from app/scenes/Collection/MembershipPreview.tsx
rename to app/scenes/Collection/components/MembershipPreview.tsx
index d8bbbf6f6..2941f2e12 100644
--- a/app/scenes/Collection/MembershipPreview.tsx
+++ b/app/scenes/Collection/components/MembershipPreview.tsx
@@ -2,10 +2,8 @@ import sortBy from "lodash/sortBy";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
-import styled from "styled-components";
import { PAGINATION_SYMBOL } from "~/stores/base/Store";
import Collection from "~/models/Collection";
-import User from "~/models/User";
import Avatar from "~/components/Avatar";
import Facepile from "~/components/Facepile";
import Fade from "~/components/Fade";
@@ -14,6 +12,7 @@ import { editCollectionPermissions } from "~/actions/definitions/collections";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
+import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
type Props = {
collection: Collection;
@@ -72,7 +71,11 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
return (
0
@@ -104,16 +107,11 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
users={sortBy(collectionUsers, "lastActiveAt")}
overflow={overflow}
limit={limit}
- renderAvatar={(user) => }
+ renderAvatar={(user) => }
/>
);
};
-const StyledAvatar = styled(Avatar)<{ model: User }>`
- transition: opacity 250ms ease-in-out;
- opacity: ${(props) => (props.model.isRecentlyActive ? 1 : 0.5)};
-`;
-
export default observer(MembershipPreview);
diff --git a/app/scenes/Collection/components/ShareButton.tsx b/app/scenes/Collection/components/ShareButton.tsx
new file mode 100644
index 000000000..705dc99c7
--- /dev/null
+++ b/app/scenes/Collection/components/ShareButton.tsx
@@ -0,0 +1,60 @@
+import { observer } from "mobx-react";
+import { GlobeIcon, PadlockIcon } from "outline-icons";
+import * as React from "react";
+import { useTranslation } from "react-i18next";
+import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
+import Collection from "~/models/Collection";
+import Button from "~/components/Button";
+import Popover from "~/components/Popover";
+import SharePopover from "~/components/Sharing/Collection/SharePopover";
+import useCurrentTeam from "~/hooks/useCurrentTeam";
+import useStores from "~/hooks/useStores";
+
+type Props = {
+ /** Collection being shared */
+ collection: Collection;
+};
+
+function ShareButton({ collection }: Props) {
+ const { t } = useTranslation();
+ const { shares } = useStores();
+ const team = useCurrentTeam();
+ const share = shares.getByCollectionId(collection.id);
+ const isPubliclyShared =
+ team.sharing !== false && collection?.sharing !== false && share?.published;
+
+ const popover = usePopoverState({
+ gutter: 0,
+ placement: "bottom-end",
+ unstable_fixed: true,
+ });
+
+ const icon = isPubliclyShared ? (
+
+ ) : collection.permission ? undefined : (
+
+ );
+
+ return (
+ <>
+
+ {(props) => (
+
+ )}
+
+
+
+
+
+ >
+ );
+}
+
+export default observer(ShareButton);
diff --git a/app/scenes/Collection.tsx b/app/scenes/Collection/index.tsx
similarity index 90%
rename from app/scenes/Collection.tsx
rename to app/scenes/Collection/index.tsx
index e0e42d798..f2b3e1e2d 100644
--- a/app/scenes/Collection.tsx
+++ b/app/scenes/Collection/index.tsx
@@ -15,6 +15,7 @@ import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import Collection from "~/models/Collection";
import Search from "~/scenes/Search";
+import { Action } from "~/components/Actions";
import Badge from "~/components/Badge";
import CenteredContent from "~/components/CenteredContent";
import CollectionDescription from "~/components/CollectionDescription";
@@ -34,11 +35,13 @@ import useCommandBarActions from "~/hooks/useCommandBarActions";
import useLastVisitedPath from "~/hooks/useLastVisitedPath";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
+import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
import { collectionPath, updateCollectionPath } from "~/utils/routeHelpers";
-import Actions from "./Collection/Actions";
-import DropToImport from "./Collection/DropToImport";
-import Empty from "./Collection/Empty";
-import MembershipPreview from "./Collection/MembershipPreview";
+import Actions from "./components/Actions";
+import DropToImport from "./components/DropToImport";
+import Empty from "./components/Empty";
+import MembershipPreview from "./components/MembershipPreview";
+import ShareButton from "./components/ShareButton";
function CollectionScene() {
const params = useParams<{ id?: string }>();
@@ -142,6 +145,10 @@ function CollectionScene() {
actions={
<>
+
+ {FeatureFlags.isEnabled(Feature.newCollectionSharing) &&
+ can.update && }
+
>
}
@@ -159,16 +166,17 @@ function CollectionScene() {
{collection.name}
- {collection.isPrivate && (
-
- {t("Private")}
-
- )}
+ {collection.isPrivate &&
+ !FeatureFlags.isEnabled(Feature.newCollectionSharing) && (
+
+ {t("Private")}
+
+ )}
: undefined;
+
return (
<>
{(props) => (
- : undefined}
- neutral
- {...props}
- >
+
)}
diff --git a/app/stores/CollectionGroupMembershipsStore.ts b/app/stores/CollectionGroupMembershipsStore.ts
index 5c4f02111..c8e567d50 100644
--- a/app/stores/CollectionGroupMembershipsStore.ts
+++ b/app/stores/CollectionGroupMembershipsStore.ts
@@ -76,4 +76,7 @@ export default class CollectionGroupMembershipsStore extends Store
+ this.orderedData.filter((cgm) => cgm.collectionId === collectionId);
}
diff --git a/app/stores/MembershipsStore.ts b/app/stores/MembershipsStore.ts
index 843524125..6210d7442 100644
--- a/app/stores/MembershipsStore.ts
+++ b/app/stores/MembershipsStore.ts
@@ -82,4 +82,9 @@ export default class MembershipsStore extends Store {
}
});
};
+
+ inCollection = (collectionId: string) =>
+ this.orderedData.filter(
+ (membership) => membership.collectionId === collectionId
+ );
}
diff --git a/app/stores/SharesStore.ts b/app/stores/SharesStore.ts
index 3ad250fe3..02d2c4034 100644
--- a/app/stores/SharesStore.ts
+++ b/app/stores/SharesStore.ts
@@ -104,6 +104,9 @@ export default class SharesStore extends Store {
return undefined;
};
+ getByCollectionId = (collectionId: string): Share | null | undefined =>
+ find(this.orderedData, (share) => share.collectionId === collectionId);
+
getByDocumentId = (documentId: string): Share | null | undefined =>
find(this.orderedData, (share) => share.documentId === documentId);
}
diff --git a/app/types.ts b/app/types.ts
index c5754358d..323948fb6 100644
--- a/app/types.ts
+++ b/app/types.ts
@@ -221,6 +221,7 @@ export const EmptySelectValue = "__empty__";
export type Permission = {
label: string;
value: CollectionPermission | DocumentPermission | typeof EmptySelectValue;
+ divider?: boolean;
};
// TODO: Can we make this type driven by the @Field decorator
diff --git a/app/utils/FeatureFlags.ts b/app/utils/FeatureFlags.ts
new file mode 100644
index 000000000..614b2c88e
--- /dev/null
+++ b/app/utils/FeatureFlags.ts
@@ -0,0 +1,43 @@
+import { observable } from "mobx";
+import Storage from "@shared/utils/Storage";
+
+export enum Feature {
+ /** New collection permissions UI */
+ newCollectionSharing = "newCollectionSharing",
+}
+
+/**
+ * A simple feature flagging system that stores flags in browser storage.
+ */
+export class FeatureFlags {
+ public static isEnabled(flag: Feature) {
+ // init on first read
+ if (this.initalized === false) {
+ this.cache = new Set();
+ for (const key of Object.values(Feature)) {
+ const value = Storage.get(key);
+ if (value === true) {
+ this.cache.add(key);
+ }
+ }
+ this.initalized = true;
+ }
+
+ return this.cache.has(flag);
+ }
+
+ public static enable(flag: Feature) {
+ this.cache.add(flag);
+ Storage.set(flag, true);
+ }
+
+ public static disable(flag: Feature) {
+ this.cache.delete(flag);
+ Storage.set(flag, false);
+ }
+
+ @observable
+ private static cache: Set = new Set();
+
+ private static initalized = false;
+}
diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json
index 83487c75e..027fc063a 100644
--- a/shared/i18n/locales/en_US/translation.json
+++ b/shared/i18n/locales/en_US/translation.json
@@ -264,6 +264,20 @@
"Documents": "Documents",
"Results": "Results",
"No results for {{query}}": "No results for {{query}}",
+ "Admin": "Admin",
+ "Invite": "Invite",
+ "{{ userName }} was added to the collection": "{{ userName }} was added to the collection",
+ "{{ count }} people added to the collection": "{{ count }} people added to the collection",
+ "{{ count }} people added to the collection_plural": "{{ count }} people added to the collection",
+ "{{ count }} people and {{ count2 }} groups added to the collection": "{{ count }} people and {{ count2 }} groups added to the collection",
+ "{{ count }} people and {{ count2 }} groups added to the collection_plural": "{{ count }} people and {{ count2 }} groups added to the collection",
+ "Add": "Add",
+ "All members": "All members",
+ "Everyone in the workspace": "Everyone in the workspace",
+ "Add or invite": "Add or invite",
+ "Viewer": "Viewer",
+ "Editor": "Editor",
+ "No matches": "No matches",
"{{ userName }} was removed from the document": "{{ userName }} was removed from the document",
"Could not remove user": "Could not remove user",
"Permissions for {{ userName }} updated": "Permissions for {{ userName }} updated",
@@ -271,11 +285,7 @@
"Has access through <2>parent2>": "Has access through <2>parent2>",
"Suspended": "Suspended",
"Invited": "Invited",
- "Viewer": "Viewer",
- "Editor": "Editor",
"Leave": "Leave",
- "All members": "All members",
- "Everyone in the workspace": "Everyone in the workspace",
"Can view": "Can view",
"Everyone in the collection": "Everyone in the collection",
"You have full access": "You have full access",
@@ -293,11 +303,9 @@
"Allow anyone with the link to access": "Allow anyone with the link to access",
"Publish to internet": "Publish to internet",
"Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future": "Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future",
- "Invite": "Invite",
"{{ userName }} was invited to the document": "{{ userName }} was invited to the document",
"{{ count }} people invited to the document": "{{ count }} people invited to the document",
"{{ count }} people invited to the document_plural": "{{ count }} people invited to the document",
- "No matches": "No matches",
"Logo": "Logo",
"Move document": "Move document",
"New doc": "New doc",
@@ -485,12 +493,6 @@
"API token created": "API token created",
"Name your token something that will help you to remember it's use in the future, for example \"local development\", \"production\", or \"continuous integration\".": "Name your token something that will help you to remember it's use in the future, for example \"local development\", \"production\", or \"continuous integration\".",
"The document archive is empty at the moment.": "The document archive is empty at the moment.",
- "This collection is only visible to those given access": "This collection is only visible to those given access",
- "Private": "Private",
- "Recently updated": "Recently updated",
- "Recently published": "Recently published",
- "Least recently updated": "Least recently updated",
- "A–Z": "A–Z",
"Collection menu": "Collection menu",
"Drop documents to import": "Drop documents to import",
"{{ collectionName }} doesn’t contain any\n documents yet.": "{{ collectionName }} doesn’t contain any\n documents yet.",
@@ -505,6 +507,12 @@
"{{ usersCount }} users with access_plural": "{{ usersCount }} users with access",
"{{ groupsCount }} groups with access": "{{ groupsCount }} group with access",
"{{ groupsCount }} groups with access_plural": "{{ groupsCount }} groups with access",
+ "This collection is only visible to those given access": "This collection is only visible to those given access",
+ "Private": "Private",
+ "Recently updated": "Recently updated",
+ "Recently published": "Recently published",
+ "Least recently updated": "Least recently updated",
+ "A–Z": "A–Z",
"{{ groupName }} was added to the collection": "{{ groupName }} was added to the collection",
"Could not add user": "Could not add user",
"Can’t find the group you’re looking for?": "Can’t find the group you’re looking for?",
@@ -513,8 +521,6 @@
"Search groups": "Search groups",
"No groups matching your search": "No groups matching your search",
"No groups left to add": "No groups left to add",
- "Add": "Add",
- "{{ userName }} was added to the collection": "{{ userName }} was added to the collection",
"Need to add someone who’s not on the team yet?": "Need to add someone who’s not on the team yet?",
"Invite people to {{ teamName }}": "Invite people to {{ teamName }}",
"Ask an admin to invite them first": "Ask an admin to invite them first",
@@ -522,7 +528,6 @@
"Search people": "Search people",
"No people matching your search": "No people matching your search",
"No people left to add": "No people left to add",
- "Admin": "Admin",
"Active <1>1> ago": "Active <1>1> ago",
"Never signed in": "Never signed in",
"{{ userName }} was removed from the collection": "{{ userName }} was removed from the collection",
diff --git a/shared/styles/theme.ts b/shared/styles/theme.ts
index 7ade24ab4..90068e4aa 100644
--- a/shared/styles/theme.ts
+++ b/shared/styles/theme.ts
@@ -182,7 +182,7 @@ export const buildDarkTheme = (input: Partial): DefaultTheme => {
textDiffInsertedBackground: "rgba(63,185,80,0.3)",
textDiffDeleted: darken(0.1, colors.almostWhite),
textDiffDeletedBackground: "rgba(248,81,73,0.15)",
- placeholder: colors.slateDark,
+ placeholder: "#596673",
sidebarBackground: colors.veryDarkBlue,
sidebarActiveBackground: lighten(0.02, colors.almostBlack),
sidebarControlHoverBackground: colors.white10,