mirror of
https://github.com/outline/outline.git
synced 2026-06-14 03:45:00 +03:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 540b514702 | |||
| e4268c9a1f | |||
| bf9065d6e6 | |||
| 3e5ae49ad9 | |||
| 9a4d754a39 | |||
| 0009a08278 | |||
| 84bc914940 |
@@ -71,7 +71,7 @@ function Avatar(props: Props) {
|
||||
<Image onError={handleError} src={src} {...rest} />
|
||||
) : model ? (
|
||||
<Initials color={model.color} {...rest}>
|
||||
{model.initial}
|
||||
{model.initial?.toUpperCase()}
|
||||
</Initials>
|
||||
) : (
|
||||
<Initials {...rest} />
|
||||
|
||||
@@ -117,12 +117,31 @@ const HoverPreviewDesktop = observer(
|
||||
<Position top={cardTop} left={cardLeft} aria-hidden>
|
||||
{isVisible ? (
|
||||
<Animate
|
||||
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
filter: "blur(5px)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
transitionEnd: { pointerEvents: "auto" },
|
||||
}}
|
||||
transition={{
|
||||
y: {
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 25,
|
||||
},
|
||||
opacity: {
|
||||
duration: 0.2,
|
||||
},
|
||||
filter: {
|
||||
duration: 0.2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{data.type === UnfurlResourceType.Mention ? (
|
||||
<HoverPreviewMention
|
||||
@@ -137,6 +156,7 @@ const HoverPreviewDesktop = observer(
|
||||
<HoverPreviewGroup
|
||||
ref={cardRef}
|
||||
name={data.name}
|
||||
description={data.description}
|
||||
memberCount={data.memberCount}
|
||||
users={data.users}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
|
||||
import User from "~/models/User";
|
||||
@@ -17,21 +18,30 @@ import ErrorBoundary from "../ErrorBoundary";
|
||||
type Props = Omit<UnfurlResponse[UnfurlResourceType.Group], "type">;
|
||||
|
||||
const HoverPreviewGroup = React.forwardRef(function _HoverPreviewGroup(
|
||||
{ name, memberCount, users }: Props,
|
||||
{ name, description, memberCount, users }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Preview as="div">
|
||||
<Card fadeOut={false} ref={ref}>
|
||||
<CardContent>
|
||||
<ErrorBoundary showTitle={false} reloadOnChunkMissing={false}>
|
||||
<Flex column gap={2} align="start">
|
||||
<Title>{name}</Title>
|
||||
<Info>
|
||||
{memberCount === 1 ? "1 member" : `${memberCount} members`}
|
||||
</Info>
|
||||
{users.length > 0 && (
|
||||
<Description>
|
||||
<Flex
|
||||
justify="space-between"
|
||||
gap={4}
|
||||
style={{ width: "100%" }}
|
||||
auto
|
||||
>
|
||||
<Flex column align="start">
|
||||
<Title>{name}</Title>
|
||||
<Info>
|
||||
{t("{{ count }} members", { count: memberCount })}
|
||||
</Info>
|
||||
</Flex>
|
||||
{users.length > 0 && (
|
||||
<Facepile
|
||||
users={users.map(
|
||||
(member) =>
|
||||
@@ -46,8 +56,9 @@ const HoverPreviewGroup = React.forwardRef(function _HoverPreviewGroup(
|
||||
overflow={Math.max(0, memberCount - users.length)}
|
||||
limit={MAX_AVATAR_DISPLAY}
|
||||
/>
|
||||
</Description>
|
||||
)}
|
||||
)}
|
||||
</Flex>
|
||||
{description && <Description>{description}</Description>}
|
||||
</Flex>
|
||||
</ErrorBoundary>
|
||||
</CardContent>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { SharedDocumentLink } from "./components/SharedDocumentLink";
|
||||
import SidebarButton from "./components/SidebarButton";
|
||||
import ToggleButton from "./components/ToggleButton";
|
||||
import { useEffect } from "react";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
|
||||
type Props = {
|
||||
share: Share;
|
||||
@@ -31,12 +32,16 @@ type Props = {
|
||||
function SharedSidebar({ share }: Props) {
|
||||
const team = useTeamContext();
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const { ui, documents } = useStores();
|
||||
const { ui, documents, collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const teamAvailable = !!team?.name;
|
||||
const rootNode = share.tree;
|
||||
const shareId = share.urlId || share.id;
|
||||
const collection = collections.get(rootNode?.id);
|
||||
const hideRootNode = collection
|
||||
? ProsemirrorHelper.isEmptyData(collection?.data)
|
||||
: false;
|
||||
|
||||
useEffect(() => {
|
||||
ui.tocVisible = share.showTOC;
|
||||
@@ -54,8 +59,10 @@ function SharedSidebar({ share }: Props) {
|
||||
image={
|
||||
<TeamLogo model={team} size={AvatarSize.XLarge} alt={t("Logo")} />
|
||||
}
|
||||
onClick={() =>
|
||||
history.push(user ? homePath() : sharedModelPath(shareId))
|
||||
onClick={
|
||||
hideRootNode
|
||||
? undefined
|
||||
: () => history.push(user ? homePath() : sharedModelPath(shareId))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -72,7 +79,11 @@ function SharedSidebar({ share }: Props) {
|
||||
</TopSection>
|
||||
<Section>
|
||||
{share.collectionId ? (
|
||||
<SharedCollectionLink node={rootNode} shareId={shareId} />
|
||||
<SharedCollectionLink
|
||||
node={rootNode}
|
||||
shareId={shareId}
|
||||
hideRootNode={hideRootNode}
|
||||
/>
|
||||
) : (
|
||||
<SharedDocumentLink
|
||||
index={0}
|
||||
|
||||
@@ -10,35 +10,37 @@ import SidebarLink from "./SidebarLink";
|
||||
type Props = {
|
||||
node: NavigationNode;
|
||||
shareId: string;
|
||||
hideRootNode?: boolean;
|
||||
};
|
||||
|
||||
function CollectionLink({ node, shareId }: Props) {
|
||||
function CollectionLink({ node, shareId, hideRootNode }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { documents, ui } = useStores();
|
||||
|
||||
const icon = node.icon ?? node.emoji;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarLink
|
||||
to={{
|
||||
pathname: sharedModelPath(shareId),
|
||||
state: {
|
||||
title: node.title,
|
||||
},
|
||||
}}
|
||||
icon={icon && <Icon value={icon} color={node.color} />}
|
||||
label={node.title || t("Untitled")}
|
||||
depth={0}
|
||||
exact={false}
|
||||
scrollIntoViewIfNeeded={true}
|
||||
isActive={() => ui.activeCollectionId === node.id}
|
||||
/>
|
||||
{!hideRootNode && (
|
||||
<SidebarLink
|
||||
to={{
|
||||
pathname: sharedModelPath(shareId),
|
||||
state: {
|
||||
title: node.title,
|
||||
},
|
||||
}}
|
||||
icon={icon && <Icon value={icon} color={node.color} />}
|
||||
label={node.title || t("Untitled")}
|
||||
depth={0}
|
||||
exact={false}
|
||||
scrollIntoViewIfNeeded={true}
|
||||
isActive={() => ui.activeCollectionId === node.id}
|
||||
/>
|
||||
)}
|
||||
{node.children.map((childNode, index) => (
|
||||
<SharedDocumentLink
|
||||
key={childNode.id}
|
||||
index={index}
|
||||
depth={2}
|
||||
depth={hideRootNode ? 0 : 2}
|
||||
shareId={shareId}
|
||||
node={childNode}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
|
||||
@@ -25,6 +25,7 @@ const SidebarButton = React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
|
||||
image,
|
||||
title,
|
||||
children,
|
||||
onClick,
|
||||
...rest
|
||||
}: SidebarButtonProps,
|
||||
ref
|
||||
@@ -38,10 +39,12 @@ const SidebarButton = React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
|
||||
>
|
||||
<Button
|
||||
{...rest}
|
||||
onClick={onClick}
|
||||
$position={position}
|
||||
as="button"
|
||||
ref={ref}
|
||||
role="button"
|
||||
disabled={!onClick}
|
||||
>
|
||||
<Content gap={8} align="center">
|
||||
{image}
|
||||
@@ -96,17 +99,17 @@ const Button = styled(Flex)<{
|
||||
text-decoration: none;
|
||||
text-align: left;
|
||||
user-select: none;
|
||||
cursor: var(--pointer);
|
||||
position: relative;
|
||||
|
||||
${undraggableOnDesktop()}
|
||||
${extraArea(4)}
|
||||
|
||||
&:active,
|
||||
&:${hover},
|
||||
&[aria-expanded="true"] {
|
||||
&:not(:disabled):active,
|
||||
&:not(:disabled):${hover},
|
||||
&:not(:disabled)[aria-expanded="true"] {
|
||||
color: ${s("sidebarText")};
|
||||
background: ${s("sidebarActiveBackground")};
|
||||
cursor: var(--pointer);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { Avatar } from "./Avatar";
|
||||
import { AvatarVariant } from "./Avatar/Avatar";
|
||||
|
||||
@@ -7,7 +6,6 @@ const TeamLogo = styled(Avatar).attrs({
|
||||
variant: AvatarVariant.Square,
|
||||
})`
|
||||
border-radius: 4px;
|
||||
box-shadow: inset 0 0 0 1px ${s("divider")};
|
||||
border: 0;
|
||||
`;
|
||||
|
||||
|
||||
@@ -30,9 +30,10 @@ const Theme: React.FC = ({ children }: Props) => {
|
||||
<ThemeProvider theme={theme}>
|
||||
<>
|
||||
<GlobalStyles
|
||||
useCursorPointer={auth.user?.getPreference(
|
||||
UserPreference.UseCursorPointer
|
||||
)}
|
||||
useCursorPointer={
|
||||
// Default to showing the cursor pointer if no user is logged in (public share)
|
||||
auth.user?.getPreference(UserPreference.UseCursorPointer) ?? true
|
||||
}
|
||||
/>
|
||||
{children}
|
||||
</>
|
||||
|
||||
@@ -22,13 +22,13 @@ import Input from "./Input";
|
||||
import SuggestionsMenuItem from "./SuggestionsMenuItem";
|
||||
import ToolbarButton from "./ToolbarButton";
|
||||
import Tooltip from "./Tooltip";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
|
||||
type Props = {
|
||||
mark?: Mark;
|
||||
from: number;
|
||||
to: number;
|
||||
dictionary: Dictionary;
|
||||
onRemoveLink?: () => void;
|
||||
onSelectLink: (options: {
|
||||
href: string;
|
||||
title?: string;
|
||||
@@ -47,16 +47,14 @@ const LinkEditor: React.FC<Props> = ({
|
||||
from,
|
||||
to,
|
||||
dictionary,
|
||||
onRemoveLink,
|
||||
onSelectLink,
|
||||
onClickLink,
|
||||
view,
|
||||
}) => {
|
||||
const getHref = () => sanitizeUrl(mark?.attrs.href) ?? "";
|
||||
const initialValue = getHref();
|
||||
const initialSelectionLength = to - from;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const discardRef = useRef(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [query, setQuery] = useState(initialValue);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const { documents } = useStores();
|
||||
@@ -79,35 +77,19 @@ const LinkEditor: React.FC<Props> = ({
|
||||
}
|
||||
}, [trimmedQuery, request]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleGlobalKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "k" && event.metaKey) {
|
||||
inputRef.current?.select();
|
||||
}
|
||||
};
|
||||
useOnClickOutside(wrapperRef, () => {
|
||||
// If the link in input is non-empty and same as it was when the editor opened, nothing to do
|
||||
if (trimmedQuery.length && trimmedQuery === initialValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleGlobalKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleGlobalKeyDown);
|
||||
// If the link is totally empty or only spaces then remove the mark
|
||||
if (!trimmedQuery) {
|
||||
return handleRemoveLink();
|
||||
}
|
||||
|
||||
// If we discarded the changes then nothing to do
|
||||
if (discardRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the link is the same as it was when the editor opened, nothing to do
|
||||
if (trimmedQuery === initialValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the link is totally empty or only spaces then remove the mark
|
||||
if (!trimmedQuery) {
|
||||
return handleRemoveLink();
|
||||
}
|
||||
|
||||
save(trimmedQuery, trimmedQuery);
|
||||
};
|
||||
}, [trimmedQuery, initialValue]);
|
||||
save(trimmedQuery, trimmedQuery);
|
||||
});
|
||||
|
||||
const save = (href: string, title?: string) => {
|
||||
href = href.trim();
|
||||
@@ -116,10 +98,10 @@ const LinkEditor: React.FC<Props> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
discardRef.current = true;
|
||||
href = sanitizeUrl(href) ?? "";
|
||||
|
||||
onSelectLink({ href, title, from, to });
|
||||
moveSelectionToEnd();
|
||||
};
|
||||
|
||||
const moveSelectionToEnd = () => {
|
||||
@@ -156,20 +138,20 @@ const LinkEditor: React.FC<Props> = ({
|
||||
save(trimmedQuery, trimmedQuery);
|
||||
}
|
||||
|
||||
if (initialSelectionLength) {
|
||||
moveSelectionToEnd();
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "Escape": {
|
||||
event.preventDefault();
|
||||
|
||||
if (initialValue) {
|
||||
setQuery(initialValue);
|
||||
moveSelectionToEnd();
|
||||
} else {
|
||||
if (!initialValue) {
|
||||
handleRemoveLink();
|
||||
}
|
||||
|
||||
// Moving selection to end causes editor state to change,
|
||||
// forcing a re-render of the top-level editor component. As
|
||||
// a result, the new selection, being devoid of any link mark,
|
||||
// prevents LinkEditor from re-rendering.
|
||||
moveSelectionToEnd();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -196,23 +178,19 @@ const LinkEditor: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
const handleRemoveLink = () => {
|
||||
discardRef.current = true;
|
||||
|
||||
const { state, dispatch } = view;
|
||||
if (mark) {
|
||||
dispatch(state.tr.removeMark(from, to, mark));
|
||||
}
|
||||
|
||||
onRemoveLink?.();
|
||||
view.focus();
|
||||
moveSelectionToEnd();
|
||||
};
|
||||
|
||||
const isInternal = isInternalUrl(query);
|
||||
const hasResults = !!results.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Wrapper>
|
||||
<div ref={wrapperRef}>
|
||||
<InputWrapper ref={wrapperRef}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
@@ -238,7 +216,7 @@ const LinkEditor: React.FC<Props> = ({
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Wrapper>
|
||||
</InputWrapper>
|
||||
<SearchResults $hasResults={hasResults}>
|
||||
<ResizingHeightContainer>
|
||||
{hasResults && (
|
||||
@@ -247,9 +225,6 @@ const LinkEditor: React.FC<Props> = ({
|
||||
<SuggestionsMenuItem
|
||||
onClick={() => {
|
||||
save(doc.url, doc.title);
|
||||
if (initialSelectionLength) {
|
||||
moveSelectionToEnd();
|
||||
}
|
||||
}}
|
||||
onPointerMove={() => setSelectedIndex(index)}
|
||||
selected={index === selectedIndex}
|
||||
@@ -276,11 +251,11 @@ const LinkEditor: React.FC<Props> = ({
|
||||
)}
|
||||
</ResizingHeightContainer>
|
||||
</SearchResults>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
const InputWrapper = styled(Flex)`
|
||||
pointer-events: all;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
|
||||
@@ -123,6 +123,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
</Flex>
|
||||
),
|
||||
title: group.name,
|
||||
subtitle: t("{{ count }} members", { count: group.memberCount }),
|
||||
section: GroupSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
|
||||
+14
-12
@@ -30,31 +30,33 @@ export default function codeMenuItems(
|
||||
)
|
||||
.map(([value, item]) => langToMenuItem({ node, value, label: item.label }));
|
||||
|
||||
const languageMenuItems = frequentLangMenuItems.length
|
||||
? [
|
||||
...frequentLangMenuItems,
|
||||
{ name: "separator" },
|
||||
...remainingLangMenuItems,
|
||||
]
|
||||
: remainingLangMenuItems;
|
||||
const getLanguageMenuItems = () =>
|
||||
frequentLangMenuItems.length
|
||||
? [
|
||||
...frequentLangMenuItems,
|
||||
{ name: "separator" },
|
||||
...remainingLangMenuItems,
|
||||
]
|
||||
: remainingLangMenuItems;
|
||||
|
||||
return [
|
||||
{
|
||||
name: "copyToClipboard",
|
||||
icon: <CopyIcon />,
|
||||
label: readOnly ? dictionary.copy : undefined,
|
||||
label: readOnly
|
||||
? getLabelForLanguage(node.attrs.language ?? "none")
|
||||
: undefined,
|
||||
tooltip: dictionary.copy,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
visible: !readOnly,
|
||||
},
|
||||
{
|
||||
visible: !readOnly,
|
||||
name: "code_block",
|
||||
icon: <ExpandedIcon />,
|
||||
label: getLabelForLanguage(node.attrs.language ?? "none"),
|
||||
children: languageMenuItems,
|
||||
icon: <ExpandedIcon />,
|
||||
children: getLanguageMenuItems(),
|
||||
visible: !readOnly,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
+5
-1
@@ -12,6 +12,10 @@ class Group extends Model implements Searchable {
|
||||
@observable
|
||||
name: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
description: string;
|
||||
|
||||
@observable
|
||||
externalId: string | undefined;
|
||||
|
||||
@@ -33,7 +37,7 @@ class Group extends Model implements Searchable {
|
||||
|
||||
@computed
|
||||
get searchContent(): string[] {
|
||||
return [this.name].filter(Boolean);
|
||||
return [this.name, this.description].filter(Boolean);
|
||||
}
|
||||
|
||||
@computed
|
||||
|
||||
@@ -164,6 +164,11 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
||||
if (ev.event.code === EditorUpdateError.code) {
|
||||
setEditorVersionBehind(true);
|
||||
}
|
||||
if (ev.event.code === 4403) {
|
||||
void auth.fetchAuth().catch(() => {
|
||||
history.replace(homePath());
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
|
||||
import { GroupPermission } from "@shared/types";
|
||||
import { GroupValidation } from "@shared/validations";
|
||||
import { EmptySelectValue, Permission } from "~/types";
|
||||
import GroupUser from "~/models/GroupUser";
|
||||
import Switch from "~/components/Switch";
|
||||
@@ -40,6 +41,7 @@ export function CreateGroupDialog() {
|
||||
const { dialogs, groups } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = React.useState<string | undefined>();
|
||||
const [description, setDescription] = React.useState<string | undefined>();
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
@@ -50,6 +52,7 @@ export function CreateGroupDialog() {
|
||||
const group = new Group(
|
||||
{
|
||||
name,
|
||||
description,
|
||||
},
|
||||
groups
|
||||
);
|
||||
@@ -67,7 +70,7 @@ export function CreateGroupDialog() {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[t, dialogs, groups, name]
|
||||
[t, dialogs, groups, name, description]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -79,7 +82,7 @@ export function CreateGroupDialog() {
|
||||
example.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Flex>
|
||||
<Flex column>
|
||||
<Input
|
||||
type="text"
|
||||
label="Name"
|
||||
@@ -89,6 +92,15 @@ export function CreateGroupDialog() {
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
<Input
|
||||
type="textarea"
|
||||
label="Description"
|
||||
placeholder={t("Optional")}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
value={description || ""}
|
||||
maxLength={GroupValidation.maxDescriptionLength}
|
||||
flex
|
||||
/>
|
||||
</Flex>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans>You’ll be able to add people to the group next.</Trans>
|
||||
@@ -104,6 +116,7 @@ export function CreateGroupDialog() {
|
||||
export function EditGroupDialog({ group, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = React.useState(group.name);
|
||||
const [description, setDescription] = React.useState(group.description || "");
|
||||
const [disableMentions, setDisableMentions] = React.useState(
|
||||
group.disableMentions || false
|
||||
);
|
||||
@@ -116,6 +129,7 @@ export function EditGroupDialog({ group, onSubmit }: Props) {
|
||||
try {
|
||||
await group.save({
|
||||
name,
|
||||
description,
|
||||
disableMentions,
|
||||
});
|
||||
onSubmit();
|
||||
@@ -125,7 +139,7 @@ export function EditGroupDialog({ group, onSubmit }: Props) {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[group, onSubmit, name, disableMentions]
|
||||
[group, onSubmit, name, description, disableMentions]
|
||||
);
|
||||
|
||||
const handleNameChange = React.useCallback(
|
||||
@@ -153,6 +167,15 @@ export function EditGroupDialog({ group, onSubmit }: Props) {
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
<Input
|
||||
type="textarea"
|
||||
label={t("Description")}
|
||||
placeholder={t("Optional")}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
value={description}
|
||||
maxLength={GroupValidation.maxDescriptionLength}
|
||||
flex
|
||||
/>
|
||||
<Switch
|
||||
id="mentions"
|
||||
label={t("Disable mentions")}
|
||||
|
||||
@@ -70,6 +70,18 @@ export function GroupsTable(props: Props) {
|
||||
),
|
||||
width: "2fr",
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "description",
|
||||
header: t("Description"),
|
||||
accessor: (group) => group.description || "",
|
||||
component: (group) => (
|
||||
<Text type="secondary" size="small" weight="normal">
|
||||
{group.description}
|
||||
</Text>
|
||||
),
|
||||
width: "2fr",
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "members",
|
||||
@@ -97,30 +109,6 @@ export function GroupsTable(props: Props) {
|
||||
width: "1fr",
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "admins",
|
||||
header: t("Admins"),
|
||||
accessor: (group) => `${group.memberCount} admins`,
|
||||
component: (group) => {
|
||||
const users = group.admins.slice(0, MAX_AVATAR_DISPLAY);
|
||||
|
||||
if (users.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupMembers
|
||||
onClick={() => handleViewMembers(group)}
|
||||
width={users.length * AvatarSize.Large}
|
||||
>
|
||||
<Facepile users={users} />
|
||||
</GroupMembers>
|
||||
);
|
||||
},
|
||||
width: "1fr",
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "createdAt",
|
||||
|
||||
Vendored
+25
-18
@@ -3,7 +3,31 @@ import "styled-components";
|
||||
|
||||
// and extend them!
|
||||
declare module "styled-components" {
|
||||
interface EditorTheme {
|
||||
interface CodeTheme {
|
||||
code: string;
|
||||
codeComment: string;
|
||||
codePunctuation: string;
|
||||
codeNumber: string;
|
||||
codeProperty: string;
|
||||
codeTag: string;
|
||||
codeString: string;
|
||||
codeClassName: string;
|
||||
codeConstant: string;
|
||||
codeParameter: string;
|
||||
codeSelector: string;
|
||||
codeAttrName: string;
|
||||
codeAttrValue: string;
|
||||
codeEntity: string;
|
||||
codeKeyword: string;
|
||||
codeFunction: string;
|
||||
codeStatement: string;
|
||||
codePlaceholder: string;
|
||||
codeInserted: string;
|
||||
codeImportant: string;
|
||||
codeOperator: string;
|
||||
}
|
||||
|
||||
interface EditorTheme extends CodeTheme {
|
||||
isDark: boolean;
|
||||
background: string;
|
||||
text: string;
|
||||
@@ -29,23 +53,6 @@ declare module "styled-components" {
|
||||
textHighlight: string;
|
||||
textHighlightForeground: string;
|
||||
selected: string;
|
||||
code: string;
|
||||
codeComment: string;
|
||||
codePunctuation: string;
|
||||
codeNumber: string;
|
||||
codeProperty: string;
|
||||
codeTag: string;
|
||||
codeString: string;
|
||||
codeClassName: string;
|
||||
codeSelector: string;
|
||||
codeAttr: string;
|
||||
codeEntity: string;
|
||||
codeKeyword: string;
|
||||
codeFunction: string;
|
||||
codeStatement: string;
|
||||
codePlaceholder: string;
|
||||
codeInserted: string;
|
||||
codeImportant: string;
|
||||
noticeInfoBackground: string;
|
||||
noticeInfoText: string;
|
||||
noticeTipBackground: string;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn("groups", "description", {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.removeColumn("groups", "description");
|
||||
},
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DataType,
|
||||
Scopes,
|
||||
} from "sequelize-typescript";
|
||||
import { GroupValidation } from "@shared/validations";
|
||||
import GroupMembership from "./GroupMembership";
|
||||
import GroupUser from "./GroupUser";
|
||||
import Team from "./Team";
|
||||
@@ -65,6 +66,10 @@ class Group extends ParanoidModel<
|
||||
@Column
|
||||
name: string;
|
||||
|
||||
@Length({ min: 0, max: GroupValidation.maxDescriptionLength, msg: `description must be ${GroupValidation.maxDescriptionLength} characters or less` })
|
||||
@Column(DataType.TEXT)
|
||||
description: string;
|
||||
|
||||
@Column
|
||||
externalId: string;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export default async function presentGroup(group: Group) {
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
externalId: group.externalId,
|
||||
memberCount: await group.memberCount,
|
||||
disableMentions: group.disableMentions,
|
||||
|
||||
@@ -65,6 +65,7 @@ const presentGroup = async (
|
||||
return {
|
||||
type: UnfurlResourceType.Group,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
memberCount,
|
||||
users: (data.users as User[]).map((user) => ({
|
||||
id: user.id,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { GroupPermission } from "@shared/types";
|
||||
import { GroupValidation } from "@shared/validations";
|
||||
import { Group } from "@server/models";
|
||||
|
||||
const BaseIdSchema = z.object({
|
||||
@@ -49,6 +50,8 @@ export const GroupsCreateSchema = z.object({
|
||||
body: z.object({
|
||||
/** Group name */
|
||||
name: z.string(),
|
||||
/** Group description */
|
||||
description: z.string().max(GroupValidation.maxDescriptionLength).optional(),
|
||||
/** Optionally link this group to an external source. */
|
||||
externalId: z.string().optional(),
|
||||
/** Whether mentions are disabled for this group */
|
||||
@@ -62,6 +65,8 @@ export const GroupsUpdateSchema = z.object({
|
||||
body: BaseIdSchema.extend({
|
||||
/** Group name */
|
||||
name: z.string().optional(),
|
||||
/** Group description */
|
||||
description: z.string().max(GroupValidation.maxDescriptionLength).optional(),
|
||||
/** Optionally link this group to an external source. */
|
||||
externalId: z.string().optional(),
|
||||
/** Whether mentions are disabled for this group */
|
||||
|
||||
@@ -86,7 +86,7 @@ router.post(
|
||||
ctx.body = {
|
||||
data: {
|
||||
shares: [presentShare(share, user?.isAdmin ?? false)],
|
||||
sharedTree: sharedTree,
|
||||
sharedTree,
|
||||
team: serializedTeam,
|
||||
collection: serializedCollection,
|
||||
document: serializedDocument,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { exitCode } from "prosemirror-commands";
|
||||
import { Command, TextSelection } from "prosemirror-state";
|
||||
import { Command, EditorState, TextSelection } from "prosemirror-state";
|
||||
import { findNextNewline, findPreviousNewline } from "../queries/findNewlines";
|
||||
import { isInCode } from "../queries/isInCode";
|
||||
import { findParentNode } from "../queries/findParentNode";
|
||||
import { isCode } from "../lib/isCode";
|
||||
import { languagesWithFourSpaceIndent } from "../lib/code";
|
||||
|
||||
const newline = "\n";
|
||||
const tabSize = 2;
|
||||
|
||||
/**
|
||||
* Moves the current selection to the previous newline, this is used inside
|
||||
@@ -93,6 +93,7 @@ export const indentInCode: Command = (state, dispatch) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tabSize = getTabSize(state);
|
||||
const spaces = " ".repeat(tabSize);
|
||||
const { tr, selection } = state;
|
||||
const { $from, from, to } = selection;
|
||||
@@ -155,6 +156,7 @@ export const outdentInCode: Command = (state, dispatch) => {
|
||||
let totalSpacesRemoved = 0;
|
||||
let spacesRemovedOnFirstLine = 0;
|
||||
const startOfFirstLine = findPreviousNewline($from);
|
||||
const tabSize = getTabSize(state);
|
||||
|
||||
while (index >= startOfFirstLine - line * tabSize) {
|
||||
const newLineBefore =
|
||||
@@ -268,3 +270,18 @@ export const splitCodeBlockOnTripleBackticks: Command = (state, dispatch) => {
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
function getTabSize(state: EditorState): number {
|
||||
const codeBlock = findParentNode(isCode)(state.selection);
|
||||
if (!codeBlock) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (languagesWithFourSpaceIndent.includes(codeBlock.node.attrs.language)) {
|
||||
return 4;
|
||||
}
|
||||
|
||||
const existingText = codeBlock.node.textContent;
|
||||
const usesFourSpaces = existingText.includes(" ");
|
||||
return usesFourSpaces ? 4 : 2;
|
||||
}
|
||||
|
||||
@@ -163,13 +163,13 @@ const codeBlockStyle = (props: Props) => css`
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.token.operator,
|
||||
.token.boolean,
|
||||
.token.number {
|
||||
color: ${props.theme.codeNumber};
|
||||
}
|
||||
|
||||
.token.property {
|
||||
.token.property,
|
||||
.token.variable {
|
||||
color: ${props.theme.codeProperty};
|
||||
}
|
||||
|
||||
@@ -177,6 +177,8 @@ const codeBlockStyle = (props: Props) => css`
|
||||
color: ${props.theme.codeTag};
|
||||
}
|
||||
|
||||
.token.char,
|
||||
.token.builtin,
|
||||
.token.string {
|
||||
color: ${props.theme.codeString};
|
||||
}
|
||||
@@ -186,7 +188,20 @@ const codeBlockStyle = (props: Props) => css`
|
||||
}
|
||||
|
||||
.token.attr-name {
|
||||
color: ${props.theme.codeAttr};
|
||||
color: ${props.theme.codeAttrName};
|
||||
}
|
||||
|
||||
.token.attr-value,
|
||||
.token.attr-value .token.punctuation {
|
||||
color: ${props.theme.codeAttrValue};
|
||||
}
|
||||
|
||||
.token.operator {
|
||||
color: ${props.theme.codeOperator};
|
||||
}
|
||||
|
||||
.token.namespace {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.token.entity,
|
||||
@@ -242,6 +257,14 @@ const codeBlockStyle = (props: Props) => css`
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.token.constant {
|
||||
color: ${props.theme.codeConstant};
|
||||
}
|
||||
|
||||
.token.parameter {
|
||||
color: ${props.theme.codeParameter};
|
||||
}
|
||||
|
||||
.token.important {
|
||||
color: ${props.theme.codeImportant};
|
||||
}
|
||||
@@ -1331,12 +1354,13 @@ code {
|
||||
border: 1px solid ${props.theme.codeBorder};
|
||||
background: ${props.theme.codeBackground};
|
||||
padding: 3px 4px;
|
||||
color: ${props.theme.codeString};
|
||||
color: ${props.theme.code};
|
||||
font-family: ${props.theme.fontFamilyMono};
|
||||
font-size: 90%;
|
||||
|
||||
.${EditorStyleHelper.codeWord} {
|
||||
white-space: nowrap;
|
||||
color: ${props.theme.codeKeyword};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -425,3 +425,11 @@ export const getFrequentCodeLanguages = () => {
|
||||
|
||||
const sortFrequencies = <T>(freqs: [T, number][]) =>
|
||||
freqs.sort((a, b) => (a[1] >= b[1] ? -1 : 1));
|
||||
|
||||
export const languagesWithFourSpaceIndent = [
|
||||
"python",
|
||||
"java",
|
||||
"cpp",
|
||||
"csharp",
|
||||
"rust",
|
||||
];
|
||||
|
||||
@@ -291,6 +291,8 @@
|
||||
"Filter options": "Filter options",
|
||||
"Filter": "Filter",
|
||||
"No results": "No results",
|
||||
"{{ count }} members": "{{ count }} member",
|
||||
"{{ count }} members_plural": "{{ count }} members",
|
||||
"{{authorName}} created <3></3>": "{{authorName}} created <3></3>",
|
||||
"{{authorName}} opened <3></3>": "{{authorName}} opened <3></3>",
|
||||
"Search emoji": "Search emoji",
|
||||
@@ -996,8 +998,10 @@
|
||||
"Check server logs for more details.": "Check server logs for more details.",
|
||||
"{{userName}} requested": "{{userName}} requested",
|
||||
"Groups are for organizing your team. They work best when centered around a function or a responsibility — Support or Engineering for example.": "Groups are for organizing your team. They work best when centered around a function or a responsibility — Support or Engineering for example.",
|
||||
"Optional": "Optional",
|
||||
"You’ll be able to add people to the group next.": "You’ll be able to add people to the group next.",
|
||||
"You can edit the name of this group at any time, however doing so too often might confuse your team mates.": "You can edit the name of this group at any time, however doing so too often might confuse your team mates.",
|
||||
"Description": "Description",
|
||||
"Disable mentions": "Disable mentions",
|
||||
"Prevent this group from being mentionable in documents or comments": "Prevent this group from being mentionable in documents or comments",
|
||||
"Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.": "Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.",
|
||||
@@ -1018,7 +1022,6 @@
|
||||
"No people left to add": "No people left to add",
|
||||
"Group admin": "Group admin",
|
||||
"Member": "Member",
|
||||
"Admins": "Admins",
|
||||
"Date created": "Date created",
|
||||
"Crop Image": "Crop Image",
|
||||
"Crop image": "Crop image",
|
||||
@@ -1048,6 +1051,7 @@
|
||||
"Domain": "Domain",
|
||||
"Views": "Views",
|
||||
"All roles": "All roles",
|
||||
"Admins": "Admins",
|
||||
"Editors": "Editors",
|
||||
"All status": "All status",
|
||||
"Active": "Active",
|
||||
@@ -1062,7 +1066,6 @@
|
||||
"The logo is displayed at the top left of the application.": "The logo is displayed at the top left of the application.",
|
||||
"Workspace logo": "Workspace logo",
|
||||
"The workspace name, usually the same as your company name.": "The workspace name, usually the same as your company name.",
|
||||
"Description": "Description",
|
||||
"A short description of your workspace.": "A short description of your workspace.",
|
||||
"Theme": "Theme",
|
||||
"Customize the interface look and feel.": "Customize the interface look and feel.",
|
||||
@@ -1220,7 +1223,6 @@
|
||||
"Deleting this version of the document will permanently and irrevocably remove it from the history.": "Deleting this version of the document will permanently and irrevocably remove it from the history.",
|
||||
"Format": "Format",
|
||||
"Add option": "Add option",
|
||||
"Optional": "Optional",
|
||||
"Choose a size for your exported document": "Choose a size for your exported document",
|
||||
"Revision renamed": "Revision renamed",
|
||||
"Failed to save revision": "Failed to save revision",
|
||||
|
||||
+35
-19
@@ -73,25 +73,29 @@ const buildBaseTheme = (input: Partial<Colors>) => {
|
||||
textHighlightForeground: colors.almostBlack,
|
||||
commentMarkBackground: transparentize(0.5, "#2BC2FF"),
|
||||
code: colors.lightBlack,
|
||||
codeComment: "#6a737d",
|
||||
codePunctuation: "#5e6687",
|
||||
codeNumber: "#d73a49",
|
||||
codeProperty: "#c08b30",
|
||||
codeTag: "#3d8fd1",
|
||||
codeClassName: "#3d8fd1",
|
||||
codeString: "#032f62",
|
||||
codeSelector: "#6679cc",
|
||||
codeAttr: "#c76b29",
|
||||
codeEntity: "#22a2c9",
|
||||
codeKeyword: "#d73a49",
|
||||
codeFunction: "#6f42c1",
|
||||
codeStatement: "#22a2c9",
|
||||
codeComment: "#008000",
|
||||
codePunctuation: "#393a34",
|
||||
codeNumber: "#0550ae",
|
||||
codeProperty: "#ff0000",
|
||||
codeTag: "#800000",
|
||||
codeClassName: "#00578a",
|
||||
codeString: "#a31515",
|
||||
codeSelector: "#800000",
|
||||
codeAttrName: "#ff0000",
|
||||
codeAttrValue: colors.lightBlack,
|
||||
codeEntity: "#ff0000",
|
||||
codeKeyword: "#00009f",
|
||||
codeFunction: "#393A34",
|
||||
codeStatement: "#ff0000",
|
||||
codePlaceholder: "#3d8fd1",
|
||||
codeInserted: "#202746",
|
||||
codeImportant: "#c94922",
|
||||
codeInserted: "#0550ae",
|
||||
codeImportant: "#e90e90",
|
||||
codeConstant: "#0550ae",
|
||||
codeParameter: colors.lightBlack,
|
||||
codeOperator: "#393a34",
|
||||
noticeInfoBackground: colors.brand.blue,
|
||||
noticeInfoText: colors.almostBlack,
|
||||
noticeTipBackground: "#F5BE31",
|
||||
noticeTipBackground: "#f5be31",
|
||||
noticeTipText: colors.almostBlack,
|
||||
noticeWarningBackground: "#d73a49",
|
||||
noticeWarningText: colors.almostBlack,
|
||||
@@ -223,13 +227,25 @@ export const buildDarkTheme = (input: Partial<Colors>): DefaultTheme => {
|
||||
code: colors.almostWhite,
|
||||
codeBackground: "#1d202a",
|
||||
codeBorder: colors.white10,
|
||||
codeComment: "#6a9955",
|
||||
codePunctuation: "#b3b3b3",
|
||||
codeProperty: "#b5cea8",
|
||||
codeNumber: "#b5cea8",
|
||||
codeTag: "#b5cea8",
|
||||
codeOperator: "#d4d4d4",
|
||||
codeConstant: "#9cdcfe",
|
||||
codeParameter: "#9cdcfe",
|
||||
codeSelector: "#ce9178",
|
||||
codeEntity: "#d4d4d4",
|
||||
codeStatement: "#d16969",
|
||||
codeInserted: "#b5cea8",
|
||||
codeString: "#ce9178",
|
||||
codeKeyword: "#569CD6",
|
||||
codeKeyword: "#569Cd6",
|
||||
codeFunction: "#dcdcaa",
|
||||
codeClassName: "#4ec9b0",
|
||||
codeImportant: "#569CD6",
|
||||
codeAttr: "#9cdcfe",
|
||||
codeImportant: "#569Cd6",
|
||||
codeAttrName: "#9cdcfe",
|
||||
codeAttrValue: "#ce9178",
|
||||
embedBorder: colors.black50,
|
||||
horizontalRule: lighten(0.1, colors.almostBlack),
|
||||
noticeInfoText: colors.white,
|
||||
|
||||
@@ -452,6 +452,8 @@ export type UnfurlResponse = {
|
||||
type: UnfurlResourceType.Group;
|
||||
/** Group name */
|
||||
name: string;
|
||||
/** Group description */
|
||||
description: string | null;
|
||||
/** Number of members in the group */
|
||||
memberCount: number;
|
||||
/** Array of group members (limited to display count) */
|
||||
|
||||
@@ -54,6 +54,11 @@ export const DocumentValidation = {
|
||||
maxRecommendedLength: 250000,
|
||||
};
|
||||
|
||||
export const GroupValidation = {
|
||||
/** The maximum length of the group description */
|
||||
maxDescriptionLength: 2000,
|
||||
};
|
||||
|
||||
export const ImportValidation = {
|
||||
/** The maximum length of the import name */
|
||||
maxNameLength: 100,
|
||||
|
||||
Reference in New Issue
Block a user