mirror of
https://github.com/outline/outline.git
synced 2026-06-14 03:45:00 +03:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bfaed5d9cb | |||
| 5923281edb | |||
| 01bfe2bde7 | |||
| 9c6780adab | |||
| 93f1d4cfc7 | |||
| 21a43dfc5e | |||
| 47fafb5d69 | |||
| 32d76eeb9e | |||
| 99bef2c02b | |||
| 1125412972 | |||
| 2e0d160fcc | |||
| 21e31be517 |
@@ -14,6 +14,7 @@
|
||||
]
|
||||
],
|
||||
"plugins": [
|
||||
"lodash",
|
||||
"styled-components",
|
||||
[
|
||||
"@babel/plugin-proposal-decorators",
|
||||
|
||||
@@ -3,7 +3,7 @@ version: 2.1
|
||||
defaults: &defaults
|
||||
working_directory: ~/outline
|
||||
docker:
|
||||
- image: cimg/node:18.12
|
||||
- image: cimg/node:14.19
|
||||
- image: cimg/redis:5.0
|
||||
- image: cimg/postgres:14.2
|
||||
environment:
|
||||
@@ -20,7 +20,6 @@ defaults: &defaults
|
||||
SMTP_FROM_EMAIL: hello@example.com
|
||||
AWS_S3_UPLOAD_BUCKET_URL: https://s3.amazonaws.com
|
||||
AWS_S3_UPLOAD_BUCKET_NAME: outline-circle
|
||||
NODE_OPTIONS: --max-old-space-size=8000
|
||||
|
||||
executors:
|
||||
docker-publisher:
|
||||
@@ -94,18 +93,13 @@ jobs:
|
||||
command: yarn test:server --forceExit
|
||||
bundle-size:
|
||||
<<: *defaults
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: build-vite
|
||||
command: yarn vite:build
|
||||
- run:
|
||||
name: Send bundle stats to RelativeCI
|
||||
command: npx relative-ci-agent
|
||||
name: build-webpack
|
||||
command: yarn build:webpack
|
||||
build-image:
|
||||
executor: docker-publisher
|
||||
steps:
|
||||
|
||||
+7
-11
@@ -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
|
||||
|
||||
@@ -139,10 +139,6 @@ MAXIMUM_IMPORT_SIZE=5120000
|
||||
# requests and this ends up being duplicative
|
||||
DEBUG=http
|
||||
|
||||
# Configure lowest severity level for server logs. Should be one of
|
||||
# error, warn, info, http, verbose, debug and silly
|
||||
LOG_LEVEL=info
|
||||
|
||||
# For a complete Slack integration with search and posting to channels the
|
||||
# following configs are also needed, some more details
|
||||
# => https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
|
||||
@@ -154,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
|
||||
@@ -171,6 +164,9 @@ SMTP_REPLY_EMAIL=
|
||||
SMTP_TLS_CIPHERS=
|
||||
SMTP_SECURE=true
|
||||
|
||||
# Custom logo that displays on the authentication screen, scaled to height: 60px
|
||||
# TEAM_LOGO=https://example.com/images/logo.png
|
||||
|
||||
# The default interface language. See translate.getoutline.com for a list of
|
||||
# available language codes and their rough percentage translated.
|
||||
DEFAULT_LANGUAGE=en_US
|
||||
|
||||
@@ -24,13 +24,8 @@ on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "00 20 * * 0"
|
||||
permissions: {}
|
||||
jobs:
|
||||
build:
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write # to comment on pull request
|
||||
|
||||
name: calibreapp/image-actions
|
||||
runs-on: ubuntu-latest
|
||||
# Only run on main repo on and PRs that match the main repo.
|
||||
|
||||
+37
-12
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"workerIdleMemoryLimit": "0.75",
|
||||
"projects": [
|
||||
{
|
||||
"displayName": "server",
|
||||
"roots": ["<rootDir>/server"],
|
||||
"verbose": false,
|
||||
"roots": [
|
||||
"<rootDir>/server"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||
@@ -12,22 +14,33 @@
|
||||
"<rootDir>/__mocks__/console.js",
|
||||
"<rootDir>/server/test/env.ts"
|
||||
],
|
||||
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
|
||||
"setupFilesAfterEnv": [
|
||||
"<rootDir>/server/test/setup.ts"
|
||||
],
|
||||
"testEnvironment": "node",
|
||||
"runner": "@getoutline/jest-runner-serial"
|
||||
},
|
||||
{
|
||||
"displayName": "app",
|
||||
"roots": ["<rootDir>/app"],
|
||||
"verbose": false,
|
||||
"roots": [
|
||||
"<rootDir>/app"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
|
||||
},
|
||||
"modulePaths": ["<rootDir>/app"],
|
||||
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/app/test/setup.ts"],
|
||||
"modulePaths": [
|
||||
"<rootDir>/app"
|
||||
],
|
||||
"setupFiles": [
|
||||
"<rootDir>/__mocks__/window.js"
|
||||
],
|
||||
"setupFilesAfterEnv": [
|
||||
"<rootDir>/app/test/setup.ts"
|
||||
],
|
||||
"testEnvironment": "jsdom",
|
||||
"testEnvironmentOptions": {
|
||||
"url": "http://localhost"
|
||||
@@ -35,25 +48,37 @@
|
||||
},
|
||||
{
|
||||
"displayName": "shared-node",
|
||||
"roots": ["<rootDir>/shared"],
|
||||
"verbose": false,
|
||||
"roots": [
|
||||
"<rootDir>/shared"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/__mocks__/console.js"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/shared/test/setup.ts"],
|
||||
"setupFiles": [
|
||||
"<rootDir>/__mocks__/console.js"
|
||||
],
|
||||
"setupFilesAfterEnv": [
|
||||
"<rootDir>/shared/test/setup.ts"
|
||||
],
|
||||
"testEnvironment": "node"
|
||||
},
|
||||
{
|
||||
"displayName": "shared-jsdom",
|
||||
"roots": ["<rootDir>/shared"],
|
||||
"verbose": false,
|
||||
"roots": [
|
||||
"<rootDir>/shared"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
||||
"setupFiles": [
|
||||
"<rootDir>/__mocks__/window.js"
|
||||
],
|
||||
"testEnvironment": "jsdom",
|
||||
"testEnvironmentOptions": {
|
||||
"url": "http://localhost"
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
|
||||
# ---
|
||||
FROM node:18-alpine AS runner
|
||||
FROM node:16.14.2-alpine3.15 AS runner
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
|
||||
@@ -4,7 +4,6 @@ FROM node:16.14.2-alpine3.15 AS deps
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
COPY ./package.json ./yarn.lock ./
|
||||
COPY ./patches ./patches
|
||||
|
||||
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
|
||||
yarn cache clean
|
||||
|
||||
@@ -195,8 +195,8 @@
|
||||
"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)",
|
||||
"TEAM_LOGO": {
|
||||
"description": "A logo that will be displayed on the signed out home page",
|
||||
"required": false
|
||||
},
|
||||
"DEFAULT_LANGUAGE": {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
CollectionIcon,
|
||||
EditIcon,
|
||||
PadlockIcon,
|
||||
PlusIcon,
|
||||
StarredIcon,
|
||||
UnstarredIcon,
|
||||
@@ -11,8 +10,7 @@ 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/Icons/CollectionIcon";
|
||||
import DynamicCollectionIcon from "~/components/CollectionIcon";
|
||||
import { createAction } from "~/actions";
|
||||
import { CollectionSection } from "~/actions/sections";
|
||||
import history from "~/utils/history";
|
||||
@@ -23,7 +21,6 @@ const ColorCollectionIcon = ({ collection }: { collection: Collection }) => {
|
||||
|
||||
export const openCollection = createAction({
|
||||
name: ({ t }) => t("Open collection"),
|
||||
analyticsName: "Open collection",
|
||||
section: CollectionSection,
|
||||
shortcut: ["o", "c"],
|
||||
icon: <CollectionIcon />,
|
||||
@@ -43,7 +40,6 @@ export const openCollection = createAction({
|
||||
|
||||
export const createCollection = createAction({
|
||||
name: ({ t }) => t("New collection"),
|
||||
analyticsName: "New collection",
|
||||
section: CollectionSection,
|
||||
icon: <PlusIcon />,
|
||||
keywords: "create",
|
||||
@@ -60,9 +56,7 @@ export const createCollection = createAction({
|
||||
});
|
||||
|
||||
export const editCollection = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? `${t("Edit")}…` : t("Edit collection"),
|
||||
analyticsName: "Edit collection",
|
||||
name: ({ t }) => t("Edit collection"),
|
||||
section: CollectionSection,
|
||||
icon: <EditIcon />,
|
||||
visible: ({ stores, activeCollectionId }) =>
|
||||
@@ -85,30 +79,8 @@ export const editCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const editCollectionPermissions = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? `${t("Permissions")}…` : t("Collection permissions"),
|
||||
analyticsName: "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"),
|
||||
analyticsName: "Star collection",
|
||||
section: CollectionSection,
|
||||
icon: <StarredIcon />,
|
||||
keywords: "favorite bookmark",
|
||||
@@ -134,7 +106,6 @@ export const starCollection = createAction({
|
||||
|
||||
export const unstarCollection = createAction({
|
||||
name: ({ t }) => t("Unstar"),
|
||||
analyticsName: "Unstar collection",
|
||||
section: CollectionSection,
|
||||
icon: <UnstarredIcon />,
|
||||
keywords: "unfavorite unbookmark",
|
||||
|
||||
@@ -17,36 +17,20 @@ import {
|
||||
TrashIcon,
|
||||
CrossIcon,
|
||||
ArchiveIcon,
|
||||
ShuffleIcon,
|
||||
HistoryIcon,
|
||||
LightBulbIcon,
|
||||
UnpublishIcon,
|
||||
PublishIcon,
|
||||
CommentIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { ExportContentType, TeamPreference } from "@shared/types";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import DocumentDelete from "~/scenes/DocumentDelete";
|
||||
import DocumentMove from "~/scenes/DocumentMove";
|
||||
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
|
||||
import DocumentPublish from "~/scenes/DocumentPublish";
|
||||
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
|
||||
import { createAction } from "~/actions";
|
||||
import { DocumentSection } from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
documentInsightsUrl,
|
||||
documentHistoryUrl,
|
||||
homePath,
|
||||
newDocumentPath,
|
||||
searchPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { homePath, newDocumentPath, searchPath } from "~/utils/routeHelpers";
|
||||
|
||||
export const openDocument = createAction({
|
||||
name: ({ t }) => t("Open document"),
|
||||
analyticsName: "Open document",
|
||||
section: DocumentSection,
|
||||
shortcut: ["o", "d"],
|
||||
keywords: "go to",
|
||||
@@ -71,13 +55,14 @@ export const openDocument = createAction({
|
||||
|
||||
export const createDocument = createAction({
|
||||
name: ({ t }) => t("New document"),
|
||||
analyticsName: "New document",
|
||||
section: DocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, stores }) =>
|
||||
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update,
|
||||
perform: ({ activeCollectionId, inStarredSection }) =>
|
||||
activeCollectionId &&
|
||||
history.push(newDocumentPath(activeCollectionId), {
|
||||
starred: inStarredSection,
|
||||
}),
|
||||
@@ -85,7 +70,6 @@ export const createDocument = createAction({
|
||||
|
||||
export const starDocument = createAction({
|
||||
name: ({ t }) => t("Star"),
|
||||
analyticsName: "Star document",
|
||||
section: DocumentSection,
|
||||
icon: <StarredIcon />,
|
||||
keywords: "favorite bookmark",
|
||||
@@ -110,7 +94,6 @@ export const starDocument = createAction({
|
||||
|
||||
export const unstarDocument = createAction({
|
||||
name: ({ t }) => t("Unstar"),
|
||||
analyticsName: "Unstar document",
|
||||
section: DocumentSection,
|
||||
icon: <UnstarredIcon />,
|
||||
keywords: "unfavorite unbookmark",
|
||||
@@ -134,76 +117,8 @@ export const unstarDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const publishDocument = createAction({
|
||||
name: ({ t }) => t("Publish"),
|
||||
analyticsName: "Publish document",
|
||||
section: DocumentSection,
|
||||
icon: <PublishIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return (
|
||||
!!document?.isDraft && stores.policies.abilities(activeDocumentId).update
|
||||
);
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (document?.publishedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (document?.collectionId) {
|
||||
await document.save({
|
||||
publish: true,
|
||||
});
|
||||
stores.toasts.showToast(t("Document published"), {
|
||||
type: "success",
|
||||
});
|
||||
} else if (document) {
|
||||
stores.dialogs.openModal({
|
||||
title: t("Publish document"),
|
||||
isCentered: true,
|
||||
content: <DocumentPublish document={document} />,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const unpublishDocument = createAction({
|
||||
name: ({ t }) => t("Unpublish"),
|
||||
analyticsName: "Unpublish document",
|
||||
section: DocumentSection,
|
||||
icon: <UnpublishIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
return stores.policies.abilities(activeDocumentId).unpublish;
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
document?.unpublish();
|
||||
|
||||
stores.toasts.showToast(t("Document unpublished"), {
|
||||
type: "success",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const subscribeDocument = createAction({
|
||||
name: ({ t }) => t("Subscribe"),
|
||||
analyticsName: "Subscribe to document",
|
||||
section: DocumentSection,
|
||||
icon: <SubscribeIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
@@ -235,7 +150,6 @@ export const subscribeDocument = createAction({
|
||||
|
||||
export const unsubscribeDocument = createAction({
|
||||
name: ({ t }) => t("Unsubscribe"),
|
||||
analyticsName: "Unsubscribe from document",
|
||||
section: DocumentSection,
|
||||
icon: <UnsubscribeIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
@@ -267,7 +181,6 @@ export const unsubscribeDocument = createAction({
|
||||
|
||||
export const downloadDocumentAsHTML = createAction({
|
||||
name: ({ t }) => t("HTML"),
|
||||
analyticsName: "Download document as HTML",
|
||||
section: DocumentSection,
|
||||
keywords: "html export",
|
||||
icon: <DownloadIcon />,
|
||||
@@ -280,41 +193,12 @@ export const downloadDocumentAsHTML = createAction({
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document?.download(ExportContentType.Html);
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsPDF = createAction({
|
||||
name: ({ t }) => t("PDF"),
|
||||
analyticsName: "Download document as PDF",
|
||||
section: DocumentSection,
|
||||
keywords: "export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId &&
|
||||
stores.policies.abilities(activeDocumentId).download &&
|
||||
env.PDF_EXPORT_ENABLED,
|
||||
perform: ({ activeDocumentId, t, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = stores.toasts.showToast(`${t("Exporting")}…`, {
|
||||
type: "loading",
|
||||
timeout: 30 * 1000,
|
||||
});
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document
|
||||
?.download(ExportContentType.Pdf)
|
||||
.finally(() => id && stores.toasts.hideToast(id));
|
||||
document?.download("text/html");
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsMarkdown = createAction({
|
||||
name: ({ t }) => t("Markdown"),
|
||||
analyticsName: "Download document as Markdown",
|
||||
section: DocumentSection,
|
||||
keywords: "md markdown export",
|
||||
icon: <DownloadIcon />,
|
||||
@@ -327,28 +211,22 @@ export const downloadDocumentAsMarkdown = createAction({
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document?.download(ExportContentType.Markdown);
|
||||
document?.download("text/markdown");
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocument = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Download") : t("Download document"),
|
||||
analyticsName: "Download document",
|
||||
section: DocumentSection,
|
||||
icon: <DownloadIcon />,
|
||||
keywords: "export",
|
||||
children: [
|
||||
downloadDocumentAsHTML,
|
||||
downloadDocumentAsPDF,
|
||||
downloadDocumentAsMarkdown,
|
||||
],
|
||||
children: [downloadDocumentAsHTML, downloadDocumentAsMarkdown],
|
||||
});
|
||||
|
||||
export const duplicateDocument = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Duplicate") : t("Duplicate document"),
|
||||
analyticsName: "Duplicate document",
|
||||
section: DocumentSection,
|
||||
icon: <DuplicateIcon />,
|
||||
keywords: "copy",
|
||||
@@ -375,17 +253,7 @@ export const duplicateDocument = createAction({
|
||||
* of the collection for all collection members to see.
|
||||
*/
|
||||
export const pinDocumentToCollection = createAction({
|
||||
name: ({ activeDocumentId = "", t, stores }) => {
|
||||
const selectedDocument = stores.documents.get(activeDocumentId);
|
||||
const collectionName = selectedDocument
|
||||
? stores.documents.getCollectionForDocument(selectedDocument)?.name
|
||||
: t("collection");
|
||||
|
||||
return t("Pin to {{collectionName}}", {
|
||||
collectionName,
|
||||
});
|
||||
},
|
||||
analyticsName: "Pin document to collection",
|
||||
name: ({ t }) => t("Pin to collection"),
|
||||
section: DocumentSection,
|
||||
icon: <PinIcon />,
|
||||
iconInContextMenu: false,
|
||||
@@ -421,7 +289,6 @@ export const pinDocumentToCollection = createAction({
|
||||
*/
|
||||
export const pinDocumentToHome = createAction({
|
||||
name: ({ t }) => t("Pin to home"),
|
||||
analyticsName: "Pin document to home",
|
||||
section: DocumentSection,
|
||||
icon: <PinIcon />,
|
||||
iconInContextMenu: false,
|
||||
@@ -453,7 +320,6 @@ export const pinDocumentToHome = createAction({
|
||||
|
||||
export const pinDocument = createAction({
|
||||
name: ({ t }) => t("Pin"),
|
||||
analyticsName: "Pin document",
|
||||
section: DocumentSection,
|
||||
icon: <PinIcon />,
|
||||
children: [pinDocumentToCollection, pinDocumentToHome],
|
||||
@@ -462,18 +328,16 @@ export const pinDocument = createAction({
|
||||
export const printDocument = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Print") : t("Print document"),
|
||||
analyticsName: "Print document",
|
||||
section: DocumentSection,
|
||||
icon: <PrintIcon />,
|
||||
visible: ({ activeDocumentId }) => !!(activeDocumentId && window.print),
|
||||
visible: ({ activeDocumentId }) => !!activeDocumentId,
|
||||
perform: async () => {
|
||||
queueMicrotask(window.print);
|
||||
window.print();
|
||||
},
|
||||
});
|
||||
|
||||
export const importDocument = createAction({
|
||||
name: ({ t }) => t("Import document"),
|
||||
analyticsName: "Import document",
|
||||
section: DocumentSection,
|
||||
icon: <ImportIcon />,
|
||||
keywords: "upload",
|
||||
@@ -522,7 +386,6 @@ export const importDocument = createAction({
|
||||
|
||||
export const createTemplate = createAction({
|
||||
name: ({ t }) => t("Templatize"),
|
||||
analyticsName: "Templatize document",
|
||||
section: DocumentSection,
|
||||
icon: <ShapesIcon />,
|
||||
keywords: "new create template",
|
||||
@@ -553,32 +416,12 @@ export const createTemplate = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const openRandomDocument = createAction({
|
||||
id: "random",
|
||||
name: ({ t }) => t(`Open random document`),
|
||||
analyticsName: "Open random document",
|
||||
section: DocumentSection,
|
||||
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",
|
||||
section: DocumentSection,
|
||||
name: ({ t }) =>
|
||||
t(`Search documents for "{{searchQuery}}"`, { searchQuery }),
|
||||
analyticsName: "Search documents",
|
||||
section: DocumentSection,
|
||||
icon: <SearchIcon />,
|
||||
perform: () => history.push(searchPath(searchQuery)),
|
||||
visible: ({ location }) => location.pathname !== searchPath(),
|
||||
@@ -586,7 +429,6 @@ export const searchDocumentsForQuery = (searchQuery: string) =>
|
||||
|
||||
export const moveDocument = createAction({
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move document",
|
||||
section: DocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
@@ -603,11 +445,15 @@ export const moveDocument = createAction({
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Move {{ documentType }}", {
|
||||
documentType: document.noun,
|
||||
title: t("Move {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
}),
|
||||
isCentered: true,
|
||||
content: <DocumentMove document={document} />,
|
||||
content: (
|
||||
<DocumentMove
|
||||
document={document}
|
||||
onRequestClose={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -615,7 +461,6 @@ export const moveDocument = createAction({
|
||||
|
||||
export const archiveDocument = createAction({
|
||||
name: ({ t }) => t("Archive"),
|
||||
analyticsName: "Archive document",
|
||||
section: DocumentSection,
|
||||
icon: <ArchiveIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
@@ -641,7 +486,6 @@ export const archiveDocument = createAction({
|
||||
|
||||
export const deleteDocument = createAction({
|
||||
name: ({ t }) => t("Delete"),
|
||||
analyticsName: "Delete document",
|
||||
section: DocumentSection,
|
||||
icon: <TrashIcon />,
|
||||
dangerous: true,
|
||||
@@ -676,7 +520,6 @@ export const deleteDocument = createAction({
|
||||
|
||||
export const permanentlyDeleteDocument = createAction({
|
||||
name: ({ t }) => t("Permanently delete"),
|
||||
analyticsName: "Permanently delete document",
|
||||
section: DocumentSection,
|
||||
icon: <CrossIcon />,
|
||||
dangerous: true,
|
||||
@@ -709,71 +552,6 @@ export const permanentlyDeleteDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const openDocumentComments = createAction({
|
||||
name: ({ t }) => t("Comments"),
|
||||
analyticsName: "Open comments",
|
||||
section: DocumentSection,
|
||||
icon: <CommentIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId ?? "");
|
||||
return (
|
||||
!!activeDocumentId &&
|
||||
can.read &&
|
||||
!can.restore &&
|
||||
!!stores.auth.team?.getPreference(TeamPreference.Commenting)
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.ui.toggleComments();
|
||||
},
|
||||
});
|
||||
|
||||
export const openDocumentHistory = createAction({
|
||||
name: ({ t }) => t("History"),
|
||||
analyticsName: "Open document history",
|
||||
section: DocumentSection,
|
||||
icon: <HistoryIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId ?? "");
|
||||
return !!activeDocumentId && can.read && !can.restore;
|
||||
},
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
history.push(documentHistoryUrl(document));
|
||||
},
|
||||
});
|
||||
|
||||
export const openDocumentInsights = createAction({
|
||||
name: ({ t }) => t("Insights"),
|
||||
analyticsName: "Open document insights",
|
||||
section: DocumentSection,
|
||||
icon: <LightBulbIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId ?? "");
|
||||
return !!activeDocumentId && can.read;
|
||||
},
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
history.push(documentInsightsUrl(document));
|
||||
},
|
||||
});
|
||||
|
||||
export const rootDocumentActions = [
|
||||
openDocument,
|
||||
archiveDocument,
|
||||
@@ -784,18 +562,12 @@ export const rootDocumentActions = [
|
||||
downloadDocument,
|
||||
starDocument,
|
||||
unstarDocument,
|
||||
publishDocument,
|
||||
unpublishDocument,
|
||||
subscribeDocument,
|
||||
unsubscribeDocument,
|
||||
duplicateDocument,
|
||||
moveDocument,
|
||||
openRandomDocument,
|
||||
permanentlyDeleteDocument,
|
||||
printDocument,
|
||||
pinDocumentToCollection,
|
||||
pinDocumentToHome,
|
||||
openDocumentComments,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
];
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
EmailIcon,
|
||||
LogoutIcon,
|
||||
ProfileIcon,
|
||||
BrowserIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import {
|
||||
@@ -25,14 +24,10 @@ import SearchQuery from "~/models/SearchQuery";
|
||||
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
|
||||
import { createAction } from "~/actions";
|
||||
import { NavigationSection, RecentSearchesSection } from "~/actions/sections";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { isMac } from "~/utils/browser";
|
||||
import history from "~/utils/history";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import {
|
||||
organizationSettingsPath,
|
||||
profileSettingsPath,
|
||||
accountPreferencesPath,
|
||||
homePath,
|
||||
searchPath,
|
||||
draftsPath,
|
||||
@@ -43,7 +38,6 @@ import {
|
||||
|
||||
export const navigateToHome = createAction({
|
||||
name: ({ t }) => t("Home"),
|
||||
analyticsName: "Navigate to home",
|
||||
section: NavigationSection,
|
||||
shortcut: ["d"],
|
||||
icon: <HomeIcon />,
|
||||
@@ -55,14 +49,12 @@ export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
|
||||
createAction({
|
||||
section: RecentSearchesSection,
|
||||
name: searchQuery.query,
|
||||
analyticsName: "Navigate to recent search query",
|
||||
icon: <SearchIcon />,
|
||||
perform: () => history.push(searchPath(searchQuery.query)),
|
||||
});
|
||||
|
||||
export const navigateToDrafts = createAction({
|
||||
name: ({ t }) => t("Drafts"),
|
||||
analyticsName: "Navigate to drafts",
|
||||
section: NavigationSection,
|
||||
icon: <EditIcon />,
|
||||
perform: () => history.push(draftsPath()),
|
||||
@@ -71,7 +63,6 @@ export const navigateToDrafts = createAction({
|
||||
|
||||
export const navigateToTemplates = createAction({
|
||||
name: ({ t }) => t("Templates"),
|
||||
analyticsName: "Navigate to templates",
|
||||
section: NavigationSection,
|
||||
icon: <ShapesIcon />,
|
||||
perform: () => history.push(templatesPath()),
|
||||
@@ -80,7 +71,6 @@ export const navigateToTemplates = createAction({
|
||||
|
||||
export const navigateToArchive = createAction({
|
||||
name: ({ t }) => t("Archive"),
|
||||
analyticsName: "Navigate to archive",
|
||||
section: NavigationSection,
|
||||
shortcut: ["g", "a"],
|
||||
icon: <ArchiveIcon />,
|
||||
@@ -90,7 +80,6 @@ export const navigateToArchive = createAction({
|
||||
|
||||
export const navigateToTrash = createAction({
|
||||
name: ({ t }) => t("Trash"),
|
||||
analyticsName: "Navigate to trash",
|
||||
section: NavigationSection,
|
||||
icon: <TrashIcon />,
|
||||
perform: () => history.push(trashPath()),
|
||||
@@ -99,7 +88,6 @@ export const navigateToTrash = createAction({
|
||||
|
||||
export const navigateToSettings = createAction({
|
||||
name: ({ t }) => t("Settings"),
|
||||
analyticsName: "Navigate to settings",
|
||||
section: NavigationSection,
|
||||
shortcut: ["g", "s"],
|
||||
icon: <SettingsIcon />,
|
||||
@@ -110,25 +98,14 @@ export const navigateToSettings = createAction({
|
||||
|
||||
export const navigateToProfileSettings = createAction({
|
||||
name: ({ t }) => t("Profile"),
|
||||
analyticsName: "Navigate to profile settings",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <ProfileIcon />,
|
||||
perform: () => history.push(profileSettingsPath()),
|
||||
});
|
||||
|
||||
export const navigateToAccountPreferences = createAction({
|
||||
name: ({ t }) => t("Preferences"),
|
||||
analyticsName: "Navigate to account preferences",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <SettingsIcon />,
|
||||
perform: () => history.push(accountPreferencesPath()),
|
||||
});
|
||||
|
||||
export const openAPIDocumentation = createAction({
|
||||
name: ({ t }) => t("API documentation"),
|
||||
analyticsName: "Open API documentation",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <OpenIcon />,
|
||||
@@ -137,7 +114,6 @@ export const openAPIDocumentation = createAction({
|
||||
|
||||
export const openFeedbackUrl = createAction({
|
||||
name: ({ t }) => t("Send us feedback"),
|
||||
analyticsName: "Open feedback",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <EmailIcon />,
|
||||
@@ -146,14 +122,12 @@ export const openFeedbackUrl = createAction({
|
||||
|
||||
export const openBugReportUrl = createAction({
|
||||
name: ({ t }) => t("Report a bug"),
|
||||
analyticsName: "Open bug report",
|
||||
section: NavigationSection,
|
||||
perform: () => window.open(githubIssuesUrl()),
|
||||
});
|
||||
|
||||
export const openChangelog = createAction({
|
||||
name: ({ t }) => t("Changelog"),
|
||||
analyticsName: "Open changelog",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <OpenIcon />,
|
||||
@@ -162,7 +136,6 @@ export const openChangelog = createAction({
|
||||
|
||||
export const openKeyboardShortcuts = createAction({
|
||||
name: ({ t }) => t("Keyboard shortcuts"),
|
||||
analyticsName: "Open keyboard shortcuts",
|
||||
section: NavigationSection,
|
||||
shortcut: ["?"],
|
||||
iconInContextMenu: false,
|
||||
@@ -175,24 +148,8 @@ export const openKeyboardShortcuts = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadApp = createAction({
|
||||
name: ({ t }) =>
|
||||
t("Download {{ platform }} app", {
|
||||
platform: isMac() ? "macOS" : "Windows",
|
||||
}),
|
||||
analyticsName: "Download app",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <BrowserIcon />,
|
||||
visible: () => !Desktop.isElectron() && isMac() && isCloudHosted,
|
||||
perform: () => {
|
||||
window.open("https://desktop.getoutline.com");
|
||||
},
|
||||
});
|
||||
|
||||
export const logout = createAction({
|
||||
name: ({ t }) => t("Log out"),
|
||||
analyticsName: "Log out",
|
||||
section: NavigationSection,
|
||||
icon: <LogoutIcon />,
|
||||
perform: () => stores.auth.logout(),
|
||||
@@ -204,7 +161,6 @@ export const rootNavigationActions = [
|
||||
navigateToTemplates,
|
||||
navigateToArchive,
|
||||
navigateToTrash,
|
||||
downloadApp,
|
||||
openAPIDocumentation,
|
||||
openFeedbackUrl,
|
||||
openBugReportUrl,
|
||||
|
||||
@@ -1,87 +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"),
|
||||
analyticsName: "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"),
|
||||
analyticsName: "Copy link to revision",
|
||||
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 = [];
|
||||
@@ -7,7 +7,6 @@ import { SettingsSection } from "~/actions/sections";
|
||||
|
||||
export const changeToDarkTheme = createAction({
|
||||
name: ({ t }) => t("Dark"),
|
||||
analyticsName: "Change to dark theme",
|
||||
icon: <MoonIcon />,
|
||||
iconInContextMenu: false,
|
||||
keywords: "theme dark night",
|
||||
@@ -18,7 +17,6 @@ export const changeToDarkTheme = createAction({
|
||||
|
||||
export const changeToLightTheme = createAction({
|
||||
name: ({ t }) => t("Light"),
|
||||
analyticsName: "Change to light theme",
|
||||
icon: <SunIcon />,
|
||||
iconInContextMenu: false,
|
||||
keywords: "theme light day",
|
||||
@@ -29,7 +27,6 @@ export const changeToLightTheme = createAction({
|
||||
|
||||
export const changeToSystemTheme = createAction({
|
||||
name: ({ t }) => t("System"),
|
||||
analyticsName: "Change to system theme",
|
||||
icon: <BrowserIcon />,
|
||||
iconInContextMenu: false,
|
||||
keywords: "theme system default",
|
||||
@@ -41,7 +38,6 @@ export const changeToSystemTheme = createAction({
|
||||
export const changeTheme = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Appearance") : t("Change theme"),
|
||||
analyticsName: "Change theme",
|
||||
placeholder: ({ t }) => t("Change theme to"),
|
||||
icon: () =>
|
||||
stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />,
|
||||
|
||||
@@ -1,76 +1,40 @@
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { stringToColor } from "@shared/utils/color";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import TeamNew from "~/scenes/TeamNew";
|
||||
import TeamLogo from "~/components/TeamLogo";
|
||||
import { createAction } from "~/actions";
|
||||
import { ActionContext } from "~/types";
|
||||
import { TeamSection } from "../sections";
|
||||
import { loadSessionsFromCookie } from "~/hooks/useSessions";
|
||||
|
||||
export const createTeamsList = ({ stores }: { stores: RootStore }) => {
|
||||
return (
|
||||
stores.auth.availableTeams?.map((session) => ({
|
||||
id: `switch-${session.id}`,
|
||||
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;
|
||||
},
|
||||
children: ({ currentTeamId }) => {
|
||||
const sessions = loadSessionsFromCookie();
|
||||
const otherSessions = sessions.filter(
|
||||
(session) => session.teamId !== currentTeamId
|
||||
);
|
||||
|
||||
return otherSessions.map((session) => ({
|
||||
id: session.url,
|
||||
name: session.name,
|
||||
analyticsName: "Switch workspace",
|
||||
section: TeamSection,
|
||||
keywords: "change switch workspace organization team",
|
||||
icon: () => (
|
||||
<StyledTeamLogo
|
||||
alt={session.name}
|
||||
model={{
|
||||
initial: session.name[0],
|
||||
avatarUrl: session.avatarUrl,
|
||||
id: session.id,
|
||||
color: stringToColor(session.id),
|
||||
}}
|
||||
size={24}
|
||||
/>
|
||||
),
|
||||
visible: ({ currentTeamId }: ActionContext) =>
|
||||
currentTeamId !== session.id,
|
||||
section: "Account",
|
||||
icon: <Logo alt={session.name} src={session.logoUrl} />,
|
||||
perform: () => (window.location.href = session.url),
|
||||
})) ?? []
|
||||
);
|
||||
};
|
||||
|
||||
export const switchTeam = createAction({
|
||||
name: ({ t }) => t("Switch workspace"),
|
||||
placeholder: ({ t }) => t("Select a workspace"),
|
||||
analyticsName: "Switch workspace",
|
||||
keywords: "change switch workspace organization team",
|
||||
section: TeamSection,
|
||||
visible: ({ stores }) =>
|
||||
!!stores.auth.availableTeams && stores.auth.availableTeams?.length > 1,
|
||||
children: createTeamsList,
|
||||
});
|
||||
|
||||
export const createTeam = createAction({
|
||||
name: ({ t }) => `${t("New workspace")}…`,
|
||||
analyticsName: "New workspace",
|
||||
keywords: "create change switch workspace organization team",
|
||||
section: TeamSection,
|
||||
icon: <PlusIcon />,
|
||||
visible: ({ stores, currentTeamId }) => {
|
||||
return stores.policies.abilities(currentTeamId ?? "").createTeam;
|
||||
},
|
||||
perform: ({ t, event, stores }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
const { user } = stores.auth;
|
||||
user &&
|
||||
stores.dialogs.openModal({
|
||||
title: t("Create a workspace"),
|
||||
content: <TeamNew user={user} />,
|
||||
});
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
const StyledTeamLogo = styled(TeamLogo)`
|
||||
const Logo = styled("img")`
|
||||
border-radius: 2px;
|
||||
border: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
export const rootTeamActions = [switchTeam, createTeam];
|
||||
export const rootTeamActions = [changeTeam];
|
||||
|
||||
@@ -7,9 +7,8 @@ import { UserSection } from "~/actions/sections";
|
||||
|
||||
export const inviteUser = createAction({
|
||||
name: ({ t }) => `${t("Invite people")}…`,
|
||||
analyticsName: "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,
|
||||
|
||||
+4
-31
@@ -9,7 +9,6 @@ import {
|
||||
MenuItemButton,
|
||||
MenuItemWithChildren,
|
||||
} from "~/types";
|
||||
import Analytics from "~/utils/Analytics";
|
||||
|
||||
function resolve<T>(value: any, context: ActionContext): T {
|
||||
return typeof value === "function" ? value(context) : value;
|
||||
@@ -18,23 +17,6 @@ function resolve<T>(value: any, context: ActionContext): T {
|
||||
export function createAction(definition: Optional<Action, "id">): Action {
|
||||
return {
|
||||
...definition,
|
||||
perform: definition.perform
|
||||
? (context) => {
|
||||
// We muse use the specific analytics name here as the action name is
|
||||
// translated and potentially contains user strings.
|
||||
if (definition.analyticsName) {
|
||||
Analytics.track("perform_action", definition.analyticsName, {
|
||||
context: context.isButton
|
||||
? "button"
|
||||
: context.isCommandBar
|
||||
? "commandbar"
|
||||
: "contextmenu",
|
||||
});
|
||||
}
|
||||
|
||||
return definition.perform?.(context);
|
||||
}
|
||||
: undefined,
|
||||
id: uuidv4(),
|
||||
};
|
||||
}
|
||||
@@ -75,16 +57,8 @@ export function actionToMenuItem(
|
||||
icon,
|
||||
visible,
|
||||
dangerous: action.dangerous,
|
||||
onClick: () => {
|
||||
try {
|
||||
action.perform?.(context);
|
||||
} catch (err) {
|
||||
context.stores.toasts.showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
selected: action.selected?.(context),
|
||||
onClick: () => action.perform && action.perform(context),
|
||||
selected: action.selected ? action.selected(context) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,7 +70,7 @@ export function actionToKBar(
|
||||
return [];
|
||||
}
|
||||
|
||||
const resolvedIcon = resolve<React.ReactElement>(action.icon, context);
|
||||
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon, context);
|
||||
const resolvedChildren = resolve<Action[]>(action.children, context);
|
||||
const resolvedSection = resolve<string>(action.section, context);
|
||||
const resolvedName = resolve<string>(action.name, context);
|
||||
@@ -111,13 +85,12 @@ export function actionToKBar(
|
||||
{
|
||||
id: action.id,
|
||||
name: resolvedName,
|
||||
analyticsName: action.analyticsName,
|
||||
section: resolvedSection,
|
||||
placeholder: resolvedPlaceholder,
|
||||
keywords: action.keywords ?? "",
|
||||
shortcut: action.shortcut || [],
|
||||
icon: resolvedIcon,
|
||||
perform: action.perform ? () => action.perform?.(context) : undefined,
|
||||
perform: action.perform ? () => action?.perform?.(context) : undefined,
|
||||
},
|
||||
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
|
||||
].concat(children.map((child) => ({ ...child, parent: action.id })));
|
||||
|
||||
@@ -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.ComponentPropsWithoutRef<"button"> & {
|
||||
export type Props = {
|
||||
/** Show the button in a disabled state */
|
||||
disabled?: boolean;
|
||||
/** Hide the button entirely if action is not applicable */
|
||||
@@ -20,47 +20,40 @@ export type Props = React.ComponentPropsWithoutRef<"button"> & {
|
||||
*/
|
||||
const ActionButton = React.forwardRef(
|
||||
(
|
||||
{ action, context, tooltip, hideOnActionDisabled, ...rest }: Props,
|
||||
{
|
||||
action,
|
||||
context,
|
||||
tooltip,
|
||||
hideOnActionDisabled,
|
||||
...rest
|
||||
}: Props & React.HTMLAttributes<HTMLButtonElement>,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) => {
|
||||
const [executing, setExecuting] = React.useState(false);
|
||||
const disabled = rest.disabled;
|
||||
|
||||
if (!context || !action) {
|
||||
return <button {...rest} ref={ref} />;
|
||||
}
|
||||
|
||||
const actionContext = { ...context, isButton: true };
|
||||
|
||||
if (
|
||||
action?.visible &&
|
||||
!action.visible(actionContext) &&
|
||||
hideOnActionDisabled
|
||||
) {
|
||||
if (action?.visible && !action.visible(context) && hideOnActionDisabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label =
|
||||
typeof action.name === "function"
|
||||
? action.name(actionContext)
|
||||
: action.name;
|
||||
typeof action.name === "function" ? action.name(context) : action.name;
|
||||
|
||||
const button = (
|
||||
<button
|
||||
{...rest}
|
||||
aria-label={label}
|
||||
disabled={disabled || executing}
|
||||
disabled={disabled}
|
||||
ref={ref}
|
||||
onClick={
|
||||
action?.perform && actionContext
|
||||
action?.perform && context
|
||||
? (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const response = action.perform?.(actionContext);
|
||||
if (response?.finally) {
|
||||
setExecuting(true);
|
||||
response.finally(() => setExecuting(false));
|
||||
}
|
||||
action.perform?.(context);
|
||||
}
|
||||
: rest.onClick
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/* global ga */
|
||||
import * as React from "react";
|
||||
import env from "~/env";
|
||||
|
||||
export default class Analytics extends React.Component {
|
||||
componentDidMount() {
|
||||
if (!env.GOOGLE_ANALYTICS_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
// standard Google Analytics script
|
||||
window.ga =
|
||||
window.ga ||
|
||||
function (...args) {
|
||||
(ga.q = ga.q || []).push(args);
|
||||
};
|
||||
|
||||
ga.l = +new Date();
|
||||
ga("create", env.GOOGLE_ANALYTICS_ID, "auto");
|
||||
ga("set", {
|
||||
dimension1: "true",
|
||||
});
|
||||
ga("send", "pageview");
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://www.google-analytics.com/analytics.js";
|
||||
script.async = true;
|
||||
|
||||
// Track PWA install event
|
||||
window.addEventListener("appinstalled", () => {
|
||||
ga("send", "event", "pwa", "install");
|
||||
});
|
||||
|
||||
if (document.body) {
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.props.children || null;
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/* eslint-disable prefer-rest-params */
|
||||
/* global ga */
|
||||
import { escape } from "lodash";
|
||||
import * as React from "react";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import env from "~/env";
|
||||
|
||||
const Analytics: React.FC = ({ children }) => {
|
||||
// Google Analytics 3
|
||||
React.useEffect(() => {
|
||||
if (!env.GOOGLE_ANALYTICS_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
// standard Google Analytics script
|
||||
window.ga =
|
||||
window.ga ||
|
||||
function (...args) {
|
||||
(ga.q = ga.q || []).push(args);
|
||||
};
|
||||
|
||||
ga.l = +new Date();
|
||||
ga("create", env.GOOGLE_ANALYTICS_ID, "auto");
|
||||
ga("send", "pageview");
|
||||
const script = document.createElement("script");
|
||||
script.type = "text/javascript";
|
||||
script.src = "https://www.google-analytics.com/analytics.js";
|
||||
script.async = true;
|
||||
|
||||
// Track PWA install event
|
||||
window.addEventListener("appinstalled", () => {
|
||||
ga("send", "event", "pwa", "install");
|
||||
});
|
||||
|
||||
document.getElementsByTagName("head")[0]?.appendChild(script);
|
||||
}, []);
|
||||
|
||||
// Google Analytics 4
|
||||
React.useEffect(() => {
|
||||
if (env.analytics.service !== IntegrationService.GoogleAnalytics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const measurementId = escape(env.analytics.settings?.measurementId);
|
||||
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
window.gtag = function () {
|
||||
window.dataLayer.push(arguments);
|
||||
};
|
||||
window.gtag("js", new Date());
|
||||
window.gtag("config", measurementId, {
|
||||
allow_google_signals: false,
|
||||
restricted_data_processing: true,
|
||||
});
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.type = "text/javascript";
|
||||
script.src = `https://www.googletagmanager.com/gtag/js?id=${measurementId}`;
|
||||
script.async = true;
|
||||
document.getElementsByTagName("head")[0]?.appendChild(script);
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default Analytics;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
fill?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function GoogleLogo({ size = 34, fill = "#FFF", className }: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={fill}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 34 34"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<g>
|
||||
<path d="M32.6162791,13.9090909 L16.8837209,13.9090909 L16.8837209,20.4772727 L25.9395349,20.4772727 C25.0953488,24.65 21.5651163,27.0454545 16.8837209,27.0454545 C11.3581395,27.0454545 6.90697674,22.5636364 6.90697674,17 C6.90697674,11.4363636 11.3581395,6.95454545 16.8837209,6.95454545 C19.2627907,6.95454545 21.4116279,7.80454545 23.1,9.19545455 L28.0116279,4.25 C25.0186047,1.62272727 21.1813953,0 16.8837209,0 C7.52093023,0 0,7.57272727 0,17 C0,26.4272727 7.52093023,34 16.8837209,34 C25.3255814,34 33,27.8181818 33,17 C33,15.9954545 32.8465116,14.9136364 32.6162791,13.9090909 Z" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default GoogleLogo;
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
fill?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function MicrosoftLogo({ size = 34, fill = "#FFF", className }: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={fill}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 34 34"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M18.0002 1H33.9998C33.9998 5.8172 34.0007 10.6344 33.9988 15.4516C28.6666 15.4508 23.3334 15.4516 18.0012 15.4516C17.9993 10.6344 18.0002 5.8172 18.0002 1Z"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M18.0009 17.5173C23.3333 17.5155 28.6667 17.5164 34 17.5164V33H18C18.0009 27.8388 17.9991 22.6776 18.0009 17.5173V17.5173Z"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M0 1H16L15.9988 15.4516H0V1Z"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M0 17.5161C5.3332 17.5179 10.6664 17.5155 15.9996 17.5179C16.0005 22.6789 15.9996 27.839 15.9996 33H0V17.5161Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default MicrosoftLogo;
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import GoogleLogo from "./GoogleLogo";
|
||||
import MicrosoftLogo from "./MicrosoftLogo";
|
||||
import SlackLogo from "./SlackLogo";
|
||||
|
||||
type Props = {
|
||||
providerName: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
function AuthLogo({ providerName, size = 16 }: Props) {
|
||||
switch (providerName) {
|
||||
case "slack":
|
||||
return (
|
||||
<Logo>
|
||||
<SlackLogo size={size} />
|
||||
</Logo>
|
||||
);
|
||||
|
||||
case "google":
|
||||
return (
|
||||
<Logo>
|
||||
<GoogleLogo size={size} />
|
||||
</Logo>
|
||||
);
|
||||
|
||||
case "azure":
|
||||
return (
|
||||
<Logo>
|
||||
<MicrosoftLogo size={size} />
|
||||
</Logo>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const Logo = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
export default AuthLogo;
|
||||
@@ -1,54 +1,49 @@
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { observer, useLocalStore } from "mobx-react";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import { withTranslation, WithTranslation } from "react-i18next";
|
||||
import { Switch, Route } from "react-router-dom";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import ErrorSuspended from "~/scenes/ErrorSuspended";
|
||||
import DocumentContext from "~/components/DocumentContext";
|
||||
import type { DocumentContextValue } from "~/components/DocumentContext";
|
||||
import Layout from "~/components/Layout";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import Sidebar from "~/components/Sidebar";
|
||||
import SidebarRight from "~/components/Sidebar/Right";
|
||||
import SettingsSidebar from "~/components/Sidebar/Settings";
|
||||
import type { Editor as TEditor } from "~/editor";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
searchPath,
|
||||
matchDocumentSlug as slug,
|
||||
newDocumentPath,
|
||||
settingsPath,
|
||||
matchDocumentHistory,
|
||||
matchDocumentSlug as slug,
|
||||
matchDocumentInsights,
|
||||
} from "~/utils/routeHelpers";
|
||||
import Fade from "./Fade";
|
||||
import withStores from "./withStores";
|
||||
|
||||
const DocumentComments = React.lazy(
|
||||
() => import("~/scenes/Document/components/Comments")
|
||||
);
|
||||
const DocumentHistory = React.lazy(
|
||||
() => import("~/scenes/Document/components/History")
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "document-history" */
|
||||
"~/components/DocumentHistory"
|
||||
)
|
||||
);
|
||||
const DocumentInsights = React.lazy(
|
||||
() => import("~/scenes/Document/components/Insights")
|
||||
const CommandBar = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "command-bar" */
|
||||
"~/components/CommandBar"
|
||||
)
|
||||
);
|
||||
const CommandBar = React.lazy(() => import("~/components/CommandBar"));
|
||||
|
||||
const AuthenticatedLayout: React.FC = ({ children }) => {
|
||||
const { ui, auth } = useStores();
|
||||
const location = useLocation();
|
||||
const can = usePolicy(ui.activeCollectionId);
|
||||
const { user, team } = auth;
|
||||
const documentContext = useLocalStore<DocumentContextValue>(() => ({
|
||||
editor: null,
|
||||
setEditor: (editor: TEditor) => {
|
||||
documentContext.editor = editor;
|
||||
},
|
||||
}));
|
||||
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();
|
||||
@@ -56,71 +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 rightRail = (
|
||||
<React.Suspense fallback={null}>
|
||||
<Switch>
|
||||
<Route
|
||||
path={`/doc/${slug}/history/:revisionId?`}
|
||||
component={DocumentHistory}
|
||||
/>
|
||||
</Switch>
|
||||
</React.Suspense>
|
||||
);
|
||||
|
||||
const showHistory = !!matchPath(location.pathname, {
|
||||
path: matchDocumentHistory,
|
||||
});
|
||||
const showInsights = !!matchPath(location.pathname, {
|
||||
path: matchDocumentInsights,
|
||||
});
|
||||
const showComments =
|
||||
!showInsights &&
|
||||
!showHistory &&
|
||||
!ui.commentsCollapsed &&
|
||||
team?.getPreference(TeamPreference.Commenting);
|
||||
|
||||
const sidebarRight = (
|
||||
<AnimatePresence>
|
||||
{(showHistory || showInsights || showComments) && (
|
||||
<Route path={`/doc/${slug}`}>
|
||||
<SidebarRight>
|
||||
<React.Suspense fallback={null}>
|
||||
{showHistory && <DocumentHistory />}
|
||||
{showInsights && <DocumentInsights />}
|
||||
{showComments && <DocumentComments />}
|
||||
</React.Suspense>
|
||||
</SidebarRight>
|
||||
</Route>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
return (
|
||||
<DocumentContext.Provider value={documentContext}>
|
||||
<Layout title={team?.name} sidebar={sidebar} sidebarRight={sidebarRight}>
|
||||
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
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>
|
||||
</DocumentContext.Provider>
|
||||
);
|
||||
};
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default observer(AuthenticatedLayout);
|
||||
export default withTranslation()(withStores(AuthenticatedLayout));
|
||||
|
||||
@@ -1,61 +1,52 @@
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import Initials from "./Initials";
|
||||
|
||||
export interface IAvatar {
|
||||
avatarUrl: string | null;
|
||||
color?: string;
|
||||
initial?: string;
|
||||
id?: string;
|
||||
}
|
||||
import User from "~/models/User";
|
||||
import placeholder from "./placeholder.png";
|
||||
|
||||
type Props = {
|
||||
src: string;
|
||||
size: number;
|
||||
src?: string;
|
||||
icon?: React.ReactNode;
|
||||
model?: IAvatar;
|
||||
user?: User;
|
||||
alt?: string;
|
||||
showBorder?: boolean;
|
||||
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
function Avatar(props: Props) {
|
||||
const { icon, showBorder, model, style, ...rest } = props;
|
||||
const src = props.src || model?.avatarUrl;
|
||||
const [error, handleError] = useBoolean(false);
|
||||
@observer
|
||||
class Avatar extends React.Component<Props> {
|
||||
@observable
|
||||
error: boolean;
|
||||
|
||||
return (
|
||||
<Relative style={style}>
|
||||
{src && !error ? (
|
||||
static defaultProps = {
|
||||
size: 24,
|
||||
};
|
||||
|
||||
handleError = () => {
|
||||
this.error = true;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { src, icon, showBorder, ...rest } = this.props;
|
||||
return (
|
||||
<AvatarWrapper>
|
||||
<CircleImg
|
||||
onError={handleError}
|
||||
src={src}
|
||||
onError={this.handleError}
|
||||
src={this.error ? placeholder : src}
|
||||
$showBorder={showBorder}
|
||||
{...rest}
|
||||
/>
|
||||
) : model ? (
|
||||
<Initials color={model.color} $showBorder={showBorder} {...rest}>
|
||||
{model.initial}
|
||||
</Initials>
|
||||
) : (
|
||||
<Initials $showBorder={showBorder} {...rest} />
|
||||
)}
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
</Relative>
|
||||
);
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
</AvatarWrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Avatar.defaultProps = {
|
||||
size: 24,
|
||||
};
|
||||
|
||||
const Relative = styled.div`
|
||||
const AvatarWrapper = styled.div`
|
||||
position: relative;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.div`
|
||||
@@ -63,7 +54,7 @@ const IconWrapper = styled.div`
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
background: ${(props) => props.theme.accent};
|
||||
background: ${(props) => props.theme.primary};
|
||||
border: 2px solid ${(props) => props.theme.background};
|
||||
border-radius: 100%;
|
||||
width: 20px;
|
||||
@@ -75,12 +66,10 @@ const CircleImg = styled.img<{ size: number; $showBorder?: boolean }>`
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 50%;
|
||||
border: ${(props) =>
|
||||
props.$showBorder === false
|
||||
? "none"
|
||||
: `2px solid ${props.theme.background}`};
|
||||
border: 2px solid
|
||||
${(props) =>
|
||||
props.$showBorder === false ? "transparent" : props.theme.background};
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
export default Avatar;
|
||||
|
||||
@@ -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 model={user} onClick={onClick} size={32} />
|
||||
</AvatarWrapper>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
<AvatarWrapper
|
||||
$isPresent={isPresent}
|
||||
$isObserving={isObserving}
|
||||
$color={user.color}
|
||||
>
|
||||
<Avatar
|
||||
src={user.avatarUrl}
|
||||
onClick={
|
||||
this.props.profileOnClick === false
|
||||
? onClick
|
||||
: this.handleOpenProfile
|
||||
}
|
||||
size={32}
|
||||
/>
|
||||
</AvatarWrapper>
|
||||
</Tooltip>
|
||||
{this.props.profileOnClick && (
|
||||
<UserProfile
|
||||
user={user}
|
||||
isOpen={this.isOpen}
|
||||
onRequestClose={this.handleCloseProfile}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Centered = styled.div`
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
type AvatarWrapperProps = {
|
||||
const AvatarWrapper = styled.div<{
|
||||
$isPresent: boolean;
|
||||
$isObserving: boolean;
|
||||
$color: string;
|
||||
};
|
||||
|
||||
const AvatarWrapper = styled.div<AvatarWrapperProps>`
|
||||
}>`
|
||||
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
|
||||
transition: opacity 250ms ease-in-out;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
|
||||
${(props) =>
|
||||
props.$isPresent &&
|
||||
css<AvatarWrapperProps>`
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: 50%;
|
||||
transition: border-color 100ms ease-in-out;
|
||||
border: 2px solid transparent;
|
||||
pointer-events: none;
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: 50%;
|
||||
transition: border-color 100ms ease-in-out;
|
||||
border: 2px solid transparent;
|
||||
pointer-events: none;
|
||||
|
||||
${(props) =>
|
||||
props.$isObserving &&
|
||||
css`
|
||||
border: 2px solid ${props.$color};
|
||||
box-shadow: inset 0 0 0 2px ${props.theme.background};
|
||||
${(props) =>
|
||||
props.$isObserving &&
|
||||
css`
|
||||
border: 2px solid ${props.$color};
|
||||
box-shadow: inset 0 0 0 2px ${props.theme.background};
|
||||
|
||||
&:hover {
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
right: -1px;
|
||||
bottom: -1px;
|
||||
}
|
||||
`}
|
||||
}
|
||||
&:hover {
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
right: -1px;
|
||||
bottom: -1px;
|
||||
}
|
||||
`}
|
||||
}
|
||||
|
||||
&:hover:after {
|
||||
border: 2px solid ${(props) => props.$color};
|
||||
box-shadow: inset 0 0 0 2px ${(props) => props.theme.background};
|
||||
}
|
||||
`}
|
||||
&:hover:after {
|
||||
border: 2px solid ${(props) => props.$color};
|
||||
box-shadow: inset 0 0 0 2px ${(props) => props.theme.background};
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(AvatarWithPresence);
|
||||
export default withTranslation()(AvatarWithPresence);
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import styled from "styled-components";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
const Initials = styled(Flex)<{
|
||||
color?: string;
|
||||
size: number;
|
||||
$showBorder?: boolean;
|
||||
}>`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #fff;
|
||||
background-color: ${(props) => props.color};
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid
|
||||
${(props) =>
|
||||
props.$showBorder === false ? "transparent" : props.theme.background};
|
||||
flex-shrink: 0;
|
||||
font-size: ${(props) => props.size / 2}px;
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
export default Initials;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 564 B |
@@ -5,7 +5,7 @@ const Badge = styled.span<{ yellow?: boolean; primary?: boolean }>`
|
||||
margin-left: 10px;
|
||||
padding: 1px 5px 2px;
|
||||
background-color: ${({ yellow, primary, theme }) =>
|
||||
yellow ? theme.yellow : primary ? theme.accent : "transparent"};
|
||||
yellow ? theme.yellow : primary ? theme.primary : "transparent"};
|
||||
color: ${({ primary, yellow, theme }) =>
|
||||
primary ? theme.white : yellow ? theme.almostBlack : theme.textTertiary};
|
||||
border: 1px solid
|
||||
|
||||
@@ -3,7 +3,7 @@ import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths } from "@shared/styles";
|
||||
import env from "~/env";
|
||||
import OutlineIcon from "./Icons/OutlineIcon";
|
||||
import OutlineLogo from "./OutlineLogo";
|
||||
|
||||
type Props = {
|
||||
href?: string;
|
||||
@@ -12,8 +12,8 @@ type Props = {
|
||||
function Branding({ href = env.URL }: Props) {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<OutlineIcon size={20} />
|
||||
{env.APP_NAME}
|
||||
<OutlineLogo size={16} />
|
||||
Outline
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+28
-60
@@ -1,29 +1,23 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import { darken, lighten, transparentize } from "polished";
|
||||
import { darken, lighten } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import ActionButton, {
|
||||
Props as ActionButtonProps,
|
||||
} from "~/components/ActionButton";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
|
||||
type RealProps = {
|
||||
$fullwidth?: boolean;
|
||||
$borderOnHover?: boolean;
|
||||
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")};
|
||||
danger?: boolean;
|
||||
iconColor?: string;
|
||||
}>`
|
||||
display: ${(props) => (props.fullwidth ? "block" : "inline-block")};
|
||||
width: ${(props) => (props.fullwidth ? "100%" : "auto")};
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: ${(props) => props.theme.accent};
|
||||
color: ${(props) => props.theme.accentText};
|
||||
background: ${(props) => props.theme.buttonBackground};
|
||||
color: ${(props) => props.theme.buttonText};
|
||||
box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 2px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
@@ -31,16 +25,15 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
height: 32px;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
cursor: var(--pointer);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
appearance: none !important;
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
${(props) =>
|
||||
!props.$borderOnHover &&
|
||||
!props.borderOnHover &&
|
||||
`
|
||||
svg {
|
||||
fill: ${props.$iconColor || "currentColor"};
|
||||
fill: ${props.iconColor || "currentColor"};
|
||||
}
|
||||
`}
|
||||
|
||||
@@ -51,14 +44,14 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&[aria-expanded="true"] {
|
||||
background: ${(props) => darken(0.05, props.theme.accent)};
|
||||
background: ${(props) => darken(0.05, props.theme.buttonBackground)};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
color: ${(props) => transparentize(0.5, props.theme.accentText)};
|
||||
background: ${(props) => lighten(0.2, props.theme.accent)};
|
||||
color: ${(props) => props.theme.white50};
|
||||
background: ${(props) => lighten(0.2, props.theme.buttonBackground)};
|
||||
|
||||
svg {
|
||||
fill: ${(props) => props.theme.white50};
|
||||
@@ -71,16 +64,16 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
background: ${props.theme.buttonNeutralBackground};
|
||||
color: ${props.theme.buttonNeutralText};
|
||||
box-shadow: ${
|
||||
props.$borderOnHover
|
||||
props.borderOnHover
|
||||
? "none"
|
||||
: `rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.buttonNeutralBorder} 0 0 0 1px inset`
|
||||
};
|
||||
|
||||
${
|
||||
props.$borderOnHover
|
||||
props.borderOnHover
|
||||
? ""
|
||||
: `svg {
|
||||
fill: ${props.$iconColor || "currentColor"};
|
||||
fill: ${props.iconColor || "currentColor"};
|
||||
}`
|
||||
}
|
||||
|
||||
@@ -88,7 +81,7 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
&:hover:not(:disabled),
|
||||
&[aria-expanded="true"] {
|
||||
background: ${
|
||||
props.$borderOnHover
|
||||
props.borderOnHover
|
||||
? props.theme.buttonNeutralBackground
|
||||
: darken(0.05, props.theme.buttonNeutralBackground)
|
||||
};
|
||||
@@ -108,7 +101,7 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.$danger &&
|
||||
props.danger &&
|
||||
`
|
||||
background: ${props.theme.danger};
|
||||
color: ${props.theme.white};
|
||||
@@ -153,18 +146,18 @@ 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;
|
||||
disclosure?: boolean;
|
||||
neutral?: boolean;
|
||||
danger?: boolean;
|
||||
primary?: boolean;
|
||||
fullwidth?: boolean;
|
||||
as?: T;
|
||||
to?: LocationDescriptor;
|
||||
borderOnHover?: boolean;
|
||||
hideIcon?: boolean;
|
||||
href?: string;
|
||||
"data-on"?: string;
|
||||
"data-event-category"?: string;
|
||||
@@ -175,39 +168,14 @@ const Button = <T extends React.ElementType = "button">(
|
||||
props: Props<T> & React.ComponentPropsWithoutRef<T>,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) => {
|
||||
const {
|
||||
type,
|
||||
children,
|
||||
value,
|
||||
disclosure,
|
||||
neutral,
|
||||
action,
|
||||
icon,
|
||||
iconColor,
|
||||
borderOnHover,
|
||||
hideIcon,
|
||||
fullwidth,
|
||||
danger,
|
||||
...rest
|
||||
} = props;
|
||||
const { type, icon, children, value, disclosure, neutral, ...rest } = props;
|
||||
const hasText = children !== undefined || value !== undefined;
|
||||
const ic = hideIcon ? undefined : action?.icon ?? icon;
|
||||
const hasIcon = ic !== undefined;
|
||||
const hasIcon = icon !== undefined;
|
||||
|
||||
return (
|
||||
<RealButton
|
||||
type={type || "button"}
|
||||
ref={ref}
|
||||
$neutral={neutral}
|
||||
action={action}
|
||||
$danger={danger}
|
||||
$fullwidth={fullwidth}
|
||||
$borderOnHover={borderOnHover}
|
||||
$iconColor={iconColor}
|
||||
{...rest}
|
||||
>
|
||||
<RealButton type={type || "button"} ref={ref} $neutral={neutral} {...rest}>
|
||||
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
||||
{hasIcon && ic}
|
||||
{hasIcon && icon}
|
||||
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
||||
{disclosure && <ExpandedIcon color="currentColor" />}
|
||||
</Inner>
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import styled from "styled-components";
|
||||
import Button, { Inner } from "./Button";
|
||||
|
||||
const ButtonSmall = styled(Button)`
|
||||
font-size: 13px;
|
||||
height: 26px;
|
||||
|
||||
${Inner} {
|
||||
padding: 0 6px;
|
||||
line-height: 26px;
|
||||
min-height: 26px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default ButtonSmall;
|
||||
@@ -63,7 +63,7 @@ const CircularProgressBar = ({
|
||||
<Circle color={theme.progressBarBackground} offset={offset} />
|
||||
{percentage > 0 && (
|
||||
<Circle
|
||||
color={theme.accent}
|
||||
color={theme.primary}
|
||||
percentage={percentage}
|
||||
offset={offset}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
const ClickablePadding = styled.div<{ grow?: boolean; minHeight?: string }>`
|
||||
min-height: ${(props) => props.minHeight || "50vh"};
|
||||
flex-grow: 100;
|
||||
cursor: text;
|
||||
const ClickablePadding = styled.div<{ grow?: boolean }>`
|
||||
min-height: 10em;
|
||||
cursor: ${({ onClick }) => (onClick ? "text" : "default")};
|
||||
${({ grow }) => grow && `flex-grow: 100;`};
|
||||
`;
|
||||
|
||||
export default ClickablePadding;
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import Document from "~/models/Document";
|
||||
import AvatarWithPresence from "~/components/Avatar/AvatarWithPresence";
|
||||
import { AvatarWithPresence } from "~/components/Avatar";
|
||||
import DocumentViews from "~/components/DocumentViews";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
@@ -90,6 +90,7 @@ function Collaborators(props: Props) {
|
||||
isEditing={isEditing}
|
||||
isObserving={isObserving}
|
||||
isCurrentUser={currentUserId === collaborator.id}
|
||||
profileOnClick={false}
|
||||
onClick={
|
||||
isObservable
|
||||
? (ev) => {
|
||||
|
||||
@@ -8,13 +8,9 @@ import useStores from "~/hooks/useStores";
|
||||
import Logger from "~/utils/Logger";
|
||||
|
||||
type Props = {
|
||||
/** The collection to show an icon for */
|
||||
collection: Collection;
|
||||
/** Whether the icon should be the "expanded" graphic when displaying the default collection icon */
|
||||
expanded?: boolean;
|
||||
/** The size of the icon, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
/** The color of the icon, defaults to the collection color */
|
||||
color?: string;
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ import CommandBarResults from "~/components/CommandBarResults";
|
||||
import SearchActions from "~/components/SearchActions";
|
||||
import rootActions from "~/actions/root";
|
||||
import useCommandBarActions from "~/hooks/useCommandBarActions";
|
||||
import useSettingsActions from "~/hooks/useSettingsActions";
|
||||
import useSettingsActions from "~/hooks/useSettingsAction";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { CommandBarAction } from "~/types";
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
@@ -38,10 +38,10 @@ function CommandBar() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchActions />
|
||||
<KBarPortal>
|
||||
<Positioner>
|
||||
<Animator>
|
||||
<SearchActions />
|
||||
<SearchInput
|
||||
placeholder={`${
|
||||
rootAction?.placeholder ||
|
||||
|
||||
@@ -92,20 +92,17 @@ const Content = styled(Flex)`
|
||||
|
||||
const Item = styled.div<{ active?: boolean }>`
|
||||
font-size: 15px;
|
||||
padding: 9px 12px;
|
||||
margin: 0 8px;
|
||||
border-radius: 4px;
|
||||
padding: 10px 16px;
|
||||
background: ${(props) =>
|
||||
props.active ? props.theme.menuItemSelected : "none"};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: var(--pointer);
|
||||
cursor: pointer;
|
||||
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
min-width: 0;
|
||||
|
||||
${(props) =>
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import Comment from "~/models/Comment";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
type Props = {
|
||||
comment: Comment;
|
||||
onSubmit?: () => void;
|
||||
};
|
||||
|
||||
function CommentDeleteDialog({ comment, onSubmit }: Props) {
|
||||
const { comments } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const { t } = useTranslation();
|
||||
const hasChildComments = comments.inThread(comment.id).length > 1;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await comment.delete();
|
||||
onSubmit?.();
|
||||
} catch (err) {
|
||||
showToast(err.message, { type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("I’m sure – Delete")}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
danger
|
||||
>
|
||||
<Text type="secondary">
|
||||
{hasChildComments ? (
|
||||
<Trans>
|
||||
Are you sure you want to permanently delete this entire comment
|
||||
thread?
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Are you sure you want to permanently delete this comment?
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(CommentDeleteDialog);
|
||||
@@ -51,7 +51,7 @@ const ConfirmationDialog: React.FC<Props> = ({
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text type="secondary">{children}</Text>
|
||||
<Button type="submit" disabled={isSaving} danger={danger} autoFocus>
|
||||
{isSaving && savingText ? savingText : submitText}
|
||||
{isSaving ? savingText : submitText}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
|
||||
@@ -39,8 +39,8 @@ const Button = styled(NudeButton)`
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 32px;
|
||||
margin: 24px;
|
||||
transform: translateX(-32px);
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: block;
|
||||
|
||||
@@ -118,8 +118,8 @@ const ContentEditable = React.forwardRef(
|
||||
}
|
||||
}, [value, contentRef]);
|
||||
|
||||
// Ensure only plain text can be pasted into input when pasting from another
|
||||
// rich text source
|
||||
// Ensure only plain text can be pasted into title when pasting from another
|
||||
// rich text editor
|
||||
const handlePaste = React.useCallback(
|
||||
(event: React.ClipboardEvent<HTMLSpanElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { MenuItem as BaseMenuItem } from "reakit/Menu";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
@@ -9,7 +8,6 @@ import MenuIconWrapper from "../MenuIconWrapper";
|
||||
|
||||
type Props = {
|
||||
onClick?: (event: React.SyntheticEvent) => void | Promise<void>;
|
||||
active?: boolean;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
dangerous?: boolean;
|
||||
@@ -20,31 +18,29 @@ type Props = {
|
||||
hide?: () => void;
|
||||
level?: number;
|
||||
icon?: React.ReactElement;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const MenuItem = (
|
||||
{
|
||||
onClick,
|
||||
children,
|
||||
active,
|
||||
selected,
|
||||
disabled,
|
||||
as,
|
||||
hide,
|
||||
icon,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.Ref<HTMLAnchorElement>
|
||||
) => {
|
||||
const MenuItem: React.FC<Props> = ({
|
||||
onClick,
|
||||
children,
|
||||
selected,
|
||||
disabled,
|
||||
as,
|
||||
hide,
|
||||
icon,
|
||||
...rest
|
||||
}) => {
|
||||
const handleClick = React.useCallback(
|
||||
(ev) => {
|
||||
if (onClick) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
onClick(ev);
|
||||
}
|
||||
|
||||
hide?.();
|
||||
if (hide) {
|
||||
hide();
|
||||
}
|
||||
},
|
||||
[onClick, hide]
|
||||
);
|
||||
@@ -67,14 +63,10 @@ const MenuItem = (
|
||||
{(props) => (
|
||||
<MenuAnchor
|
||||
{...props}
|
||||
$active={active}
|
||||
$toggleable={selected !== undefined}
|
||||
as={onClick ? "button" : as}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
ref={mergeRefs([
|
||||
ref,
|
||||
props.ref as React.RefObject<HTMLAnchorElement>,
|
||||
])}
|
||||
>
|
||||
{selected !== undefined && (
|
||||
<>
|
||||
@@ -105,7 +97,6 @@ type MenuAnchorProps = {
|
||||
disabled?: boolean;
|
||||
dangerous?: boolean;
|
||||
disclosure?: boolean;
|
||||
$active?: boolean;
|
||||
};
|
||||
|
||||
export const MenuAnchorCSS = css<MenuAnchorProps>`
|
||||
@@ -113,7 +104,6 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
padding-left: ${(props) => 12 + (props.level || 0) * 10}px;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
@@ -137,52 +127,37 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
|
||||
opacity: ${(props) => (props.disabled ? ".5" : 1)};
|
||||
}
|
||||
|
||||
${(props) => props.disabled && "pointer-events: none;"}
|
||||
|
||||
${(props) =>
|
||||
props.$active === undefined &&
|
||||
!props.disabled &&
|
||||
`
|
||||
props.disabled
|
||||
? "pointer-events: none;"
|
||||
: `
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.focus-visible {
|
||||
color: ${props.theme.accentText};
|
||||
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
|
||||
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.accentText};
|
||||
fill: ${props.theme.white};
|
||||
}
|
||||
}
|
||||
}
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.$active &&
|
||||
!props.disabled &&
|
||||
`
|
||||
color: ${props.theme.accentText};
|
||||
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
|
||||
box-shadow: none;
|
||||
cursor: var(--pointer);
|
||||
|
||||
svg {
|
||||
fill: ${props.theme.accentText};
|
||||
}
|
||||
`}
|
||||
`};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 4px 12px;
|
||||
padding-right: ${(props: MenuAnchorProps) =>
|
||||
props.disclosure ? 32 : 12}px;
|
||||
font-size: 14px;
|
||||
`}
|
||||
`};
|
||||
`;
|
||||
|
||||
export const MenuAnchor = styled.a`
|
||||
${MenuAnchorCSS}
|
||||
`;
|
||||
|
||||
export default React.forwardRef<HTMLAnchorElement, Props>(MenuItem);
|
||||
export default MenuItem;
|
||||
|
||||
@@ -11,5 +11,5 @@ export default function Separator(rest: React.HTMLAttributes<HTMLHRElement>) {
|
||||
}
|
||||
|
||||
const HorizontalRule = styled.hr`
|
||||
margin: 6px 0;
|
||||
margin: 0.5em 12px;
|
||||
`;
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
useMenuState,
|
||||
MenuButton,
|
||||
MenuItem as BaseMenuItem,
|
||||
MenuStateReturn,
|
||||
} from "reakit/Menu";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -26,7 +25,7 @@ import MouseSafeArea from "./MouseSafeArea";
|
||||
import Separator from "./Separator";
|
||||
import ContextMenu from ".";
|
||||
|
||||
type Props = Omit<MenuStateReturn, "items"> & {
|
||||
type Props = {
|
||||
actions?: (Action | MenuSeparator | MenuHeading)[];
|
||||
context?: Partial<ActionContext>;
|
||||
items?: TMenuItem[];
|
||||
@@ -38,15 +37,13 @@ const Disclosure = styled(ExpandedIcon)`
|
||||
right: 8px;
|
||||
`;
|
||||
|
||||
type SubMenuProps = MenuStateReturn & {
|
||||
templateItems: TMenuItem[];
|
||||
parentMenuState: Omit<MenuStateReturn, "items">;
|
||||
title: React.ReactNode;
|
||||
};
|
||||
|
||||
const SubMenu = React.forwardRef(
|
||||
const Submenu = React.forwardRef(
|
||||
(
|
||||
{ templateItems, title, parentMenuState, ...rest }: SubMenuProps,
|
||||
{
|
||||
templateItems,
|
||||
title,
|
||||
...rest
|
||||
}: { templateItems: TMenuItem[]; title: React.ReactNode },
|
||||
ref: React.LegacyRef<HTMLButtonElement>
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -62,11 +59,7 @@ const SubMenu = React.forwardRef(
|
||||
</MenuAnchor>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
aria-label={t("Submenu")}
|
||||
onClick={parentMenuState.hide}
|
||||
>
|
||||
<ContextMenu {...menu} aria-label={t("Submenu")}>
|
||||
<MouseSafeArea parentRef={menu.unstable_popoverRef} />
|
||||
<Template {...menu} items={templateItems} />
|
||||
</ContextMenu>
|
||||
@@ -184,9 +177,8 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
return (
|
||||
<BaseMenuItem
|
||||
key={index}
|
||||
as={SubMenu}
|
||||
as={Submenu}
|
||||
templateItems={item.items}
|
||||
parentMenuState={menu}
|
||||
title={<Title title={item.title} icon={item.icon} />}
|
||||
{...menu}
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Menu, MenuStateReturn } from "reakit/Menu";
|
||||
import { Portal } from "react-portal";
|
||||
import { Menu } from "reakit/Menu";
|
||||
import styled, { DefaultTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
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";
|
||||
@@ -36,23 +36,21 @@ export type Placement =
|
||||
| "left"
|
||||
| "left-start";
|
||||
|
||||
type Props = MenuStateReturn & {
|
||||
type Props = {
|
||||
"aria-label": string;
|
||||
/** The parent menu state if this is a submenu. */
|
||||
parentMenuState?: MenuStateReturn;
|
||||
/** Called when the context menu is opened. */
|
||||
visible?: boolean;
|
||||
placement?: Placement;
|
||||
animating?: boolean;
|
||||
unstable_disclosureRef?: React.RefObject<HTMLElement | null>;
|
||||
onOpen?: () => void;
|
||||
/** Called when the context menu is closed. */
|
||||
onClose?: () => void;
|
||||
/** Called when the context menu is clicked. */
|
||||
onClick?: (ev: React.MouseEvent) => void;
|
||||
hide?: () => void;
|
||||
};
|
||||
|
||||
const ContextMenu: React.FC<Props> = ({
|
||||
children,
|
||||
onOpen,
|
||||
onClose,
|
||||
parentMenuState,
|
||||
...rest
|
||||
}) => {
|
||||
const previousVisible = usePrevious(rest.visible);
|
||||
@@ -61,7 +59,6 @@ const ContextMenu: React.FC<Props> = ({
|
||||
const { ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const { setIsMenuOpen } = useMenuContext();
|
||||
const isMobile = useMobile();
|
||||
|
||||
useUnmount(() => {
|
||||
setIsMenuOpen(false);
|
||||
@@ -69,17 +66,19 @@ const ContextMenu: React.FC<Props> = ({
|
||||
|
||||
React.useEffect(() => {
|
||||
if (rest.visible && !previousVisible) {
|
||||
onOpen?.();
|
||||
|
||||
if (!parentMenuState) {
|
||||
if (onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
if (rest["aria-label"] !== t("Submenu")) {
|
||||
setIsMenuOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (!rest.visible && previousVisible) {
|
||||
onClose?.();
|
||||
|
||||
if (!parentMenuState) {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
if (rest["aria-label"] !== t("Submenu")) {
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
}
|
||||
@@ -90,7 +89,7 @@ const ContextMenu: React.FC<Props> = ({
|
||||
rest.visible,
|
||||
ui.sidebarCollapsed,
|
||||
setIsMenuOpen,
|
||||
parentMenuState,
|
||||
rest,
|
||||
t,
|
||||
]);
|
||||
|
||||
@@ -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`
|
||||
@@ -205,7 +202,7 @@ export const Background = styled(Scrollable)<BackgroundProps>`
|
||||
max-width: 100%;
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
padding: 6px 0;
|
||||
min-width: 180px;
|
||||
min-height: 44px;
|
||||
max-height: 75vh;
|
||||
|
||||
@@ -2,8 +2,8 @@ import { HomeIcon } from "outline-icons";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Optional } from "utility-types";
|
||||
import CollectionIcon from "~/components/CollectionIcon";
|
||||
import Flex from "~/components/Flex";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import InputSelect from "~/components/InputSelect";
|
||||
import { IconWrapper } from "~/components/Sidebar/components/SidebarLink";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
|
||||
import { useDesktopTitlebar } from "~/hooks/useDesktopTitlebar";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
|
||||
export default function DesktopEventHandler() {
|
||||
useDesktopTitlebar();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const { dialogs } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
|
||||
React.useEffect(() => {
|
||||
Desktop.bridge?.redirect((path: string, replace = false) => {
|
||||
if (replace) {
|
||||
history.replace(path);
|
||||
} else {
|
||||
history.push(path);
|
||||
}
|
||||
});
|
||||
|
||||
Desktop.bridge?.updateDownloaded(() => {
|
||||
showToast("An update is ready to install.", {
|
||||
type: "info",
|
||||
timeout: Infinity,
|
||||
action: {
|
||||
text: "Install now",
|
||||
onClick: () => {
|
||||
Desktop.bridge?.restartAndInstall();
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Desktop.bridge?.focus(() => {
|
||||
window.document.body.classList.remove("backgrounded");
|
||||
});
|
||||
|
||||
Desktop.bridge?.blur(() => {
|
||||
window.document.body.classList.add("backgrounded");
|
||||
});
|
||||
|
||||
Desktop.bridge?.openKeyboardShortcuts(() => {
|
||||
dialogs.openGuide({
|
||||
title: t("Keyboard shortcuts"),
|
||||
content: <KeyboardShortcuts />,
|
||||
});
|
||||
});
|
||||
}, [t, history, dialogs, showToast]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import * as React from "react";
|
||||
import Guide from "~/components/Guide";
|
||||
import Modal from "~/components/Modal";
|
||||
|
||||
@@ -3,12 +3,11 @@ import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import Document from "~/models/Document";
|
||||
import Breadcrumb from "~/components/Breadcrumb";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import CollectionIcon from "~/components/CollectionIcon";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
import { MenuInternalLink, NavigationNode } from "~/types";
|
||||
import { collectionUrl } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
@@ -59,7 +58,7 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
const category = useCategory(document);
|
||||
const collection = collections.get(document.collectionId);
|
||||
|
||||
let collectionNode: MenuInternalLink | undefined;
|
||||
let collectionNode: MenuInternalLink;
|
||||
|
||||
if (collection) {
|
||||
collectionNode = {
|
||||
@@ -68,7 +67,7 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
to: collectionUrl(collection.url),
|
||||
};
|
||||
} else if (document.collectionId && !collection) {
|
||||
} else {
|
||||
collectionNode = {
|
||||
type: "route",
|
||||
title: t("Deleted Collection"),
|
||||
@@ -78,9 +77,8 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
}
|
||||
|
||||
const path = React.useMemo(
|
||||
() => collection?.pathToDocument(document.id).slice(0, -1) || [],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[collection, document, document.collectionId, document.parentDocumentId]
|
||||
() => collection?.pathToDocument?.(document.id).slice(0, -1) || [],
|
||||
[collection, document]
|
||||
);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
@@ -90,9 +88,7 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
output.push(category);
|
||||
}
|
||||
|
||||
if (collectionNode) {
|
||||
output.push(collectionNode);
|
||||
}
|
||||
output.push(collectionNode);
|
||||
|
||||
path.forEach((node: NavigationNode) => {
|
||||
output.push({
|
||||
|
||||
+104
-121
@@ -3,19 +3,18 @@ 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";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import CollectionIcon from "./Icons/CollectionIcon";
|
||||
import EmojiIcon from "./Icons/EmojiIcon";
|
||||
import Squircle from "./Squircle";
|
||||
import CollectionIcon from "./CollectionIcon";
|
||||
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,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;
|
||||
@@ -1,408 +0,0 @@
|
||||
import FuzzySearch from "fuzzy-search";
|
||||
import { includes, difference, concat, filter, map, fill } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { StarredIcon, DocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import { FixedSizeList as List } from "react-window";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import parseTitle from "@shared/utils/parseTitle";
|
||||
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
||||
import Flex from "~/components/Flex";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
||||
import { Outline } from "~/components/Input";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
import { ancestors, descendants } from "~/utils/tree";
|
||||
|
||||
type Props = {
|
||||
/** Action taken upon submission of selected item, could be publish, move etc. */
|
||||
onSubmit: () => void;
|
||||
|
||||
/** A side-effect of item selection */
|
||||
onSelect: (item: NavigationNode | null) => void;
|
||||
|
||||
/** Items to be shown in explorer */
|
||||
items: NavigationNode[];
|
||||
};
|
||||
|
||||
function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
const isMobile = useMobile();
|
||||
const { collections, documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
const [searchTerm, setSearchTerm] = React.useState<string>();
|
||||
const [selectedNode, selectNode] = React.useState<NavigationNode | null>(
|
||||
null
|
||||
);
|
||||
const [initialScrollOffset, setInitialScrollOffset] = React.useState<number>(
|
||||
0
|
||||
);
|
||||
const [nodes, setNodes] = React.useState<NavigationNode[]>([]);
|
||||
const [activeNode, setActiveNode] = React.useState<number>(0);
|
||||
const [expandedNodes, setExpandedNodes] = React.useState<string[]>([]);
|
||||
const [itemRefs, setItemRefs] = React.useState<
|
||||
React.RefObject<HTMLSpanElement>[]
|
||||
>([]);
|
||||
|
||||
const inputSearchRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>(
|
||||
null
|
||||
);
|
||||
const listRef = React.useRef<List<NavigationNode[]>>(null);
|
||||
|
||||
const VERTICAL_PADDING = 6;
|
||||
const HORIZONTAL_PADDING = 24;
|
||||
|
||||
const searchIndex = React.useMemo(() => {
|
||||
return new FuzzySearch(items, ["title"], {
|
||||
caseSensitive: false,
|
||||
});
|
||||
}, [items]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (searchTerm) {
|
||||
selectNode(null);
|
||||
setExpandedNodes([]);
|
||||
}
|
||||
setActiveNode(0);
|
||||
}, [searchTerm]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let results;
|
||||
|
||||
if (searchTerm) {
|
||||
results = searchIndex.search(searchTerm);
|
||||
} else {
|
||||
results = items.filter((item) => item.type === "collection");
|
||||
}
|
||||
|
||||
setInitialScrollOffset(0);
|
||||
setNodes(results);
|
||||
}, [searchTerm, items, searchIndex]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setItemRefs((itemRefs) =>
|
||||
map(
|
||||
fill(Array(items.length), 0),
|
||||
(_, i) => itemRefs[i] || React.createRef()
|
||||
)
|
||||
);
|
||||
}, [items.length]);
|
||||
|
||||
React.useEffect(() => {
|
||||
onSelect(selectedNode);
|
||||
}, [selectedNode, onSelect]);
|
||||
|
||||
const scrollNodeIntoView = React.useCallback(
|
||||
(node: number) => {
|
||||
if (itemRefs[node] && itemRefs[node].current) {
|
||||
scrollIntoView(itemRefs[node].current as HTMLSpanElement, {
|
||||
behavior: "auto",
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
},
|
||||
[itemRefs]
|
||||
);
|
||||
|
||||
const handleSearch = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchTerm(ev.target.value);
|
||||
};
|
||||
|
||||
const isExpanded = (node: number) => {
|
||||
return includes(expandedNodes, nodes[node].id);
|
||||
};
|
||||
|
||||
const calculateInitialScrollOffset = (itemCount: number) => {
|
||||
if (listRef.current) {
|
||||
const { height, itemSize } = listRef.current.props;
|
||||
const { scrollOffset } = listRef.current.state as {
|
||||
scrollOffset: number;
|
||||
};
|
||||
const itemsHeight = itemCount * itemSize;
|
||||
return itemsHeight < height ? 0 : scrollOffset;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const collapse = (node: number) => {
|
||||
const descendantIds = descendants(nodes[node]).map((des) => des.id);
|
||||
setExpandedNodes(
|
||||
difference(expandedNodes, [...descendantIds, nodes[node].id])
|
||||
);
|
||||
|
||||
// remove children
|
||||
const newNodes = filter(nodes, (node) => !includes(descendantIds, node.id));
|
||||
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
|
||||
setInitialScrollOffset(scrollOffset);
|
||||
setNodes(newNodes);
|
||||
};
|
||||
|
||||
const expand = (node: number) => {
|
||||
setExpandedNodes(concat(expandedNodes, nodes[node].id));
|
||||
|
||||
// add children
|
||||
const newNodes = nodes.slice();
|
||||
newNodes.splice(node + 1, 0, ...descendants(nodes[node], 1));
|
||||
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
|
||||
setInitialScrollOffset(scrollOffset);
|
||||
setNodes(newNodes);
|
||||
};
|
||||
|
||||
const isSelected = (node: number) => {
|
||||
if (!selectedNode) {
|
||||
return false;
|
||||
}
|
||||
const selectedNodeId = selectedNode.id;
|
||||
const nodeId = nodes[node].id;
|
||||
|
||||
return selectedNodeId === nodeId;
|
||||
};
|
||||
|
||||
const hasChildren = (node: number) => {
|
||||
return nodes[node].children.length > 0;
|
||||
};
|
||||
|
||||
const toggleCollapse = (node: number) => {
|
||||
if (!hasChildren(node)) {
|
||||
return;
|
||||
}
|
||||
if (isExpanded(node)) {
|
||||
collapse(node);
|
||||
} else {
|
||||
expand(node);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelect = (node: number) => {
|
||||
if (isSelected(node)) {
|
||||
selectNode(null);
|
||||
} else {
|
||||
selectNode(nodes[node]);
|
||||
}
|
||||
};
|
||||
|
||||
const ListItem = ({
|
||||
index,
|
||||
data,
|
||||
style,
|
||||
}: {
|
||||
index: number;
|
||||
data: NavigationNode[];
|
||||
style: React.CSSProperties;
|
||||
}) => {
|
||||
const node = data[index];
|
||||
const isCollection = node.type === "collection";
|
||||
let icon, title, path;
|
||||
|
||||
if (isCollection) {
|
||||
const col = collections.get(node.collectionId as string);
|
||||
icon = col && (
|
||||
<CollectionIcon collection={col} expanded={isExpanded(index)} />
|
||||
);
|
||||
title = node.title;
|
||||
} else {
|
||||
const doc = documents.get(node.id);
|
||||
const { strippedTitle, emoji } = parseTitle(node.title);
|
||||
title = strippedTitle;
|
||||
|
||||
if (emoji) {
|
||||
icon = <EmojiIcon emoji={emoji} />;
|
||||
} else if (doc?.isStarred) {
|
||||
icon = <StarredIcon color={theme.yellow} />;
|
||||
} else {
|
||||
icon = <DocumentIcon />;
|
||||
}
|
||||
|
||||
path = ancestors(node)
|
||||
.map((a) => parseTitle(a.title).strippedTitle)
|
||||
.join(" / ");
|
||||
}
|
||||
|
||||
return searchTerm ? (
|
||||
<DocumentExplorerSearchResult
|
||||
selected={isSelected(index)}
|
||||
active={activeNode === index}
|
||||
style={{
|
||||
...style,
|
||||
top: (style.top as number) + VERTICAL_PADDING,
|
||||
left: (style.left as number) + HORIZONTAL_PADDING,
|
||||
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
||||
}}
|
||||
onPointerMove={() => setActiveNode(index)}
|
||||
onClick={() => toggleSelect(index)}
|
||||
icon={icon}
|
||||
title={title}
|
||||
path={path}
|
||||
/>
|
||||
) : (
|
||||
<DocumentExplorerNode
|
||||
style={{
|
||||
...style,
|
||||
top: (style.top as number) + VERTICAL_PADDING,
|
||||
left: (style.left as number) + HORIZONTAL_PADDING,
|
||||
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
||||
}}
|
||||
onPointerMove={() => setActiveNode(index)}
|
||||
onClick={() => toggleSelect(index)}
|
||||
onDisclosureClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
toggleCollapse(index);
|
||||
}}
|
||||
selected={isSelected(index)}
|
||||
active={activeNode === index}
|
||||
expanded={isExpanded(index)}
|
||||
icon={icon}
|
||||
title={title}
|
||||
depth={node.depth as number}
|
||||
hasChildren={hasChildren(index)}
|
||||
ref={itemRefs[index]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const focusSearchInput = () => {
|
||||
inputSearchRef.current?.focus();
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
return Math.min(activeNode + 1, nodes.length - 1);
|
||||
};
|
||||
|
||||
const prev = () => {
|
||||
return Math.max(activeNode - 1, 0);
|
||||
};
|
||||
|
||||
const handleKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
switch (ev.key) {
|
||||
case "ArrowDown": {
|
||||
ev.preventDefault();
|
||||
setActiveNode(next());
|
||||
scrollNodeIntoView(next());
|
||||
break;
|
||||
}
|
||||
case "ArrowUp": {
|
||||
ev.preventDefault();
|
||||
if (activeNode === 0) {
|
||||
focusSearchInput();
|
||||
} else {
|
||||
setActiveNode(prev());
|
||||
scrollNodeIntoView(prev());
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "ArrowLeft": {
|
||||
if (!searchTerm && isExpanded(activeNode)) {
|
||||
toggleCollapse(activeNode);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "ArrowRight": {
|
||||
if (!searchTerm) {
|
||||
toggleCollapse(activeNode);
|
||||
// let the nodes re-render first and then scroll
|
||||
setTimeout(() => scrollNodeIntoView(activeNode), 0);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Enter": {
|
||||
if (isModKey(ev)) {
|
||||
onSubmit();
|
||||
} else {
|
||||
toggleSelect(activeNode);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const innerElementType = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ style, ...rest }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
...style,
|
||||
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<Container tabIndex={-1} onKeyDown={handleKeyDown}>
|
||||
<ListSearch
|
||||
ref={inputSearchRef}
|
||||
onChange={handleSearch}
|
||||
placeholder={`${t("Search collections & documents")}…`}
|
||||
autoFocus
|
||||
/>
|
||||
<ListContainer>
|
||||
{nodes.length ? (
|
||||
<AutoSizer>
|
||||
{({ width, height }: { width: number; height: number }) => (
|
||||
<Flex role="listbox" column>
|
||||
<List
|
||||
ref={listRef}
|
||||
key={nodes.length}
|
||||
width={width}
|
||||
height={height}
|
||||
itemData={nodes}
|
||||
itemCount={nodes.length}
|
||||
itemSize={isMobile ? 48 : 32}
|
||||
innerElementType={innerElementType}
|
||||
initialScrollOffset={initialScrollOffset}
|
||||
itemKey={(index, results) => results[index].id}
|
||||
>
|
||||
{ListItem}
|
||||
</List>
|
||||
</Flex>
|
||||
)}
|
||||
</AutoSizer>
|
||||
) : (
|
||||
<FlexContainer>
|
||||
<Text type="secondary">{t("No results found")}.</Text>
|
||||
</FlexContainer>
|
||||
)}
|
||||
</ListContainer>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const Container = styled.div``;
|
||||
|
||||
const FlexContainer = styled(Flex)`
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const ListSearch = styled(InputSearch)`
|
||||
${Outline} {
|
||||
border-radius: 16px;
|
||||
}
|
||||
margin-bottom: 4px;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
`;
|
||||
|
||||
const ListContainer = styled.div`
|
||||
height: 65vh;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
height: 40vh;
|
||||
`}
|
||||
`;
|
||||
|
||||
export default observer(DocumentExplorer);
|
||||
@@ -1,134 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Flex from "~/components/Flex";
|
||||
import Disclosure from "~/components/Sidebar/components/Disclosure";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
type Props = {
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
style: React.CSSProperties;
|
||||
expanded: boolean;
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
depth: number;
|
||||
hasChildren: boolean;
|
||||
|
||||
onDisclosureClick: (ev: React.MouseEvent) => void;
|
||||
onPointerMove: (ev: React.MouseEvent) => void;
|
||||
onClick: (ev: React.MouseEvent) => void;
|
||||
};
|
||||
|
||||
function DocumentExplorerNode(
|
||||
{
|
||||
selected,
|
||||
active,
|
||||
style,
|
||||
expanded,
|
||||
icon,
|
||||
title,
|
||||
depth,
|
||||
hasChildren,
|
||||
onDisclosureClick,
|
||||
onPointerMove,
|
||||
onClick,
|
||||
}: Props,
|
||||
ref: React.RefObject<HTMLSpanElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const OFFSET = 12;
|
||||
const ICON_SIZE = 24;
|
||||
|
||||
const width = depth ? depth * ICON_SIZE + OFFSET : ICON_SIZE;
|
||||
|
||||
return (
|
||||
<Node
|
||||
ref={ref}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onClick={onClick}
|
||||
style={style}
|
||||
onPointerMove={onPointerMove}
|
||||
role="option"
|
||||
>
|
||||
<Spacer width={width}>
|
||||
{hasChildren && (
|
||||
<StyledDisclosure
|
||||
expanded={expanded}
|
||||
onClick={onDisclosureClick}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
)}
|
||||
</Spacer>
|
||||
{icon}
|
||||
<Title>{title || t("Untitled")}</Title>
|
||||
</Node>
|
||||
);
|
||||
}
|
||||
|
||||
const Title = styled(Text)`
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin: 0 4px 0 4px;
|
||||
color: inherit;
|
||||
`;
|
||||
|
||||
const StyledDisclosure = styled(Disclosure)`
|
||||
position: relative;
|
||||
left: auto;
|
||||
margin-top: 2px;
|
||||
`;
|
||||
|
||||
const Spacer = styled(Flex)<{ width: number }>`
|
||||
flex-direction: row-reverse;
|
||||
flex-shrink: 0;
|
||||
width: ${(props) => props.width}px;
|
||||
`;
|
||||
|
||||
export const Node = styled.span<{
|
||||
active: boolean;
|
||||
selected: boolean;
|
||||
style: React.CSSProperties;
|
||||
}>`
|
||||
display: flex;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
font-size: 16px;
|
||||
width: ${(props) => props.style.width};
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: var(--pointer);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background: ${(props) =>
|
||||
!props.selected && props.active && props.theme.listItemHoverBackground};
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.selected &&
|
||||
`
|
||||
background: ${props.theme.accent};
|
||||
color: ${props.theme.white};
|
||||
|
||||
svg {
|
||||
fill: ${props.theme.white};
|
||||
}
|
||||
`}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 4px;
|
||||
font-size: 15px;
|
||||
`}
|
||||
`;
|
||||
|
||||
export default observer(React.forwardRef(DocumentExplorerNode));
|
||||
@@ -1,85 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import styled from "styled-components";
|
||||
import { Node as SearchResult } from "~/components/DocumentExplorerNode";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
type Props = {
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
style: React.CSSProperties;
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
path?: string;
|
||||
|
||||
onPointerMove: (ev: React.MouseEvent) => void;
|
||||
onClick: (ev: React.MouseEvent) => void;
|
||||
};
|
||||
|
||||
function DocumentExplorerSearchResult({
|
||||
selected,
|
||||
active,
|
||||
style,
|
||||
icon,
|
||||
title,
|
||||
path,
|
||||
onPointerMove,
|
||||
onClick,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const ref = React.useCallback(
|
||||
(node: HTMLSpanElement | null) => {
|
||||
if (active && node) {
|
||||
scrollIntoView(node, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "auto",
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
},
|
||||
[active]
|
||||
);
|
||||
|
||||
return (
|
||||
<SearchResult
|
||||
ref={ref}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onClick={onClick}
|
||||
style={style}
|
||||
onPointerMove={onPointerMove}
|
||||
role="option"
|
||||
>
|
||||
{icon}
|
||||
<Flex>
|
||||
<Title>{title || t("Untitled")}</Title>
|
||||
<Path $selected={selected} size="xsmall">
|
||||
{path}
|
||||
</Path>
|
||||
</Flex>
|
||||
</SearchResult>
|
||||
);
|
||||
}
|
||||
|
||||
const Title = styled(Text)`
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
margin: 0 4px 0 4px;
|
||||
color: inherit;
|
||||
`;
|
||||
|
||||
const Path = styled(Text)<{ $selected: boolean }>`
|
||||
padding-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin: 0 4px 0 8px;
|
||||
color: ${(props) =>
|
||||
props.$selected ? props.theme.white50 : props.theme.textTertiary};
|
||||
`;
|
||||
|
||||
export default observer(DocumentExplorerSearchResult);
|
||||
@@ -0,0 +1,133 @@
|
||||
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 from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Event from "~/models/Event";
|
||||
import Button from "~/components/Button";
|
||||
import Empty from "~/components/Empty";
|
||||
import Flex from "~/components/Flex";
|
||||
import PaginatedEventList from "~/components/PaginatedEventList";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { documentUrl } from "~/utils/routeHelpers";
|
||||
|
||||
const EMPTY_ARRAY: Event[] = [];
|
||||
|
||||
function DocumentHistory() {
|
||||
const { events, documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch<{ documentSlug: string }>();
|
||||
const history = useHistory();
|
||||
const document = documents.getByUrl(match.params.documentSlug);
|
||||
|
||||
const eventsInDocument = document
|
||||
? events.inDocument(document.id)
|
||||
: EMPTY_ARRAY;
|
||||
|
||||
const onCloseHistory = () => {
|
||||
if (document) {
|
||||
history.push(documentUrl(document));
|
||||
} else {
|
||||
history.goBack();
|
||||
}
|
||||
};
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
if (
|
||||
eventsInDocument[0] &&
|
||||
document &&
|
||||
eventsInDocument[0].createdAt !== document.updatedAt
|
||||
) {
|
||||
eventsInDocument.unshift(
|
||||
new Event(
|
||||
{
|
||||
name: "documents.latest_version",
|
||||
documentId: document.id,
|
||||
createdAt: document.updatedAt,
|
||||
actor: document.updatedBy,
|
||||
},
|
||||
events
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return eventsInDocument;
|
||||
}, [eventsInDocument, events, document]);
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
{document ? (
|
||||
<Position column>
|
||||
<Header>
|
||||
<Title>{t("History")}</Title>
|
||||
<Button
|
||||
icon={<CloseIcon />}
|
||||
onClick={onCloseHistory}
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
</Header>
|
||||
<Scrollable topShadow>
|
||||
<PaginatedEventList
|
||||
aria-label={t("History")}
|
||||
fetch={events.fetchPage}
|
||||
events={items}
|
||||
options={{
|
||||
documentId: document.id,
|
||||
}}
|
||||
document={document}
|
||||
empty={<Empty>{t("Oh weird, there's nothing here")}</Empty>}
|
||||
/>
|
||||
</Scrollable>
|
||||
</Position>
|
||||
) : null}
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
const Position = styled(Flex)`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: ${(props) => props.theme.sidebarWidth}px;
|
||||
`;
|
||||
|
||||
const Sidebar = styled(Flex)`
|
||||
display: none;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
background: ${(props) => props.theme.background};
|
||||
width: ${(props) => props.theme.sidebarWidth}px;
|
||||
border-left: 1px solid ${(props) => props.theme.divider};
|
||||
z-index: 1;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Title = styled(Flex)`
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
width: 0;
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const Header = styled(Flex)`
|
||||
align-items: center;
|
||||
position: relative;
|
||||
padding: 12px;
|
||||
color: ${(props) => props.theme.text};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default observer(DocumentHistory);
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { observer, useObserver } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import styled from "styled-components";
|
||||
import Document from "~/models/Document";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import DocumentViews from "~/components/DocumentViews";
|
||||
import Popover from "~/components/Popover";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
isDraft: boolean;
|
||||
to?: LocationDescriptor;
|
||||
rtl?: boolean;
|
||||
};
|
||||
|
||||
function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
|
||||
const { views } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const documentViews = useObserver(() => views.inDocument(document.id));
|
||||
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",
|
||||
modal: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<Meta document={document} to={to} {...rest}>
|
||||
{totalViewers && !isDraft ? (
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(props) => (
|
||||
<>
|
||||
•
|
||||
<a {...props}>
|
||||
{t("Viewed by")}{" "}
|
||||
{onlyYou
|
||||
? t("only you")
|
||||
: `${totalViewers} ${
|
||||
totalViewers === 1 ? t("person") : t("people")
|
||||
}`}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</PopoverDisclosure>
|
||||
) : null}
|
||||
<Popover {...popover} width={300} aria-label={t("Viewers")} tabIndex={0}>
|
||||
<DocumentViews document={document} isOpen={popover.visible} />
|
||||
</Popover>
|
||||
</Meta>
|
||||
);
|
||||
}
|
||||
|
||||
const Meta = styled(DocumentMeta)<{ rtl?: boolean }>`
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
margin: -12px 0 2em 0;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(DocumentMetaWithViews);
|
||||
@@ -1,8 +1,7 @@
|
||||
import { TFunction } from "i18next";
|
||||
import { observer } from "mobx-react";
|
||||
import { DoneIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation, TFunction } from "react-i18next";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Document from "~/models/Document";
|
||||
import CircularProgressBar from "~/components/CircularProgressBar";
|
||||
@@ -44,7 +43,7 @@ function DocumentTasks({ document }: Props) {
|
||||
<>
|
||||
{completed === total ? (
|
||||
<Done
|
||||
color={theme.accent}
|
||||
color={theme.primary}
|
||||
size={20}
|
||||
$animated={done && previousDone === false}
|
||||
/>
|
||||
|
||||
@@ -43,10 +43,10 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
<PaginatedList
|
||||
aria-label={t("Viewers")}
|
||||
items={users}
|
||||
renderItem={(model: User) => {
|
||||
const view = documentViews.find((v) => v.user.id === model.id);
|
||||
const isPresent = presentIds.includes(model.id);
|
||||
const isEditing = editingIds.includes(model.id);
|
||||
renderItem={(item: User) => {
|
||||
const view = documentViews.find((v) => v.user.id === item.id);
|
||||
const isPresent = presentIds.includes(item.id);
|
||||
const isEditing = editingIds.includes(item.id);
|
||||
const subtitle = isPresent
|
||||
? isEditing
|
||||
? t("Currently editing")
|
||||
@@ -58,10 +58,10 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
});
|
||||
return (
|
||||
<ListItem
|
||||
key={model.id}
|
||||
title={model.name}
|
||||
key={item.id}
|
||||
title={item.name}
|
||||
subtitle={subtitle}
|
||||
image={<Avatar key={model.id} model={model} size={32} />}
|
||||
image={<Avatar key={item.id} src={item.avatarUrl} size={32} />}
|
||||
border={false}
|
||||
small
|
||||
/>
|
||||
|
||||
+44
-93
@@ -1,15 +1,13 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { deburr, difference, sortBy } from "lodash";
|
||||
import { deburr, sortBy } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
|
||||
import { TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { Optional } from "utility-types";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { Heading } from "@shared/utils/ProsemirrorHelper";
|
||||
import { Heading } from "@shared/editor/lib/getHeadings";
|
||||
import { getDataTransferFiles } from "@shared/utils/files";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
@@ -25,12 +23,18 @@ import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import { NotFoundError } from "~/utils/errors";
|
||||
import { uploadFile } from "~/utils/files";
|
||||
import history from "~/utils/history";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { isHash } from "~/utils/urls";
|
||||
import DocumentBreadcrumb from "./DocumentBreadcrumb";
|
||||
|
||||
const LazyLoadedEditor = React.lazy(() => import("~/editor"));
|
||||
const LazyLoadedEditor = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "shared-editor" */
|
||||
"~/editor"
|
||||
)
|
||||
);
|
||||
|
||||
export type Props = Optional<
|
||||
EditorProps,
|
||||
@@ -44,42 +48,31 @@ export type Props = Optional<
|
||||
> & {
|
||||
shareId?: string | undefined;
|
||||
embedsDisabled?: boolean;
|
||||
grow?: boolean;
|
||||
onHeadingsChange?: (headings: Heading[]) => void;
|
||||
onSynced?: () => Promise<void>;
|
||||
onPublish?: (event: React.MouseEvent) => any;
|
||||
bottomPadding?: string;
|
||||
};
|
||||
|
||||
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const {
|
||||
id,
|
||||
shareId,
|
||||
onChange,
|
||||
onHeadingsChange,
|
||||
onCreateCommentMark,
|
||||
onDeleteCommentMark,
|
||||
} = props;
|
||||
const { auth, comments, documents } = useStores();
|
||||
const { id, shareId, onChange, onHeadingsChange } = props;
|
||||
const { documents } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const dictionary = useDictionary();
|
||||
const embeds = useEmbeds(!shareId);
|
||||
const history = useHistory();
|
||||
const localRef = React.useRef<SharedEditor>();
|
||||
const preferences = auth.user?.preferences;
|
||||
const previousHeadings = React.useRef<Heading[] | null>(null);
|
||||
const [
|
||||
activeLinkElement,
|
||||
setActiveLink,
|
||||
] = React.useState<HTMLAnchorElement | null>(null);
|
||||
const previousCommentIds = React.useRef<string[]>();
|
||||
activeLinkEvent,
|
||||
setActiveLinkEvent,
|
||||
] = React.useState<MouseEvent | null>(null);
|
||||
const previousHeadings = React.useRef<Heading[] | null>(null);
|
||||
|
||||
const handleLinkActive = React.useCallback((element: HTMLAnchorElement) => {
|
||||
setActiveLink(element);
|
||||
const handleLinkActive = React.useCallback((event: MouseEvent) => {
|
||||
setActiveLinkEvent(event);
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const handleLinkInactive = React.useCallback(() => {
|
||||
setActiveLink(null);
|
||||
setActiveLinkEvent(null);
|
||||
}, []);
|
||||
|
||||
const handleSearchLink = React.useCallback(
|
||||
@@ -134,18 +127,17 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
[documents]
|
||||
);
|
||||
|
||||
const handleUploadFile = React.useCallback(
|
||||
const onUploadFile = React.useCallback(
|
||||
async (file: File) => {
|
||||
const result = await uploadFile(file, {
|
||||
documentId: id,
|
||||
preset: AttachmentPreset.DocumentAttachment,
|
||||
});
|
||||
return result.url;
|
||||
},
|
||||
[id]
|
||||
);
|
||||
|
||||
const handleClickLink = React.useCallback(
|
||||
const onClickLink = React.useCallback(
|
||||
(href: string, event: MouseEvent) => {
|
||||
// on page hash
|
||||
if (isHash(href)) {
|
||||
@@ -167,16 +159,8 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
}
|
||||
}
|
||||
|
||||
// Link to our own API should be opened in a new tab, not in the app
|
||||
if (navigateTo.startsWith("/api/")) {
|
||||
window.open(href, "_blank");
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
@@ -184,12 +168,12 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
window.open(href, "_blank");
|
||||
}
|
||||
},
|
||||
[history, shareId]
|
||||
[shareId]
|
||||
);
|
||||
|
||||
const focusAtEnd = React.useCallback(() => {
|
||||
localRef?.current?.focusAtEnd();
|
||||
}, [localRef]);
|
||||
ref?.current?.focusAtEnd();
|
||||
}, [ref]);
|
||||
|
||||
const handleDrop = React.useCallback(
|
||||
(event: React.DragEvent<HTMLDivElement>) => {
|
||||
@@ -197,7 +181,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
event.stopPropagation();
|
||||
const files = getDataTransferFiles(event);
|
||||
|
||||
const view = localRef?.current?.view;
|
||||
const view = ref?.current?.view;
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
@@ -232,7 +216,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
);
|
||||
|
||||
insertFiles(view, event, pos, files, {
|
||||
uploadFile: handleUploadFile,
|
||||
uploadFile: onUploadFile,
|
||||
onFileUploadStart: props.onFileUploadStart,
|
||||
onFileUploadStop: props.onFileUploadStop,
|
||||
onShowToast: showToast,
|
||||
@@ -241,11 +225,11 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
});
|
||||
},
|
||||
[
|
||||
localRef,
|
||||
ref,
|
||||
props.onFileUploadStart,
|
||||
props.onFileUploadStop,
|
||||
dictionary,
|
||||
handleUploadFile,
|
||||
onUploadFile,
|
||||
showToast,
|
||||
]
|
||||
);
|
||||
@@ -262,7 +246,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
// Calculate if headings have changed and trigger callback if so
|
||||
const updateHeadings = React.useCallback(() => {
|
||||
if (onHeadingsChange) {
|
||||
const headings = localRef?.current?.getHeadings();
|
||||
const headings = ref?.current?.getHeadings();
|
||||
if (
|
||||
headings &&
|
||||
headings.map((h) => h.level + h.title).join("") !==
|
||||
@@ -272,87 +256,54 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
onHeadingsChange(headings);
|
||||
}
|
||||
}
|
||||
}, [localRef, onHeadingsChange]);
|
||||
|
||||
const updateComments = React.useCallback(() => {
|
||||
if (onCreateCommentMark && onDeleteCommentMark) {
|
||||
const commentMarks = localRef.current?.getComments();
|
||||
const commentIds = comments.orderedData.map((c) => c.id);
|
||||
const commentMarkIds = commentMarks?.map((c) => c.id);
|
||||
const newCommentIds = difference(
|
||||
commentMarkIds,
|
||||
previousCommentIds.current ?? [],
|
||||
commentIds
|
||||
);
|
||||
|
||||
newCommentIds.forEach((commentId) => {
|
||||
const mark = commentMarks?.find((c) => c.id === commentId);
|
||||
if (mark) {
|
||||
onCreateCommentMark(mark.id, mark.userId);
|
||||
}
|
||||
});
|
||||
|
||||
const removedCommentIds = difference(
|
||||
previousCommentIds.current ?? [],
|
||||
commentMarkIds ?? []
|
||||
);
|
||||
|
||||
removedCommentIds.forEach((commentId) => {
|
||||
onDeleteCommentMark(commentId);
|
||||
});
|
||||
|
||||
previousCommentIds.current = commentMarkIds;
|
||||
}
|
||||
}, [onCreateCommentMark, onDeleteCommentMark, comments.orderedData]);
|
||||
}, [ref, onHeadingsChange]);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(event) => {
|
||||
onChange?.(event);
|
||||
updateHeadings();
|
||||
updateComments();
|
||||
},
|
||||
[onChange, updateComments, updateHeadings]
|
||||
[onChange, updateHeadings]
|
||||
);
|
||||
|
||||
const handleRefChanged = React.useCallback(
|
||||
(node: SharedEditor | null) => {
|
||||
if (node) {
|
||||
if (node && !previousHeadings.current) {
|
||||
updateHeadings();
|
||||
updateComments();
|
||||
}
|
||||
},
|
||||
[updateComments, updateHeadings]
|
||||
[updateHeadings]
|
||||
);
|
||||
|
||||
return (
|
||||
<ErrorBoundary reloadOnChunkMissing>
|
||||
<>
|
||||
<LazyLoadedEditor
|
||||
ref={mergeRefs([ref, localRef, handleRefChanged])}
|
||||
uploadFile={handleUploadFile}
|
||||
ref={mergeRefs([ref, handleRefChanged])}
|
||||
uploadFile={onUploadFile}
|
||||
onShowToast={showToast}
|
||||
embeds={embeds}
|
||||
userPreferences={preferences}
|
||||
dictionary={dictionary}
|
||||
{...props}
|
||||
onHoverLink={handleLinkActive}
|
||||
onClickLink={handleClickLink}
|
||||
onClickLink={onClickLink}
|
||||
onSearchLink={handleSearchLink}
|
||||
onChange={handleChange}
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
/>
|
||||
{props.bottomPadding && !props.readOnly && (
|
||||
{props.grow && !props.readOnly && (
|
||||
<ClickablePadding
|
||||
onClick={focusAtEnd}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
minHeight={props.bottomPadding}
|
||||
grow
|
||||
/>
|
||||
)}
|
||||
{activeLinkElement && !shareId && (
|
||||
{activeLinkEvent && !shareId && (
|
||||
<HoverPreview
|
||||
element={activeLinkElement}
|
||||
node={activeLinkEvent.target as HTMLAnchorElement}
|
||||
event={activeLinkEvent}
|
||||
onClose={handleLinkInactive}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -25,7 +25,6 @@ const Span = styled.span<{ $size: number }>`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
width: ${(props) => props.$size}px;
|
||||
height: ${(props) => props.$size}px;
|
||||
text-indent: -0.15em;
|
||||
@@ -30,7 +30,7 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
if (
|
||||
this.props.reloadOnChunkMissing &&
|
||||
error.message &&
|
||||
error.message.match(/dynamically imported module/)
|
||||
error.message.match(/chunk/)
|
||||
) {
|
||||
// If the editor bundle fails to load then reload the entire window. This
|
||||
// can happen if a deploy happens between the user loading the initial JS
|
||||
@@ -60,9 +60,7 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
if (this.error) {
|
||||
const error = this.error;
|
||||
const isReported = !!env.SENTRY_DSN && isCloudHosted;
|
||||
const isChunkError = this.error.message.match(
|
||||
/dynamically imported module/
|
||||
);
|
||||
const isChunkError = this.error.message.match(/chunk/);
|
||||
|
||||
if (isChunkError) {
|
||||
return (
|
||||
|
||||
@@ -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;
|
||||
@@ -142,7 +128,7 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
onClick={handleTimeClick}
|
||||
/>
|
||||
}
|
||||
image={<Avatar model={event.actor} size={32} />}
|
||||
image={<Avatar src={event.actor?.avatarUrl} size={32} />}
|
||||
subtitle={
|
||||
<Subtitle>
|
||||
{icon}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { FileOperationFormat } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
type Props = {
|
||||
collection?: Collection;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
function ExportDialog({ collection, onSubmit }: Props) {
|
||||
const [format, setFormat] = React.useState<FileOperationFormat>(
|
||||
FileOperationFormat.MarkdownZip
|
||||
);
|
||||
const { showToast } = useToasts();
|
||||
const { collections, notificationSettings } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const appName = env.APP_NAME;
|
||||
|
||||
React.useEffect(() => {
|
||||
notificationSettings.fetchPage({});
|
||||
}, [notificationSettings]);
|
||||
|
||||
const handleFormatChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormat(ev.target.value as FileOperationFormat);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (collection) {
|
||||
await collection.export(format);
|
||||
} else {
|
||||
await collections.export(format);
|
||||
}
|
||||
onSubmit();
|
||||
showToast(t("Export started"), { type: "success" });
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
title: "Markdown",
|
||||
description: t(
|
||||
"A ZIP file containing the images, and documents in the Markdown format."
|
||||
),
|
||||
value: FileOperationFormat.MarkdownZip,
|
||||
},
|
||||
{
|
||||
title: "HTML",
|
||||
description: t(
|
||||
"A ZIP file containing the images, and documents as HTML files."
|
||||
),
|
||||
value: FileOperationFormat.HTMLZip,
|
||||
},
|
||||
{
|
||||
title: "JSON",
|
||||
description: t(
|
||||
"Structured data that can be used to transfer data to another compatible {{ appName }} instance.",
|
||||
{
|
||||
appName,
|
||||
}
|
||||
),
|
||||
value: FileOperationFormat.JSON,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ConfirmationDialog onSubmit={handleSubmit} submitText={t("Export")}>
|
||||
{collection && (
|
||||
<Text>
|
||||
<Trans
|
||||
defaults="Exporting the collection <em>{{collectionName}}</em> may take some time."
|
||||
values={{
|
||||
collectionName: collection.name,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>{" "}
|
||||
{notificationSettings.getByEvent("emails.export_completed") &&
|
||||
t("You will receive an email when it's complete.")}
|
||||
</Text>
|
||||
)}
|
||||
<Flex gap={12} column>
|
||||
{items.map((item) => (
|
||||
<Option>
|
||||
<input
|
||||
type="radio"
|
||||
name="format"
|
||||
value={item.value}
|
||||
checked={format === item.value}
|
||||
onChange={handleFormatChange}
|
||||
/>
|
||||
<div>
|
||||
<Text size="small" weight="bold">
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text size="small">{item.description}</Text>
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</Flex>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
const Option = styled.label`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(ExportDialog);
|
||||
@@ -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>
|
||||
@@ -39,7 +35,7 @@ function Facepile({
|
||||
}
|
||||
|
||||
function DefaultAvatar(user: User) {
|
||||
return <Avatar model={user} size={32} />;
|
||||
return <Avatar user={user} src={user.avatarUrl} size={32} />;
|
||||
}
|
||||
|
||||
const AvatarWrapper = styled.div`
|
||||
@@ -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);
|
||||
|
||||
@@ -10,7 +10,6 @@ type TFilterOption = {
|
||||
key: string;
|
||||
label: string;
|
||||
note?: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -58,7 +57,6 @@ const FilterOptions = ({
|
||||
selected={option.key === activeKey}
|
||||
{...menu}
|
||||
>
|
||||
{option.icon && <Icon>{option.icon}</Icon>}
|
||||
{option.note ? (
|
||||
<LabelWithNote>
|
||||
{option.label}
|
||||
@@ -108,12 +106,6 @@ const StyledButton = styled(Button)`
|
||||
}
|
||||
`;
|
||||
|
||||
const Icon = styled.div`
|
||||
margin-right: 8px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
margin-right: 8px;
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
fill?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function GithubLogo({ size = 34, fill = "#FFF", className }: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={fill}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 36 36"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
fill="#191717"
|
||||
d="M18,1.4C9,1.4,1.7,8.7,1.7,17.7c0,7.2,4.7,13.3,11.1,15.5 c0.8,0.1,1.1-0.4,1.1-0.8c0-0.4,0-1.4,0-2.8c-4.5,1-5.5-2.2-5.5-2.2c-0.7-1.9-1.8-2.4-1.8-2.4c-1.5-1,0.1-1,0.1-1 c1.6,0.1,2.5,1.7,2.5,1.7c1.5,2.5,3.8,1.8,4.7,1.4c0.1-1.1,0.6-1.8,1-2.2c-3.6-0.4-7.4-1.8-7.4-8.1c0-1.8,0.6-3.2,1.7-4.4 c-0.2-0.4-0.7-2.1,0.2-4.3c0,0,1.4-0.4,4.5,1.7c1.3-0.4,2.7-0.5,4.1-0.5c1.4,0,2.8,0.2,4.1,0.5c3.1-2.1,4.5-1.7,4.5-1.7 c0.9,2.2,0.3,3.9,0.2,4.3c1,1.1,1.7,2.6,1.7,4.4c0,6.3-3.8,7.6-7.4,8c0.6,0.5,1.1,1.5,1.1,3c0,2.2,0,3.9,0,4.5 c0,0.4,0.3,0.9,1.1,0.8c6.5-2.2,11.1-8.3,11.1-15.5C34.3,8.7,27,1.4,18,1.4z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default GithubLogo;
|
||||
@@ -1,9 +1,10 @@
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { GroupIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import CollectionGroupMembership from "~/models/CollectionGroupMembership";
|
||||
import Group from "~/models/Group";
|
||||
import GroupMembers from "~/scenes/GroupMembers";
|
||||
@@ -11,11 +12,9 @@ import Facepile from "~/components/Facepile";
|
||||
import Flex from "~/components/Flex";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Modal from "~/components/Modal";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import NudeButton from "./NudeButton";
|
||||
import withStores from "~/components/withStores";
|
||||
|
||||
type Props = {
|
||||
type Props = RootStore & {
|
||||
group: Group;
|
||||
membership?: CollectionGroupMembership;
|
||||
showFacepile?: boolean;
|
||||
@@ -23,57 +22,69 @@ type Props = {
|
||||
renderActions: (params: { openMembersModal: () => void }) => React.ReactNode;
|
||||
};
|
||||
|
||||
function GroupListItem({ group, showFacepile, renderActions }: Props) {
|
||||
const { groupMemberships } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const [
|
||||
membersModalOpen,
|
||||
setMembersModalOpen,
|
||||
setMembersModalClosed,
|
||||
] = useBoolean();
|
||||
const memberCount = group.memberCount;
|
||||
const membershipsInGroup = groupMemberships.inGroup(group.id);
|
||||
const users = membershipsInGroup
|
||||
.slice(0, MAX_AVATAR_DISPLAY)
|
||||
.map((gm) => gm.user);
|
||||
const overflow = memberCount - users.length;
|
||||
@observer
|
||||
class GroupListItem extends React.Component<Props> {
|
||||
@observable
|
||||
membersModalOpen = false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem
|
||||
image={
|
||||
<Image>
|
||||
<GroupIcon size={24} />
|
||||
</Image>
|
||||
}
|
||||
title={<Title onClick={setMembersModalOpen}>{group.name}</Title>}
|
||||
subtitle={t("{{ count }} member", { count: memberCount })}
|
||||
actions={
|
||||
<Flex align="center" gap={8}>
|
||||
{showFacepile && (
|
||||
<NudeButton
|
||||
width="auto"
|
||||
height="auto"
|
||||
onClick={setMembersModalOpen}
|
||||
>
|
||||
<Facepile users={users} overflow={overflow} />
|
||||
</NudeButton>
|
||||
)}
|
||||
{renderActions({
|
||||
openMembersModal: setMembersModalOpen,
|
||||
})}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
title={t("Group members")}
|
||||
onRequestClose={setMembersModalClosed}
|
||||
isOpen={membersModalOpen}
|
||||
>
|
||||
<GroupMembers group={group} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
handleMembersModalOpen = () => {
|
||||
this.membersModalOpen = true;
|
||||
};
|
||||
|
||||
handleMembersModalClose = () => {
|
||||
this.membersModalOpen = false;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { group, groupMemberships, showFacepile, renderActions } = this.props;
|
||||
const memberCount = group.memberCount;
|
||||
const membershipsInGroup = groupMemberships.inGroup(group.id);
|
||||
const users = membershipsInGroup
|
||||
.slice(0, MAX_AVATAR_DISPLAY)
|
||||
.map((gm) => gm.user);
|
||||
const overflow = memberCount - users.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem
|
||||
image={
|
||||
<Image>
|
||||
<GroupIcon size={24} />
|
||||
</Image>
|
||||
}
|
||||
title={
|
||||
<Title onClick={this.handleMembersModalOpen}>{group.name}</Title>
|
||||
}
|
||||
subtitle={
|
||||
<>
|
||||
{memberCount} member{memberCount === 1 ? "" : "s"}
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<Flex align="center" gap={8}>
|
||||
{showFacepile && (
|
||||
<Facepile
|
||||
onClick={this.handleMembersModalOpen}
|
||||
users={users}
|
||||
overflow={overflow}
|
||||
/>
|
||||
)}
|
||||
{renderActions({
|
||||
openMembersModal: this.handleMembersModalOpen,
|
||||
})}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
title="Group members"
|
||||
onRequestClose={this.handleMembersModalClose}
|
||||
isOpen={this.membersModalOpen}
|
||||
>
|
||||
<GroupMembers group={group} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Image = styled(Flex)`
|
||||
@@ -88,8 +99,8 @@ const Image = styled(Flex)`
|
||||
const Title = styled.span`
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
cursor: var(--pointer);
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(GroupListItem);
|
||||
export default withStores(GroupListItem);
|
||||
|
||||
+10
-24
@@ -12,23 +12,22 @@ import Flex from "~/components/Flex";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
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,13 +50,8 @@ function Header({ left, title, actions, hasSidebar }: Props) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
align="center"
|
||||
shrink={false}
|
||||
$passThrough={passThrough}
|
||||
$insetTitleAdjust={ui.sidebarIsClosed && Desktop.hasInsetTitlebar()}
|
||||
>
|
||||
{left || hasMobileSidebar ? (
|
||||
<Wrapper align="center" shrink={false} $passThrough={passThrough}>
|
||||
{breadcrumb || hasMobileSidebar ? (
|
||||
<Breadcrumbs>
|
||||
{hasMobileSidebar && (
|
||||
<MobileMenuButton
|
||||
@@ -67,7 +61,7 @@ function Header({ left, title, actions, hasSidebar }: Props) {
|
||||
neutral
|
||||
/>
|
||||
)}
|
||||
{left}
|
||||
{breadcrumb}
|
||||
</Breadcrumbs>
|
||||
) : null}
|
||||
|
||||
@@ -104,12 +98,7 @@ const Actions = styled(Flex)`
|
||||
`};
|
||||
`;
|
||||
|
||||
type WrapperProps = {
|
||||
$passThrough?: boolean;
|
||||
$insetTitleAdjust?: boolean;
|
||||
};
|
||||
|
||||
const Wrapper = styled(Flex)<WrapperProps>`
|
||||
const Wrapper = styled(Flex)<{ $passThrough?: boolean }>`
|
||||
top: 0;
|
||||
z-index: ${depths.header};
|
||||
position: sticky;
|
||||
@@ -131,8 +120,6 @@ const Wrapper = styled(Flex)<WrapperProps>`
|
||||
transform: translate3d(0, 0, 0);
|
||||
min-height: 64px;
|
||||
justify-content: flex-start;
|
||||
${draggableOnDesktop()}
|
||||
${fadeOnDesktopBackgrounded()}
|
||||
|
||||
@supports (backdrop-filter: blur(20px)) {
|
||||
backdrop-filter: blur(20px);
|
||||
@@ -146,8 +133,7 @@ const Wrapper = styled(Flex)<WrapperProps>`
|
||||
${breakpoint("tablet")`
|
||||
padding: 16px;
|
||||
justify-content: center;
|
||||
${(props: WrapperProps) => props.$insetTitleAdjust && `padding-left: 64px;`}
|
||||
`};
|
||||
`};
|
||||
`;
|
||||
|
||||
const Title = styled("div")`
|
||||
@@ -157,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")`
|
||||
|
||||
@@ -14,13 +14,14 @@ const DELAY_OPEN = 300;
|
||||
const DELAY_CLOSE = 300;
|
||||
|
||||
type Props = {
|
||||
element: HTMLAnchorElement;
|
||||
node: HTMLAnchorElement;
|
||||
event: MouseEvent;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function HoverPreviewInternal({ element, onClose }: Props) {
|
||||
function HoverPreviewInternal({ node, onClose }: Props) {
|
||||
const { documents } = useStores();
|
||||
const slug = parseDocumentSlug(element.href);
|
||||
const slug = parseDocumentSlug(node.href);
|
||||
const [isVisible, setVisible] = React.useState(false);
|
||||
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
const timerOpen = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
@@ -67,13 +68,13 @@ function HoverPreviewInternal({ element, onClose }: Props) {
|
||||
cardRef.current.addEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
|
||||
element.addEventListener("mouseout", startCloseTimer);
|
||||
element.addEventListener("mouseover", stopCloseTimer);
|
||||
element.addEventListener("mouseover", startOpenTimer);
|
||||
node.addEventListener("mouseout", startCloseTimer);
|
||||
node.addEventListener("mouseover", stopCloseTimer);
|
||||
node.addEventListener("mouseover", startOpenTimer);
|
||||
return () => {
|
||||
element.removeEventListener("mouseout", startCloseTimer);
|
||||
element.removeEventListener("mouseover", stopCloseTimer);
|
||||
element.removeEventListener("mouseover", startOpenTimer);
|
||||
node.removeEventListener("mouseout", startCloseTimer);
|
||||
node.removeEventListener("mouseover", stopCloseTimer);
|
||||
node.removeEventListener("mouseover", startOpenTimer);
|
||||
|
||||
if (cardRef.current) {
|
||||
cardRef.current.removeEventListener("mouseenter", stopCloseTimer);
|
||||
@@ -87,9 +88,9 @@ function HoverPreviewInternal({ element, onClose }: Props) {
|
||||
clearTimeout(timerClose.current);
|
||||
}
|
||||
};
|
||||
}, [element, slug]);
|
||||
}, [node, slug]);
|
||||
|
||||
const anchorBounds = element.getBoundingClientRect();
|
||||
const anchorBounds = node.getBoundingClientRect();
|
||||
const cardBounds = cardRef.current?.getBoundingClientRect();
|
||||
const left = cardBounds
|
||||
? Math.min(anchorBounds.left, window.innerWidth - 16 - 350)
|
||||
@@ -104,7 +105,7 @@ function HoverPreviewInternal({ element, onClose }: Props) {
|
||||
aria-hidden
|
||||
>
|
||||
<div ref={cardRef}>
|
||||
<HoverPreviewDocument url={element.href}>
|
||||
<HoverPreviewDocument url={node.href}>
|
||||
{(content: React.ReactNode) =>
|
||||
isVisible ? (
|
||||
<Animate>
|
||||
@@ -123,18 +124,18 @@ function HoverPreviewInternal({ element, onClose }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function HoverPreview({ element, ...rest }: Props) {
|
||||
function HoverPreview({ node, ...rest }: Props) {
|
||||
const isMobile = useMobile();
|
||||
if (isMobile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// previews only work for internal doc links for now
|
||||
if (isExternalUrl(element.href)) {
|
||||
if (isExternalUrl(node.href)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <HoverPreviewInternal {...rest} element={element} />;
|
||||
return <HoverPreviewInternal {...rest} node={node} />;
|
||||
}
|
||||
|
||||
const Animate = styled.div`
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import DocumentMetaWithViews from "~/components/DocumentMetaWithViews";
|
||||
import Editor from "~/components/Editor";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -30,7 +30,10 @@ function HoverPreviewDocument({ url, children }: Props) {
|
||||
{children(
|
||||
<Content to={document.url}>
|
||||
<Heading>{document.titleWithDefault}</Heading>
|
||||
<DocumentMeta document={document} />
|
||||
<DocumentMetaWithViews
|
||||
isDraft={document.isDraft}
|
||||
document={document}
|
||||
/>
|
||||
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
@@ -47,7 +50,7 @@ function HoverPreviewDocument({ url, children }: Props) {
|
||||
}
|
||||
|
||||
const Content = styled(Link)`
|
||||
cursor: var(--pointer);
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const Heading = styled.h2`
|
||||
|
||||
@@ -46,15 +46,17 @@ import Flex from "~/components/Flex";
|
||||
import { LabelText } from "~/components/Input";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Text from "~/components/Text";
|
||||
import DelayedMount from "./DelayedMount";
|
||||
|
||||
const style = {
|
||||
width: 30,
|
||||
height: 30,
|
||||
};
|
||||
|
||||
const TwitterPicker = React.lazy(
|
||||
() => import("react-color/lib/components/twitter/Twitter")
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "twitter-picker" */
|
||||
"react-color/lib/components/twitter/Twitter"
|
||||
)
|
||||
);
|
||||
|
||||
export const icons = {
|
||||
@@ -239,7 +241,7 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
|
||||
aria-label={t("Choose icon")}
|
||||
>
|
||||
<Icons>
|
||||
{Object.keys(icons).map((name, index) => {
|
||||
{Object.keys(icons).map((name) => {
|
||||
return (
|
||||
<MenuItem
|
||||
key={name}
|
||||
@@ -247,15 +249,7 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
|
||||
{...menu}
|
||||
>
|
||||
{(props) => (
|
||||
<IconButton
|
||||
style={
|
||||
{
|
||||
...style,
|
||||
"--delay": `${index * 8}ms`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<IconButton style={style} {...props}>
|
||||
<Icon as={icons[name].component} color={color} size={30} />
|
||||
</IconButton>
|
||||
)}
|
||||
@@ -263,14 +257,8 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
|
||||
);
|
||||
})}
|
||||
</Icons>
|
||||
<Colors>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<DelayedMount>
|
||||
<Text>{t("Loading")}…</Text>
|
||||
</DelayedMount>
|
||||
}
|
||||
>
|
||||
<Flex>
|
||||
<React.Suspense fallback={<Loading>{t("Loading")}…</Loading>}>
|
||||
<ColorPicker
|
||||
color={color}
|
||||
onChange={(color) => onChange(color.hex, icon)}
|
||||
@@ -278,10 +266,6 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
|
||||
triangle="hide"
|
||||
styles={{
|
||||
default: {
|
||||
body: {
|
||||
padding: 0,
|
||||
marginRight: -8,
|
||||
},
|
||||
hash: {
|
||||
color: theme.text,
|
||||
background: theme.inputBorder,
|
||||
@@ -295,7 +279,7 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Colors>
|
||||
</Flex>
|
||||
</ContextMenu>
|
||||
</Wrapper>
|
||||
);
|
||||
@@ -303,11 +287,6 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
|
||||
|
||||
const Icon = styled.svg`
|
||||
transition: fill 150ms ease-in-out;
|
||||
transition-delay: var(--delay);
|
||||
`;
|
||||
|
||||
const Colors = styled(Flex)`
|
||||
padding: 8px;
|
||||
`;
|
||||
|
||||
const Label = styled.label`
|
||||
@@ -315,7 +294,7 @@ const Label = styled.label`
|
||||
`;
|
||||
|
||||
const Icons = styled.div`
|
||||
padding: 8px;
|
||||
padding: 16px 8px 0 16px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
width: 276px;
|
||||
@@ -335,10 +314,18 @@ const IconButton = styled(NudeButton)`
|
||||
height: 30px;
|
||||
`;
|
||||
|
||||
const Loading = styled(Text)`
|
||||
padding: 16px;
|
||||
`;
|
||||
|
||||
const ColorPicker = styled(TwitterPicker)`
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
width: 100% !important;
|
||||
width: auto !important;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
width: 276px;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Wrapper = styled("div")`
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
/** The size of the icon, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
/** The color of the icon, defaults to the current text color */
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export default function GoogleIcon({
|
||||
size = 24,
|
||||
color = "currentColor",
|
||||
}: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={color}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="-8 -8 48 48"
|
||||
version="1.1"
|
||||
>
|
||||
<path d="M32.6162791,13.9090909 L16.8837209,13.9090909 L16.8837209,20.4772727 L25.9395349,20.4772727 C25.0953488,24.65 21.5651163,27.0454545 16.8837209,27.0454545 C11.3581395,27.0454545 6.90697674,22.5636364 6.90697674,17 C6.90697674,11.4363636 11.3581395,6.95454545 16.8837209,6.95454545 C19.2627907,6.95454545 21.4116279,7.80454545 23.1,9.19545455 L28.0116279,4.25 C25.0186047,1.62272727 21.1813953,0 16.8837209,0 C7.52093023,0 0,7.57272727 0,17 C0,26.4272727 7.52093023,34 16.8837209,34 C25.3255814,34 33,27.8181818 33,17 C33,15.9954545 32.8465116,14.9136364 32.6162791,13.9090909 Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
/** The size of the icon, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
/** The color of the icon, defaults to the current text color */
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export default function MarkdownIcon({
|
||||
size = 24,
|
||||
color = "currentColor",
|
||||
...rest
|
||||
}: Props) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...rest}
|
||||
>
|
||||
<path
|
||||
d="M19.2692 7H3.86538C3.38745 7 3 7.38476 3 7.85938V16.2812C3 16.7559 3.38745 17.1406 3.86538 17.1406H19.2692C19.7472 17.1406 20.1346 16.7559 20.1346 16.2812V7.85938C20.1346 7.38476 19.7472 7 19.2692 7Z"
|
||||
stroke={color}
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
d="M5.16345 14.9922V9.14844H6.89422L8.62499 11.2969L10.3558 9.14844H12.0865V14.9922H10.3558V11.6406L8.62499 13.7891L6.89422 11.6406V14.9922H5.16345ZM15.9808 14.9922L13.3846 12.1562H15.1154V9.14844H16.8461V12.1562H18.5769L15.9808 14.9922Z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
/** The size of the icon, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
/** The color of the icon, defaults to the current text color */
|
||||
color?: string;
|
||||
/* Whether the safe area should be removed and have graphic across full size */
|
||||
cover?: boolean;
|
||||
};
|
||||
|
||||
export default function OutlineIcon({
|
||||
size = 24,
|
||||
cover,
|
||||
color = "currentColor",
|
||||
}: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={color}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={cover ? "2 2 20 20" : "0 0 24 24"}
|
||||
version="1.1"
|
||||
>
|
||||
<path d="M14.6667 20.2155V20.7163C14.6667 21.4253 14.0697 22 13.3333 22C13.1044 22 12.8792 21.9432 12.6797 21.8351L4.67965 17.5028C4.25982 17.2754 4 16.8478 4 16.384V7.61623C4 7.15248 4.25982 6.72478 4.67965 6.49742L12.6797 2.16508C13.3215 1.81751 14.1344 2.03666 14.4954 2.65456C14.6077 2.8467 14.6667 3.06343 14.6667 3.28388V3.78471L15.6169 3.51027C16.3222 3.30655 17.0655 3.69189 17.2771 4.37093C17.3144 4.49059 17.3333 4.61486 17.3333 4.73979V5.26091L18.5013 5.12036C19.232 5.03242 19.8984 5.53141 19.9897 6.23488C19.9966 6.2877 20 6.34088 20 6.3941V17.6061C20 18.3151 19.403 18.8898 18.6667 18.8898C18.6114 18.8898 18.5561 18.8865 18.5013 18.8799L17.3333 18.7393V19.2604C17.3333 19.9694 16.7364 20.5441 16 20.5441C15.8702 20.5441 15.7412 20.5259 15.6169 20.49L14.6667 20.2155ZM14.6667 18.8753L16 19.2604V4.73979L14.6667 5.12488V18.8753ZM17.3333 6.55456V17.4457L18.6667 17.6061V6.3941L17.3333 6.55456ZM5.33333 7.61623V16.384L13.3333 20.7163V3.28388L5.33333 7.61623ZM6.66667 8.47006L8 7.82823V16.172L6.66667 15.5302V8.47006Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
+72
-107
@@ -1,10 +1,10 @@
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
|
||||
const RealTextarea = styled.textarea<{ hasIcon?: boolean }>`
|
||||
border: 0;
|
||||
@@ -32,7 +32,6 @@ const RealInput = styled.input<{ hasIcon?: boolean }>`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
@@ -59,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")};
|
||||
`;
|
||||
@@ -99,9 +96,6 @@ export const Outline = styled(Flex)<{
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
background: ${(props) => props.theme.background};
|
||||
|
||||
/* Prevents an issue where input placeholder appears in a selected style when double clicking title bar */
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
export const LabelText = styled.div`
|
||||
@@ -119,121 +113,92 @@ export type Props = React.InputHTMLAttributes<
|
||||
flex?: boolean;
|
||||
short?: boolean;
|
||||
margin?: string | number;
|
||||
error?: string;
|
||||
icon?: React.ReactNode;
|
||||
/* Callback is triggered with the CMD+Enter keyboard combo */
|
||||
onRequestSubmit?: (
|
||||
ev: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => unknown;
|
||||
innerRef?: React.Ref<any>;
|
||||
onFocus?: (ev: React.SyntheticEvent) => unknown;
|
||||
onBlur?: (ev: React.SyntheticEvent) => unknown;
|
||||
};
|
||||
|
||||
function Input(
|
||||
props: Props,
|
||||
ref: React.RefObject<HTMLInputElement | HTMLTextAreaElement>
|
||||
) {
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
@observer
|
||||
class Input extends React.Component<Props> {
|
||||
input = this.props.innerRef;
|
||||
|
||||
const handleBlur = (ev: React.SyntheticEvent) => {
|
||||
setFocused(false);
|
||||
@observable
|
||||
focused = false;
|
||||
|
||||
if (props.onBlur) {
|
||||
props.onBlur(ev);
|
||||
handleBlur = (ev: React.SyntheticEvent) => {
|
||||
this.focused = false;
|
||||
|
||||
if (this.props.onBlur) {
|
||||
this.props.onBlur(ev);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = (ev: React.SyntheticEvent) => {
|
||||
setFocused(true);
|
||||
handleFocus = (ev: React.SyntheticEvent) => {
|
||||
this.focused = true;
|
||||
|
||||
if (props.onFocus) {
|
||||
props.onFocus(ev);
|
||||
if (this.props.onFocus) {
|
||||
this.props.onFocus(ev);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (
|
||||
ev: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
if (ev.key === "Enter" && ev.metaKey) {
|
||||
if (props.onRequestSubmit) {
|
||||
props.onRequestSubmit(ev);
|
||||
}
|
||||
}
|
||||
render() {
|
||||
const {
|
||||
type = "text",
|
||||
icon,
|
||||
label,
|
||||
margin,
|
||||
className,
|
||||
short,
|
||||
flex,
|
||||
labelHidden,
|
||||
onFocus,
|
||||
onBlur,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
if (props.onKeyDown) {
|
||||
props.onKeyDown(ev);
|
||||
}
|
||||
};
|
||||
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||
|
||||
const {
|
||||
type = "text",
|
||||
icon,
|
||||
label,
|
||||
margin,
|
||||
error,
|
||||
className,
|
||||
short,
|
||||
flex,
|
||||
labelHidden,
|
||||
onFocus,
|
||||
onBlur,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||
|
||||
return (
|
||||
<Wrapper className={className} short={short} flex={flex}>
|
||||
<label>
|
||||
{label &&
|
||||
(labelHidden ? (
|
||||
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
|
||||
) : (
|
||||
wrappedLabel
|
||||
))}
|
||||
<Outline focused={focused} margin={margin}>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
{type === "textarea" ? (
|
||||
<RealTextarea
|
||||
ref={ref as React.RefObject<HTMLTextAreaElement>}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
hasIcon={!!icon}
|
||||
{...rest}
|
||||
/>
|
||||
) : (
|
||||
<RealInput
|
||||
ref={ref as React.RefObject<HTMLInputElement>}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
hasIcon={!!icon}
|
||||
type={type}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
</Outline>
|
||||
</label>
|
||||
{error && (
|
||||
<TextWrapper>
|
||||
<StyledText type="danger" size="xsmall">
|
||||
{error}
|
||||
</StyledText>
|
||||
</TextWrapper>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
return (
|
||||
<Wrapper className={className} short={short} flex={flex}>
|
||||
<label>
|
||||
{label &&
|
||||
(labelHidden ? (
|
||||
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
|
||||
) : (
|
||||
wrappedLabel
|
||||
))}
|
||||
<Outline focused={this.focused} margin={margin}>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
{type === "textarea" ? (
|
||||
<RealTextarea
|
||||
ref={this.props.innerRef}
|
||||
onBlur={this.props.onBlur}
|
||||
onFocus={this.handleFocus}
|
||||
hasIcon={!!icon}
|
||||
{...rest}
|
||||
/>
|
||||
) : (
|
||||
<RealInput
|
||||
ref={this.props.innerRef}
|
||||
onBlur={this.props.onBlur}
|
||||
onFocus={this.handleFocus}
|
||||
hasIcon={!!icon}
|
||||
type={type}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
</Outline>
|
||||
</label>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const TextWrapper = styled.span`
|
||||
min-height: 16px;
|
||||
display: block;
|
||||
margin-top: -16px;
|
||||
`;
|
||||
export const ReactHookWrappedInput = React.forwardRef(
|
||||
(props: Omit<Props, "innerRef">, ref: React.Ref<any>) => {
|
||||
return <Input {...{ ...props, innerRef: ref }} />;
|
||||
}
|
||||
);
|
||||
|
||||
export const StyledText = styled(Text)`
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
export default React.forwardRef(Input);
|
||||
export default Input;
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import ContextMenu from "./ContextMenu";
|
||||
import DelayedMount from "./DelayedMount";
|
||||
import Input, { Props as InputProps } from "./Input";
|
||||
import NudeButton from "./NudeButton";
|
||||
import Relative from "./Sidebar/components/Relative";
|
||||
import Text from "./Text";
|
||||
|
||||
type Props = Omit<InputProps, "onChange"> & {
|
||||
value: string | undefined;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
const InputColor: React.FC<Props> = ({ value, onChange, ...rest }) => {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
return (
|
||||
<Relative>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder="#"
|
||||
maxLength={7}
|
||||
{...rest}
|
||||
/>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<SwatchButton
|
||||
aria-label={t("Show menu")}
|
||||
{...props}
|
||||
$background={value}
|
||||
/>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("Select a color")}>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<DelayedMount>
|
||||
<Text>{t("Loading")}…</Text>
|
||||
</DelayedMount>
|
||||
}
|
||||
>
|
||||
<StyledColorPicker
|
||||
disableAlpha
|
||||
color={value}
|
||||
onChange={(color) => onChange(color.hex)}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</ContextMenu>
|
||||
</Relative>
|
||||
);
|
||||
};
|
||||
|
||||
const SwatchButton = styled(NudeButton)<{ $background: string | undefined }>`
|
||||
background: ${(props) => props.$background};
|
||||
border: 1px solid ${(props) => props.theme.inputBorder};
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
right: 6px;
|
||||
`;
|
||||
|
||||
const ColorPicker = React.lazy(
|
||||
() => import("react-color/lib/components/chrome/Chrome")
|
||||
);
|
||||
|
||||
const StyledColorPicker = styled(ColorPicker)`
|
||||
background: inherit !important;
|
||||
box-shadow: none !important;
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
user-select: none;
|
||||
|
||||
input {
|
||||
user-select: text;
|
||||
color: ${(props) => props.theme.text} !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export default InputColor;
|
||||
@@ -42,7 +42,7 @@ function InputSearch(
|
||||
onBlur={handleBlur}
|
||||
margin={0}
|
||||
labelHidden
|
||||
ref={ref}
|
||||
innerRef={ref}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
@@ -68,7 +67,7 @@ function InputSearchPage({
|
||||
|
||||
return (
|
||||
<InputMaxWidth
|
||||
ref={inputRef}
|
||||
innerRef={inputRef}
|
||||
type="search"
|
||||
placeholder={placeholder || `${t("Search")}…`}
|
||||
value={value}
|
||||
|
||||
@@ -8,18 +8,12 @@ import {
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import styled, { css } from "styled-components";
|
||||
import Button, { Inner } from "~/components/Button";
|
||||
import Text from "~/components/Text";
|
||||
import useMenuHeight from "~/hooks/useMenuHeight";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
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";
|
||||
|
||||
@@ -42,7 +36,7 @@ export type Props = {
|
||||
icon?: React.ReactNode;
|
||||
options: Option[];
|
||||
note?: React.ReactNode;
|
||||
onChange?: (value: string | null) => void;
|
||||
onChange: (value: string | null) => void;
|
||||
};
|
||||
|
||||
const getOptionFromValue = (options: Option[], value: string | null) => {
|
||||
@@ -78,25 +72,16 @@ const InputSelect = (props: Props) => {
|
||||
disabled,
|
||||
});
|
||||
|
||||
const isMobile = useMobile();
|
||||
const previousValue = React.useRef<string | null>(value);
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const selectedRef = React.useRef<HTMLDivElement>(null);
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const minWidth = buttonRef.current?.offsetWidth || 0;
|
||||
const margin = 8;
|
||||
const menuMaxHeight = useMenuHeight(
|
||||
const maxHeight = useMenuHeight(
|
||||
select.visible,
|
||||
select.unstable_disclosureRef,
|
||||
margin
|
||||
select.unstable_disclosureRef
|
||||
);
|
||||
const maxHeight = Math.min(
|
||||
menuMaxHeight ?? 0,
|
||||
window.innerHeight -
|
||||
(buttonRef.current?.getBoundingClientRect().bottom ?? 0) -
|
||||
margin
|
||||
);
|
||||
|
||||
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||
const selectedValueIndex = options.findIndex(
|
||||
(option) => option.value === select.selectedValue
|
||||
@@ -109,21 +94,32 @@ const InputSelect = (props: Props) => {
|
||||
previousValue.current = select.selectedValue;
|
||||
|
||||
async function load() {
|
||||
await onChange?.(select.selectedValue);
|
||||
await onChange(select.selectedValue);
|
||||
}
|
||||
|
||||
load();
|
||||
}, [onChange, select.selectedValue]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (select.visible) {
|
||||
requestAnimationFrame(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.scrollTop = selectedValueIndex * 32;
|
||||
}
|
||||
// Ensure selected option is visible when opening the input
|
||||
React.useEffect(() => {
|
||||
if (!select.animating && selectedRef.current) {
|
||||
scrollIntoView(selectedRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "auto",
|
||||
block: "start",
|
||||
});
|
||||
}
|
||||
}, [select.visible, selectedValueIndex]);
|
||||
}, [select.animating]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (select.visible) {
|
||||
const offset = Math.round(
|
||||
(selectedRef.current?.getBoundingClientRect().top || 0) -
|
||||
(contentRef.current?.getBoundingClientRect().top || 0)
|
||||
);
|
||||
setOffset(offset);
|
||||
}
|
||||
}, [select.visible]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -156,9 +152,17 @@ const InputSelect = (props: Props) => {
|
||||
placement: Placement;
|
||||
}
|
||||
) => {
|
||||
const topAnchor = props.style?.top === "0";
|
||||
if (!props.style) {
|
||||
props.style = {};
|
||||
}
|
||||
const topAnchor = props.style.top === "0";
|
||||
const rightAnchor = props.placement === "bottom-end";
|
||||
|
||||
// offset top of select to place selected item under the cursor
|
||||
if (selectedValueIndex !== -1) {
|
||||
props.style.top = `-${offset + 32}px`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Positioner {...props}>
|
||||
<Background
|
||||
@@ -166,7 +170,6 @@ const InputSelect = (props: Props) => {
|
||||
ref={contentRef}
|
||||
topAnchor={topAnchor}
|
||||
rightAnchor={rightAnchor}
|
||||
hiddenScrollbars
|
||||
style={
|
||||
maxHeight && topAnchor
|
||||
? {
|
||||
@@ -208,15 +211,11 @@ const InputSelect = (props: Props) => {
|
||||
{note}
|
||||
</Text>
|
||||
)}
|
||||
{select.visible && isMobile && <Backdrop />}
|
||||
{select.visible && <Backdrop />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Background = styled(ContextMenuBackground)`
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
`;
|
||||
|
||||
const Placeholder = styled.span`
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
`;
|
||||
@@ -276,9 +275,9 @@ const Positioner = styled(Position)`
|
||||
${StyledSelectOption} {
|
||||
&[aria-selected="true"] {
|
||||
color: ${(props) => props.theme.white};
|
||||
background: ${(props) => props.theme.accent};
|
||||
background: ${(props) => props.theme.primary};
|
||||
box-shadow: none;
|
||||
cursor: var(--pointer);
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
fill: ${(props) => props.theme.white};
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function InputSelectPermission(
|
||||
value = "";
|
||||
}
|
||||
|
||||
onChange?.(value);
|
||||
onChange(value);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ const Key = styled.kbd<Props>`
|
||||
border: solid 1px ${(props) => props.theme.slateLight};
|
||||
border-bottom-color: ${(props) => props.theme.slate};
|
||||
border-radius: 3px;
|
||||
box-shadow: inset 0 -1px 0 ${(props) => props.theme.slate};
|
||||
`;
|
||||
|
||||
export default Key;
|
||||
|
||||
@@ -6,7 +6,6 @@ import { languages, languageOptions } from "@shared/i18n";
|
||||
import ButtonLink from "~/components/ButtonLink";
|
||||
import Flex from "~/components/Flex";
|
||||
import NoticeTip from "~/components/NoticeTip";
|
||||
import env from "~/env";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { detectLanguage } from "~/utils/language";
|
||||
@@ -58,7 +57,6 @@ export default function LanguagePrompt() {
|
||||
|
||||
const option = find(languageOptions, (o) => o.value === language);
|
||||
const optionLabel = option ? option.label : "";
|
||||
const appName = env.APP_NAME;
|
||||
|
||||
return (
|
||||
<NoticeTip>
|
||||
@@ -66,7 +64,7 @@ export default function LanguagePrompt() {
|
||||
<LanguageIcon />
|
||||
<span>
|
||||
<Trans>
|
||||
{{ appName }} is available in your language{" "}
|
||||
Outline is available in your language{" "}
|
||||
{{
|
||||
optionLabel,
|
||||
}}
|
||||
|
||||
@@ -7,7 +7,6 @@ import Flex from "~/components/Flex";
|
||||
import { LoadingIndicatorBar } from "~/components/LoadingIndicator";
|
||||
import SkipNavContent from "~/components/SkipNavContent";
|
||||
import SkipNavLink from "~/components/SkipNavLink";
|
||||
import env from "~/env";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { MenuProvider } from "~/hooks/useMenuContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -16,17 +15,12 @@ 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.sidebarIsClosed;
|
||||
const sidebarCollapsed = !sidebar || ui.isEditing || ui.sidebarCollapsed;
|
||||
|
||||
useKeyDown(".", (event) => {
|
||||
if (isModKey(event)) {
|
||||
@@ -37,7 +31,7 @@ const Layout: React.FC<Props> = ({
|
||||
return (
|
||||
<Container column auto>
|
||||
<Helmet>
|
||||
<title>{title ? title : env.APP_NAME}</title>
|
||||
<title>{title ? title : "Outline"}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</Helmet>
|
||||
|
||||
@@ -66,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;
|
||||
@@ -52,7 +51,7 @@ const ListItem = (
|
||||
$border={border}
|
||||
$small={small}
|
||||
activeStyle={{
|
||||
background: theme.accent,
|
||||
background: theme.primary,
|
||||
}}
|
||||
{...rest}
|
||||
as={NavLink}
|
||||
@@ -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;
|
||||
|
||||
@@ -28,7 +28,7 @@ const Container = styled.div`
|
||||
const Loader = styled.div`
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background-color: ${(props) => props.theme.accent};
|
||||
background-color: ${(props) => props.theme.primary};
|
||||
`;
|
||||
|
||||
export default LoadingIndicatorBar;
|
||||
|
||||
@@ -15,7 +15,6 @@ import useMobile from "~/hooks/useMobile";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useUnmount from "~/hooks/useUnmount";
|
||||
import { fadeAndScaleIn } from "~/styles/animations";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
|
||||
let openModals = 0;
|
||||
type Props = {
|
||||
@@ -223,7 +222,7 @@ const Back = styled(NudeButton)`
|
||||
position: absolute;
|
||||
display: none;
|
||||
align-items: center;
|
||||
top: ${Desktop.hasInsetTitlebar() ? "3rem" : "2rem"};
|
||||
top: 2rem;
|
||||
left: 2rem;
|
||||
opacity: 0.75;
|
||||
color: ${(props) => props.theme.text};
|
||||
@@ -252,9 +251,8 @@ const Small = styled.div`
|
||||
animation: ${fadeAndScaleIn} 250ms ease;
|
||||
|
||||
margin: auto auto;
|
||||
width: 30vw;
|
||||
min-width: 350px;
|
||||
max-width: 500px;
|
||||
max-width: 30vw;
|
||||
z-index: ${depths.modal};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,36 +2,28 @@ import styled from "styled-components";
|
||||
import ActionButton, {
|
||||
Props as ActionButtonProps,
|
||||
} from "~/components/ActionButton";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
|
||||
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;
|
||||
${undraggableOnDesktop()}
|
||||
`;
|
||||
|
||||
export default NudeButton;
|
||||
export default StyledNudeButton;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
fill?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function OutlineLogo({ size = 32, fill = "#333", className }: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={fill}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 64 64"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M32,57.6 L32,59.1606101 C32,61.3697491 30.209139,63.1606101 28,63.1606101 C27.3130526,63.1606101 26.6376816,62.9836959 26.038955,62.6469122 L2.03895504,49.1469122 C0.779447116,48.438439 -4.3614532e-15,47.1057033 -7.10542736e-15,45.6606101 L-7.10542736e-15,18.3393899 C-7.28240024e-15,16.8942967 0.779447116,15.561561 2.03895504,14.8530878 L26.038955,1.35308779 C27.9643866,0.270032565 30.4032469,0.952913469 31.4863021,2.87834498 C31.8230858,3.47707155 32,4.15244252 32,4.83938994 L32,6.4 L34.8506085,5.54481746 C36.9665799,4.91002604 39.1965137,6.11075966 39.8313051,8.22673106 C39.9431692,8.59961116 40,8.98682435 40,9.3761226 L40,11 L43.5038611,10.5620174 C45.6959408,10.2880074 47.6951015,11.8429102 47.9691115,14.0349899 C47.9896839,14.1995692 48,14.3652688 48,14.5311289 L48,49.4688711 C48,51.6780101 46.209139,53.4688711 44,53.4688711 C43.8341399,53.4688711 43.6684404,53.458555 43.5038611,53.4379826 L40,53 L40,54.6238774 C40,56.8330164 38.209139,58.6238774 36,58.6238774 C35.6107017,58.6238774 35.2234886,58.5670466 34.8506085,58.4551825 L32,57.6 Z M32,53.4238774 L36,54.6238774 L36,9.3761226 L32,10.5761226 L32,53.4238774 Z M40,15.0311289 L40,48.9688711 L44,49.4688711 L44,14.5311289 L40,15.0311289 Z M5.32907052e-15,44.4688711 L5.32907052e-15,19.5311289 L3.55271368e-15,44.4688711 Z M4,18.3393899 L4,45.6606101 L28,59.1606101 L28,4.83938994 L4,18.3393899 Z M8,21 L12,19 L12,45 L8,43 L8,21 Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default OutlineLogo;
|
||||
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { cdnPath } from "@shared/utils/urls";
|
||||
import env from "~/env";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
@@ -17,16 +16,15 @@ const PageTitle = ({ title, favicon }: Props) => {
|
||||
return (
|
||||
<Helmet>
|
||||
<title>
|
||||
{team?.name ? `${title} - ${team.name}` : `${title} - ${env.APP_NAME}`}
|
||||
{team?.name ? `${title} - ${team.name}` : `${title} - Outline`}
|
||||
</title>
|
||||
{favicon ? (
|
||||
<link rel="shortcut icon" href={favicon} key={favicon} />
|
||||
<link rel="shortcut icon" href={favicon} />
|
||||
) : (
|
||||
<link
|
||||
rel="shortcut icon"
|
||||
type="image/png"
|
||||
key="favicon"
|
||||
href={cdnPath("/images/favicon-32.png")}
|
||||
href={cdnPath("/favicon-32.png")}
|
||||
sizes="32x32"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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)`
|
||||
const Heading = styled("h3")`
|
||||
font-size: 14px;
|
||||
padding: 0 12px;
|
||||
`;
|
||||
|
||||
const Heading = styled("h3")`
|
||||
font-size: 15px;
|
||||
padding: 0 4px;
|
||||
`;
|
||||
|
||||
export default PaginatedEventList;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import "../stores";
|
||||
import { shallow } from "enzyme";
|
||||
import { TFunction } from "i18next";
|
||||
import * as React from "react";
|
||||
import { getI18n } from "react-i18next";
|
||||
import { DEFAULT_PAGINATION_LIMIT } from "~/stores/BaseStore";
|
||||
@@ -17,7 +16,7 @@ describe("PaginatedList", () => {
|
||||
const props = {
|
||||
i18n,
|
||||
tReady: true,
|
||||
t: ((key: string) => key) as TFunction,
|
||||
t: (key: string) => key,
|
||||
logout: () => {
|
||||
//
|
||||
},
|
||||
|
||||
@@ -11,7 +11,7 @@ import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
|
||||
import DelayedMount from "~/components/DelayedMount";
|
||||
import PlaceholderList from "~/components/List/Placeholder";
|
||||
import withStores from "~/components/withStores";
|
||||
import { dateToHeading } from "~/utils/date";
|
||||
import { dateToHeading } from "~/utils/dates";
|
||||
|
||||
export interface PaginatedItem {
|
||||
id: string;
|
||||
@@ -29,7 +29,6 @@ type Props<T> = WithTranslation &
|
||||
empty?: React.ReactNode;
|
||||
loading?: React.ReactElement;
|
||||
items?: T[];
|
||||
className?: string;
|
||||
renderItem: (
|
||||
item: T,
|
||||
index: number,
|
||||
@@ -54,14 +53,11 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
@observable
|
||||
isFetching = false;
|
||||
|
||||
@observable
|
||||
isFetchingInitial = !this.props.items?.length;
|
||||
|
||||
@observable
|
||||
fetchCounter = 0;
|
||||
|
||||
@observable
|
||||
renderCount = 15;
|
||||
renderCount: number = DEFAULT_PAGINATION_LIMIT;
|
||||
|
||||
@observable
|
||||
offset = 0;
|
||||
@@ -88,7 +84,6 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
this.allowLoadMore = true;
|
||||
this.renderCount = DEFAULT_PAGINATION_LIMIT;
|
||||
this.isFetching = false;
|
||||
this.isFetchingInitial = false;
|
||||
this.isFetchingMore = false;
|
||||
};
|
||||
|
||||
@@ -99,7 +94,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
}
|
||||
this.isFetching = true;
|
||||
const counter = ++this.fetchCounter;
|
||||
const limit = this.props.options?.limit ?? DEFAULT_PAGINATION_LIMIT;
|
||||
const limit = DEFAULT_PAGINATION_LIMIT;
|
||||
this.error = undefined;
|
||||
|
||||
try {
|
||||
@@ -116,7 +111,6 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
}
|
||||
|
||||
this.renderCount += limit;
|
||||
this.isFetchingInitial = false;
|
||||
} catch (err) {
|
||||
this.error = err;
|
||||
} finally {
|
||||
@@ -164,15 +158,13 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
const showLoading =
|
||||
this.isFetching &&
|
||||
!this.isFetchingMore &&
|
||||
(!items?.length || (this.fetchCounter <= 1 && this.isFetchingInitial));
|
||||
(!items?.length || this.fetchCounter === 0);
|
||||
|
||||
if (showLoading) {
|
||||
return (
|
||||
this.props.loading || (
|
||||
<DelayedMount>
|
||||
<div className={this.props.className}>
|
||||
<PlaceholderList count={5} />
|
||||
</div>
|
||||
<PlaceholderList count={5} />
|
||||
</DelayedMount>
|
||||
)
|
||||
);
|
||||
@@ -192,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 = "";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user