mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bfaed5d9cb | |||
| 5923281edb | |||
| 01bfe2bde7 | |||
| 9c6780adab | |||
| 93f1d4cfc7 | |||
| 21a43dfc5e | |||
| 47fafb5d69 | |||
| 32d76eeb9e | |||
| 99bef2c02b | |||
| 1125412972 | |||
| 2e0d160fcc | |||
| 21e31be517 |
+4
-7
@@ -21,7 +21,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 +38,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 +131,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 +150,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
|
||||
|
||||
+1
-1
@@ -11,4 +11,4 @@ fakes3/*
|
||||
.idea
|
||||
*.pem
|
||||
*.key
|
||||
*.cert
|
||||
*.cert
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
TrashIcon,
|
||||
CrossIcon,
|
||||
ArchiveIcon,
|
||||
ShuffleIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
@@ -417,24 +416,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",
|
||||
@@ -585,7 +566,6 @@ export const rootDocumentActions = [
|
||||
unsubscribeDocument,
|
||||
duplicateDocument,
|
||||
moveDocument,
|
||||
openRandomDocument,
|
||||
permanentlyDeleteDocument,
|
||||
printDocument,
|
||||
pinDocumentToCollection,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = [];
|
||||
@@ -1,64 +1,40 @@
|
||||
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;
|
||||
export const changeTeam = createAction({
|
||||
name: ({ t }) => t("Switch team"),
|
||||
placeholder: ({ t }) => t("Select a team"),
|
||||
keywords: "change workspace organization",
|
||||
section: "Account",
|
||||
visible: ({ currentTeamId }) => {
|
||||
const sessions = loadSessionsFromCookie();
|
||||
const otherSessions = sessions.filter(
|
||||
(session) => session.teamId !== currentTeamId
|
||||
);
|
||||
return otherSessions.length > 0;
|
||||
},
|
||||
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} />,
|
||||
});
|
||||
children: ({ currentTeamId }) => {
|
||||
const sessions = loadSessionsFromCookie();
|
||||
const otherSessions = sessions.filter(
|
||||
(session) => session.teamId !== currentTeamId
|
||||
);
|
||||
|
||||
return otherSessions.map((session) => ({
|
||||
id: session.url,
|
||||
name: session.name,
|
||||
section: "Account",
|
||||
icon: <Logo alt={session.name} src={session.logoUrl} />,
|
||||
perform: () => (window.location.href = session.url),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
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];
|
||||
export const rootTeamActions = [changeTeam];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,7 +2,6 @@ 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";
|
||||
@@ -12,7 +11,6 @@ export default [
|
||||
...rootDocumentActions,
|
||||
...rootUserActions,
|
||||
...rootNavigationActions,
|
||||
...rootRevisionActions,
|
||||
...rootSettingsActions,
|
||||
...rootDeveloperActions,
|
||||
...rootTeamActions,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -90,6 +90,7 @@ function Collaborators(props: Props) {
|
||||
isEditing={isEditing}
|
||||
isObserving={isObserving}
|
||||
isCurrentUser={currentUserId === collaborator.id}
|
||||
profileOnClick={false}
|
||||
onClick={
|
||||
isObservable
|
||||
? (ev) => {
|
||||
|
||||
@@ -38,10 +38,10 @@ function CommandBar() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchActions />
|
||||
<KBarPortal>
|
||||
<Positioner>
|
||||
<Animator>
|
||||
<SearchActions />
|
||||
<SearchInput
|
||||
placeholder={`${
|
||||
rootAction?.placeholder ||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
{t("in")}
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -25,14 +25,13 @@ 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"
|
||||
)
|
||||
);
|
||||
@@ -160,10 +159,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);
|
||||
|
||||
@@ -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);
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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")`
|
||||
|
||||
@@ -50,7 +50,7 @@ function HoverPreviewDocument({ url, children }: Props) {
|
||||
}
|
||||
|
||||
const Content = styled(Link)`
|
||||
cursor: var(--pointer);
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const Heading = styled.h2`
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)`
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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));
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -4,13 +4,11 @@ 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";
|
||||
|
||||
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
@@ -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};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -296,7 +296,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
);
|
||||
|
||||
this.socket.on("teams.update", (event: PartialWithId<Team>) => {
|
||||
auth.team?.updateFromJson(event);
|
||||
auth.updateTeam(event);
|
||||
});
|
||||
|
||||
this.socket.on("pins.create", (event: PartialWithId<Pin>) => {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -427,7 +427,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
|
||||
const embedItems: EmbedDescriptor[] = [];
|
||||
|
||||
for (const embed of embeds) {
|
||||
if (embed.title && embed.visible !== false) {
|
||||
if (embed.title) {
|
||||
embedItems.push(
|
||||
new EmbedDescriptor({
|
||||
...embed,
|
||||
|
||||
@@ -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")};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
+2
-33
@@ -23,7 +23,6 @@ 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";
|
||||
@@ -102,6 +101,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 */
|
||||
@@ -572,9 +573,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 +580,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 +587,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,
|
||||
|
||||
@@ -12,21 +12,19 @@ import {
|
||||
BuildingBlocksIcon,
|
||||
DownloadIcon,
|
||||
WebhooksIcon,
|
||||
SettingsIcon,
|
||||
} from "outline-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Details from "~/scenes/Settings/Details";
|
||||
import Drawio from "~/scenes/Settings/Drawio";
|
||||
import Export from "~/scenes/Settings/Export";
|
||||
import Features from "~/scenes/Settings/Features";
|
||||
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 +34,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";
|
||||
|
||||
@@ -85,14 +82,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 +119,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 +140,7 @@ const useAuthorizedSettingsConfig = () => {
|
||||
icon: GroupIcon,
|
||||
},
|
||||
Shares: {
|
||||
name: t("Shared Links"),
|
||||
name: t("Share Links"),
|
||||
path: "/settings/shares",
|
||||
component: Shares,
|
||||
enabled: true,
|
||||
@@ -183,10 +172,10 @@ const useAuthorizedSettingsConfig = () => {
|
||||
group: t("Integrations"),
|
||||
icon: WebhooksIcon,
|
||||
},
|
||||
SelfHosted: {
|
||||
name: t("Self Hosted"),
|
||||
path: "/settings/integrations/self-hosted",
|
||||
component: SelfHosted,
|
||||
Drawio: {
|
||||
name: t("Draw.io"),
|
||||
path: "/settings/integrations/drawio",
|
||||
component: Drawio,
|
||||
enabled: can.update,
|
||||
group: t("Integrations"),
|
||||
icon: BuildingBlocksIcon,
|
||||
@@ -209,13 +198,12 @@ const useAuthorizedSettingsConfig = () => {
|
||||
},
|
||||
}),
|
||||
[
|
||||
t,
|
||||
can.createApiKey,
|
||||
can.update,
|
||||
can.createImport,
|
||||
can.createExport,
|
||||
can.createWebhookSubscription,
|
||||
team.collaborativeEditing,
|
||||
can.createExport,
|
||||
can.createImport,
|
||||
can.update,
|
||||
t,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -28,9 +28,7 @@ export default function useEmbeds(loadIfMissing = false) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!integrations.isLoaded && !integrations.isFetching && loadIfMissing) {
|
||||
fetchEmbedIntegrations();
|
||||
}
|
||||
!integrations.isLoaded && loadIfMissing && fetchEmbedIntegrations();
|
||||
}, [integrations, loadIfMissing]);
|
||||
|
||||
return React.useMemo(
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -10,7 +10,7 @@ const useMenuHeight = (
|
||||
const isMobile = useMobile();
|
||||
const { height: windowHeight } = useWindowSize();
|
||||
|
||||
React.useEffect(() => {
|
||||
React.useLayoutEffect(() => {
|
||||
const padding = 8;
|
||||
|
||||
if (visible && !isMobile) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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,11 +186,6 @@ 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);
|
||||
@@ -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,
|
||||
|
||||
@@ -43,7 +43,6 @@ 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 +79,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,
|
||||
@@ -97,22 +96,6 @@ function DocumentMenu({
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
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();
|
||||
}
|
||||
|
||||
if (onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
}, [data, loading, onOpen, request]);
|
||||
|
||||
const handleRestore = React.useCallback(
|
||||
async (
|
||||
@@ -236,7 +219,7 @@ function DocumentMenu({
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
aria-label={t("Document options")}
|
||||
onOpen={handleOpen}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Template
|
||||
@@ -280,7 +263,7 @@ function DocumentMenu({
|
||||
type: "route",
|
||||
title: t("Edit"),
|
||||
to: editDocumentUrl(document),
|
||||
visible: !!can.update && !team.seamlessEditing,
|
||||
visible: !!can.update && !team.collaborativeEditing,
|
||||
icon: <EditIcon />,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@ import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { navigateToSettings, logout } from "~/actions/definitions/navigation";
|
||||
import { createTeam, switchTeamList } from "~/actions/definitions/teams";
|
||||
import { changeTeam } from "~/actions/definitions/teams";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useSessions from "~/hooks/useSessions";
|
||||
@@ -34,13 +34,7 @@ const OrganizationMenu: React.FC = ({ children }) => {
|
||||
// NOTE: it's useful to memoize on the team id and session because the action
|
||||
// menu is not cached at all.
|
||||
const actions = React.useMemo(() => {
|
||||
return [
|
||||
...switchTeamList,
|
||||
createTeam,
|
||||
separator(),
|
||||
navigateToSettings,
|
||||
logout,
|
||||
];
|
||||
return [navigateToSettings, separator(), changeTeam, logout];
|
||||
}, [team.id, sessions]);
|
||||
|
||||
return (
|
||||
|
||||
+61
-19
@@ -1,18 +1,19 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { RestoreIcon, LinkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import Document from "~/models/Document";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import MenuItem from "~/components/ContextMenu/MenuItem";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import {
|
||||
copyLinkToRevision,
|
||||
restoreRevision,
|
||||
} from "~/actions/definitions/revisions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import separator from "./separator";
|
||||
import Separator from "~/components/ContextMenu/Separator";
|
||||
import CopyToClipboard from "~/components/CopyToClipboard";
|
||||
import MenuIconWrapper from "~/components/MenuIconWrapper";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import { documentHistoryUrl } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -20,14 +21,47 @@ type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function RevisionMenu({ document, className }: Props) {
|
||||
function RevisionMenu({ document, revisionId, className }: Props) {
|
||||
const { showToast } = useToasts();
|
||||
const team = useCurrentTeam();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
const context = useActionContext({
|
||||
activeDocumentId: document.id,
|
||||
});
|
||||
const history = useHistory();
|
||||
|
||||
const handleRestore = React.useCallback(
|
||||
async (ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
|
||||
if (team.collaborativeEditing) {
|
||||
history.push(document.url, {
|
||||
restore: true,
|
||||
revisionId,
|
||||
});
|
||||
} else {
|
||||
await document.restore({
|
||||
revisionId,
|
||||
});
|
||||
showToast(t("Document restored"), {
|
||||
type: "success",
|
||||
});
|
||||
history.push(document.url);
|
||||
}
|
||||
},
|
||||
[history, showToast, t, team.collaborativeEditing, document, revisionId]
|
||||
);
|
||||
|
||||
const handleCopy = React.useCallback(() => {
|
||||
showToast(t("Link copied"), {
|
||||
type: "info",
|
||||
});
|
||||
}, [showToast, t]);
|
||||
|
||||
const url = `${window.location.origin}${documentHistoryUrl(
|
||||
document,
|
||||
revisionId
|
||||
)}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -38,13 +72,21 @@ function RevisionMenu({ document, className }: Props) {
|
||||
{...menu}
|
||||
/>
|
||||
<ContextMenu {...menu} aria-label={t("Revision options")}>
|
||||
<Template
|
||||
items={[
|
||||
actionToMenuItem(restoreRevision, context),
|
||||
separator(),
|
||||
actionToMenuItem(copyLinkToRevision, context),
|
||||
]}
|
||||
/>
|
||||
<MenuItem {...menu} onClick={handleRestore}>
|
||||
<MenuIconWrapper>
|
||||
<RestoreIcon />
|
||||
</MenuIconWrapper>
|
||||
{t("Restore version")}
|
||||
</MenuItem>
|
||||
<Separator />
|
||||
<CopyToClipboard text={url} onCopy={handleCopy}>
|
||||
<MenuItem {...menu}>
|
||||
<MenuIconWrapper>
|
||||
<LinkIcon />
|
||||
</MenuIconWrapper>
|
||||
{t("Copy link")}
|
||||
</MenuItem>
|
||||
</CopyToClipboard>
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
|
||||
+13
-10
@@ -2,7 +2,6 @@ import { addDays, differenceInDays } from "date-fns";
|
||||
import { floor } from "lodash";
|
||||
import { action, autorun, computed, observable, set } from "mobx";
|
||||
import parseTitle from "@shared/utils/parseTitle";
|
||||
import { isRTL } from "@shared/utils/rtl";
|
||||
import DocumentsStore from "~/stores/DocumentsStore";
|
||||
import User from "~/models/User";
|
||||
import type { NavigationNode } from "~/types";
|
||||
@@ -107,19 +106,23 @@ export default class Document extends ParanoidModel {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the direction of the document text, either "rtl" or "ltr"
|
||||
* Best-guess the text direction of the document based on the language the
|
||||
* title is written in. Note: wrapping as a computed getter means that it will
|
||||
* only be called directly when the title changes.
|
||||
*/
|
||||
@computed
|
||||
get dir(): "rtl" | "ltr" {
|
||||
return this.rtl ? "rtl" : "ltr";
|
||||
}
|
||||
const element = document.createElement("p");
|
||||
element.innerText = this.title;
|
||||
element.style.visibility = "hidden";
|
||||
element.dir = "auto";
|
||||
|
||||
/**
|
||||
* Returns true if the document text is right-to-left
|
||||
*/
|
||||
@computed
|
||||
get rtl() {
|
||||
return isRTL(this.title);
|
||||
// element must appear in body for direction to be computed
|
||||
document.body?.appendChild(element);
|
||||
const direction = window.getComputedStyle(element).direction;
|
||||
document.body?.removeChild(element);
|
||||
|
||||
return direction === "rtl" ? "rtl" : "ltr";
|
||||
}
|
||||
|
||||
@computed
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { computed } from "mobx";
|
||||
import { isRTL } from "@shared/utils/rtl";
|
||||
import BaseModel from "./BaseModel";
|
||||
import User from "./User";
|
||||
|
||||
@@ -8,34 +6,13 @@ class Revision extends BaseModel {
|
||||
|
||||
documentId: string;
|
||||
|
||||
/** The document title when the revision was created */
|
||||
title: string;
|
||||
|
||||
/** Markdown string of the content when revision was created */
|
||||
text: string;
|
||||
|
||||
/** HTML string representing the revision as a diff from the previous version */
|
||||
html: string;
|
||||
|
||||
createdAt: string;
|
||||
|
||||
createdBy: User;
|
||||
|
||||
/**
|
||||
* Returns the direction of the revision text, either "rtl" or "ltr"
|
||||
*/
|
||||
@computed
|
||||
get dir(): "rtl" | "ltr" {
|
||||
return this.rtl ? "rtl" : "ltr";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the revision text is right-to-left
|
||||
*/
|
||||
@computed
|
||||
get rtl() {
|
||||
return isRTL(this.title);
|
||||
}
|
||||
}
|
||||
|
||||
export default Revision;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { computed, observable } from "mobx";
|
||||
import { TeamPreference, TeamPreferences } from "@shared/types";
|
||||
import BaseModel from "./BaseModel";
|
||||
import Field from "./decorators/Field";
|
||||
|
||||
@@ -52,10 +51,6 @@ class Team extends BaseModel {
|
||||
@observable
|
||||
defaultUserRole: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
preferences: TeamPreferences | null;
|
||||
|
||||
domain: string | null | undefined;
|
||||
|
||||
url: string;
|
||||
@@ -68,45 +63,6 @@ class Team extends BaseModel {
|
||||
get signinMethods(): string {
|
||||
return "SSO";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether this team is using a separate editing mode behind an "Edit"
|
||||
* button rather than seamless always-editing.
|
||||
*
|
||||
* @returns True if editing mode is seamless (no button)
|
||||
*/
|
||||
@computed
|
||||
get seamlessEditing(): boolean {
|
||||
return (
|
||||
this.collaborativeEditing &&
|
||||
this.getPreference(TeamPreference.SeamlessEdit, true)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value for a specific preference key, or return the fallback if
|
||||
* none is set.
|
||||
*
|
||||
* @param key The TeamPreference key to retrieve
|
||||
* @param fallback An optional fallback value, defaults to false.
|
||||
* @returns The value
|
||||
*/
|
||||
getPreference(key: TeamPreference, fallback = false): boolean {
|
||||
return this.preferences?.[key] ?? fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value for a specific preference key.
|
||||
*
|
||||
* @param key The TeamPreference key to retrieve
|
||||
* @param value The value to set
|
||||
*/
|
||||
setPreference(key: TeamPreference, value: boolean) {
|
||||
this.preferences = {
|
||||
...this.preferences,
|
||||
[key]: value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default Team;
|
||||
|
||||
+1
-43
@@ -1,7 +1,5 @@
|
||||
import { subMinutes } from "date-fns";
|
||||
import { computed, observable } from "mobx";
|
||||
import { now } from "mobx-utils";
|
||||
import type { Role, UserPreference, UserPreferences } from "@shared/types";
|
||||
import type { Role } from "@shared/types";
|
||||
import ParanoidModel from "./ParanoidModel";
|
||||
import Field from "./decorators/Field";
|
||||
|
||||
@@ -26,10 +24,6 @@ class User extends ParanoidModel {
|
||||
@observable
|
||||
language: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
preferences: UserPreferences | null;
|
||||
|
||||
email: string;
|
||||
|
||||
isAdmin: boolean;
|
||||
@@ -45,17 +39,6 @@ class User extends ParanoidModel {
|
||||
return !this.lastActiveAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user has been recently active. Recently is currently defined
|
||||
* as within the last 5 minutes.
|
||||
*
|
||||
* @returns true if the user has been active recently
|
||||
*/
|
||||
@computed
|
||||
get isRecentlyActive(): boolean {
|
||||
return new Date(this.lastActiveAt) > subMinutes(now(10000), 5);
|
||||
}
|
||||
|
||||
@computed
|
||||
get role(): Role {
|
||||
if (this.isAdmin) {
|
||||
@@ -66,31 +49,6 @@ class User extends ParanoidModel {
|
||||
return "member";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value for a specific preference key, or return the fallback if
|
||||
* none is set.
|
||||
*
|
||||
* @param key The UserPreference key to retrieve
|
||||
* @param fallback An optional fallback value, defaults to false.
|
||||
* @returns The value
|
||||
*/
|
||||
getPreference(key: UserPreference, fallback = false): boolean {
|
||||
return this.preferences?.[key] ?? fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value for a specific preference key.
|
||||
*
|
||||
* @param key The UserPreference key to retrieve
|
||||
* @param value The value to set
|
||||
*/
|
||||
setPreference(key: UserPreference, value: boolean) {
|
||||
this.preferences = {
|
||||
...this.preferences,
|
||||
[key]: value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default User;
|
||||
|
||||
@@ -15,10 +15,6 @@ class WebhookSubscription extends BaseModel {
|
||||
@observable
|
||||
url: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
secret: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
enabled: boolean;
|
||||
|
||||
@@ -7,7 +7,7 @@ import Drafts from "~/scenes/Drafts";
|
||||
import Error404 from "~/scenes/Error404";
|
||||
import Templates from "~/scenes/Templates";
|
||||
import Trash from "~/scenes/Trash";
|
||||
import AuthenticatedLayout from "~/components/AuthenticatedLayout";
|
||||
import Layout from "~/components/AuthenticatedLayout";
|
||||
import CenteredContent from "~/components/CenteredContent";
|
||||
import PlaceholderDocument from "~/components/PlaceholderDocument";
|
||||
import Route from "~/components/ProfiledRoute";
|
||||
@@ -26,7 +26,7 @@ const SettingsRoutes = React.lazy(
|
||||
const Document = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "preload-document" */
|
||||
/* webpackChunkName: "document" */
|
||||
"~/scenes/Document"
|
||||
)
|
||||
);
|
||||
@@ -68,7 +68,7 @@ function AuthenticatedRoutes() {
|
||||
|
||||
return (
|
||||
<WebsocketProvider>
|
||||
<AuthenticatedLayout>
|
||||
<Layout>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<CenteredContent>
|
||||
@@ -115,7 +115,7 @@ function AuthenticatedRoutes() {
|
||||
<Route component={Error404} />
|
||||
</Switch>
|
||||
</React.Suspense>
|
||||
</AuthenticatedLayout>
|
||||
</Layout>
|
||||
</WebsocketProvider>
|
||||
);
|
||||
}
|
||||
|
||||
+5
-12
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { Switch, Redirect } from "react-router-dom";
|
||||
import { Switch } from "react-router-dom";
|
||||
import DelayedMount from "~/components/DelayedMount";
|
||||
import FullscreenLoading from "~/components/FullscreenLoading";
|
||||
import Route from "~/components/ProfiledRoute";
|
||||
@@ -8,14 +8,14 @@ import { matchDocumentSlug as slug } from "~/utils/routeHelpers";
|
||||
const Authenticated = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "preload-authenticated" */
|
||||
/* webpackChunkName: "authenticated" */
|
||||
"~/components/Authenticated"
|
||||
)
|
||||
);
|
||||
const AuthenticatedRoutes = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "preload-authenticated-routes" */
|
||||
/* webpackChunkName: "authenticated-routes" */
|
||||
"./authenticated"
|
||||
)
|
||||
);
|
||||
@@ -55,17 +55,10 @@ export default function Routes() {
|
||||
<Route exact path="/create" component={Login} />
|
||||
<Route exact path="/logout" component={Logout} />
|
||||
|
||||
<Redirect exact from="/share/:shareId" to="/s/:shareId" />
|
||||
<Route exact path="/s/:shareId" component={SharedDocument} />
|
||||
|
||||
<Redirect
|
||||
exact
|
||||
from={`/share/:shareId/doc/${slug}`}
|
||||
to={`/s/:shareId/doc/${slug}`}
|
||||
/>
|
||||
<Route exact path="/share/:shareId" component={SharedDocument} />
|
||||
<Route
|
||||
exact
|
||||
path={`/s/:shareId/doc/${slug}`}
|
||||
path={`/share/:shareId/doc/${slug}`}
|
||||
component={SharedDocument}
|
||||
/>
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Route,
|
||||
useHistory,
|
||||
useRouteMatch,
|
||||
useLocation,
|
||||
} from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
@@ -19,7 +18,6 @@ import CenteredContent from "~/components/CenteredContent";
|
||||
import CollectionDescription from "~/components/CollectionDescription";
|
||||
import CollectionIcon from "~/components/CollectionIcon";
|
||||
import Heading from "~/components/Heading";
|
||||
import InputSearchPage from "~/components/InputSearchPage";
|
||||
import PlaceholderList from "~/components/List/Placeholder";
|
||||
import PaginatedDocumentList from "~/components/PaginatedDocumentList";
|
||||
import PinnedDocuments from "~/components/PinnedDocuments";
|
||||
@@ -31,36 +29,27 @@ import Tabs from "~/components/Tabs";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { editCollection } from "~/actions/definitions/collections";
|
||||
import useCommandBarActions from "~/hooks/useCommandBarActions";
|
||||
import useLastVisitedPath from "~/hooks/useLastVisitedPath";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { collectionUrl, updateCollectionUrl } from "~/utils/routeHelpers";
|
||||
import Actions from "./Collection/Actions";
|
||||
import DropToImport from "./Collection/DropToImport";
|
||||
import Empty from "./Collection/Empty";
|
||||
import MembershipPreview from "./Collection/MembershipPreview";
|
||||
|
||||
function CollectionScene() {
|
||||
const params = useParams<{ id?: string }>();
|
||||
const history = useHistory();
|
||||
const match = useRouteMatch();
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const { documents, pins, collections, ui } = useStores();
|
||||
const [isFetching, setFetching] = React.useState(false);
|
||||
const [error, setError] = React.useState<Error | undefined>();
|
||||
const currentPath = location.pathname;
|
||||
const [, setLastVisitedPath] = useLastVisitedPath();
|
||||
|
||||
const id = params.id || "";
|
||||
const collection: Collection | null | undefined =
|
||||
collections.getByUrl(id) || collections.get(id);
|
||||
const can = usePolicy(collection?.id || "");
|
||||
|
||||
React.useEffect(() => {
|
||||
setLastVisitedPath(currentPath);
|
||||
}, [currentPath, setLastVisitedPath]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (collection?.name) {
|
||||
const canonicalUrl = updateCollectionUrl(match.url, collection);
|
||||
@@ -123,28 +112,13 @@ function CollectionScene() {
|
||||
key={collection.id}
|
||||
centered={false}
|
||||
textTitle={collection.name}
|
||||
left={
|
||||
collection.isEmpty ? undefined : (
|
||||
<InputSearchPage
|
||||
source="collection"
|
||||
placeholder={`${t("Search in collection")}…`}
|
||||
label={t("Search in collection")}
|
||||
collectionId={collection.id}
|
||||
/>
|
||||
)
|
||||
}
|
||||
title={
|
||||
<>
|
||||
<CollectionIcon collection={collection} expanded />
|
||||
{collection.name}
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<MembershipPreview collection={collection} />
|
||||
<Actions collection={collection} />
|
||||
</>
|
||||
}
|
||||
actions={<Actions collection={collection} />}
|
||||
>
|
||||
<DropToImport
|
||||
accept={documents.importFileTypes.join(", ")}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Link } from "react-router-dom";
|
||||
import Collection from "~/models/Collection";
|
||||
import { Action, Separator } from "~/components/Actions";
|
||||
import Button from "~/components/Button";
|
||||
import InputSearchPage from "~/components/InputSearchPage";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import CollectionMenu from "~/menus/CollectionMenu";
|
||||
@@ -21,26 +22,38 @@ function Actions({ collection }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{can.update && (
|
||||
{!collection.isEmpty && (
|
||||
<>
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip={t("New document")}
|
||||
shortcut="n"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
as={Link}
|
||||
to={collection ? newDocumentPath(collection.id) : ""}
|
||||
disabled={!collection}
|
||||
icon={<PlusIcon />}
|
||||
>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<InputSearchPage
|
||||
source="collection"
|
||||
placeholder={`${t("Search in collection")}…`}
|
||||
label={`${t("Search in collection")}…`}
|
||||
collectionId={collection.id}
|
||||
/>
|
||||
</Action>
|
||||
<Separator />
|
||||
{can.update && (
|
||||
<>
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip={t("New document")}
|
||||
shortcut="n"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
as={Link}
|
||||
to={collection ? newDocumentPath(collection.id) : ""}
|
||||
disabled={!collection}
|
||||
icon={<PlusIcon />}
|
||||
>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Action>
|
||||
|
||||
@@ -5,11 +5,12 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Collection from "~/models/Collection";
|
||||
import CollectionPermissions from "~/scenes/CollectionPermissions";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Modal from "~/components/Modal";
|
||||
import Text from "~/components/Text";
|
||||
import { editCollectionPermissions } from "~/actions/definitions/collections";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
|
||||
@@ -20,10 +21,13 @@ type Props = {
|
||||
function EmptyCollection({ collection }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(collection);
|
||||
const context = useActionContext();
|
||||
const collectionName = collection ? collection.name : "";
|
||||
|
||||
console.log({ context });
|
||||
const [
|
||||
permissionsModalOpen,
|
||||
handlePermissionsModalOpen,
|
||||
handlePermissionsModalClose,
|
||||
] = useBoolean();
|
||||
|
||||
return (
|
||||
<Centered column>
|
||||
@@ -44,20 +48,23 @@ function EmptyCollection({ collection }: Props) {
|
||||
{can.update && (
|
||||
<Empty>
|
||||
<Link to={newDocumentPath(collection.id)}>
|
||||
<Button icon={<NewDocumentIcon color="currentColor" />} neutral>
|
||||
<Button icon={<NewDocumentIcon color="currentColor" />}>
|
||||
{t("Create a document")}
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
action={editCollectionPermissions}
|
||||
context={context}
|
||||
hideOnActionDisabled
|
||||
neutral
|
||||
>
|
||||
|
||||
<Button onClick={handlePermissionsModalOpen} neutral>
|
||||
{t("Manage permissions")}…
|
||||
</Button>
|
||||
</Empty>
|
||||
)}
|
||||
<Modal
|
||||
title={t("Collection permissions")}
|
||||
onRequestClose={handlePermissionsModalClose}
|
||||
isOpen={permissionsModalOpen}
|
||||
>
|
||||
<CollectionPermissions collection={collection} />
|
||||
</Modal>
|
||||
</Centered>
|
||||
);
|
||||
}
|
||||
@@ -72,7 +79,6 @@ const Centered = styled(Flex)`
|
||||
const Empty = styled(Flex)`
|
||||
justify-content: center;
|
||||
margin: 10px 0;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
export default observer(EmptyCollection);
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { sortBy } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { PAGINATION_SYMBOL } from "~/stores/BaseStore";
|
||||
import Collection from "~/models/Collection";
|
||||
import User from "~/models/User";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Fade from "~/components/Fade";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { editCollectionPermissions } from "~/actions/definitions/collections";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
const MembershipPreview = ({ collection, limit = 8 }: Props) => {
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [usersCount, setUsersCount] = React.useState(0);
|
||||
const [groupsCount, setGroupsCount] = React.useState(0);
|
||||
const { t } = useTranslation();
|
||||
const { memberships, collectionGroupMemberships, users } = useStores();
|
||||
const collectionUsers = users.inCollection(collection.id);
|
||||
const context = useActionContext();
|
||||
const isMobile = useMobile();
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (collection.permission || isMobile) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const options = {
|
||||
id: collection.id,
|
||||
limit,
|
||||
};
|
||||
const [users, groups] = await Promise.all([
|
||||
memberships.fetchPage(options),
|
||||
collectionGroupMemberships.fetchPage(options),
|
||||
]);
|
||||
setUsersCount(users[PAGINATION_SYMBOL].total);
|
||||
setGroupsCount(groups[PAGINATION_SYMBOL].total);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [
|
||||
isMobile,
|
||||
collection.permission,
|
||||
collection.id,
|
||||
collectionGroupMemberships,
|
||||
memberships,
|
||||
limit,
|
||||
]);
|
||||
|
||||
if (isLoading || collection.permission || isMobile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const overflow = usersCount - groupsCount - collectionUsers.length;
|
||||
|
||||
return (
|
||||
<NudeButton
|
||||
context={context}
|
||||
action={editCollectionPermissions}
|
||||
tooltip={{
|
||||
tooltip:
|
||||
usersCount > 0
|
||||
? groupsCount > 0
|
||||
? groupsCount > 1
|
||||
? t(
|
||||
`{{ usersCount }} users and {{ groupsCount }} groups with access`,
|
||||
{ usersCount, count: usersCount }
|
||||
)
|
||||
: t(`{{ usersCount }} users and a group have access`, {
|
||||
usersCount,
|
||||
count: usersCount,
|
||||
})
|
||||
: t(`{{ usersCount }} users with access`, {
|
||||
usersCount,
|
||||
count: usersCount,
|
||||
})
|
||||
: t(`{{ groupsCount }} groups with access`, {
|
||||
groupsCount,
|
||||
count: groupsCount,
|
||||
}),
|
||||
delay: 250,
|
||||
}}
|
||||
width="auto"
|
||||
height="auto"
|
||||
>
|
||||
<Fade>
|
||||
<Facepile
|
||||
users={sortBy(collectionUsers, "lastActiveAt")}
|
||||
overflow={overflow}
|
||||
limit={limit}
|
||||
renderAvatar={(user) => (
|
||||
<StyledAvatar user={user} src={user.avatarUrl} size={32} />
|
||||
)}
|
||||
/>
|
||||
</Fade>
|
||||
</NudeButton>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledAvatar = styled(Avatar)<{ user: User }>`
|
||||
transition: opacity 250ms ease-in-out;
|
||||
opacity: ${(props) => (props.user.isRecentlyActive ? 1 : 0.5)};
|
||||
`;
|
||||
|
||||
export default observer(MembershipPreview);
|
||||
@@ -39,7 +39,7 @@ function CollectionExport({ collection, onSubmit }: Props) {
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text type="secondary">
|
||||
<Trans
|
||||
defaults="Exporting the collection <em>{{collectionName}}</em> may take some time."
|
||||
defaults="Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be a zip of folders with files in Markdown format. Please visit the Export section on settings to get the zip."
|
||||
values={{
|
||||
collectionName: collection.name,
|
||||
}}
|
||||
@@ -48,14 +48,8 @@ function CollectionExport({ collection, onSubmit }: Props) {
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
<Trans>
|
||||
Your documents will be a zip of folders with files in Markdown
|
||||
format. Please visit the Export section in Settings to get the zip.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Button type="submit" disabled={isLoading} primary>
|
||||
{isLoading ? `${t("Exporting")}…` : t("Export collection")}
|
||||
{isLoading ? `${t("Exporting")}…` : t("Export Collection")}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
|
||||
@@ -109,9 +109,7 @@ class AddPeopleToCollection extends React.Component<Props> {
|
||||
<Empty>{t("No people left to add")}</Empty>
|
||||
)
|
||||
}
|
||||
items={users
|
||||
.notInCollection(collection.id, this.query)
|
||||
.filter((member) => member.id !== user.id)}
|
||||
items={users.notInCollection(collection.id, this.query)}
|
||||
fetch={this.query ? undefined : users.fetchPage}
|
||||
renderItem={(item: User) => (
|
||||
<MemberListItem
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import invariant from "invariant";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import Group from "~/models/Group";
|
||||
import User from "~/models/User";
|
||||
import Button from "~/components/Button";
|
||||
@@ -26,14 +26,13 @@ import CollectionGroupMemberListItem from "./components/CollectionGroupMemberLis
|
||||
import MemberListItem from "./components/MemberListItem";
|
||||
|
||||
type Props = {
|
||||
collectionId: string;
|
||||
collection: Collection;
|
||||
};
|
||||
|
||||
function CollectionPermissions({ collectionId }: Props) {
|
||||
function CollectionPermissions({ collection }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const {
|
||||
collections,
|
||||
memberships,
|
||||
collectionGroupMemberships,
|
||||
users,
|
||||
@@ -41,8 +40,6 @@ function CollectionPermissions({ collectionId }: Props) {
|
||||
auth,
|
||||
} = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const collection = collections.get(collectionId);
|
||||
invariant(collection, "Collection not found");
|
||||
|
||||
const [
|
||||
addGroupModalOpen,
|
||||
@@ -213,7 +210,7 @@ function CollectionPermissions({ collectionId }: Props) {
|
||||
<PermissionExplainer size="small">
|
||||
{!collection.permission && (
|
||||
<Trans
|
||||
defaults="The <em>{{ collectionName }}</em> collection is private. Workspace members have no access to it by default."
|
||||
defaults="The <em>{{ collectionName }}</em> collection is private. Team members have no access to it by default."
|
||||
values={{
|
||||
collectionName,
|
||||
}}
|
||||
@@ -224,7 +221,8 @@ function CollectionPermissions({ collectionId }: Props) {
|
||||
)}
|
||||
{collection.permission === CollectionPermission.ReadWrite && (
|
||||
<Trans
|
||||
defaults="Workspace members can view and edit documents in the <em>{{ collectionName }}</em> collection by default."
|
||||
defaults="Team members can view and edit documents in the <em>{{ collectionName }}</em> collection by
|
||||
default."
|
||||
values={{
|
||||
collectionName,
|
||||
}}
|
||||
@@ -235,8 +233,7 @@ function CollectionPermissions({ collectionId }: Props) {
|
||||
)}
|
||||
{collection.permission === CollectionPermission.Read && (
|
||||
<Trans
|
||||
defaults="Workspace members can view documents in the <em>{{ collectionName }}</em> collection by
|
||||
default."
|
||||
defaults="Team members can view documents in the <em>{{ collectionName }}</em> collection by default."
|
||||
values={{
|
||||
collectionName,
|
||||
}}
|
||||
@@ -288,7 +285,9 @@ function CollectionPermissions({ collectionId }: Props) {
|
||||
<Divider />
|
||||
{isEmpty && (
|
||||
<Empty>
|
||||
<Trans>Add additional access for individual members and groups</Trans>
|
||||
<Trans>
|
||||
Add specific access for individual groups and team members
|
||||
</Trans>
|
||||
</Empty>
|
||||
)}
|
||||
<PaginatedList
|
||||
|
||||
@@ -3,7 +3,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RouteComponentProps, useLocation, Redirect } from "react-router-dom";
|
||||
import { RouteComponentProps, useLocation } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { setCookie } from "tiny-cookie";
|
||||
import DocumentModel from "~/models/Document";
|
||||
@@ -12,7 +12,6 @@ import ErrorOffline from "~/scenes/ErrorOffline";
|
||||
import Layout from "~/components/Layout";
|
||||
import Sidebar from "~/components/Sidebar/Shared";
|
||||
import Text from "~/components/Text";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { NavigationNode } from "~/types";
|
||||
import { AuthorizationError, OfflineError } from "~/utils/errors";
|
||||
@@ -79,17 +78,12 @@ function SharedDocumentScene(props: Props) {
|
||||
const { ui } = useStores();
|
||||
const theme = useTheme();
|
||||
const location = useLocation();
|
||||
const searchParams = React.useMemo(
|
||||
() => new URLSearchParams(location.search),
|
||||
[location.search]
|
||||
);
|
||||
const { t } = useTranslation();
|
||||
const [response, setResponse] = React.useState<Response>();
|
||||
const [error, setError] = React.useState<Error | null | undefined>();
|
||||
const { documents } = useStores();
|
||||
const { shareId, documentSlug } = props.match.params;
|
||||
const documentId = useDocumentId(documentSlug, response);
|
||||
const can = usePolicy(response?.document.id ?? "");
|
||||
|
||||
// ensure the wider page color always matches the theme
|
||||
React.useEffect(() => {
|
||||
@@ -146,10 +140,6 @@ function SharedDocumentScene(props: Props) {
|
||||
return <Loading location={props.location} />;
|
||||
}
|
||||
|
||||
if (response && searchParams.get("edit") === "true" && can.update) {
|
||||
return <Redirect to={response.document.url} />;
|
||||
}
|
||||
|
||||
const sidebar = response.sharedTree ? (
|
||||
<Sidebar rootNode={response.sharedTree} shareId={shareId} />
|
||||
) : undefined;
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as React from "react";
|
||||
const MultiplayerEditor = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "preload-multiplayer-editor" */
|
||||
/* webpackChunkName: "multiplayer-editor" */
|
||||
"./MultiplayerEditor"
|
||||
)
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import Logger from "~/utils/Logger";
|
||||
import { NotFoundError, OfflineError } from "~/utils/errors";
|
||||
import history from "~/utils/history";
|
||||
import { matchDocumentEdit } from "~/utils/routeHelpers";
|
||||
import HideSidebar from "./HideSidebar";
|
||||
import Loading from "./Loading";
|
||||
|
||||
type Params = {
|
||||
@@ -41,15 +42,7 @@ type Props = RouteComponentProps<Params, StaticContext, LocationState> & {
|
||||
};
|
||||
|
||||
function DataLoader({ match, children }: Props) {
|
||||
const {
|
||||
ui,
|
||||
views,
|
||||
shares,
|
||||
documents,
|
||||
auth,
|
||||
revisions,
|
||||
subscriptions,
|
||||
} = useStores();
|
||||
const { ui, shares, documents, auth, revisions, subscriptions } = useStores();
|
||||
const { team } = auth;
|
||||
const [error, setError] = React.useState<Error | null>(null);
|
||||
const { revisionId, shareId, documentSlug } = match.params;
|
||||
@@ -64,7 +57,7 @@ function DataLoader({ match, children }: Props) {
|
||||
? documents.getSharedTree(document.id)
|
||||
: undefined;
|
||||
const isEditRoute = match.path === matchDocumentEdit;
|
||||
const isEditing = isEditRoute || !!auth.team?.seamlessEditing;
|
||||
const isEditing = isEditRoute || !!auth.team?.collaborativeEditing;
|
||||
const can = usePolicy(document?.id);
|
||||
const location = useLocation<LocationState>();
|
||||
|
||||
@@ -96,7 +89,7 @@ function DataLoader({ match, children }: Props) {
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchSubscription() {
|
||||
if (document?.id && !revisionId) {
|
||||
if (document?.id) {
|
||||
try {
|
||||
await subscriptions.fetchPage({
|
||||
documentId: document.id,
|
||||
@@ -108,22 +101,7 @@ function DataLoader({ match, children }: Props) {
|
||||
}
|
||||
}
|
||||
fetchSubscription();
|
||||
}, [document?.id, subscriptions, revisionId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchViews() {
|
||||
if (document?.id && !document?.isDeleted && !revisionId) {
|
||||
try {
|
||||
await views.fetchPage({
|
||||
documentId: document.id,
|
||||
});
|
||||
} catch (err) {
|
||||
Logger.error("Failed to fetch views", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
fetchViews();
|
||||
}, [document?.id, document?.isDeleted, revisionId, views]);
|
||||
}, [document?.id, subscriptions]);
|
||||
|
||||
const onCreateLink = React.useCallback(
|
||||
async (title: string) => {
|
||||
@@ -175,6 +153,7 @@ function DataLoader({ match, children }: Props) {
|
||||
return (
|
||||
<>
|
||||
<Loading location={location} />
|
||||
{isEditing && !team?.collaborativeEditing && <HideSidebar ui={ui} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -189,6 +168,7 @@ function DataLoader({ match, children }: Props) {
|
||||
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
{isEditing && !team.collaborativeEditing && <HideSidebar ui={ui} />}
|
||||
{children({
|
||||
document,
|
||||
revision,
|
||||
|
||||
@@ -45,8 +45,6 @@ import {
|
||||
} from "~/utils/routeHelpers";
|
||||
import Container from "./Container";
|
||||
import Contents from "./Contents";
|
||||
import DocumentContext from "./DocumentContext";
|
||||
import type { DocumentContextValue } from "./DocumentContext";
|
||||
import Editor from "./Editor";
|
||||
import Header from "./Header";
|
||||
import KeyboardShortcutsButton from "./KeyboardShortcutsButton";
|
||||
@@ -54,7 +52,6 @@ import MarkAsViewed from "./MarkAsViewed";
|
||||
import Notices from "./Notices";
|
||||
import PublicReferences from "./PublicReferences";
|
||||
import References from "./References";
|
||||
import RevisionViewer from "./RevisionViewer";
|
||||
|
||||
const AUTOSAVE_DELAY = 3000;
|
||||
|
||||
@@ -112,14 +109,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
@observable
|
||||
headings: Heading[] = [];
|
||||
|
||||
@observable
|
||||
documentContext: DocumentContextValue = {
|
||||
editor: null,
|
||||
setEditor: action((editor: TEditor) => {
|
||||
this.documentContext.editor = editor;
|
||||
}),
|
||||
};
|
||||
|
||||
getEditorText: () => string = () => this.props.document.text;
|
||||
|
||||
componentDidMount() {
|
||||
@@ -442,6 +431,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
} = this.props;
|
||||
const team = auth.team;
|
||||
const isShare = !!shareId;
|
||||
const value = revision ? revision.text : document.text;
|
||||
const embedsDisabled =
|
||||
(team && team.documentEmbeds === false) || document.embedsDisabled;
|
||||
|
||||
@@ -462,203 +452,182 @@ class DocumentScene extends React.Component<Props> {
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<DocumentContext.Provider value={this.documentContext}>
|
||||
{this.props.location.pathname !== canonicalUrl && (
|
||||
<Redirect
|
||||
to={{
|
||||
pathname: canonicalUrl,
|
||||
state: this.props.location.state,
|
||||
hash: this.props.location.hash,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<RegisterKeyDown trigger="m" handler={this.goToMove} />
|
||||
<RegisterKeyDown trigger="e" handler={this.goToEdit} />
|
||||
<RegisterKeyDown trigger="Escape" handler={this.goBack} />
|
||||
<RegisterKeyDown trigger="h" handler={this.goToHistory} />
|
||||
<RegisterKeyDown
|
||||
trigger="p"
|
||||
handler={(event) => {
|
||||
if (isModKey(event) && event.shiftKey) {
|
||||
this.onPublish(event);
|
||||
}
|
||||
{this.props.location.pathname !== canonicalUrl && (
|
||||
<Redirect
|
||||
to={{
|
||||
pathname: canonicalUrl,
|
||||
state: this.props.location.state,
|
||||
hash: this.props.location.hash,
|
||||
}}
|
||||
/>
|
||||
<RegisterKeyDown
|
||||
trigger="h"
|
||||
handler={(event) => {
|
||||
if (event.ctrlKey && event.altKey) {
|
||||
this.onToggleTableOfContents(event);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Background key={revision ? revision.id : document.id} column auto>
|
||||
<Route
|
||||
path={`${document.url}/move`}
|
||||
component={() => (
|
||||
<Modal
|
||||
title={`Move ${document.noun}`}
|
||||
onRequestClose={this.goBack}
|
||||
isOpen
|
||||
>
|
||||
<DocumentMove
|
||||
document={document}
|
||||
onRequestClose={this.goBack}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
/>
|
||||
<PageTitle
|
||||
title={document.titleWithDefault.replace(
|
||||
document.emoji || "",
|
||||
""
|
||||
)}
|
||||
favicon={document.emoji ? emojiToUrl(document.emoji) : undefined}
|
||||
/>
|
||||
{(this.isUploading || this.isSaving) && <LoadingIndicator />}
|
||||
<Container justify="center" column auto>
|
||||
{!readOnly && (
|
||||
<>
|
||||
<Prompt
|
||||
when={
|
||||
this.isEditorDirty &&
|
||||
!this.isUploading &&
|
||||
!team?.collaborativeEditing
|
||||
}
|
||||
message={(location, action) => {
|
||||
if (
|
||||
// a URL replace matching the current document indicates a title change
|
||||
// no guard is needed for this transition
|
||||
action === "REPLACE" &&
|
||||
location.pathname === editDocumentUrl(document)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return t(
|
||||
`You have unsaved changes.\nAre you sure you want to discard them?`
|
||||
) as string;
|
||||
}}
|
||||
/>
|
||||
<Prompt
|
||||
when={this.isUploading && !this.isEditorDirty}
|
||||
message={t(
|
||||
`Images are still uploading.\nAre you sure you want to discard them?`
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Header
|
||||
document={document}
|
||||
documentHasHeadings={hasHeadings}
|
||||
shareId={shareId}
|
||||
isRevision={!!revision}
|
||||
isDraft={document.isDraft}
|
||||
isEditing={!readOnly && !team?.seamlessEditing}
|
||||
isSaving={this.isSaving}
|
||||
isPublishing={this.isPublishing}
|
||||
publishingIsDisabled={
|
||||
document.isSaving || this.isPublishing || this.isEmpty
|
||||
}
|
||||
savingIsDisabled={document.isSaving || this.isEmpty}
|
||||
sharedTree={this.props.sharedTree}
|
||||
onSelectTemplate={this.replaceDocument}
|
||||
onSave={this.onSave}
|
||||
headings={this.headings}
|
||||
/>
|
||||
<MaxWidth
|
||||
archived={document.isArchived}
|
||||
showContents={showContents}
|
||||
isEditing={!readOnly}
|
||||
isFullWidth={document.fullWidth}
|
||||
column
|
||||
auto
|
||||
)}
|
||||
<RegisterKeyDown trigger="m" handler={this.goToMove} />
|
||||
<RegisterKeyDown trigger="e" handler={this.goToEdit} />
|
||||
<RegisterKeyDown trigger="Escape" handler={this.goBack} />
|
||||
<RegisterKeyDown trigger="h" handler={this.goToHistory} />
|
||||
<RegisterKeyDown
|
||||
trigger="p"
|
||||
handler={(event) => {
|
||||
if (isModKey(event) && event.shiftKey) {
|
||||
this.onPublish(event);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<RegisterKeyDown
|
||||
trigger="h"
|
||||
handler={(event) => {
|
||||
if (event.ctrlKey && event.altKey) {
|
||||
this.onToggleTableOfContents(event);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Background key={revision ? revision.id : document.id} column auto>
|
||||
<Route
|
||||
path={`${document.url}/move`}
|
||||
component={() => (
|
||||
<Modal
|
||||
title={`Move ${document.noun}`}
|
||||
onRequestClose={this.goBack}
|
||||
isOpen
|
||||
>
|
||||
<Notices document={document} readOnly={readOnly} />
|
||||
<React.Suspense fallback={<PlaceholderDocument />}>
|
||||
<Flex auto={!readOnly}>
|
||||
{revision ? (
|
||||
<RevisionViewer
|
||||
isDraft={document.isDraft}
|
||||
document={document}
|
||||
revision={revision}
|
||||
id={revision.id}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{showContents && (
|
||||
<Contents
|
||||
headings={this.headings}
|
||||
isFullWidth={document.fullWidth}
|
||||
/>
|
||||
)}
|
||||
<Editor
|
||||
id={document.id}
|
||||
key={embedsDisabled ? "disabled" : "enabled"}
|
||||
ref={this.editor}
|
||||
multiplayer={collaborativeEditing}
|
||||
<DocumentMove
|
||||
document={document}
|
||||
onRequestClose={this.goBack}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
/>
|
||||
<PageTitle
|
||||
title={document.titleWithDefault.replace(document.emoji || "", "")}
|
||||
favicon={document.emoji ? emojiToUrl(document.emoji) : undefined}
|
||||
/>
|
||||
{(this.isUploading || this.isSaving) && <LoadingIndicator />}
|
||||
<Container justify="center" column auto>
|
||||
{!readOnly && (
|
||||
<>
|
||||
<Prompt
|
||||
when={
|
||||
this.isEditorDirty &&
|
||||
!this.isUploading &&
|
||||
!team?.collaborativeEditing
|
||||
}
|
||||
message={(location, action) => {
|
||||
if (
|
||||
// a URL replace matching the current document indicates a title change
|
||||
// no guard is needed for this transition
|
||||
action === "REPLACE" &&
|
||||
location.pathname === editDocumentUrl(document)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return t(
|
||||
`You have unsaved changes.\nAre you sure you want to discard them?`
|
||||
) as string;
|
||||
}}
|
||||
/>
|
||||
<Prompt
|
||||
when={this.isUploading && !this.isEditorDirty}
|
||||
message={t(
|
||||
`Images are still uploading.\nAre you sure you want to discard them?`
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Header
|
||||
document={document}
|
||||
documentHasHeadings={hasHeadings}
|
||||
shareId={shareId}
|
||||
isRevision={!!revision}
|
||||
isDraft={document.isDraft}
|
||||
isEditing={!readOnly && !team?.collaborativeEditing}
|
||||
isSaving={this.isSaving}
|
||||
isPublishing={this.isPublishing}
|
||||
publishingIsDisabled={
|
||||
document.isSaving || this.isPublishing || this.isEmpty
|
||||
}
|
||||
savingIsDisabled={document.isSaving || this.isEmpty}
|
||||
sharedTree={this.props.sharedTree}
|
||||
onSelectTemplate={this.replaceDocument}
|
||||
onSave={this.onSave}
|
||||
headings={this.headings}
|
||||
/>
|
||||
<MaxWidth
|
||||
archived={document.isArchived}
|
||||
showContents={showContents}
|
||||
isEditing={!readOnly}
|
||||
isFullWidth={document.fullWidth}
|
||||
column
|
||||
auto
|
||||
>
|
||||
<Notices document={document} readOnly={readOnly} />
|
||||
<React.Suspense fallback={<PlaceholderDocument />}>
|
||||
<Flex auto={!readOnly}>
|
||||
{showContents && (
|
||||
<Contents
|
||||
headings={this.headings}
|
||||
isFullWidth={document.fullWidth}
|
||||
/>
|
||||
)}
|
||||
<Editor
|
||||
id={document.id}
|
||||
key={embedsDisabled ? "disabled" : "enabled"}
|
||||
ref={this.editor}
|
||||
multiplayer={collaborativeEditing}
|
||||
shareId={shareId}
|
||||
isDraft={document.isDraft}
|
||||
template={document.isTemplate}
|
||||
title={revision ? revision.title : document.title}
|
||||
document={document}
|
||||
value={readOnly ? value : undefined}
|
||||
defaultValue={value}
|
||||
embedsDisabled={embedsDisabled}
|
||||
onSynced={this.onSynced}
|
||||
onFileUploadStart={this.onFileUploadStart}
|
||||
onFileUploadStop={this.onFileUploadStop}
|
||||
onSearchLink={this.props.onSearchLink}
|
||||
onCreateLink={this.props.onCreateLink}
|
||||
onChangeTitle={this.onChangeTitle}
|
||||
onChange={this.onChange}
|
||||
onHeadingsChange={this.onHeadingsChange}
|
||||
onSave={this.onSave}
|
||||
onPublish={this.onPublish}
|
||||
onCancel={this.goBack}
|
||||
readOnly={readOnly}
|
||||
readOnlyWriteCheckboxes={readOnly && abilities.update}
|
||||
>
|
||||
{shareId && (
|
||||
<ReferencesWrapper isOnlyTitle={document.isOnlyTitle}>
|
||||
<PublicReferences
|
||||
shareId={shareId}
|
||||
isDraft={document.isDraft}
|
||||
template={document.isTemplate}
|
||||
document={document}
|
||||
value={readOnly ? document.text : undefined}
|
||||
defaultValue={document.text}
|
||||
embedsDisabled={embedsDisabled}
|
||||
onSynced={this.onSynced}
|
||||
onFileUploadStart={this.onFileUploadStart}
|
||||
onFileUploadStop={this.onFileUploadStop}
|
||||
onSearchLink={this.props.onSearchLink}
|
||||
onCreateLink={this.props.onCreateLink}
|
||||
onChangeTitle={this.onChangeTitle}
|
||||
onChange={this.onChange}
|
||||
onHeadingsChange={this.onHeadingsChange}
|
||||
onSave={this.onSave}
|
||||
onPublish={this.onPublish}
|
||||
onCancel={this.goBack}
|
||||
readOnly={readOnly}
|
||||
readOnlyWriteCheckboxes={readOnly && abilities.update}
|
||||
>
|
||||
{shareId && (
|
||||
<ReferencesWrapper
|
||||
isOnlyTitle={document.isOnlyTitle}
|
||||
>
|
||||
<PublicReferences
|
||||
shareId={shareId}
|
||||
documentId={document.id}
|
||||
sharedTree={this.props.sharedTree}
|
||||
/>
|
||||
</ReferencesWrapper>
|
||||
)}
|
||||
{!isShare && !revision && (
|
||||
<>
|
||||
<MarkAsViewed document={document} />
|
||||
<ReferencesWrapper
|
||||
isOnlyTitle={document.isOnlyTitle}
|
||||
>
|
||||
<References document={document} />
|
||||
</ReferencesWrapper>
|
||||
</>
|
||||
)}
|
||||
</Editor>
|
||||
documentId={document.id}
|
||||
sharedTree={this.props.sharedTree}
|
||||
/>
|
||||
</ReferencesWrapper>
|
||||
)}
|
||||
{!isShare && !revision && (
|
||||
<>
|
||||
<MarkAsViewed document={document} />
|
||||
<ReferencesWrapper isOnlyTitle={document.isOnlyTitle}>
|
||||
<References document={document} />
|
||||
</ReferencesWrapper>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</React.Suspense>
|
||||
</MaxWidth>
|
||||
{isShare &&
|
||||
!parseDomain(window.location.origin).custom &&
|
||||
!auth.user && (
|
||||
<Branding href="//www.getoutline.com?ref=sharelink" />
|
||||
)}
|
||||
</Container>
|
||||
</Background>
|
||||
{!isShare && (
|
||||
<>
|
||||
<KeyboardShortcutsButton />
|
||||
<ConnectionStatus />
|
||||
</>
|
||||
)}
|
||||
</DocumentContext.Provider>
|
||||
</Editor>
|
||||
</Flex>
|
||||
</React.Suspense>
|
||||
</MaxWidth>
|
||||
{isShare && !parseDomain(window.location.origin).custom && (
|
||||
<Branding href="//www.getoutline.com?ref=sharelink" />
|
||||
)}
|
||||
</Container>
|
||||
</Background>
|
||||
{!isShare && (
|
||||
<>
|
||||
<KeyboardShortcutsButton />
|
||||
<ConnectionStatus />
|
||||
</>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { Editor } from "~/editor";
|
||||
|
||||
export type DocumentContextValue = {
|
||||
/** The current editor instance for this document. */
|
||||
editor: Editor | null;
|
||||
/** Set the current editor instance for this document. */
|
||||
setEditor: (editor: Editor) => void;
|
||||
};
|
||||
|
||||
const DocumentContext = React.createContext<DocumentContextValue>({
|
||||
editor: null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
setEditor() {},
|
||||
});
|
||||
|
||||
export const useDocumentContext = () => React.useContext(DocumentContext);
|
||||
|
||||
export default DocumentContext;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user