Compare commits

..

1 Commits

Author SHA1 Message Date
tommoor dcd4c07ace chore: Compressed inefficient images automatically 2022-08-07 20:05:51 +00:00
534 changed files with 10424 additions and 18704 deletions
+1 -14
View File
@@ -70,15 +70,6 @@ jobs:
- run:
name: test
command: yarn test:app
test-shared:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: test
command: yarn test:shared
test-server:
<<: *defaults
steps:
@@ -90,7 +81,7 @@ jobs:
command: ./node_modules/.bin/sequelize db:migrate --url $DATABASE_URL_TEST
- run:
name: test
command: yarn test:server --forceExit
command: yarn test:server
bundle-size:
<<: *defaults
steps:
@@ -149,9 +140,6 @@ workflows:
- test-server:
requires:
- build
- test-shared:
requires:
- build
- test-app:
requires:
- build
@@ -161,7 +149,6 @@ workflows:
- bundle-size:
requires:
- test-app
- test-shared
- test-server
build-docker:
+4 -16
View File
@@ -1,7 +1,5 @@
# –––––––––––––––– REQUIRED ––––––––––––––––
NODE_ENV=production
# Generate a hex-encoded 32-byte random key. You should use `openssl rand -hex 32`
# in your terminal to generate a random value.
SECRET_KEY=generate_a_new_key
@@ -21,7 +19,7 @@ DATABASE_CONNECTION_POOL_MAX=
# For redis you can either specify an ioredis compatible url like this
REDIS_URL=redis://localhost:6379
# or alternatively, if you would like to provide additional connection options,
# or alternatively, if you would like to provide addtional connection options,
# use a base64 encoded JSON connection option object. Refer to the ioredis documentation
# for a list of available options.
# Example: Use Redis Sentinel for high availability
@@ -38,7 +36,7 @@ PORT=3000
COLLABORATION_URL=
# To support uploading of images for avatars and document attachments an
# s3-compatible storage must be provided. AWS S3 is recommended for redundancy
# s3-compatible storage must be provided. AWS S3 is recommended for redundency
# however if you want to keep all file storage local an alternative such as
# minio (https://github.com/minio/minio) can be used.
@@ -131,7 +129,7 @@ ENABLE_UPDATES=true
# available memory by 512 for a rough estimate
WEB_CONCURRENCY=1
# Override the maximum size of document imports, could be required if you have
# Override the maxium size of document imports, could be required if you have
# especially large Word documents with embedded imagery
MAXIMUM_IMPORT_SIZE=5120000
@@ -150,11 +148,8 @@ SLACK_MESSAGE_ACTIONS=true
# Optionally enable google analytics to track pageviews in the knowledge base
GOOGLE_ANALYTICS_ID=
# Optionally enable Sentry (sentry.io) to track errors and performance,
# and optionally add a Sentry proxy tunnel for bypassing ad blockers in the UI:
# https://docs.sentry.io/platforms/javascript/troubleshooting/#using-the-tunnel-option)
# Optionally enable Sentry (sentry.io) to track errors and performance
SENTRY_DSN=
SENTRY_TUNNEL=
# To support sending outgoing transactional emails such as "document updated" or
# "you've been invited" you'll need to provide authentication for an SMTP server
@@ -173,10 +168,3 @@ SMTP_SECURE=true
# The default interface language. See translate.getoutline.com for a list of
# available language codes and their rough percentage translated.
DEFAULT_LANGUAGE=en_US
# Optionally enable rate limiter at application web server
RATE_LIMITER_ENABLED=true
# Configure default throttling parameters for rate limiter
RATE_LIMITER_REQUESTS=1000
RATE_LIMITER_DURATION_WINDOW=60
-1
View File
@@ -25,7 +25,6 @@
"rules": {
"eqeqeq": 2,
"curly": 2,
"object-shorthand": "error",
"no-mixed-operators": "off",
"no-useless-escape": "off",
"es/no-regexp-lookbehind-assertions": "error",
+1 -1
View File
@@ -11,4 +11,4 @@ fakes3/*
.idea
*.pem
*.key
*.cert
*.cert
-88
View File
@@ -1,88 +0,0 @@
{
"projects": [
{
"displayName": "server",
"verbose": false,
"roots": [
"<rootDir>/server"
],
"moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1"
},
"setupFiles": [
"<rootDir>/__mocks__/console.js",
"<rootDir>/server/test/env.ts"
],
"setupFilesAfterEnv": [
"<rootDir>/server/test/setup.ts"
],
"testEnvironment": "node",
"runner": "@getoutline/jest-runner-serial"
},
{
"displayName": "app",
"verbose": false,
"roots": [
"<rootDir>/app"
],
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
},
"modulePaths": [
"<rootDir>/app"
],
"setupFiles": [
"<rootDir>/__mocks__/window.js"
],
"setupFilesAfterEnv": [
"<rootDir>/app/test/setup.ts"
],
"testEnvironment": "jsdom",
"testEnvironmentOptions": {
"url": "http://localhost"
}
},
{
"displayName": "shared-node",
"verbose": false,
"roots": [
"<rootDir>/shared"
],
"moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1"
},
"setupFiles": [
"<rootDir>/__mocks__/console.js"
],
"setupFilesAfterEnv": [
"<rootDir>/shared/test/setup.ts"
],
"testEnvironment": "node"
},
{
"displayName": "shared-jsdom",
"verbose": false,
"roots": [
"<rootDir>/shared"
],
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
},
"setupFiles": [
"<rootDir>/__mocks__/window.js"
],
"testEnvironment": "jsdom",
"testEnvironmentOptions": {
"url": "http://localhost"
}
}
]
}
+17
View File
@@ -0,0 +1,17 @@
export default class Queue {
name;
constructor(name) {
this.name = name;
}
process = (fn) => {
console.log(`Registered function ${this.name}`);
this.processFn = fn;
};
add = (data) => {
console.log(`Running ${this.name}`);
return this.processFn({ data });
};
}
+1
View File
@@ -1 +1,2 @@
// Mock for node-uuid
global.console.warn = () => {};
-4
View File
@@ -195,10 +195,6 @@
"description": "An API key for Sentry if you wish to collect error reporting (optional)",
"required": false
},
"SENTRY_TUNNEL": {
"description": "A sentry tunnel URL for bypassing ad blockers in the UI (optional)",
"required": false
},
"TEAM_LOGO": {
"description": "A logo that will be displayed on the signed out home page",
"required": false
+27
View File
@@ -0,0 +1,27 @@
{
"testURL": "http://localhost",
"verbose": false,
"rootDir": "..",
"roots": [
"<rootDir>/app",
"<rootDir>/shared"
],
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
},
"moduleDirectories": [
"node_modules"
],
"modulePaths": [
"<rootDir>/app"
],
"setupFiles": [
"<rootDir>/__mocks__/window.js"
],
"setupFilesAfterEnv": [
"./app/test/setup.ts"
],
"testEnvironment": "jsdom"
}
+1 -24
View File
@@ -1,7 +1,6 @@
import {
CollectionIcon,
EditIcon,
PadlockIcon,
PlusIcon,
StarredIcon,
UnstarredIcon,
@@ -11,7 +10,6 @@ import stores from "~/stores";
import Collection from "~/models/Collection";
import CollectionEdit from "~/scenes/CollectionEdit";
import CollectionNew from "~/scenes/CollectionNew";
import CollectionPermissions from "~/scenes/CollectionPermissions";
import DynamicCollectionIcon from "~/components/CollectionIcon";
import { createAction } from "~/actions";
import { CollectionSection } from "~/actions/sections";
@@ -58,8 +56,7 @@ export const createCollection = createAction({
});
export const editCollection = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Edit")}` : t("Edit collection"),
name: ({ t }) => t("Edit collection"),
section: CollectionSection,
icon: <EditIcon />,
visible: ({ stores, activeCollectionId }) =>
@@ -82,26 +79,6 @@ export const editCollection = createAction({
},
});
export const editCollectionPermissions = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Permissions")}` : t("Collection permissions"),
section: CollectionSection,
icon: <PadlockIcon />,
visible: ({ stores, activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId }) => {
if (!activeCollectionId) {
return;
}
stores.dialogs.openModal({
title: t("Collection permissions"),
content: <CollectionPermissions collectionId={activeCollectionId} />,
});
},
});
export const starCollection = createAction({
name: ({ t }) => t("Star"),
section: CollectionSection,
+11 -260
View File
@@ -11,19 +11,9 @@ import {
ImportIcon,
PinIcon,
SearchIcon,
UnsubscribeIcon,
SubscribeIcon,
MoveIcon,
TrashIcon,
CrossIcon,
ArchiveIcon,
ShuffleIcon,
} from "outline-icons";
import * as React from "react";
import { getEventFiles } from "@shared/utils/files";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentMove from "~/scenes/DocumentMove";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
import { createAction } from "~/actions";
import { DocumentSection } from "~/actions/sections";
@@ -118,111 +108,22 @@ export const unstarDocument = createAction({
},
});
export const subscribeDocument = createAction({
name: ({ t }) => t("Subscribe"),
section: DocumentSection,
icon: <SubscribeIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).subscribe
);
},
perform: ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
document?.subscribe();
stores.toasts.showToast(t("Subscribed to document notifications"), {
type: "success",
});
},
});
export const unsubscribeDocument = createAction({
name: ({ t }) => t("Unsubscribe"),
section: DocumentSection,
icon: <UnsubscribeIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
!!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).unsubscribe
);
},
perform: ({ activeDocumentId, stores, currentUserId, t }) => {
if (!activeDocumentId || !currentUserId) {
return;
}
const document = stores.documents.get(activeDocumentId);
document?.unsubscribe(currentUserId);
stores.toasts.showToast(t("Unsubscribed from document notifications"), {
type: "success",
});
},
});
export const downloadDocumentAsHTML = createAction({
name: ({ t }) => t("HTML"),
section: DocumentSection,
keywords: "html export",
icon: <DownloadIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
document?.download("text/html");
},
});
export const downloadDocumentAsMarkdown = createAction({
name: ({ t }) => t("Markdown"),
section: DocumentSection,
keywords: "md markdown export",
icon: <DownloadIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
document?.download("text/markdown");
},
});
export const downloadDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Download") : t("Download document"),
section: DocumentSection,
icon: <DownloadIcon />,
keywords: "export",
children: [downloadDocumentAsHTML, downloadDocumentAsMarkdown],
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
document?.download();
},
});
export const duplicateDocument = createAction({
@@ -395,7 +296,7 @@ export const createTemplate = createAction({
return false;
}
const document = stores.documents.get(activeDocumentId);
return !!(
return (
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update &&
!document?.isTemplate &&
@@ -417,24 +318,6 @@ export const createTemplate = createAction({
},
});
export const openRandomDocument = createAction({
id: "random",
section: DocumentSection,
name: ({ t }) => t(`Open random document`),
icon: <ShuffleIcon />,
perform: ({ stores, activeDocumentId }) => {
const documentPaths = stores.collections.pathsToDocuments.filter(
(path) => path.type === "document" && path.id !== activeDocumentId
);
const documentPath =
documentPaths[Math.round(Math.random() * documentPaths.length)];
if (documentPath) {
history.push(documentPath.url);
}
},
});
export const searchDocumentsForQuery = (searchQuery: string) =>
createAction({
id: "search",
@@ -446,147 +329,15 @@ export const searchDocumentsForQuery = (searchQuery: string) =>
visible: ({ location }) => location.pathname !== searchPath(),
});
export const moveDocument = createAction({
name: ({ t }) => t("Move"),
section: DocumentSection,
icon: <MoveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
perform: ({ activeDocumentId, stores, t }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
stores.dialogs.openModal({
title: t("Move {{ documentName }}", {
documentName: document.noun,
}),
content: (
<DocumentMove
document={document}
onRequestClose={stores.dialogs.closeAllModals}
/>
),
});
}
},
});
export const archiveDocument = createAction({
name: ({ t }) => t("Archive"),
section: DocumentSection,
icon: <ArchiveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).archive;
},
perform: async ({ activeDocumentId, stores, t }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
await document.archive();
stores.toasts.showToast(t("Document archived"), {
type: "success",
});
}
},
});
export const deleteDocument = createAction({
name: ({ t }) => t("Delete"),
section: DocumentSection,
icon: <TrashIcon />,
dangerous: true,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).delete;
},
perform: ({ activeDocumentId, stores, t }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
stores.dialogs.openModal({
title: t("Delete {{ documentName }}", {
documentName: document.noun,
}),
isCentered: true,
content: (
<DocumentDelete
document={document}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
}
},
});
export const permanentlyDeleteDocument = createAction({
name: ({ t }) => t("Permanently delete"),
section: DocumentSection,
icon: <CrossIcon />,
dangerous: true,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).permanentDelete;
},
perform: ({ activeDocumentId, stores, t }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
stores.dialogs.openModal({
title: t("Permanently delete {{ documentName }}", {
documentName: document.noun,
}),
isCentered: true,
content: (
<DocumentPermanentDelete
document={document}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
}
},
});
export const rootDocumentActions = [
openDocument,
archiveDocument,
createDocument,
createTemplate,
deleteDocument,
importDocument,
downloadDocument,
starDocument,
unstarDocument,
subscribeDocument,
unsubscribeDocument,
duplicateDocument,
moveDocument,
openRandomDocument,
permanentlyDeleteDocument,
printDocument,
pinDocumentToCollection,
pinDocumentToHome,
-9
View File
@@ -28,7 +28,6 @@ import history from "~/utils/history";
import {
organizationSettingsPath,
profileSettingsPath,
accountPreferencesPath,
homePath,
searchPath,
draftsPath,
@@ -105,14 +104,6 @@ export const navigateToProfileSettings = createAction({
perform: () => history.push(profileSettingsPath()),
});
export const navigateToAccountPreferences = createAction({
name: ({ t }) => t("Preferences"),
section: NavigationSection,
iconInContextMenu: false,
icon: <SettingsIcon />,
perform: () => history.push(accountPreferencesPath()),
});
export const openAPIDocumentation = createAction({
name: ({ t }) => t("API documentation"),
section: NavigationSection,
-85
View File
@@ -1,85 +0,0 @@
import copy from "copy-to-clipboard";
import { LinkIcon, RestoreIcon } from "outline-icons";
import * as React from "react";
import { matchPath } from "react-router-dom";
import stores from "~/stores";
import { createAction } from "~/actions";
import { RevisionSection } from "~/actions/sections";
import history from "~/utils/history";
import { documentHistoryUrl, matchDocumentHistory } from "~/utils/routeHelpers";
export const restoreRevision = createAction({
name: ({ t }) => t("Restore revision"),
icon: <RestoreIcon />,
section: RevisionSection,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
perform: async ({ t, event, location, activeDocumentId }) => {
event?.preventDefault();
if (!activeDocumentId) {
return;
}
const match = matchPath<{ revisionId: string }>(location.pathname, {
path: matchDocumentHistory,
});
const revisionId = match?.params.revisionId;
const { team } = stores.auth;
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
if (team?.collaborativeEditing) {
history.push(document.url, {
restore: true,
revisionId,
});
} else {
await document.restore({
revisionId,
});
stores.toasts.showToast(t("Document restored"), {
type: "success",
});
history.push(document.url);
}
},
});
export const copyLinkToRevision = createAction({
name: ({ t }) => t("Copy link"),
icon: <LinkIcon />,
section: RevisionSection,
perform: async ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return;
}
const match = matchPath<{ revisionId: string }>(location.pathname, {
path: matchDocumentHistory,
});
const revisionId = match?.params.revisionId;
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
const url = `${window.location.origin}${documentHistoryUrl(
document,
revisionId
)}`;
copy(url, {
format: "text/plain",
onCopy: () => {
stores.toasts.showToast(t("Link copied"), {
type: "info",
});
},
});
},
});
export const rootRevisionActions = [];
-64
View File
@@ -1,64 +0,0 @@
import { PlusIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import TeamNew from "~/scenes/TeamNew";
import { createAction } from "~/actions";
import { loadSessionsFromCookie } from "~/hooks/useSessions";
import { TeamSection } from "../sections";
export const switchTeamList = getSessions().map((session) => {
return createAction({
name: session.name,
section: TeamSection,
keywords: "change switch workspace organization team",
icon: () => <Logo alt={session.name} src={session.logoUrl} />,
visible: ({ currentTeamId }) => currentTeamId !== session.teamId,
perform: () => (window.location.href = session.url),
});
});
const switchTeam = createAction({
name: ({ t }) => t("Switch workspace"),
placeholder: ({ t }) => t("Select a workspace"),
keywords: "change switch workspace organization team",
section: TeamSection,
visible: ({ currentTeamId }) =>
getSessions({ exclude: currentTeamId }).length > 0,
children: switchTeamList,
});
export const createTeam = createAction({
name: ({ t }) => `${t("New workspace")}`,
keywords: "create change switch workspace organization team",
section: TeamSection,
icon: <PlusIcon />,
visible: ({ stores, currentTeamId }) => {
return stores.policies.abilities(currentTeamId ?? "").createTeam;
},
perform: ({ t, event, stores }) => {
event?.preventDefault();
event?.stopPropagation();
const { user } = stores.auth;
user &&
stores.dialogs.openModal({
title: t("Create a workspace"),
content: <TeamNew user={user} />,
});
},
});
function getSessions(params?: { exclude?: string }) {
const sessions = loadSessionsFromCookie();
const otherSessions = sessions.filter(
(session) => session.teamId !== params?.exclude
);
return otherSessions;
}
const Logo = styled("img")`
border-radius: 2px;
width: 24px;
height: 24px;
`;
export const rootTeamActions = [switchTeam, createTeam];
+1 -1
View File
@@ -8,7 +8,7 @@ import { UserSection } from "~/actions/sections";
export const inviteUser = createAction({
name: ({ t }) => `${t("Invite people")}`,
icon: <PlusIcon />,
keywords: "team member workspace user",
keywords: "team member user",
section: UserSection,
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").inviteUser,
-1
View File
@@ -56,7 +56,6 @@ export function actionToMenuItem(
title,
icon,
visible,
dangerous: action.dangerous,
onClick: () => action.perform && action.perform(context),
selected: action.selected ? action.selected(context) : undefined,
};
-4
View File
@@ -2,9 +2,7 @@ import { rootCollectionActions } from "./definitions/collections";
import { rootDeveloperActions } from "./definitions/developer";
import { rootDocumentActions } from "./definitions/documents";
import { rootNavigationActions } from "./definitions/navigation";
import { rootRevisionActions } from "./definitions/revisions";
import { rootSettingsActions } from "./definitions/settings";
import { rootTeamActions } from "./definitions/teams";
import { rootUserActions } from "./definitions/users";
export default [
@@ -12,8 +10,6 @@ export default [
...rootDocumentActions,
...rootUserActions,
...rootNavigationActions,
...rootRevisionActions,
...rootSettingsActions,
...rootDeveloperActions,
...rootTeamActions,
];
-4
View File
@@ -6,15 +6,11 @@ export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
export const DocumentSection = ({ t }: ActionContext) => t("Document");
export const RevisionSection = ({ t }: ActionContext) => t("Revision");
export const SettingsSection = ({ t }: ActionContext) => t("Settings");
export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
export const UserSection = ({ t }: ActionContext) => t("People");
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
export const RecentSearchesSection = ({ t }: ActionContext) =>
t("Recent searches");
+1 -1
View File
@@ -2,7 +2,7 @@ import * as React from "react";
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
import { Action, ActionContext } from "~/types";
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
export type Props = {
/** Show the button in a disabled state */
disabled?: boolean;
/** Hide the button entirely if action is not applicable */
@@ -2,8 +2,8 @@
import * as React from "react";
import env from "~/env";
const Analytics: React.FC = ({ children }) => {
React.useEffect(() => {
export default class Analytics extends React.Component {
componentDidMount() {
if (!env.GOOGLE_ANALYTICS_ID) {
return;
}
@@ -33,9 +33,9 @@ const Analytics: React.FC = ({ children }) => {
if (document.body) {
document.body.appendChild(script);
}
}, []);
}
return <>{children}</>;
};
export default Analytics;
render() {
return this.props.children || null;
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import {
CompositeStateReturn,
} from "reakit/Composite";
type Props = React.HTMLAttributes<HTMLDivElement> & {
type Props = {
children: (composite: CompositeStateReturn) => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
};
+54 -55
View File
@@ -1,23 +1,23 @@
import { AnimatePresence } from "framer-motion";
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
import { withTranslation, WithTranslation } from "react-i18next";
import { Switch, Route } from "react-router-dom";
import RootStore from "~/stores/RootStore";
import ErrorSuspended from "~/scenes/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import Sidebar from "~/components/Sidebar";
import SettingsSidebar from "~/components/Sidebar/Settings";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import {
searchPath,
matchDocumentSlug as slug,
newDocumentPath,
settingsPath,
matchDocumentHistory,
} from "~/utils/routeHelpers";
import Fade from "./Fade";
import withStores from "./withStores";
const DocumentHistory = React.lazy(
() =>
@@ -34,13 +34,16 @@ const CommandBar = React.lazy(
)
);
const AuthenticatedLayout: React.FC = ({ children }) => {
const { ui, auth } = useStores();
const location = useLocation();
const can = usePolicy(ui.activeCollectionId);
const { user, team } = auth;
type Props = WithTranslation & RootStore;
const goToSearch = (ev: KeyboardEvent) => {
@observer
class AuthenticatedLayout extends React.Component<Props> {
scrollable: HTMLDivElement | null | undefined;
@observable
keyboardShortcutsOpen = false;
goToSearch = (ev: KeyboardEvent) => {
if (!ev.metaKey && !ev.ctrlKey) {
ev.preventDefault();
ev.stopPropagation();
@@ -48,64 +51,60 @@ const AuthenticatedLayout: React.FC = ({ children }) => {
}
};
const goToNewDocument = (event: KeyboardEvent) => {
goToNewDocument = (event: KeyboardEvent) => {
if (event.metaKey || event.altKey) {
return;
}
const { activeCollectionId } = ui;
if (!activeCollectionId || !can.update) {
const { activeCollectionId } = this.props.ui;
if (!activeCollectionId) {
return;
}
const can = this.props.policies.abilities(activeCollectionId);
if (!can.update) {
return;
}
history.push(newDocumentPath(activeCollectionId));
};
if (auth.isSuspended) {
return <ErrorSuspended />;
}
render() {
const { auth } = this.props;
const { user, team } = auth;
const showSidebar = auth.authenticated && user && team;
if (auth.isSuspended) {
return <ErrorSuspended />;
}
const showSidebar = auth.authenticated && user && team;
const sidebar = showSidebar ? (
<Fade>
<Switch>
<Route path={settingsPath()} component={SettingsSidebar} />
<Route component={Sidebar} />
</Switch>
</Fade>
) : undefined;
const sidebar = showSidebar ? (
<Fade>
<Switch>
<Route path={settingsPath()} component={SettingsSidebar} />
<Route component={Sidebar} />
</Switch>
</Fade>
) : undefined;
const sidebarRight = (
<React.Suspense fallback={null}>
<AnimatePresence key={ui.activeDocumentId}>
<Switch
location={location}
key={
matchPath(location.pathname, {
path: matchDocumentHistory,
})
? "history"
: ""
}
>
const rightRail = (
<React.Suspense fallback={null}>
<Switch>
<Route
key="document-history"
path={`/doc/${slug}/history/:revisionId?`}
component={DocumentHistory}
/>
</Switch>
</AnimatePresence>
</React.Suspense>
);
</React.Suspense>
);
return (
<Layout title={team?.name} sidebar={sidebar} sidebarRight={sidebarRight}>
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
<RegisterKeyDown trigger="t" handler={goToSearch} />
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<CommandBar />
</Layout>
);
};
return (
<Layout title={team?.name} sidebar={sidebar} rightRail={rightRail}>
<RegisterKeyDown trigger="n" handler={this.goToNewDocument} />
<RegisterKeyDown trigger="t" handler={this.goToSearch} />
<RegisterKeyDown trigger="/" handler={this.goToSearch} />
{this.props.children}
<CommandBar />
</Layout>
);
}
}
export default observer(AuthenticatedLayout);
export default withTranslation()(withStores(AuthenticatedLayout));
+105 -78
View File
@@ -1,114 +1,141 @@
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { WithTranslation, withTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import User from "~/models/User";
import UserProfile from "~/scenes/UserProfile";
import Avatar from "~/components/Avatar";
import Tooltip from "~/components/Tooltip";
type Props = {
type Props = WithTranslation & {
user: User;
isPresent: boolean;
isEditing: boolean;
isObserving: boolean;
isCurrentUser: boolean;
profileOnClick: boolean;
onClick?: React.MouseEventHandler<HTMLImageElement>;
};
function AvatarWithPresence({
onClick,
user,
isPresent,
isEditing,
isObserving,
isCurrentUser,
}: Props) {
const { t } = useTranslation();
const status = isPresent
? isEditing
? t("currently editing")
: t("currently viewing")
: t("previously edited");
@observer
class AvatarWithPresence extends React.Component<Props> {
@observable
isOpen = false;
return (
<>
<Tooltip
tooltip={
<Centered>
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
{status && (
<>
<br />
{status}
</>
)}
</Centered>
}
placement="bottom"
>
<AvatarWrapper
$isPresent={isPresent}
$isObserving={isObserving}
$color={user.color}
handleOpenProfile = () => {
this.isOpen = true;
};
handleCloseProfile = () => {
this.isOpen = false;
};
render() {
const {
onClick,
user,
isPresent,
isEditing,
isObserving,
isCurrentUser,
t,
} = this.props;
const status = isPresent
? isEditing
? t("currently editing")
: t("currently viewing")
: t("previously edited");
return (
<>
<Tooltip
tooltip={
<Centered>
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
{status && (
<>
<br />
{status}
</>
)}
</Centered>
}
placement="bottom"
>
<Avatar src={user.avatarUrl} onClick={onClick} size={32} />
</AvatarWrapper>
</Tooltip>
</>
);
<AvatarWrapper
$isPresent={isPresent}
$isObserving={isObserving}
$color={user.color}
>
<Avatar
src={user.avatarUrl}
onClick={
this.props.profileOnClick === false
? onClick
: this.handleOpenProfile
}
size={32}
/>
</AvatarWrapper>
</Tooltip>
{this.props.profileOnClick && (
<UserProfile
user={user}
isOpen={this.isOpen}
onRequestClose={this.handleCloseProfile}
/>
)}
</>
);
}
}
const Centered = styled.div`
text-align: center;
`;
type AvatarWrapperProps = {
const AvatarWrapper = styled.div<{
$isPresent: boolean;
$isObserving: boolean;
$color: string;
};
const AvatarWrapper = styled.div<AvatarWrapperProps>`
}>`
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
transition: opacity 250ms ease-in-out;
border-radius: 50%;
position: relative;
${(props) =>
props.$isPresent &&
css<AvatarWrapperProps>`
&:after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 50%;
transition: border-color 100ms ease-in-out;
border: 2px solid transparent;
pointer-events: none;
&:after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 50%;
transition: border-color 100ms ease-in-out;
border: 2px solid transparent;
pointer-events: none;
${(props) =>
props.$isObserving &&
css`
border: 2px solid ${props.$color};
box-shadow: inset 0 0 0 2px ${props.theme.background};
${(props) =>
props.$isObserving &&
css`
border: 2px solid ${props.$color};
box-shadow: inset 0 0 0 2px ${props.theme.background};
&:hover {
top: -1px;
left: -1px;
right: -1px;
bottom: -1px;
}
`}
}
&:hover {
top: -1px;
left: -1px;
right: -1px;
bottom: -1px;
}
`}
}
&:hover:after {
border: 2px solid ${(props) => props.$color};
box-shadow: inset 0 0 0 2px ${(props) => props.theme.background};
}
`}
&:hover:after {
border: 2px solid ${(props) => props.$color};
box-shadow: inset 0 0 0 2px ${(props) => props.theme.background};
}
`;
export default observer(AvatarWithPresence);
export default withTranslation()(AvatarWithPresence);
-1
View File
@@ -67,7 +67,6 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
display: flex;
flex-shrink: 1;
min-width: 0;
cursor: var(--pointer);
color: ${(props) => props.theme.text};
font-size: 15px;
height: 24px;
+6 -18
View File
@@ -3,19 +3,14 @@ import { ExpandedIcon } from "outline-icons";
import { darken, lighten } from "polished";
import * as React from "react";
import styled from "styled-components";
import ActionButton, {
Props as ActionButtonProps,
} from "~/components/ActionButton";
type RealProps = {
const RealButton = styled.button<{
fullwidth?: boolean;
borderOnHover?: boolean;
$neutral?: boolean;
danger?: boolean;
iconColor?: string;
};
const RealButton = styled(ActionButton)<RealProps>`
}>`
display: ${(props) => (props.fullwidth ? "block" : "inline-block")};
width: ${(props) => (props.fullwidth ? "100%" : "auto")};
margin: 0;
@@ -30,7 +25,7 @@ const RealButton = styled(ActionButton)<RealProps>`
height: 32px;
text-decoration: none;
flex-shrink: 0;
cursor: var(--pointer);
cursor: pointer;
user-select: none;
appearance: none !important;
@@ -151,7 +146,7 @@ export const Inner = styled.span<{
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
`;
export type Props<T> = ActionButtonProps & {
export type Props<T> = {
icon?: React.ReactNode;
iconColor?: string;
children?: React.ReactNode;
@@ -173,19 +168,12 @@ const Button = <T extends React.ElementType = "button">(
props: Props<T> & React.ComponentPropsWithoutRef<T>,
ref: React.Ref<HTMLButtonElement>
) => {
const { type, children, value, disclosure, neutral, action, ...rest } = props;
const { type, icon, children, value, disclosure, neutral, ...rest } = props;
const hasText = children !== undefined || value !== undefined;
const icon = action?.icon ?? rest.icon;
const hasIcon = icon !== undefined;
return (
<RealButton
type={type || "button"}
ref={ref}
$neutral={neutral}
action={action}
{...rest}
>
<RealButton type={type || "button"} ref={ref} $neutral={neutral} {...rest}>
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
{hasIcon && icon}
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
+1
View File
@@ -90,6 +90,7 @@ function Collaborators(props: Props) {
isEditing={isEditing}
isObserving={isObserving}
isCurrentUser={currentUserId === collaborator.id}
profileOnClick={false}
onClick={
isObservable
? (ev) => {
+1 -1
View File
@@ -25,7 +25,7 @@ function CollectionDescription({ collection }: Props) {
const [isExpanded, setExpanded] = React.useState(false);
const [isEditing, setEditing] = React.useState(false);
const [isDirty, setDirty] = React.useState(false);
const can = usePolicy(collection);
const can = usePolicy(collection.id);
const handleStartEditing = React.useCallback(() => {
setEditing(true);
+1 -1
View File
@@ -38,10 +38,10 @@ function CommandBar() {
return (
<>
<SearchActions />
<KBarPortal>
<Positioner>
<Animator>
<SearchActions />
<SearchInput
placeholder={`${
rootAction?.placeholder ||
+1 -1
View File
@@ -98,7 +98,7 @@ const Item = styled.div<{ active?: boolean }>`
display: flex;
align-items: center;
justify-content: space-between;
cursor: var(--pointer);
cursor: pointer;
text-overflow: ellipsis;
white-space: nowrap;
+1 -1
View File
@@ -139,7 +139,7 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
color: ${props.theme.white};
background: ${props.dangerous ? props.theme.danger : props.theme.primary};
box-shadow: none;
cursor: var(--pointer);
cursor: pointer;
svg {
fill: ${props.theme.white};
+29 -32
View File
@@ -1,6 +1,7 @@
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Portal } from "react-portal";
import { Menu } from "reakit/Menu";
import styled, { DefaultTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -8,7 +9,6 @@ import { depths } from "@shared/styles";
import Scrollable from "~/components/Scrollable";
import useMenuContext from "~/hooks/useMenuContext";
import useMenuHeight from "~/hooks/useMenuHeight";
import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import useUnmount from "~/hooks/useUnmount";
@@ -59,7 +59,6 @@ const ContextMenu: React.FC<Props> = ({
const { ui } = useStores();
const { t } = useTranslation();
const { setIsMenuOpen } = useMenuContext();
const isMobile = useMobile();
useUnmount(() => {
setIsMenuOpen(false);
@@ -116,7 +115,7 @@ const ContextMenu: React.FC<Props> = ({
// trigger and the bottom of the window
return (
<>
<Menu hideOnClickOutside={!isMobile} preventBodyScroll={false} {...rest}>
<Menu hideOnClickOutside preventBodyScroll={false} {...rest}>
{(props) => {
// kind of hacky, but this is an effective way of telling which way
// the menu will _actually_ be placed when taking into account screen
@@ -126,38 +125,32 @@ const ContextMenu: React.FC<Props> = ({
const rightAnchor = props.placement === "bottom-end";
return (
<>
{isMobile && (
<Backdrop
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
rest.hide?.();
}}
/>
)}
<Position {...props}>
<Background
dir="auto"
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
hiddenScrollbars
style={
maxHeight && topAnchor
? {
maxHeight,
}
: undefined
}
>
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
</>
<Position {...props}>
<Background
dir="auto"
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
hiddenScrollbars
style={
maxHeight && topAnchor
? {
maxHeight,
}
: undefined
}
>
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
);
}}
</Menu>
{(rest.visible || rest.animating) && (
<Portal>
<Backdrop onClick={rest.hide} />
</Portal>
)}
</>
);
};
@@ -173,6 +166,10 @@ export const Backdrop = styled.div`
bottom: 0;
background: ${(props) => props.theme.backdrop};
z-index: ${depths.menu - 1};
${breakpoint("tablet")`
display: none;
`};
`;
export const Position = styled.div`
+103 -120
View File
@@ -3,10 +3,11 @@ import { CSS } from "@dnd-kit/utilities";
import { m } from "framer-motion";
import { observer } from "mobx-react";
import { CloseIcon, DocumentIcon, ClockIcon } from "outline-icons";
import { getLuminance, transparentize } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import styled, { css } from "styled-components";
import Document from "~/models/Document";
import Pin from "~/models/Pin";
import Flex from "~/components/Flex";
@@ -14,8 +15,6 @@ import NudeButton from "~/components/NudeButton";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
import CollectionIcon from "./CollectionIcon";
import EmojiIcon from "./EmojiIcon";
import Squircle from "./Squircle";
import Text from "./Text";
import Tooltip from "./Tooltip";
@@ -33,7 +32,6 @@ type Props = {
function DocumentCard(props: Props) {
const { t } = useTranslation();
const { collections } = useStores();
const theme = useTheme();
const { document, pin, canUpdatePin, isDraggable } = props;
const collection = collections.get(document.collectionId);
const {
@@ -43,24 +41,16 @@ function DocumentCard(props: Props) {
transform,
transition,
isDragging,
} = useSortable({
id: props.document.id,
disabled: !isDraggable || !canUpdatePin,
});
} = useSortable({ id: props.document.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const handleUnpin = React.useCallback(
(ev) => {
ev.preventDefault();
ev.stopPropagation();
pin?.delete();
},
[pin]
);
const handleUnpin = React.useCallback(() => {
pin?.delete();
}, [pin]);
return (
<Reorderable
@@ -68,8 +58,6 @@ function DocumentCard(props: Props) {
style={style}
$isDragging={isDragging}
{...attributes}
{...listeners}
tabIndex={-1}
>
<AnimatePresence
initial={{ opacity: 0, scale: 0.95 }}
@@ -85,6 +73,12 @@ function DocumentCard(props: Props) {
>
<DocumentLink
dir={document.dir}
style={{
background:
collection?.color && getLuminance(collection.color) < 0.6
? collection.color
: undefined,
}}
$isDragging={isDragging}
to={{
pathname: document.url,
@@ -94,117 +88,89 @@ function DocumentCard(props: Props) {
}}
>
<Content justify="space-between" column>
<Fold
width="20"
height="21"
viewBox="0 0 20 21"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M19.5 20.5H6C2.96243 20.5 0.5 18.0376 0.5 15V0.5H0.792893L19.5 19.2071V20.5Z" />
<path d="M19.5 19.5H6C2.96243 19.5 0.5 17.0376 0.5 14V0.5H0.792893L19.5 19.2071V19.5Z" />
</Fold>
{document.emoji ? (
<Squircle color={theme.slateLight}>
<EmojiIcon emoji={document.emoji} size={26} />
</Squircle>
{collection?.icon &&
collection?.icon !== "collection" &&
!pin?.collectionId ? (
<CollectionIcon collection={collection} color="white" />
) : (
<Squircle color={collection?.color}>
{collection?.icon &&
collection?.icon !== "collection" &&
!pin?.collectionId ? (
<CollectionIcon collection={collection} color="white" />
) : (
<DocumentIcon color="white" />
)}
</Squircle>
<DocumentIcon color="white" />
)}
<div>
<Heading dir={document.dir}>
{document.emoji
? document.titleWithDefault.replace(document.emoji, "")
: document.titleWithDefault}
</Heading>
<Heading dir={document.dir}>{document.titleWithDefault}</Heading>
<DocumentMeta size="xsmall">
<Clock color="currentColor" size={18} />
<Time
dateTime={document.updatedAt}
tooltipDelay={500}
addSuffix
shorten
/>
<ClockIcon color="currentColor" size={18} />{" "}
<Time dateTime={document.updatedAt} addSuffix shorten />
</DocumentMeta>
</div>
</Content>
{canUpdatePin && (
<Actions dir={document.dir} gap={4}>
{!isDragging && pin && (
<Tooltip tooltip={t("Unpin")}>
<PinButton onClick={handleUnpin} aria-label={t("Unpin")}>
<CloseIcon color="currentColor" />
</PinButton>
</Tooltip>
)}
</Actions>
)}
</DocumentLink>
{canUpdatePin && (
<Actions dir={document.dir} gap={4}>
{!isDragging && pin && (
<Tooltip tooltip={t("Unpin")}>
<PinButton onClick={handleUnpin} aria-label={t("Unpin")}>
<CloseIcon color="currentColor" />
</PinButton>
</Tooltip>
)}
{isDraggable && (
<DragHandle $isDragging={isDragging} {...listeners}>
:::
</DragHandle>
)}
</Actions>
)}
</AnimatePresence>
</Reorderable>
);
}
const Clock = styled(ClockIcon)`
flex-shrink: 0;
`;
const AnimatePresence = styled(m.div)`
width: 100%;
height: 100%;
`;
const Fold = styled.svg`
fill: ${(props) => props.theme.background};
stroke: ${(props) => props.theme.inputBorder};
background: ${(props) => props.theme.background};
position: absolute;
top: -1px;
right: -2px;
`;
const PinButton = styled(NudeButton)`
color: ${(props) => props.theme.textTertiary};
color: ${(props) => props.theme.white75};
&:hover,
&:active {
color: ${(props) => props.theme.text};
color: ${(props) => props.theme.white};
}
`;
const Actions = styled(Flex)`
position: absolute;
top: 4px;
right: ${(props) => (props.dir === "rtl" ? "auto" : "4px")};
left: ${(props) => (props.dir === "rtl" ? "4px" : "auto")};
top: 12px;
right: ${(props) => (props.dir === "rtl" ? "auto" : "12px")};
left: ${(props) => (props.dir === "rtl" ? "12px" : "auto")};
opacity: 0;
color: ${(props) => props.theme.textTertiary};
transition: opacity 100ms ease-in-out;
// move actions above content
z-index: 2;
`;
const DragHandle = styled.div<{ $isDragging: boolean }>`
cursor: ${(props) => (props.$isDragging ? "grabbing" : "grab")};
padding: 0 4px;
font-weight: bold;
color: ${(props) => props.theme.white75};
line-height: 1.35;
&:hover,
&:active {
color: ${(props) => props.theme.white};
}
`;
const AnimatePresence = m.div;
const Reorderable = styled.div<{ $isDragging: boolean }>`
position: relative;
user-select: none;
touch-action: none;
width: 170px;
height: 180px;
transition: box-shadow 200ms ease;
border-radius: 8px;
// move above other cards when dragging
z-index: ${(props) => (props.$isDragging ? 1 : "inherit")};
pointer-events: ${(props) => (props.$isDragging ? "none" : "inherit")};
transform: scale(${(props) => (props.$isDragging ? "1.025" : "1")});
box-shadow: ${(props) =>
props.$isDragging ? "0 0 20px rgba(0,0,0,0.3);" : "0 0 0 rgba(0,0,0,0)"};
&:hover ${Actions} {
opacity: 1;
@@ -214,34 +180,45 @@ const Reorderable = styled.div<{ $isDragging: boolean }>`
const Content = styled(Flex)`
min-width: 0;
height: 100%;
// move content above ::after
position: relative;
z-index: 1;
`;
const DocumentMeta = styled(Text)`
display: flex;
align-items: center;
gap: 2px;
color: ${(props) => props.theme.textTertiary};
margin: 0 0 0 -2px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
color: ${(props) => transparentize(0.25, props.theme.white)};
margin: 0;
`;
const DocumentLink = styled(Link)<{
$menuOpen?: boolean;
$isDragging?: boolean;
}>`
position: relative;
display: block;
padding: 12px;
width: 100%;
height: 100%;
border-radius: 8px;
cursor: var(--pointer);
background: ${(props) => props.theme.background};
height: 160px;
background: ${(props) => props.theme.slate};
color: ${(props) => props.theme.white};
transition: transform 50ms ease-in-out;
border: 1px solid ${(props) => props.theme.inputBorder};
border-bottom-width: 2px;
border-right-width: 2px;
&:after {
content: "";
display: block;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.1));
border-radius: 8px;
pointer-events: none;
}
${Actions} {
opacity: 0;
@@ -251,22 +228,28 @@ const DocumentLink = styled(Link)<{
&:active,
&:focus,
&:focus-within {
transform: ${(props) => (props.$isDragging ? "scale(1.1)" : "scale(1.08)")}
rotate(-2deg);
box-shadow: ${(props) =>
props.$isDragging
? "0 0 20px rgba(0,0,0,0.2);"
: "0 0 10px rgba(0,0,0,0.1)"};
z-index: 1;
${Fold} {
display: none;
}
${Actions} {
opacity: 1;
}
${(props) =>
!props.$isDragging &&
css`
&:after {
background: rgba(0, 0, 0, 0.1);
}
`}
}
${(props) =>
props.$menuOpen &&
css`
background: ${(props) => props.theme.listItemHoverBackground};
${Actions} {
opacity: 1;
}
`}
`;
const Heading = styled.h3`
@@ -276,7 +259,7 @@ const Heading = styled.h3`
max-height: 66px; // 3*line-height
overflow: hidden;
color: ${(props) => props.theme.text};
color: ${(props) => props.theme.white};
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
`;
+6 -31
View File
@@ -1,10 +1,9 @@
import { m } from "framer-motion";
import { observer } from "mobx-react";
import { CloseIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useRouteMatch } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Event from "~/models/Event";
import Button from "~/components/Button";
@@ -12,7 +11,6 @@ import Empty from "~/components/Empty";
import Flex from "~/components/Flex";
import PaginatedEventList from "~/components/PaginatedEventList";
import Scrollable from "~/components/Scrollable";
import useKeyDown from "~/hooks/useKeyDown";
import useStores from "~/hooks/useStores";
import { documentUrl } from "~/utils/routeHelpers";
@@ -23,7 +21,6 @@ function DocumentHistory() {
const { t } = useTranslation();
const match = useRouteMatch<{ documentSlug: string }>();
const history = useHistory();
const theme = useTheme();
const document = documents.getByUrl(match.params.documentSlug);
const eventsInDocument = document
@@ -47,8 +44,7 @@ function DocumentHistory() {
eventsInDocument.unshift(
new Event(
{
id: "live",
name: "documents.live_editing",
name: "documents.latest_version",
documentId: document.id,
createdAt: document.updatedAt,
actor: document.updatedBy,
@@ -61,25 +57,8 @@ function DocumentHistory() {
return eventsInDocument;
}, [eventsInDocument, events, document]);
useKeyDown("Escape", onCloseHistory);
return (
<Sidebar
initial={{
width: 0,
}}
animate={{
transition: {
type: "spring",
bounce: 0.2,
duration: 0.6,
},
width: theme.sidebarWidth,
}}
exit={{
width: 0,
}}
>
<Sidebar>
{document ? (
<Position column>
<Header>
@@ -100,7 +79,7 @@ function DocumentHistory() {
documentId: document.id,
}}
document={document}
empty={<EmptyHistory>{t("No history yet")}</EmptyHistory>}
empty={<Empty>{t("Oh weird, there's nothing here")}</Empty>}
/>
</Scrollable>
</Position>
@@ -109,10 +88,6 @@ function DocumentHistory() {
);
}
const EmptyHistory = styled(Empty)`
padding: 0 12px;
`;
const Position = styled(Flex)`
position: fixed;
top: 0;
@@ -120,7 +95,7 @@ const Position = styled(Flex)`
width: ${(props) => props.theme.sidebarWidth}px;
`;
const Sidebar = styled(m.div)`
const Sidebar = styled(Flex)`
display: none;
position: relative;
flex-shrink: 0;
@@ -150,7 +125,7 @@ const Title = styled(Flex)`
const Header = styled(Flex)`
align-items: center;
position: relative;
padding: 16px 12px;
padding: 12px;
color: ${(props) => props.theme.text};
flex-shrink: 0;
`;
+4 -5
View File
@@ -49,8 +49,8 @@ function DocumentListItem(
ref: React.RefObject<HTMLAnchorElement>
) {
const { t } = useTranslation();
const user = useCurrentUser();
const team = useCurrentTeam();
const currentUser = useCurrentUser();
const currentTeam = useCurrentTeam();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const {
@@ -70,7 +70,7 @@ function DocumentListItem(
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
const can = usePolicy(team);
const can = usePolicy(currentTeam.id);
const canCollection = usePolicy(document.collectionId);
return (
@@ -96,7 +96,7 @@ function DocumentListItem(
highlight={highlight}
dir={document.dir}
/>
{document.isBadgedNew && document.createdBy.id !== user.id && (
{document.isBadgedNew && document.createdBy.id !== currentUser.id && (
<Badge yellow>{t("New")}</Badge>
)}
{canStar && (
@@ -201,7 +201,6 @@ const DocumentLink = styled(Link)<{
border-radius: 8px;
max-height: 50vh;
width: calc(100vw - 8px);
cursor: var(--pointer);
&:focus-visible {
outline: none;
+19 -27
View File
@@ -12,13 +12,30 @@ import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
const Container = styled(Flex)<{ rtl?: boolean }>`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
color: ${(props) => props.theme.textTertiary};
font-size: 13px;
white-space: nowrap;
overflow: hidden;
min-width: 0;
`;
const Viewed = styled.span`
text-overflow: ellipsis;
overflow: hidden;
`;
const Modified = styled.span<{ highlight?: boolean }>`
font-weight: ${(props) => (props.highlight ? "600" : "400")};
`;
type Props = {
showCollection?: boolean;
showPublished?: boolean;
showLastViewed?: boolean;
showParentDocuments?: boolean;
document: Document;
replace?: boolean;
to?: LocationDescriptor;
};
@@ -29,7 +46,6 @@ const DocumentMeta: React.FC<Props> = ({
showParentDocuments,
document,
children,
replace,
to,
...rest
}) => {
@@ -147,13 +163,7 @@ const DocumentMeta: React.FC<Props> = ({
return (
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
{to ? (
<Link to={to} replace={replace}>
{content}
</Link>
) : (
content
)}
{to ? <Link to={to}>{content}</Link> : content}
{showCollection && collection && (
<span>
&nbsp;{t("in")}&nbsp;
@@ -182,22 +192,4 @@ const DocumentMeta: React.FC<Props> = ({
);
};
const Container = styled(Flex)<{ rtl?: boolean }>`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
color: ${(props) => props.theme.textTertiary};
font-size: 13px;
white-space: nowrap;
overflow: hidden;
min-width: 0;
`;
const Viewed = styled.span`
text-overflow: ellipsis;
overflow: hidden;
`;
const Modified = styled.span<{ highlight?: boolean }>`
font-weight: ${(props) => (props.highlight ? "600" : "400")};
`;
export default observer(DocumentMeta);
+9 -1
View File
@@ -24,6 +24,14 @@ function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
const totalViewers = documentViews.length;
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
React.useEffect(() => {
if (!document.isDeleted) {
views.fetchPage({
documentId: document.id,
});
}
}, [views, document.id, document.isDeleted]);
const popover = usePopoverState({
gutter: 8,
placement: "bottom",
@@ -31,7 +39,7 @@ function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
});
return (
<Meta document={document} to={to} replace {...rest}>
<Meta document={document} to={to} {...rest}>
{totalViewers && !isDraft ? (
<PopoverDisclosure {...popover}>
{(props) => (
+6 -11
View File
@@ -1,12 +1,12 @@
import { formatDistanceToNow } from "date-fns";
import { deburr, sortBy } from "lodash";
import { observer } from "mobx-react";
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
import { TextSelection } from "prosemirror-state";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import mergeRefs from "react-merge-refs";
import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import embeds from "@shared/editor/embeds";
import { Heading } from "@shared/editor/lib/getHeadings";
import { getDataTransferFiles } from "@shared/utils/files";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
@@ -18,21 +18,19 @@ import ErrorBoundary from "~/components/ErrorBoundary";
import HoverPreview from "~/components/HoverPreview";
import type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
import useDictionary from "~/hooks/useDictionary";
import useEmbeds from "~/hooks/useEmbeds";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { NotFoundError } from "~/utils/errors";
import { uploadFile } from "~/utils/files";
import history from "~/utils/history";
import { isModKey } from "~/utils/keyboard";
import { sharedDocumentPath } from "~/utils/routeHelpers";
import { isHash } from "~/utils/urls";
import DocumentBreadcrumb from "./DocumentBreadcrumb";
const LazyLoadedEditor = React.lazy(
() =>
import(
/* webpackChunkName: "preload-shared-editor" */
/* webpackChunkName: "shared-editor" */
"~/editor"
)
);
@@ -60,7 +58,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const { documents } = useStores();
const { showToast } = useToasts();
const dictionary = useDictionary();
const embeds = useEmbeds(!shareId);
const [
activeLinkEvent,
setActiveLinkEvent,
@@ -160,10 +157,8 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
}
}
// If we're navigating to an internal document link then prepend the
// share route to the URL so that the document is loaded in context
if (shareId && navigateTo.includes("/doc/")) {
navigateTo = sharedDocumentPath(shareId, navigateTo);
if (shareId) {
navigateTo = `/share/${shareId}${navigateTo}`;
}
history.push(navigateTo);
@@ -315,4 +310,4 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
);
}
export default observer(React.forwardRef(Editor));
export default React.forwardRef(Editor);
+23 -38
View File
@@ -1,13 +1,11 @@
import { LocationDescriptor } from "history";
import { observer } from "mobx-react";
import {
TrashIcon,
ArchiveIcon,
EditIcon,
PublishIcon,
MoveIcon,
CheckboxIcon,
UnpublishIcon,
LightningIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -22,7 +20,7 @@ import CompositeItem, {
} from "~/components/List/CompositeItem";
import Item, { Actions } from "~/components/List/Item";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
import usePolicy from "~/hooks/usePolicy";
import RevisionMenu from "~/menus/RevisionMenu";
import { documentHistoryUrl } from "~/utils/routeHelpers";
@@ -34,45 +32,36 @@ type Props = {
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const { t } = useTranslation();
const { revisions } = useStores();
const location = useLocation();
const can = usePolicy(document.id);
const opts = {
userName: event.actor.name,
};
const isRevision = event.name === "revisions.create";
let meta, icon, to: LocationDescriptor | undefined;
let meta, icon, to;
const ref = React.useRef<HTMLAnchorElement>(null);
// the time component tends to steal focus when clicked
// ...so forward the focus back to the parent item
const handleTimeClick = () => {
const handleTimeClick = React.useCallback(() => {
ref.current?.focus();
};
const prefetchRevision = () => {
if (event.name === "revisions.create" && event.modelId) {
revisions.fetch(event.modelId);
}
};
}, [ref]);
switch (event.name) {
case "revisions.create":
icon = <EditIcon color="currentColor" size={16} />;
meta = t("{{userName}} edited", opts);
to = {
pathname: documentHistoryUrl(document, event.modelId || ""),
state: { retainScrollPosition: true },
};
break;
case "documents.live_editing":
icon = <LightningIcon color="currentColor" size={16} />;
meta = t("Latest");
to = {
pathname: documentHistoryUrl(document),
state: { retainScrollPosition: true },
};
break;
case "documents.latest_version": {
if (latest) {
icon = <CheckboxIcon color="currentColor" size={16} checked />;
meta = t("Latest version");
to = documentHistoryUrl(document);
break;
} else {
icon = <EditIcon color="currentColor" size={16} />;
meta = t("{{userName}} edited", opts);
to = documentHistoryUrl(document, event.modelId || "");
break;
}
}
case "documents.archive":
icon = <ArchiveIcon color="currentColor" size={16} />;
@@ -115,10 +104,7 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
return null;
}
const isActive =
typeof to === "string"
? location.pathname === to
: location.pathname === to?.pathname;
const isActive = location.pathname === to;
if (document.isDeleted) {
to = undefined;
@@ -150,11 +136,10 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
</Subtitle>
}
actions={
isRevision && isActive && event.modelId ? (
isRevision && isActive && event.modelId && can.update ? (
<RevisionMenu document={document} revisionId={event.modelId} />
) : undefined
}
onMouseEnter={prefetchRevision}
ref={ref}
{...rest}
/>
@@ -181,7 +166,7 @@ const Subtitle = styled.span`
const ItemStyle = css`
border: 0;
position: relative;
margin: 8px 0;
margin: 8px;
padding: 8px;
border-radius: 8px;
@@ -232,4 +217,4 @@ const CompositeListItem = styled(CompositeItem)`
${ItemStyle}
`;
export default observer(EventListItem);
export default EventListItem;
+4 -8
View File
@@ -9,7 +9,7 @@ type Props = {
users: User[];
size?: number;
overflow?: number;
limit?: number;
onClick?: React.MouseEventHandler<HTMLDivElement>;
renderAvatar?: (user: User) => React.ReactNode;
};
@@ -17,7 +17,6 @@ function Facepile({
users,
overflow = 0,
size = 32,
limit = 8,
renderAvatar = DefaultAvatar,
...rest
}: Props) {
@@ -25,13 +24,10 @@ function Facepile({
<Avatars {...rest}>
{overflow > 0 && (
<More size={size}>
<span>
{users.length ? "+" : ""}
{overflow}
</span>
<span>+{overflow}</span>
</More>
)}
{users.slice(0, limit).map((user) => (
{users.map((user) => (
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
))}
</Avatars>
@@ -69,7 +65,7 @@ const More = styled.div<{ size: number }>`
const Avatars = styled(Flex)`
align-items: center;
flex-direction: row-reverse;
cursor: var(--pointer);
cursor: pointer;
`;
export default observer(Facepile);
+5 -8
View File
@@ -13,7 +13,6 @@ import Flex from "~/components/Flex";
import ListItem from "~/components/List/Item";
import Modal from "~/components/Modal";
import withStores from "~/components/withStores";
import NudeButton from "./NudeButton";
type Props = RootStore & {
group: Group;
@@ -64,13 +63,11 @@ class GroupListItem extends React.Component<Props> {
actions={
<Flex align="center" gap={8}>
{showFacepile && (
<NudeButton
width="auto"
height="auto"
<Facepile
onClick={this.handleMembersModalOpen}
>
<Facepile users={users} overflow={overflow} />
</NudeButton>
users={users}
overflow={overflow}
/>
)}
{renderActions({
openMembersModal: this.handleMembersModalOpen,
@@ -102,7 +99,7 @@ const Image = styled(Flex)`
const Title = styled.span`
&:hover {
text-decoration: underline;
cursor: var(--pointer);
cursor: pointer;
}
`;
+6 -6
View File
@@ -15,19 +15,19 @@ import useStores from "~/hooks/useStores";
import { supportsPassiveListener } from "~/utils/browser";
type Props = {
left?: React.ReactNode;
breadcrumb?: React.ReactNode;
title: React.ReactNode;
actions?: React.ReactNode;
hasSidebar?: boolean;
};
function Header({ left, title, actions, hasSidebar }: Props) {
function Header({ breadcrumb, title, actions, hasSidebar }: Props) {
const { ui } = useStores();
const isMobile = useMobile();
const hasMobileSidebar = hasSidebar && isMobile;
const passThrough = !actions && !left && !title;
const passThrough = !actions && !breadcrumb && !title;
const [isScrolled, setScrolled] = React.useState(false);
const handleScroll = React.useMemo(
@@ -51,7 +51,7 @@ function Header({ left, title, actions, hasSidebar }: Props) {
return (
<Wrapper align="center" shrink={false} $passThrough={passThrough}>
{left || hasMobileSidebar ? (
{breadcrumb || hasMobileSidebar ? (
<Breadcrumbs>
{hasMobileSidebar && (
<MobileMenuButton
@@ -61,7 +61,7 @@ function Header({ left, title, actions, hasSidebar }: Props) {
neutral
/>
)}
{left}
{breadcrumb}
</Breadcrumbs>
) : null}
@@ -143,7 +143,7 @@ const Title = styled("div")`
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
cursor: var(--pointer);
cursor: pointer;
min-width: 0;
${breakpoint("tablet")`
+1 -1
View File
@@ -50,7 +50,7 @@ function HoverPreviewDocument({ url, children }: Props) {
}
const Content = styled(Link)`
cursor: var(--pointer);
cursor: pointer;
`;
const Heading = styled.h2`
+2 -4
View File
@@ -58,13 +58,11 @@ const Wrapper = styled.div<{
flex?: boolean;
short?: boolean;
minHeight?: number;
minWidth?: number;
maxHeight?: number;
}>`
flex: ${(props) => (props.flex ? "1" : "0")};
width: ${(props) => (props.short ? "49%" : "auto")};
max-width: ${(props) => (props.short ? "350px" : "100%")};
min-width: ${({ minWidth }) => (minWidth ? `${minWidth}px` : "initial")};
min-height: ${({ minHeight }) => (minHeight ? `${minHeight}px` : "0")};
max-height: ${({ maxHeight }) => (maxHeight ? `${maxHeight}px` : "initial")};
`;
@@ -175,7 +173,7 @@ class Input extends React.Component<Props> {
{type === "textarea" ? (
<RealTextarea
ref={this.props.innerRef}
onBlur={this.handleBlur}
onBlur={this.props.onBlur}
onFocus={this.handleFocus}
hasIcon={!!icon}
{...rest}
@@ -183,7 +181,7 @@ class Input extends React.Component<Props> {
) : (
<RealInput
ref={this.props.innerRef}
onBlur={this.handleBlur}
onBlur={this.props.onBlur}
onFocus={this.handleFocus}
hasIcon={!!icon}
type={type}
+5 -6
View File
@@ -35,11 +35,14 @@ function InputSearchPage({
const history = useHistory();
const { t } = useTranslation();
const [isFocused, setFocused, setUnfocused] = useBoolean(false);
const focus = React.useCallback(() => {
inputRef.current?.focus();
}, []);
useKeyDown("f", (ev: KeyboardEvent) => {
if (isModKey(ev) && document.activeElement !== inputRef.current) {
if (isModKey(ev)) {
ev.preventDefault();
inputRef.current?.focus();
focus();
}
});
@@ -54,10 +57,6 @@ function InputSearchPage({
})
);
}
if (ev.key === "Escape") {
ev.preventDefault();
inputRef.current?.blur();
}
if (onKeyDown) {
onKeyDown(ev);
+2 -13
View File
@@ -13,13 +13,7 @@ import styled, { css } from "styled-components";
import Button, { Inner } from "~/components/Button";
import Text from "~/components/Text";
import useMenuHeight from "~/hooks/useMenuHeight";
import { fadeAndScaleIn } from "~/styles/animations";
import {
Position,
Background as ContextMenuBackground,
Backdrop,
Placement,
} from "./ContextMenu";
import { Position, Background, Backdrop, Placement } from "./ContextMenu";
import { MenuAnchorCSS } from "./ContextMenu/MenuItem";
import { LabelText } from "./Input";
@@ -176,7 +170,6 @@ const InputSelect = (props: Props) => {
ref={contentRef}
topAnchor={topAnchor}
rightAnchor={rightAnchor}
hiddenScrollbars
style={
maxHeight && topAnchor
? {
@@ -223,10 +216,6 @@ const InputSelect = (props: Props) => {
);
};
const Background = styled(ContextMenuBackground)`
animation: ${fadeAndScaleIn} 200ms ease;
`;
const Placeholder = styled.span`
color: ${(props) => props.theme.placeholder};
`;
@@ -288,7 +277,7 @@ const Positioner = styled(Position)`
color: ${(props) => props.theme.white};
background: ${(props) => props.theme.primary};
box-shadow: none;
cursor: var(--pointer);
cursor: pointer;
svg {
fill: ${(props) => props.theme.white};
+2 -3
View File
@@ -1,7 +1,6 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { $Diff } from "utility-types";
import { CollectionPermission } from "@shared/types";
import InputSelect, { Props, Option } from "./InputSelect";
export default function InputSelectPermission(
@@ -32,11 +31,11 @@ export default function InputSelectPermission(
options={[
{
label: t("View and edit"),
value: CollectionPermission.ReadWrite,
value: "read_write",
},
{
label: t("View only"),
value: CollectionPermission.Read,
value: "read",
},
{
label: t("No access"),
+3 -8
View File
@@ -15,15 +15,10 @@ import { isModKey } from "~/utils/keyboard";
type Props = {
title?: string;
sidebar?: React.ReactNode;
sidebarRight?: React.ReactNode;
rightRail?: React.ReactNode;
};
const Layout: React.FC<Props> = ({
title,
children,
sidebar,
sidebarRight,
}) => {
const Layout: React.FC<Props> = ({ title, children, sidebar, rightRail }) => {
const { ui } = useStores();
const sidebarCollapsed = !sidebar || ui.isEditing || ui.sidebarCollapsed;
@@ -65,7 +60,7 @@ const Layout: React.FC<Props> = ({
{children}
</Content>
{sidebarRight}
{rightRail}
</Container>
</Container>
);
+4 -5
View File
@@ -1,12 +1,11 @@
import { LocationDescriptor } from "history";
import * as React from "react";
import styled, { useTheme } from "styled-components";
import Flex from "~/components/Flex";
import NavLink from "~/components/NavLink";
export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
export type Props = {
image?: React.ReactNode;
to?: LocationDescriptor;
to?: string;
exact?: boolean;
title: React.ReactNode;
subtitle?: React.ReactNode;
@@ -73,7 +72,7 @@ const ListItem = (
const Wrapper = styled.a<{
$small?: boolean;
$border?: boolean;
to?: LocationDescriptor;
to?: string;
}>`
display: flex;
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
@@ -87,7 +86,7 @@ const Wrapper = styled.a<{
border-bottom: 0;
}
cursor: ${({ to }) => (to ? "var(--pointer)" : "default")};
cursor: ${({ to }) => (to ? "pointer" : "default")};
`;
const Image = styled(Flex)`
+2 -2
View File
@@ -14,7 +14,7 @@ type Props = {
body?: PlaceholderTextProps;
};
const Placeholder = ({ count, className, header, body }: Props) => {
const ListPlaceHolder = ({ count, className, header, body }: Props) => {
return (
<Fade>
{times(count || 2, (index) => (
@@ -31,4 +31,4 @@ const Item = styled(Flex)`
padding: 10px 0;
`;
export default Placeholder;
export default ListPlaceHolder;
+2 -3
View File
@@ -1,4 +1,3 @@
import { LocationDescriptor } from "history";
import * as React from "react";
import { match, NavLink, Route } from "react-router-dom";
@@ -13,7 +12,7 @@ type Props = React.ComponentProps<typeof NavLink> & {
) => React.ReactNode;
exact?: boolean;
activeStyle?: React.CSSProperties;
to: LocationDescriptor;
to: string;
};
function NavLinkWithChildrenFunc(
@@ -21,7 +20,7 @@ function NavLinkWithChildrenFunc(
ref?: React.Ref<HTMLAnchorElement>
) {
return (
<Route path={typeof to === "string" ? to : to?.pathname} exact={exact}>
<Route path={to} exact={exact}>
{({ match, location }) => (
<NavLink {...rest} to={to} exact={exact} ref={ref}>
{children
+7 -13
View File
@@ -4,32 +4,26 @@ import ActionButton, {
} from "~/components/ActionButton";
type Props = ActionButtonProps & {
width?: number | string;
height?: number | string;
width?: number;
height?: number;
size?: number;
type?: "button" | "submit" | "reset";
};
const NudeButton = styled(ActionButton).attrs((props: Props) => ({
const StyledNudeButton = styled(ActionButton).attrs((props: Props) => ({
type: "type" in props ? props.type : "button",
}))<Props>`
width: ${(props) =>
typeof props.width === "string"
? props.width
: `${props.width || props.size || 24}px`};
height: ${(props) =>
typeof props.height === "string"
? props.height
: `${props.height || props.size || 24}px`};
width: ${(props) => props.width || props.size || 24}px;
height: ${(props) => props.height || props.size || 24}px;
background: none;
border-radius: 4px;
display: inline-block;
line-height: 0;
border: 0;
padding: 0;
cursor: var(--pointer);
cursor: pointer;
user-select: none;
color: inherit;
`;
export default NudeButton;
export default StyledNudeButton;
+13 -16
View File
@@ -13,7 +13,6 @@ type Props = {
heading?: React.ReactNode;
empty?: React.ReactNode;
};
const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
empty,
heading,
@@ -24,34 +23,32 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
...rest
}: Props) {
return (
<StyledPaginatedList
<PaginatedList
items={events}
empty={empty}
heading={heading}
fetch={fetch}
options={options}
renderItem={(item: Event, index, compositeProps) => (
<EventListItem
key={item.id}
event={item}
document={document}
latest={index === 0}
{...compositeProps}
/>
)}
renderItem={(item: Event, index, compositeProps) => {
return (
<EventListItem
key={item.id}
event={item}
document={document}
latest={index === 0}
{...compositeProps}
/>
);
}}
renderHeading={(name) => <Heading>{name}</Heading>}
{...rest}
/>
);
});
const StyledPaginatedList = styled(PaginatedList)`
padding: 0 8px;
`;
const Heading = styled("h3")`
font-size: 14px;
padding: 0 4px;
padding: 0 12px;
`;
export default PaginatedEventList;
+1 -5
View File
@@ -29,7 +29,6 @@ type Props<T> = WithTranslation &
empty?: React.ReactNode;
loading?: React.ReactElement;
items?: T[];
className?: string;
renderItem: (
item: T,
index: number,
@@ -165,9 +164,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
return (
this.props.loading || (
<DelayedMount>
<div className={this.props.className}>
<PlaceholderList count={5} />
</div>
<PlaceholderList count={5} />
</DelayedMount>
)
);
@@ -187,7 +184,6 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
<ArrowKeyNavigation
aria-label={this.props["aria-label"]}
onEscape={onEscape}
className={this.props.className}
>
{(composite: CompositeStateReturn) => {
let previousHeading = "";
+6 -11
View File
@@ -42,12 +42,7 @@ function PinnedDocuments({ limit, pins, canUpdate, ...rest }: Props) {
}, [pins]);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
delay: 100,
tolerance: 5,
},
}),
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
@@ -59,8 +54,8 @@ function PinnedDocuments({ limit, pins, canUpdate, ...rest }: Props) {
if (over && active.id !== over.id) {
setItems((items) => {
const activePos = items.indexOf(active.id as string);
const overPos = items.indexOf(over.id as string);
const activePos = items.indexOf(active.id);
const overPos = items.indexOf(over.id);
const overIndex = pins[overPos]?.index || null;
const nextIndex = pins[overPos + 1]?.index || null;
@@ -126,7 +121,7 @@ function PinnedDocuments({ limit, pins, canUpdate, ...rest }: Props) {
const List = styled.div`
display: grid;
column-gap: 8px;
row-gap: 12px;
row-gap: 8px;
grid-template-columns: repeat(2, minmax(0, 1fr));
padding: 0;
list-style: none;
@@ -136,11 +131,11 @@ const List = styled.div`
display: none;
}
${breakpoint("mobileLarge")`
${breakpoint("tablet")`
grid-template-columns: repeat(3, minmax(0, 1fr));
`};
${breakpoint("tablet")`
${breakpoint("desktop")`
grid-template-columns: repeat(4, minmax(0, 1fr));
`};
`;
+1 -1
View File
@@ -46,7 +46,7 @@ const Contents = styled.div<{ $shrink?: boolean; $width?: number }>`
border-radius: 6px;
padding: ${(props) => (props.$shrink ? "6px 0" : "12px 24px")};
max-height: 50vh;
overflow-y: auto;
overflow-y: scroll;
box-shadow: ${(props) => props.theme.menuShadow};
width: ${(props) => props.$width}px;
+3 -3
View File
@@ -8,7 +8,7 @@ type Props = {
icon?: React.ReactNode;
title?: React.ReactNode;
textTitle?: string;
left?: React.ReactNode;
breadcrumb?: React.ReactNode;
actions?: React.ReactNode;
centered?: boolean;
};
@@ -18,7 +18,7 @@ const Scene: React.FC<Props> = ({
icon,
textTitle,
actions,
left,
breadcrumb,
children,
centered,
}) => {
@@ -37,7 +37,7 @@ const Scene: React.FC<Props> = ({
)
}
actions={actions}
left={left}
breadcrumb={breadcrumb}
/>
{centered !== false ? (
<CenteredContent withStickyHeader>{children}</CenteredContent>
+3 -10
View File
@@ -8,14 +8,11 @@ type Props = {
};
export default function ScrollToTop({ children }: Props) {
const location = useLocation<{ retainScrollPosition?: boolean }>();
const location = useLocation();
const previousLocationPathname = usePrevious(location.pathname);
React.useEffect(() => {
if (
location.pathname === previousLocationPathname ||
location.state?.retainScrollPosition
) {
if (location.pathname === previousLocationPathname) {
return;
}
// exception for when entering or exiting document edit, scroll position should not reset
@@ -26,11 +23,7 @@ export default function ScrollToTop({ children }: Props) {
return;
}
window.scrollTo(0, 0);
}, [
location.pathname,
previousLocationPathname,
location.state?.retainScrollPosition,
]);
}, [location.pathname, previousLocationPathname]);
return children;
}
+1 -1
View File
@@ -92,7 +92,7 @@ const Wrapper = styled.div<{
return "none";
}};
transition: box-shadow 100ms ease-in-out;
transition: all 100ms ease-in-out;
${(props) =>
props.$hiddenScrollbars &&
+1 -3
View File
@@ -10,9 +10,7 @@ export default function SearchActions() {
const { searches } = useStores();
React.useEffect(() => {
if (!searches.isLoaded) {
searches.fetchPage({});
}
searches.fetchPage({});
}, [searches]);
const { searchQuery } = useKBar((state) => ({
+1 -5
View File
@@ -7,7 +7,6 @@ import breakpoint from "styled-components-breakpoint";
import Document from "~/models/Document";
import Highlight, { Mark } from "~/components/Highlight";
import { hover } from "~/styles";
import { sharedDocumentPath } from "~/utils/routeHelpers";
type Props = {
document: Document;
@@ -39,9 +38,7 @@ function DocumentListItem(
ref={ref}
dir={document.dir}
to={{
pathname: shareId
? sharedDocumentPath(shareId, document.url)
: document.url,
pathname: shareId ? `/share/${shareId}${document.url}` : document.url,
state: {
title: document.titleWithDefault,
},
@@ -83,7 +80,6 @@ const DocumentLink = styled(Link)<{
align-items: center;
padding: 6px 12px;
max-height: 50vh;
cursor: var(--pointer);
&:not(:last-child) {
margin-bottom: 4px;
+1 -1
View File
@@ -36,7 +36,7 @@ function AppSidebar() {
const { documents } = useStores();
const team = useCurrentTeam();
const user = useCurrentUser();
const can = usePolicy(team);
const can = usePolicy(team.id);
React.useEffect(() => {
if (!user.isViewer) {
@@ -44,7 +44,7 @@ const CollectionLink: React.FC<Props> = ({
const { dialogs, documents, collections } = useStores();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [isEditing, setIsEditing] = React.useState(false);
const canUpdate = usePolicy(collection).update;
const canUpdate = usePolicy(collection.id).update;
const { t } = useTranslation();
const history = useHistory();
const inStarredSection = useStarredContext();
@@ -25,7 +25,7 @@ function CollectionLinkChildren({
expanded,
prefetchDocument,
}: Props) {
const can = usePolicy(collection);
const can = usePolicy(collection.id);
const { showToast } = useToasts();
const manualSort = collection.sort.field === "index";
const { documents } = useStores();
@@ -43,10 +43,6 @@ function Collections() {
}),
});
React.useEffect(() => {
collections.fetchPage({ limit: 100 });
}, [collections]);
return (
<Flex column>
<Header id="collections" title={t("Collections")}>
@@ -54,6 +50,8 @@ function Collections() {
<PaginatedList
aria-label={t("Collections")}
items={collections.orderedData}
fetch={collections.fetchPage}
options={{ limit: 100 }}
loading={<PlaceholderCollections />}
heading={
isDraggingAnyCollection ? (
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import NudeButton from "~/components/NudeButton";
type Props = React.ComponentProps<typeof Button> & {
type Props = {
onClick?: React.MouseEventHandler<HTMLButtonElement>;
expanded: boolean;
root?: boolean;
@@ -291,21 +291,6 @@ function InnerDocumentLink(
const isExpanded = expanded && !isDragging;
const hasChildren = nodeChildren.length > 0;
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent) => {
if (!hasChildren) {
return;
}
if (ev.key === "ArrowRight" && !expanded) {
setExpanded(true);
}
if (ev.key === "ArrowLeft" && expanded) {
setExpanded(false);
}
},
[hasChildren, expanded]
);
return (
<>
<Relative onDragLeave={resetHoverExpanding}>
@@ -314,7 +299,6 @@ function InnerDocumentLink(
ref={drag}
$isDragging={isDragging}
$isMoving={isMoving}
onKeyDown={handleKeyDown}
>
<div ref={dropToReparent}>
<DropToImport documentId={node.id} activeClassName="activeDropZone">
@@ -39,7 +39,7 @@ function DraggableCollectionLink({
const [expanded, setExpanded] = React.useState(
collection.id === ui.activeCollectionId && !locationStateStarred
);
const can = usePolicy(collection);
const can = usePolicy(collection.id);
const belowCollectionIndex = belowCollection ? belowCollection.index : null;
// Drop to reorder collection
+2 -2
View File
@@ -14,7 +14,7 @@ type Props = {
*/
export const Header: React.FC<Props> = ({ id, title, children }) => {
const [firstRender, setFirstRender] = React.useState(true);
const [expanded, setExpanded] = usePersistedState<boolean>(
const [expanded, setExpanded] = usePersistedState(
`sidebar-header-${id}`,
true
);
@@ -80,7 +80,7 @@ const Button = styled.button`
&:not(:disabled):hover,
&:not(:disabled):active {
color: ${(props) => props.theme.textSecondary};
cursor: var(--pointer);
cursor: pointer;
}
`;
@@ -5,7 +5,6 @@ import Collection from "~/models/Collection";
import Document from "~/models/Document";
import useStores from "~/hooks/useStores";
import { NavigationNode } from "~/types";
import { sharedDocumentPath } from "~/utils/routeHelpers";
import Disclosure from "./Disclosure";
import SidebarLink from "./SidebarLink";
@@ -93,7 +92,7 @@ function DocumentLink(
<>
<SidebarLink
to={{
pathname: sharedDocumentPath(shareId, node.url),
pathname: `/share/${shareId}${node.url}`,
state: {
title: node.title,
},
@@ -68,7 +68,7 @@ const Wrapper = styled(Flex)<{ minHeight: number }>`
text-align: left;
overflow: hidden;
user-select: none;
cursor: var(--pointer);
cursor: pointer;
&:active,
&:hover,
@@ -104,7 +104,6 @@ function SidebarLink(
expanded={expanded}
onClick={onDisclosureClick}
root={depth === 0}
tabIndex={-1}
/>
)}
{icon && <IconWrapper>{icon}</IconWrapper>}
@@ -179,7 +178,7 @@ const Link = styled(NavLink)<{
color: ${(props) =>
props.$isActiveDrop ? props.theme.white : props.theme.sidebarText};
font-size: 16px;
cursor: var(--pointer);
cursor: pointer;
overflow: hidden;
${(props) =>
+1 -1
View File
@@ -47,7 +47,7 @@ export const ToggleButton = styled.button<{ $direction?: "left" | "right" }>`
${breakpoint("tablet")`
pointer-events: all;
cursor: var(--pointer);
cursor: pointer;
`}
@media (hover: none) {
+391
View File
@@ -0,0 +1,391 @@
import invariant from "invariant";
import { find } from "lodash";
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { io, Socket } from "socket.io-client";
import RootStore from "~/stores/RootStore";
import withStores from "~/components/withStores";
import { AuthorizationError, NotFoundError } from "~/utils/errors";
import { getVisibilityListener, getPageVisible } from "~/utils/pageVisibility";
type SocketWithAuthentication = Socket & {
authenticated?: boolean;
};
export const SocketContext = React.createContext<SocketWithAuthentication | null>(
null
);
type Props = RootStore;
@observer
class SocketProvider extends React.Component<Props> {
@observable
socket: SocketWithAuthentication | null;
componentDidMount() {
this.createConnection();
document.addEventListener(getVisibilityListener(), this.checkConnection);
}
componentWillUnmount() {
if (this.socket) {
this.socket.authenticated = false;
this.socket.disconnect();
}
document.removeEventListener(getVisibilityListener(), this.checkConnection);
}
checkConnection = () => {
if (this.socket?.disconnected && getPageVisible()) {
// null-ifying this reference is important, do not remove. Without it
// references to old sockets are potentially held in context
this.socket.close();
this.socket = null;
this.createConnection();
}
};
createConnection = () => {
this.socket = io(window.location.origin, {
path: "/realtime",
transports: ["websocket"],
reconnectionDelay: 1000,
reconnectionDelayMax: 30000,
});
invariant(this.socket, "Socket should be defined");
this.socket.authenticated = false;
const {
auth,
toasts,
documents,
collections,
groups,
pins,
stars,
memberships,
policies,
presence,
views,
fileOperations,
} = this.props;
if (!auth.token) {
return;
}
this.socket.on("connect", () => {
// immediately send current users token to the websocket backend where it
// is verified, if all goes well an 'authenticated' message will be
// received in response
this.socket?.emit("authentication", {
token: auth.token,
});
});
this.socket.on("disconnect", () => {
// when the socket is disconnected we need to clear all presence state as
// it's no longer reliable.
presence.clear();
});
// on reconnection, reset the transports option, as the Websocket
// connection may have failed (caused by proxy, firewall, browser, ...)
this.socket.io.on("reconnect_attempt", () => {
if (this.socket) {
this.socket.io.opts.transports = auth?.team?.domain
? ["websocket"]
: ["websocket", "polling"];
}
});
this.socket.on("authenticated", () => {
if (this.socket) {
this.socket.authenticated = true;
}
});
this.socket.on("unauthorized", (err: Error) => {
if (this.socket) {
this.socket.authenticated = false;
}
toasts.showToast(err.message, {
type: "error",
});
throw err;
});
this.socket.on("entities", async (event: any) => {
if (event.documentIds) {
for (const documentDescriptor of event.documentIds) {
const documentId = documentDescriptor.id;
let document = documents.get(documentId) || {};
if (event.event === "documents.delete") {
const document = documents.get(documentId);
if (document) {
document.deletedAt = documentDescriptor.updatedAt;
}
policies.remove(documentId);
continue;
}
// if we already have the latest version (it was us that performed
// the change) then we don't need to update anything either.
// @ts-expect-error ts-migrate(2339) FIXME: Property 'title' does not exist on type '{}'.
const { title, updatedAt } = document;
if (updatedAt === documentDescriptor.updatedAt) {
continue;
}
// otherwise, grab the latest version of the document
try {
document = await documents.fetch(documentId, {
force: true,
});
} catch (err) {
if (
err instanceof AuthorizationError ||
err instanceof NotFoundError
) {
documents.remove(documentId);
return;
}
}
// if the title changed then we need to update the collection also
// @ts-expect-error ts-migrate(2339) FIXME: Property 'title' does not exist on type '{}'.
if (title !== document.title) {
if (!event.collectionIds) {
event.collectionIds = [];
}
const existing = find(event.collectionIds, {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type '{}... Remove this comment to see the full error message
id: document.collectionId,
});
if (!existing) {
event.collectionIds.push({
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type '{}... Remove this comment to see the full error message
id: document.collectionId,
});
}
}
}
}
if (event.collectionIds) {
for (const collectionDescriptor of event.collectionIds) {
const collectionId = collectionDescriptor.id;
const collection = collections.get(collectionId);
if (event.event === "collections.delete") {
if (collection) {
collection.deletedAt = collectionDescriptor.updatedAt;
}
const deletedDocuments = documents.inCollection(collectionId);
deletedDocuments.forEach((doc) => {
doc.deletedAt = collectionDescriptor.updatedAt;
policies.remove(doc.id);
});
documents.removeCollectionDocuments(collectionId);
memberships.removeCollectionMemberships(collectionId);
collections.remove(collectionId);
policies.remove(collectionId);
continue;
}
// if we already have the latest version (it was us that performed
// the change) then we don't need to update anything either.
if (collection?.updatedAt === collectionDescriptor.updatedAt) {
continue;
}
try {
await collections.fetch(collectionId, {
force: true,
});
} catch (err) {
if (
err instanceof AuthorizationError ||
err instanceof NotFoundError
) {
documents.removeCollectionDocuments(collectionId);
memberships.removeCollectionMemberships(collectionId);
collections.remove(collectionId);
policies.remove(collectionId);
return;
}
}
}
}
if (event.groupIds) {
for (const groupDescriptor of event.groupIds) {
const groupId = groupDescriptor.id;
const group = groups.get(groupId) || {};
// if we already have the latest version (it was us that performed
// the change) then we don't need to update anything either.
// @ts-expect-error ts-migrate(2339) FIXME: Property 'updatedAt' does not exist on type '{}'.
const { updatedAt } = group;
if (updatedAt === groupDescriptor.updatedAt) {
continue;
}
try {
await groups.fetch(groupId, {
force: true,
});
} catch (err) {
if (
err instanceof AuthorizationError ||
err instanceof NotFoundError
) {
groups.remove(groupId);
}
}
}
}
if (event.teamIds) {
await auth.fetch();
}
});
this.socket.on("pins.create", (event: any) => {
pins.add(event);
});
this.socket.on("pins.update", (event: any) => {
pins.add(event);
});
this.socket.on("pins.delete", (event: any) => {
pins.remove(event.modelId);
});
this.socket.on("stars.create", (event: any) => {
stars.add(event);
});
this.socket.on("stars.update", (event: any) => {
stars.add(event);
});
this.socket.on("stars.delete", (event: any) => {
stars.remove(event.modelId);
});
this.socket.on("documents.permanent_delete", (event: any) => {
documents.remove(event.documentId);
});
// received when a user is given access to a collection
// if the user is us then we go ahead and load the collection from API.
this.socket.on("collections.add_user", (event: any) => {
if (auth.user && event.userId === auth.user.id) {
collections.fetch(event.collectionId, {
force: true,
});
}
// Document policies might need updating as the permission changes
documents.inCollection(event.collectionId).forEach((document) => {
policies.remove(document.id);
});
});
// received when a user is removed from having access to a collection
// to keep state in sync we must update our UI if the user is us,
// or otherwise just remove any membership state we have for that user.
this.socket.on("collections.remove_user", (event: any) => {
if (auth.user && event.userId === auth.user.id) {
collections.remove(event.collectionId);
memberships.removeCollectionMemberships(event.collectionId);
documents.removeCollectionDocuments(event.collectionId);
} else {
memberships.remove(`${event.userId}-${event.collectionId}`);
}
});
this.socket.on("collections.update_index", (event: any) => {
const collection = collections.get(event.collectionId);
if (collection) {
collection.updateIndex(event.index);
}
});
this.socket.on("fileOperations.create", async (event: any) => {
const user = auth.user;
if (user) {
fileOperations.add({ ...event, user });
}
});
this.socket.on("fileOperations.update", async (event: any) => {
const user = auth.user;
if (user) {
fileOperations.add({ ...event, user });
}
});
// received a message from the API server that we should request
// to join a specific room. Forward that to the ws server.
this.socket.on("join", (event: any) => {
this.socket?.emit("join", event);
});
// received a message from the API server that we should request
// to leave a specific room. Forward that to the ws server.
this.socket.on("leave", (event: any) => {
this.socket?.emit("leave", event);
});
// received whenever we join a document room, the payload includes
// userIds that are present/viewing and those that are editing.
this.socket.on("document.presence", (event: any) => {
presence.init(event.documentId, event.userIds, event.editingIds);
});
// received whenever a new user joins a document room, aka they
// navigate to / start viewing a document
this.socket.on("user.join", (event: any) => {
presence.touch(event.documentId, event.userId, event.isEditing);
views.touch(event.documentId, event.userId);
});
// received whenever a new user leaves a document room, aka they
// navigate away / stop viewing a document
this.socket.on("user.leave", (event: any) => {
presence.leave(event.documentId, event.userId);
views.touch(event.documentId, event.userId);
});
// received when another client in a document room wants to change
// or update it's presence. Currently the only property is whether
// the client is in editing state or not.
this.socket.on("user.presence", (event: any) => {
presence.touch(event.documentId, event.userId, event.isEditing);
});
};
render() {
return (
<SocketContext.Provider value={this.socket}>
{this.props.children}
</SocketContext.Provider>
);
}
}
export default withStores(SocketProvider);
-40
View File
@@ -1,40 +0,0 @@
import * as React from "react";
import styled from "styled-components";
import Flex from "./Flex";
type Props = {
size?: number;
color?: string;
};
const Squircle: React.FC<Props> = ({ color, size = 28, children }) => {
return (
<Wrapper
style={{ width: size, height: size }}
align="center"
justify="center"
>
<svg width={size} height={size} viewBox="0 0 28 28">
<path
fill={color}
d="M0 11.1776C0 1.97285 1.97285 0 11.1776 0H16.8224C26.0272 0 28 1.97285 28 11.1776V16.8224C28 26.0272 26.0272 28 16.8224 28H11.1776C1.97285 28 0 26.0272 0 16.8224V11.1776Z"
/>
</svg>
<Content>{children}</Content>
</Wrapper>
);
};
const Wrapper = styled(Flex)`
position: relative;
`;
const Content = styled.div`
display: flex;
transform: translate(-50%, -50%);
position: absolute;
top: 50%;
left: 50%;
`;
export default Squircle;
+1 -1
View File
@@ -86,7 +86,7 @@ const Input = styled.label<{ width: number; height: number }>`
const Slider = styled.span<{ width: number; height: number }>`
position: absolute;
cursor: var(--pointer);
cursor: pointer;
top: 0;
left: 0;
right: 0;
+3 -11
View File
@@ -2,15 +2,13 @@ import { observer } from "mobx-react";
import * as React from "react";
import { ThemeProvider } from "styled-components";
import { breakpoints } from "@shared/styles";
import GlobalStyles from "@shared/styles/globals";
import { dark, light, lightMobile, darkMobile } from "@shared/styles/theme";
import { UserPreference } from "@shared/types";
import useMediaQuery from "~/hooks/useMediaQuery";
import useStores from "~/hooks/useStores";
import { TooltipStyles } from "./Tooltip";
import GlobalStyles from "~/styles/globals";
const Theme: React.FC = ({ children }) => {
const { auth, ui } = useStores();
const { ui } = useStores();
const resolvedTheme = ui.resolvedTheme === "dark" ? dark : light;
const resolvedMobileTheme =
ui.resolvedTheme === "dark" ? darkMobile : lightMobile;
@@ -29,13 +27,7 @@ const Theme: React.FC = ({ children }) => {
return (
<ThemeProvider theme={theme}>
<>
<TooltipStyles />
<GlobalStyles
useCursorPointer={auth.user?.getPreference(
UserPreference.UseCursorPointer,
true
)}
/>
<GlobalStyles />
{children}
</>
</ThemeProvider>
+12 -138
View File
@@ -1,8 +1,7 @@
import Tippy, { TippyProps } from "@tippyjs/react";
import Tippy, { TippyProps } from "@tippy.js/react";
import { TFunctionResult } from "i18next";
import * as React from "react";
import styled, { createGlobalStyle } from "styled-components";
import { roundArrow } from "tippy.js";
import styled from "styled-components";
export type Props = Omit<TippyProps, "content" | "theme"> & {
tooltip: React.ReactChild | React.ReactChild[] | TFunctionResult;
@@ -13,7 +12,7 @@ function Tooltip({ shortcut, tooltip, delay = 50, ...rest }: Props) {
let content = <>{tooltip}</>;
if (!tooltip) {
return rest.children ?? null;
return rest.children;
}
if (shortcut) {
@@ -25,8 +24,9 @@ function Tooltip({ shortcut, tooltip, delay = 50, ...rest }: Props) {
}
return (
<Tippy
arrow={roundArrow}
<StyledTippy
arrow
arrowType="round"
animation="shift-away"
content={content}
delay={delay}
@@ -52,139 +52,13 @@ const Shortcut = styled.kbd`
border-radius: 3px;
`;
export const TooltipStyles = createGlobalStyle`
.tippy-box[data-animation=fade][data-state=hidden]{
opacity:0
}
[data-tippy-root]{
max-width:calc(100vw - 10px)
}
.tippy-box{
position:relative;
background-color: ${(props) => props.theme.tooltipBackground};
color: ${(props) => props.theme.tooltipText};
border-radius:4px;
font-size:13px;
line-height:1.4;
white-space:normal;
outline:0;
transition-property:transform,visibility,opacity
}
.tippy-box[data-placement^=top]>.tippy-arrow{
bottom:0
}
.tippy-box[data-placement^=top]>.tippy-arrow:before{
bottom:-7px;
left:0;
border-width:8px 8px 0;
border-top-color:initial;
transform-origin:center top
}
.tippy-box[data-placement^=bottom]>.tippy-arrow{
top:0
}
.tippy-box[data-placement^=bottom]>.tippy-arrow:before{
top:-7px;
left:0;
border-width:0 8px 8px;
border-bottom-color:initial;
transform-origin:center bottom
}
.tippy-box[data-placement^=left]>.tippy-arrow{
right:0
}
.tippy-box[data-placement^=left]>.tippy-arrow:before{
border-width:8px 0 8px 8px;
border-left-color:initial;
right:-7px;
transform-origin:center left
}
.tippy-box[data-placement^=right]>.tippy-arrow{
left:0
}
.tippy-box[data-placement^=right]>.tippy-arrow:before{
left:-7px;
border-width:8px 8px 8px 0;
border-right-color:initial;
transform-origin:center right
}
.tippy-box[data-inertia][data-state=visible]{
transition-timing-function:cubic-bezier(.54,1.5,.38,1.11)
}
.tippy-arrow{
width:16px;
height:16px;
color: ${(props) => props.theme.tooltipBackground};
}
.tippy-arrow:before{
content:"";
position:absolute;
border-color:transparent;
border-style:solid
}
.tippy-content{
position:relative;
padding:5px 9px;
z-index:1
}
/* Arrow Styles */
.tippy-box[data-placement^=top]>.tippy-svg-arrow{
bottom:0
}
.tippy-box[data-placement^=top]>.tippy-svg-arrow:after,.tippy-box[data-placement^=top]>.tippy-svg-arrow>svg{
top:16px;
transform:rotate(180deg)
}
.tippy-box[data-placement^=bottom]>.tippy-svg-arrow{
top:0
}
.tippy-box[data-placement^=bottom]>.tippy-svg-arrow>svg{
bottom:16px
}
.tippy-box[data-placement^=left]>.tippy-svg-arrow{
right:0
}
.tippy-box[data-placement^=left]>.tippy-svg-arrow:after,.tippy-box[data-placement^=left]>.tippy-svg-arrow>svg{
transform:rotate(90deg);
top:calc(50% - 3px);
left:11px
}
.tippy-box[data-placement^=right]>.tippy-svg-arrow{
left:0
}
.tippy-box[data-placement^=right]>.tippy-svg-arrow:after,.tippy-box[data-placement^=right]>.tippy-svg-arrow>svg{
transform:rotate(-90deg);
top:calc(50% - 3px);
right:11px
}
.tippy-svg-arrow{
width:16px;
height:16px;
fill: ${(props) => props.theme.tooltipBackground};
text-align:initial
}
.tippy-svg-arrow,.tippy-svg-arrow>svg{
position:absolute
}
const StyledTippy = styled(Tippy)`
font-size: 13px;
background-color: ${(props) => props.theme.tooltipBackground};
color: ${(props) => props.theme.tooltipText};
/* Animation */
.tippy-box[data-animation=shift-away][data-state=hidden]{opacity:0}.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=top]{transform:translateY(10px)}.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=bottom]{transform:translateY(-10px)}.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=left]{transform:translateX(10px)}.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=right]{transform:translateX(-10px)}
.tippy-box[data-animation=shift-away][data-state=hidden]{
opacity:0
}
.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=top]{
transform:translateY(10px)
}
.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=bottom]{
transform:translateY(-10px)
}
.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=left]{
transform:translateX(10px)
}
.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=right]{
transform:translateX(-10px)
svg {
fill: ${(props) => props.theme.tooltipBackground};
}
`;
-448
View File
@@ -1,448 +0,0 @@
import invariant from "invariant";
import { find } from "lodash";
import { action, observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { io, Socket } from "socket.io-client";
import RootStore from "~/stores/RootStore";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import FileOperation from "~/models/FileOperation";
import Group from "~/models/Group";
import Pin from "~/models/Pin";
import Star from "~/models/Star";
import Subscription from "~/models/Subscription";
import Team from "~/models/Team";
import withStores from "~/components/withStores";
import {
PartialWithId,
WebsocketCollectionUpdateIndexEvent,
WebsocketCollectionUserEvent,
WebsocketEntitiesEvent,
WebsocketEntityDeletedEvent,
} from "~/types";
import { AuthorizationError, NotFoundError } from "~/utils/errors";
import { getVisibilityListener, getPageVisible } from "~/utils/pageVisibility";
type SocketWithAuthentication = Socket & {
authenticated?: boolean;
};
export const WebsocketContext = React.createContext<SocketWithAuthentication | null>(
null
);
type Props = RootStore;
@observer
class WebsocketProvider extends React.Component<Props> {
@observable
socket: SocketWithAuthentication | null;
componentDidMount() {
this.createConnection();
document.addEventListener(getVisibilityListener(), this.checkConnection);
}
componentWillUnmount() {
if (this.socket) {
this.socket.authenticated = false;
this.socket.disconnect();
}
document.removeEventListener(getVisibilityListener(), this.checkConnection);
}
checkConnection = () => {
if (this.socket?.disconnected && getPageVisible()) {
// null-ifying this reference is important, do not remove. Without it
// references to old sockets are potentially held in context
this.socket.close();
this.socket = null;
this.createConnection();
}
};
createConnection = () => {
this.socket = io(window.location.origin, {
path: "/realtime",
transports: ["websocket"],
reconnectionDelay: 1000,
reconnectionDelayMax: 30000,
});
invariant(this.socket, "Socket should be defined");
this.socket.authenticated = false;
const {
auth,
toasts,
documents,
collections,
groups,
pins,
stars,
memberships,
policies,
presence,
views,
subscriptions,
fileOperations,
} = this.props;
if (!auth.token) {
return;
}
this.socket.on("connect", () => {
// immediately send current users token to the websocket backend where it
// is verified, if all goes well an 'authenticated' message will be
// received in response
this.socket?.emit("authentication", {
token: auth.token,
});
});
this.socket.on("disconnect", () => {
// when the socket is disconnected we need to clear all presence state as
// it's no longer reliable.
presence.clear();
});
// on reconnection, reset the transports option, as the Websocket
// connection may have failed (caused by proxy, firewall, browser, ...)
this.socket.io.on("reconnect_attempt", () => {
if (this.socket) {
this.socket.io.opts.transports = auth?.team?.domain
? ["websocket"]
: ["websocket", "polling"];
}
});
this.socket.on("authenticated", () => {
if (this.socket) {
this.socket.authenticated = true;
}
});
this.socket.on("unauthorized", (err: Error) => {
if (this.socket) {
this.socket.authenticated = false;
}
toasts.showToast(err.message, {
type: "error",
});
throw err;
});
this.socket.on(
"entities",
action(async (event: WebsocketEntitiesEvent) => {
if (event.documentIds) {
for (const documentDescriptor of event.documentIds) {
const documentId = documentDescriptor.id;
let document = documents.get(documentId);
const previousTitle = document?.title;
// if we already have the latest version (it was us that performed
// the change) then we don't need to update anything either.
if (document?.updatedAt === documentDescriptor.updatedAt) {
continue;
}
// otherwise, grab the latest version of the document
try {
document = await documents.fetch(documentId, {
force: true,
});
} catch (err) {
if (
err instanceof AuthorizationError ||
err instanceof NotFoundError
) {
documents.remove(documentId);
return;
}
}
// if the title changed then we need to update the collection also
if (document && previousTitle !== document.title) {
if (!event.collectionIds) {
event.collectionIds = [];
}
const existing = find(event.collectionIds, {
id: document.collectionId,
});
if (!existing) {
event.collectionIds.push({
id: document.collectionId,
});
}
}
}
}
if (event.collectionIds) {
for (const collectionDescriptor of event.collectionIds) {
const collectionId = collectionDescriptor.id;
const collection = collections.get(collectionId);
// if we already have the latest version (it was us that performed
// the change) then we don't need to update anything either.
if (collection?.updatedAt === collectionDescriptor.updatedAt) {
continue;
}
try {
await collections.fetch(collectionId, {
force: true,
});
} catch (err) {
if (
err instanceof AuthorizationError ||
err instanceof NotFoundError
) {
documents.removeCollectionDocuments(collectionId);
memberships.removeCollectionMemberships(collectionId);
collections.remove(collectionId);
policies.remove(collectionId);
return;
}
}
}
}
})
);
this.socket.on(
"documents.update",
action(
(event: PartialWithId<Document> & { title: string; url: string }) => {
documents.add(event);
if (event.collectionId) {
const collection = collections.get(event.collectionId);
collection?.updateDocument(event);
}
}
)
);
this.socket.on(
"documents.archive",
action((event: PartialWithId<Document>) => {
documents.add(event);
policies.remove(event.id);
if (event.collectionId) {
const collection = collections.get(event.collectionId);
collection?.removeDocument(event.id);
}
})
);
this.socket.on(
"documents.delete",
action((event: PartialWithId<Document>) => {
documents.add(event);
policies.remove(event.id);
if (event.collectionId) {
const collection = collections.get(event.collectionId);
collection?.removeDocument(event.id);
}
})
);
this.socket.on(
"documents.permanent_delete",
(event: WebsocketEntityDeletedEvent) => {
documents.remove(event.modelId);
}
);
this.socket.on("groups.create", (event: PartialWithId<Group>) => {
groups.add(event);
});
this.socket.on("groups.update", (event: PartialWithId<Group>) => {
groups.add(event);
});
this.socket.on("groups.delete", (event: WebsocketEntityDeletedEvent) => {
groups.remove(event.modelId);
});
this.socket.on("collections.create", (event: PartialWithId<Collection>) => {
collections.add(event);
});
this.socket.on(
"collections.delete",
action((event: WebsocketEntityDeletedEvent) => {
const collectionId = event.modelId;
const deletedAt = new Date().toISOString();
const deletedDocuments = documents.inCollection(collectionId);
deletedDocuments.forEach((doc) => {
doc.deletedAt = deletedAt;
policies.remove(doc.id);
});
documents.removeCollectionDocuments(collectionId);
memberships.removeCollectionMemberships(collectionId);
collections.remove(collectionId);
policies.remove(collectionId);
})
);
this.socket.on("teams.update", (event: PartialWithId<Team>) => {
auth.team?.updateFromJson(event);
});
this.socket.on("pins.create", (event: PartialWithId<Pin>) => {
pins.add(event);
});
this.socket.on("pins.update", (event: PartialWithId<Pin>) => {
pins.add(event);
});
this.socket.on("pins.delete", (event: WebsocketEntityDeletedEvent) => {
pins.remove(event.modelId);
});
this.socket.on("stars.create", (event: PartialWithId<Star>) => {
stars.add(event);
});
this.socket.on("stars.update", (event: PartialWithId<Star>) => {
stars.add(event);
});
this.socket.on("stars.delete", (event: WebsocketEntityDeletedEvent) => {
stars.remove(event.modelId);
});
// received when a user is given access to a collection
// if the user is us then we go ahead and load the collection from API.
this.socket.on(
"collections.add_user",
action((event: WebsocketCollectionUserEvent) => {
if (auth.user && event.userId === auth.user.id) {
collections.fetch(event.collectionId, {
force: true,
});
}
// Document policies might need updating as the permission changes
documents.inCollection(event.collectionId).forEach((document) => {
policies.remove(document.id);
});
})
);
// received when a user is removed from having access to a collection
// to keep state in sync we must update our UI if the user is us,
// or otherwise just remove any membership state we have for that user.
this.socket.on(
"collections.remove_user",
action((event: WebsocketCollectionUserEvent) => {
if (auth.user && event.userId === auth.user.id) {
collections.remove(event.collectionId);
memberships.removeCollectionMemberships(event.collectionId);
documents.removeCollectionDocuments(event.collectionId);
} else {
memberships.remove(`${event.userId}-${event.collectionId}`);
}
})
);
this.socket.on(
"collections.update_index",
action((event: WebsocketCollectionUpdateIndexEvent) => {
const collection = collections.get(event.collectionId);
if (collection) {
collection.updateIndex(event.index);
}
})
);
this.socket.on(
"fileOperations.create",
(event: PartialWithId<FileOperation>) => {
fileOperations.add(event);
}
);
this.socket.on(
"fileOperations.update",
(event: PartialWithId<FileOperation>) => {
fileOperations.add(event);
}
);
this.socket.on(
"subscriptions.create",
(event: PartialWithId<Subscription>) => {
subscriptions.add(event);
}
);
this.socket.on(
"subscriptions.delete",
(event: WebsocketEntityDeletedEvent) => {
subscriptions.remove(event.modelId);
}
);
// received a message from the API server that we should request
// to join a specific room. Forward that to the ws server.
this.socket.on("join", (event: any) => {
this.socket?.emit("join", event);
});
// received a message from the API server that we should request
// to leave a specific room. Forward that to the ws server.
this.socket.on("leave", (event: any) => {
this.socket?.emit("leave", event);
});
// received whenever we join a document room, the payload includes
// userIds that are present/viewing and those that are editing.
this.socket.on("document.presence", (event: any) => {
presence.init(event.documentId, event.userIds, event.editingIds);
});
// received whenever a new user joins a document room, aka they
// navigate to / start viewing a document
this.socket.on("user.join", (event: any) => {
presence.touch(event.documentId, event.userId, event.isEditing);
views.touch(event.documentId, event.userId);
});
// received whenever a new user leaves a document room, aka they
// navigate away / stop viewing a document
this.socket.on("user.leave", (event: any) => {
presence.leave(event.documentId, event.userId);
views.touch(event.documentId, event.userId);
});
// received when another client in a document room wants to change
// or update it's presence. Currently the only property is whether
// the client is in editing state or not.
this.socket.on("user.presence", (event: any) => {
presence.touch(event.documentId, event.userId, event.isEditing);
});
};
render() {
return (
<WebsocketContext.Provider value={this.socket}>
{this.props.children}
</WebsocketContext.Provider>
);
}
}
export default withStores(WebsocketProvider);
+1 -1
View File
@@ -76,7 +76,7 @@ const MenuItem = styled.button<{
line-height: 1;
width: 100%;
height: 36px;
cursor: var(--pointer);
cursor: pointer;
border: none;
opacity: ${(props) => (props.disabled ? ".5" : "1")};
color: ${(props) =>
+6 -9
View File
@@ -7,10 +7,9 @@ import { Portal } from "react-portal";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import insertFiles from "@shared/editor/commands/insertFiles";
import { EmbedDescriptor } from "@shared/editor/embeds";
import { CommandFactory } from "@shared/editor/lib/Extension";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { MenuItem } from "@shared/editor/types";
import { EmbedDescriptor, MenuItem } from "@shared/editor/types";
import { depths } from "@shared/styles";
import { getEventFiles } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
@@ -427,13 +426,11 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
const embedItems: EmbedDescriptor[] = [];
for (const embed of embeds) {
if (embed.title && embed.visible !== false) {
embedItems.push(
new EmbedDescriptor({
...embed,
name: "embed",
})
);
if (embed.title) {
embedItems.push({
...embed,
name: "embed",
});
}
}
+3 -3
View File
@@ -11,7 +11,7 @@ import { setTextSelection } from "prosemirror-utils";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import styled from "styled-components";
import { isInternalUrl, sanitizeUrl } from "@shared/utils/urls";
import { isInternalUrl, sanitizeHref } from "@shared/utils/urls";
import Flex from "~/components/Flex";
import { Dictionary } from "~/hooks/useDictionary";
import { ToastOptions } from "~/types";
@@ -70,7 +70,7 @@ class LinkEditor extends React.Component<Props, State> {
};
get href(): string {
return sanitizeUrl(this.props.mark?.attrs.href) ?? "";
return sanitizeHref(this.props.mark?.attrs.href) ?? "";
}
get suggestedLinkTitle(): string {
@@ -113,7 +113,7 @@ class LinkEditor extends React.Component<Props, State> {
this.discardInputValue = true;
const { from, to } = this.props;
href = sanitizeUrl(href) ?? "";
href = sanitizeHref(href) ?? "";
this.props.onSelectLink({ href, title, from, to });
};
+1 -1
View File
@@ -61,7 +61,7 @@ const ListItem = styled.li<{
text-decoration: none;
overflow: hidden;
white-space: nowrap;
cursor: var(--pointer);
cursor: pointer;
user-select: none;
line-height: ${(props) => (props.compact ? "inherit" : "1.2")};
height: ${(props) => (props.compact ? "28px" : "auto")};
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -7,7 +7,7 @@ export default styled.button<Props>`
flex: 0;
width: 24px;
height: 24px;
cursor: var(--pointer);
cursor: pointer;
border: none;
background: none;
transition: opacity 100ms ease-in-out;
+1 -1
View File
@@ -7,7 +7,7 @@ type Props = {
};
const WrappedTooltip: React.FC<Props> = ({ children, tooltip }) => (
<Tooltip offset={[0, 16]} delay={150} tooltip={tooltip} placement="top">
<Tooltip offset="0, 8" delay={150} tooltip={tooltip} placement="top">
<TooltipContent>{children}</TooltipContent>
</Tooltip>
);
+5 -41
View File
@@ -16,22 +16,17 @@ import { EditorState, Selection, Plugin, Transaction } from "prosemirror-state";
import { Decoration, EditorView } from "prosemirror-view";
import * as React from "react";
import { DefaultTheme, ThemeProps } from "styled-components";
import EditorContainer from "@shared/editor/components/Styles";
import { EmbedDescriptor } from "@shared/editor/embeds";
import Extension, { CommandFactory } from "@shared/editor/lib/Extension";
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import getHeadings from "@shared/editor/lib/getHeadings";
import getTasks from "@shared/editor/lib/getTasks";
import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
import textBetween from "@shared/editor/lib/textBetween";
import Mark from "@shared/editor/marks/Mark";
import Node from "@shared/editor/nodes/Node";
import ReactNode from "@shared/editor/nodes/ReactNode";
import fullExtensionsPackage from "@shared/editor/packages/full";
import { EventType } from "@shared/editor/types";
import { IntegrationType } from "@shared/types";
import { EmbedDescriptor, EventType } from "@shared/editor/types";
import EventEmitter from "@shared/utils/events";
import Integration from "~/models/Integration";
import Flex from "~/components/Flex";
import { Dictionary } from "~/hooks/useDictionary";
import Logger from "~/utils/Logger";
@@ -42,6 +37,7 @@ import EmojiMenu from "./components/EmojiMenu";
import { SearchResult } from "./components/LinkEditor";
import LinkToolbar from "./components/LinkToolbar";
import SelectionToolbar from "./components/SelectionToolbar";
import EditorContainer from "./components/Styles";
import WithTheme from "./components/WithTheme";
export { default as Extension } from "@shared/editor/lib/Extension";
@@ -102,6 +98,8 @@ export type Props = {
) => void;
/** Callback when user hovers on any link in the document */
onHoverLink?: (event: MouseEvent) => boolean;
/** Callback when user clicks on any hashtag in the document */
onClickHashtag?: (tag: string, event: MouseEvent) => void;
/** Callback when user presses any key with document focused */
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
/** Collection of embed types to render in the document */
@@ -112,8 +110,6 @@ export type Props = {
onShowToast: (message: string) => void;
className?: string;
style?: React.CSSProperties;
embedIntegrations?: Integration<IntegrationType.Embed>[];
};
type State = {
@@ -434,7 +430,7 @@ export class Editor extends React.PureComponent<
state: this.createState(this.props.value),
editable: () => !this.props.readOnly,
nodeViews: this.nodeViews,
dispatchTransaction(transaction) {
dispatchTransaction: function (transaction) {
// callback is bound to have the view instance as its this binding
const { state, transactions } = this.state.applyTransaction(
transaction
@@ -572,9 +568,6 @@ export class Editor extends React.PureComponent<
this.setState({ blockMenuOpen: false });
};
/**
* Focus the editor at the start of the content.
*/
public focusAtStart = () => {
const selection = Selection.atStart(this.view.state.doc);
const transaction = this.view.state.tr.setSelection(selection);
@@ -582,9 +575,6 @@ export class Editor extends React.PureComponent<
this.view.focus();
};
/**
* Focus the editor at the end of the content.
*/
public focusAtEnd = () => {
const selection = Selection.atEnd(this.view.state.doc);
const transaction = this.view.state.tr.setSelection(selection);
@@ -592,40 +582,14 @@ export class Editor extends React.PureComponent<
this.view.focus();
};
/**
* Return the headings in the current editor.
*
* @returns A list of headings in the document
*/
public getHeadings = () => {
return getHeadings(this.view.state.doc);
};
/**
* Return the tasks/checkmarks in the current editor.
*
* @returns A list of tasks in the document
*/
public getTasks = () => {
return getTasks(this.view.state.doc);
};
/**
* Return the plain text content of the current editor.
*
* @returns A string of text
*/
public getPlainText = () => {
const { doc } = this.view.state;
const textSerializers = Object.fromEntries(
Object.entries(this.schema.nodes)
.filter(([, node]) => node.spec.toPlainText)
.map(([name, node]) => [name, node.spec.toPlainText])
);
return textBetween(doc, 0, doc.content.size, textSerializers);
};
public render() {
const {
dir,
+7 -29
View File
@@ -9,10 +9,8 @@ import {
LinkIcon,
TeamIcon,
BeakerIcon,
BuildingBlocksIcon,
DownloadIcon,
WebhooksIcon,
SettingsIcon,
} from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
@@ -23,10 +21,8 @@ import Groups from "~/scenes/Settings/Groups";
import Import from "~/scenes/Settings/Import";
import Members from "~/scenes/Settings/Members";
import Notifications from "~/scenes/Settings/Notifications";
import Preferences from "~/scenes/Settings/Preferences";
import Profile from "~/scenes/Settings/Profile";
import Security from "~/scenes/Settings/Security";
import SelfHosted from "~/scenes/Settings/SelfHosted";
import Shares from "~/scenes/Settings/Shares";
import Slack from "~/scenes/Settings/Slack";
import Tokens from "~/scenes/Settings/Tokens";
@@ -36,7 +32,6 @@ import SlackIcon from "~/components/SlackIcon";
import ZapierIcon from "~/components/ZapierIcon";
import env from "~/env";
import isCloudHosted from "~/utils/isCloudHosted";
import { accountPreferencesPath } from "~/utils/routeHelpers";
import useCurrentTeam from "./useCurrentTeam";
import usePolicy from "./usePolicy";
@@ -72,7 +67,7 @@ type ConfigType = {
const useAuthorizedSettingsConfig = () => {
const team = useCurrentTeam();
const can = usePolicy(team);
const can = usePolicy(team.id);
const { t } = useTranslation();
const config: ConfigType = React.useMemo(
@@ -85,14 +80,6 @@ const useAuthorizedSettingsConfig = () => {
group: t("Account"),
icon: ProfileIcon,
},
Preferences: {
name: t("Preferences"),
path: accountPreferencesPath(),
component: Preferences,
enabled: true,
group: t("Account"),
icon: SettingsIcon,
},
Notifications: {
name: t("Notifications"),
path: "/settings/notifications",
@@ -130,7 +117,7 @@ const useAuthorizedSettingsConfig = () => {
name: t("Features"),
path: "/settings/features",
component: Features,
enabled: can.update && team.collaborativeEditing,
enabled: can.update,
group: t("Team"),
icon: BeakerIcon,
},
@@ -151,7 +138,7 @@ const useAuthorizedSettingsConfig = () => {
icon: GroupIcon,
},
Shares: {
name: t("Shared Links"),
name: t("Share Links"),
path: "/settings/shares",
component: Shares,
enabled: true,
@@ -183,14 +170,6 @@ const useAuthorizedSettingsConfig = () => {
group: t("Integrations"),
icon: WebhooksIcon,
},
SelfHosted: {
name: t("Self Hosted"),
path: "/settings/integrations/self-hosted",
component: SelfHosted,
enabled: can.update,
group: t("Integrations"),
icon: BuildingBlocksIcon,
},
Slack: {
name: "Slack",
path: "/settings/integrations/slack",
@@ -209,13 +188,12 @@ const useAuthorizedSettingsConfig = () => {
},
}),
[
t,
can.createApiKey,
can.update,
can.createImport,
can.createExport,
can.createWebhookSubscription,
team.collaborativeEditing,
can.createExport,
can.createImport,
can.update,
t,
]
);
-50
View File
@@ -1,50 +0,0 @@
import { find } from "lodash";
import * as React from "react";
import embeds, { EmbedDescriptor } from "@shared/editor/embeds";
import { IntegrationType } from "@shared/types";
import Integration from "~/models/Integration";
import Logger from "~/utils/Logger";
import useStores from "./useStores";
/**
* Hook to get all embed configuration for the current team
*
* @param loadIfMissing Should we load integration settings if they are not
* locally available
* @returns A list of embed descriptors
*/
export default function useEmbeds(loadIfMissing = false) {
const { integrations } = useStores();
React.useEffect(() => {
async function fetchEmbedIntegrations() {
try {
await integrations.fetchPage({
limit: 100,
type: IntegrationType.Embed,
});
} catch (err) {
Logger.error("Failed to fetch embed integrations", err);
}
}
if (!integrations.isLoaded && !integrations.isFetching && loadIfMissing) {
fetchEmbedIntegrations();
}
}, [integrations, loadIfMissing]);
return React.useMemo(
() =>
embeds.map((e) => {
const em: Integration<IntegrationType.Embed> | undefined = find(
integrations.orderedData,
(i) => i.service === e.component.name.toLowerCase()
);
return new EmbedDescriptor({
...e,
settings: em?.settings,
});
}),
[integrations.orderedData]
);
}
-25
View File
@@ -1,25 +0,0 @@
import * as React from "react";
import usePersistedState from "~/hooks/usePersistedState";
/**
* Hook to set locally and return the path that the user last visited. This is
* used to redirect the user back to the last page they were on if preferred.
*
* @returns A tuple of the last visited path and a method to set it.
*/
export default function useLastVisitedPath(): [string, (path: string) => void] {
const [lastVisitedPath, setLastVisitedPath] = usePersistedState<string>(
"lastVisitedPath",
"/",
{ listen: false }
);
const setPathAsLastVisitedPath = React.useCallback(
(path: string) => {
path !== lastVisitedPath && setLastVisitedPath(path);
},
[lastVisitedPath, setLastVisitedPath]
);
return [lastVisitedPath, setPathAsLastVisitedPath];
}
+1 -1
View File
@@ -10,7 +10,7 @@ const useMenuHeight = (
const isMobile = useMobile();
const { height: windowHeight } = useWindowSize();
React.useEffect(() => {
React.useLayoutEffect(() => {
const padding = 8;
if (visible && !isMobile) {
+16 -26
View File
@@ -4,25 +4,18 @@ import Logger from "~/utils/Logger";
import Storage from "~/utils/Storage";
import useEventListener from "./useEventListener";
type Options = {
/* Whether to listen and react to changes in the value from other tabs */
listen?: boolean;
};
/**
* A hook with the same API as `useState` that persists its value locally and
* syncs the value between browser tabs.
*
* @param key Key to store value under
* @param defaultValue An optional default value if no key exists
* @param options Options for the hook
* @returns Tuple of the current value and a function to update it
*/
export default function usePersistedState<T extends Primitive>(
export default function usePersistedState(
key: string,
defaultValue: T,
options?: Options
): [T, (value: T) => void] {
defaultValue: Primitive
) {
const [storedValue, setStoredValue] = React.useState(() => {
if (typeof window === "undefined") {
return defaultValue;
@@ -30,26 +23,23 @@ export default function usePersistedState<T extends Primitive>(
return Storage.get(key) ?? defaultValue;
});
const setValue = React.useCallback(
(value: T | ((value: T) => void)) => {
try {
// Allow value to be a function so we have same API as useState
const valueToStore =
value instanceof Function ? value(storedValue) : value;
const setValue = (value: Primitive | ((value: Primitive) => void)) => {
try {
// Allow value to be a function so we have same API as useState
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
Storage.set(key, valueToStore);
} catch (error) {
// A more advanced implementation would handle the error case
Logger.debug("misc", "Failed to persist state", { error });
}
},
[key, storedValue]
);
setStoredValue(valueToStore);
Storage.set(key, valueToStore);
} catch (error) {
// A more advanced implementation would handle the error case
Logger.debug("misc", "Failed to persist state", { error });
}
};
// Listen to the key changing in other tabs so we can keep UI in sync
useEventListener("storage", (event: StorageEvent) => {
if (options?.listen && event.key === key && event.newValue) {
if (event.key === key && event.newValue) {
setStoredValue(JSON.parse(event.newValue));
}
});
+4 -26
View File
@@ -1,34 +1,12 @@
import * as React from "react";
import BaseModel from "~/models/BaseModel";
import useStores from "./useStores";
/**
* Retrieve the abilities of a policy for a given entity, if the policy is not
* located in the store, it will be fetched from the server.
* Quick access to retrieve the abilities of a policy for a given entity
*
* @param entity The model or model id
* @returns The policy for the model
* @param entityId The entity id
* @returns The available abilities
*/
export default function usePolicy(entity: string | BaseModel | undefined) {
export default function usePolicy(entityId: string) {
const { policies } = useStores();
const triggered = React.useRef(false);
const entityId = entity
? typeof entity === "string"
? entity
: entity.id
: "";
React.useEffect(() => {
if (entity && typeof entity !== "string") {
// The policy for this model is missing and we haven't tried to fetch it
// yet, go ahead and do that now. The force flag is needed otherwise the
// network request will be skipped due to the model existing in the store
if (!policies.get(entity.id) && !triggered.current) {
triggered.current = true;
void entity.store.fetch(entity.id, { force: true });
}
}
}, [policies, entity]);
return policies.abilities(entityId);
}
-43
View File
@@ -1,43 +0,0 @@
import * as React from "react";
type RequestResponse<T> = {
/** The return value of the request function. */
data: T | undefined;
/** The request error, if any. */
error: unknown;
/** Whether the request is currently in progress. */
loading: boolean;
/** Function to start the request. */
request: () => Promise<T | undefined>;
};
/**
* A hook to make an API request and track its state within a component.
*
* @param requestFn The function to call to make the request, it should return a promise.
* @returns
*/
export default function useRequest<T = unknown>(
requestFn: () => Promise<T>
): RequestResponse<T> {
const [data, setData] = React.useState<T>();
const [loading, setLoading] = React.useState<boolean>(false);
const [error, setError] = React.useState();
const request = React.useCallback(async () => {
setLoading(true);
try {
const response = await requestFn();
setData(response);
return response;
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
return undefined;
}, [requestFn]);
return { data, loading, error, request };
}
+1 -1
View File
@@ -8,7 +8,7 @@ type Session = {
teamId: string;
};
export function loadSessionsFromCookie(): Session[] {
function loadSessionsFromCookie(): Session[] {
const sessions = JSON.parse(getCookie("sessions") || "{}");
return Object.keys(sessions).map((teamId) => ({
teamId,
-2
View File
@@ -6,7 +6,6 @@ import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import {
navigateToProfileSettings,
navigateToAccountPreferences,
openKeyboardShortcuts,
openChangelog,
openAPIDocumentation,
@@ -45,7 +44,6 @@ const AccountMenu: React.FC = ({ children }) => {
openBugReportUrl,
changeTheme,
navigateToProfileSettings,
navigateToAccountPreferences,
separator(),
logout,
];
+41 -17
View File
@@ -1,9 +1,11 @@
import { observer } from "mobx-react";
import {
NewDocumentIcon,
EditIcon,
TrashIcon,
ImportIcon,
ExportIcon,
PadlockIcon,
AlphabeticalSortIcon,
ManualSortIcon,
UnstarredIcon,
@@ -16,17 +18,13 @@ import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import { getEventFiles } from "@shared/utils/files";
import Collection from "~/models/Collection";
import CollectionEdit from "~/scenes/CollectionEdit";
import CollectionExport from "~/scenes/CollectionExport";
import CollectionPermissions from "~/scenes/CollectionPermissions";
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
import ContextMenu, { Placement } from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import { actionToMenuItem } from "~/actions";
import {
editCollection,
editCollectionPermissions,
} from "~/actions/definitions/collections";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -62,10 +60,28 @@ function CollectionMenu({
const history = useHistory();
const file = React.useRef<HTMLInputElement>(null);
const handlePermissions = React.useCallback(() => {
dialogs.openModal({
title: t("Collection permissions"),
content: <CollectionPermissions collection={collection} />,
});
}, [collection, dialogs, t]);
const handleEdit = React.useCallback(() => {
dialogs.openModal({
title: t("Edit collection"),
content: (
<CollectionEdit
collectionId={collection.id}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [collection.id, dialogs, t]);
const handleExport = React.useCallback(() => {
dialogs.openModal({
title: t("Export collection"),
isCentered: true,
content: (
<CollectionExport
collection={collection}
@@ -170,14 +186,9 @@ function CollectionMenu({
[collection]
);
const context = useActionContext({
isContextMenu: true,
activeCollectionId: collection.id,
});
const alphabeticalSort = collection.sort.field === "title";
const can = usePolicy(collection);
const canUserInTeam = usePolicy(team);
const can = usePolicy(collection.id);
const canUserInTeam = usePolicy(team.id);
const items: MenuItem[] = React.useMemo(
() => [
{
@@ -214,8 +225,6 @@ function CollectionMenu({
{
type: "separator",
},
actionToMenuItem(editCollection, context),
actionToMenuItem(editCollectionPermissions, context),
{
type: "submenu",
title: t("Sort in sidebar"),
@@ -240,6 +249,20 @@ function CollectionMenu({
},
],
},
{
type: "button",
title: `${t("Edit")}`,
visible: can.update,
onClick: handleEdit,
icon: <EditIcon />,
},
{
type: "button",
title: `${t("Permissions")}`,
visible: can.update,
onClick: handlePermissions,
icon: <PadlockIcon />,
},
{
type: "button",
title: `${t("Export")}`,
@@ -270,8 +293,9 @@ function CollectionMenu({
handleStar,
handleNewDocument,
handleImportDocument,
context,
alphabeticalSort,
handleEdit,
handlePermissions,
canUserInTeam.createExport,
handleExport,
handleDelete,
+190 -45
View File
@@ -1,11 +1,20 @@
import { observer } from "mobx-react";
import {
EditIcon,
StarredIcon,
UnstarredIcon,
DuplicateIcon,
ArchiveIcon,
TrashIcon,
MoveIcon,
HistoryIcon,
UnpublishIcon,
PrintIcon,
ImportIcon,
NewDocumentIcon,
DownloadIcon,
RestoreIcon,
CrossIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -16,34 +25,23 @@ import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { getEventFiles } from "@shared/utils/files";
import Document from "~/models/Document";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentMove from "~/scenes/DocumentMove";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import CollectionIcon from "~/components/CollectionIcon";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Separator from "~/components/ContextMenu/Separator";
import Template from "~/components/ContextMenu/Template";
import Flex from "~/components/Flex";
import Modal from "~/components/Modal";
import Switch from "~/components/Switch";
import { actionToMenuItem } from "~/actions";
import {
pinDocument,
createTemplate,
subscribeDocument,
unsubscribeDocument,
moveDocument,
deleteDocument,
permanentlyDeleteDocument,
downloadDocument,
importDocument,
starDocument,
unstarDocument,
duplicateDocument,
archiveDocument,
} from "~/actions/definitions/documents";
import { pinDocument, createTemplate } from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useMobile from "~/hooks/useMobile";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { MenuItem } from "~/types";
@@ -80,7 +78,7 @@ function DocumentMenu({
onClose,
}: Props) {
const team = useCurrentTeam();
const { policies, collections, documents, subscriptions } = useStores();
const { policies, collections, documents } = useStores();
const { showToast } = useToasts();
const menu = useMenuState({
modal,
@@ -96,23 +94,38 @@ function DocumentMenu({
});
const { t } = useTranslation();
const isMobile = useMobile();
const [renderModals, setRenderModals] = React.useState(false);
const [showDeleteModal, setShowDeleteModal] = React.useState(false);
const [
showPermanentDeleteModal,
setShowPermanentDeleteModal,
] = React.useState(false);
const [showMoveModal, setShowMoveModal] = React.useState(false);
const file = React.useRef<HTMLInputElement>(null);
const { data, loading, request } = useRequest(() =>
subscriptions.fetchPage({
documentId: document.id,
event: "documents.update",
})
);
const handleOpen = React.useCallback(async () => {
if (!data && !loading) {
request();
}
const handleOpen = React.useCallback(() => {
setRenderModals(true);
if (onOpen) {
onOpen();
}
}, [data, loading, onOpen, request]);
}, [onOpen]);
const handleDuplicate = React.useCallback(async () => {
const duped = await document.duplicate();
// when duplicating, go straight to the duplicated document content
history.push(duped.url);
showToast(t("Document duplicated"), {
type: "success",
});
}, [t, history, showToast, document]);
const handleArchive = React.useCallback(async () => {
await document.archive();
showToast(t("Document archived"), {
type: "success",
});
}, [showToast, t, document]);
const handleRestore = React.useCallback(
async (
@@ -141,8 +154,26 @@ function DocumentMenu({
window.print();
}, [menu]);
const handleStar = React.useCallback(
(ev: React.SyntheticEvent) => {
ev.preventDefault();
ev.stopPropagation();
document.star();
},
[document]
);
const handleUnstar = React.useCallback(
(ev: React.SyntheticEvent) => {
ev.preventDefault();
ev.stopPropagation();
document.unstar();
},
[document]
);
const collection = collections.get(document.collectionId);
const can = usePolicy(document);
const can = usePolicy(document.id);
const canViewHistory = can.read && !can.restore;
const restoreItems = React.useMemo(
() => [
@@ -174,6 +205,19 @@ function DocumentMenu({
ev.stopPropagation();
}, []);
const handleImportDocument = React.useCallback(
(ev: React.SyntheticEvent) => {
ev.preventDefault();
ev.stopPropagation();
// simulate a click on the file upload input element
if (file.current) {
file.current.click();
}
},
[file]
);
const handleFilePicked = React.useCallback(
async (ev: React.ChangeEvent<HTMLInputElement>) => {
const files = getEventFiles(ev);
@@ -269,10 +313,22 @@ function DocumentMenu({
...restoreItems,
],
},
actionToMenuItem(starDocument, context),
actionToMenuItem(unstarDocument, context),
actionToMenuItem(subscribeDocument, context),
actionToMenuItem(unsubscribeDocument, context),
{
type: "button",
title: t("Unstar"),
onClick: handleUnstar,
visible: document.isStarred && !!can.unstar,
icon: <UnstarredIcon />,
},
{
type: "button",
title: t("Star"),
onClick: handleStar,
visible: !document.isStarred && !!can.star,
icon: <StarredIcon />,
},
// Pin document
actionToMenuItem(pinDocument, context),
{
type: "separator",
},
@@ -280,7 +336,7 @@ function DocumentMenu({
type: "route",
title: t("Edit"),
to: editDocumentUrl(document),
visible: !!can.update && !team.seamlessEditing,
visible: !!can.update && !team.collaborativeEditing,
icon: <EditIcon />,
},
{
@@ -292,9 +348,22 @@ function DocumentMenu({
visible: !!can.createChildDocument,
icon: <NewDocumentIcon />,
},
actionToMenuItem(importDocument, context),
{
type: "button",
title: t("Import document"),
visible: can.createChildDocument,
onClick: handleImportDocument,
icon: <ImportIcon />,
},
// Templatize document
actionToMenuItem(createTemplate, context),
actionToMenuItem(duplicateDocument, context),
{
type: "button",
title: t("Duplicate"),
onClick: handleDuplicate,
visible: !!can.update,
icon: <DuplicateIcon />,
},
{
type: "button",
title: t("Unpublish"),
@@ -302,18 +371,39 @@ function DocumentMenu({
visible: !!can.unpublish,
icon: <UnpublishIcon />,
},
actionToMenuItem(archiveDocument, context),
actionToMenuItem(moveDocument, context),
actionToMenuItem(pinDocument, context),
{
type: "button",
title: t("Archive"),
onClick: handleArchive,
visible: !!can.archive,
icon: <ArchiveIcon />,
},
{
type: "button",
title: `${t("Move")}`,
onClick: () => setShowMoveModal(true),
visible: !!can.move,
icon: <MoveIcon />,
},
{
type: "button",
title: `${t("Delete")}`,
dangerous: true,
onClick: () => setShowDeleteModal(true),
visible: !!can.delete,
icon: <TrashIcon />,
},
{
type: "button",
title: `${t("Permanently delete")}`,
dangerous: true,
onClick: () => setShowPermanentDeleteModal(true),
visible: can.permanentDelete,
icon: <CrossIcon />,
},
{
type: "separator",
},
actionToMenuItem(deleteDocument, context),
actionToMenuItem(permanentlyDeleteDocument, context),
{
type: "separator",
},
actionToMenuItem(downloadDocument, context),
{
type: "route",
title: t("History"),
@@ -323,6 +413,13 @@ function DocumentMenu({
visible: canViewHistory,
icon: <HistoryIcon />,
},
{
type: "button",
title: t("Download"),
onClick: document.download,
visible: !!can.download,
icon: <DownloadIcon />,
},
{
type: "button",
title: t("Print"),
@@ -367,6 +464,54 @@ function DocumentMenu({
</>
)}
</ContextMenu>
{renderModals && (
<>
{can.move && (
<Modal
title={t("Move {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setShowMoveModal(false)}
isOpen={showMoveModal}
>
<DocumentMove
document={document}
onRequestClose={() => setShowMoveModal(false)}
/>
</Modal>
)}
{can.delete && (
<Modal
title={t("Delete {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setShowDeleteModal(false)}
isOpen={showDeleteModal}
isCentered
>
<DocumentDelete
document={document}
onSubmit={() => setShowDeleteModal(false)}
/>
</Modal>
)}
{can.permanentDelete && (
<Modal
title={t("Permanently delete {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setShowPermanentDeleteModal(false)}
isOpen={showPermanentDeleteModal}
isCentered
>
<DocumentPermanentDelete
document={document}
onSubmit={() => setShowPermanentDeleteModal(false)}
/>
</Modal>
)}
</>
)}
</>
);
}
+1 -1
View File
@@ -24,7 +24,7 @@ function GroupMenu({ group, onMembers }: Props) {
});
const [editModalOpen, setEditModalOpen] = React.useState(false);
const [deleteModalOpen, setDeleteModalOpen] = React.useState(false);
const can = usePolicy(group);
const can = usePolicy(group.id);
return (
<>
+1 -1
View File
@@ -27,7 +27,7 @@ function NewDocumentMenu() {
const { t } = useTranslation();
const team = useCurrentTeam();
const { collections, policies } = useStores();
const can = usePolicy(team);
const can = usePolicy(team.id);
const items = React.useMemo(
() =>
collections.orderedData.reduce<MenuItem[]>((filtered, collection) => {
+1 -1
View File
@@ -22,7 +22,7 @@ function NewTemplateMenu() {
const { t } = useTranslation();
const team = useCurrentTeam();
const { collections, policies } = useStores();
const can = usePolicy(team);
const can = usePolicy(team.id);
const items = React.useMemo(
() =>

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