mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
Add per-share branding: title and logoUrl overrides (#12003)
* feat: add title and logoUrl to Share model Agent-Logs-Url: https://github.com/outline/outline/sessions/9bc9d438-6892-4903-9d32-6b6868f4fd97 Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com> * fix: use STRING(4096) for logoUrl column in migration Agent-Logs-Url: https://github.com/outline/outline/sessions/9bc9d438-6892-4903-9d32-6b6868f4fd97 Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com> * feat: use share title and logoUrl to override team branding on shared page Agent-Logs-Url: https://github.com/outline/outline/sessions/854d6d22-e80b-4673-b3b2-0f9cf43a3246 Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com> * refactor: use ShareValidation class constants for title/logoUrl max lengths Agent-Logs-Url: https://github.com/outline/outline/sessions/ea462d6a-d4d3-4882-ab8e-88060bf64877 Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com> * fix: use ShareValidation constants in @Length msg template literals Agent-Logs-Url: https://github.com/outline/outline/sessions/694116c2-47e8-4001-a103-c8a62c7ac71e Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com> * feat: add display settings popover with custom title and icon for shares Move share toggles (search indexing, email subscriptions, show last modified, show TOC) into a popover triggered by a settings cog. The popover also includes inputs for a custom site title and icon upload to override team branding on shared pages. Rename logoUrl to iconUrl, loosen URL validation to allow relative attachment paths, and surface the popover in the shared page header for users with edit permission. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * styling * Display branding on single shared pages * Review comments * refactor * PR feedback * Lose 'Remove icon' button --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com> Co-authored-by: Tom Moor <tom@getoutline.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import debounce from "lodash/debounce";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { observer } from "mobx-react";
|
||||
import { CopyIcon, GlobeIcon, QuestionMarkIcon } from "outline-icons";
|
||||
import { CopyIcon, GlobeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
@@ -22,6 +23,7 @@ import env from "~/env";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { ListItem } from "../components/ListItem";
|
||||
import ShareSettingsPopover from "../components/ShareSettingsPopover";
|
||||
import { DomainPrefix, ShareLinkInput, StyledInfoIcon } from "../components";
|
||||
|
||||
type Props = {
|
||||
@@ -50,70 +52,24 @@ function InnerPublicAccess(
|
||||
setUrlId(share?.urlId);
|
||||
}, [share?.urlId]);
|
||||
|
||||
const handleIndexingChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
allowIndexing: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleSubscriptionsChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
allowSubscriptions: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleShowLastModifiedChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
showLastUpdated: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleShowTOCChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
showTOC: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handlePublishedChange = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
if (checked && !share) {
|
||||
setCreating(true);
|
||||
await shares.create({
|
||||
const newShare = await shares.create({
|
||||
type: "collection",
|
||||
collectionId: collection.id,
|
||||
published: true,
|
||||
});
|
||||
copy(newShare.url);
|
||||
toast.success(t("Public link copied to clipboard"));
|
||||
} else if (share) {
|
||||
await share.save({ published: checked });
|
||||
if (checked) {
|
||||
copy(share.url);
|
||||
toast.success(t("Public link copied to clipboard"));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
@@ -121,7 +77,7 @@ function InnerPublicAccess(
|
||||
setCreating(false);
|
||||
}
|
||||
},
|
||||
[share, shares, collection]
|
||||
[t, share, shares, collection]
|
||||
);
|
||||
|
||||
const handleUrlChange = React.useMemo(
|
||||
@@ -172,7 +128,7 @@ function InnerPublicAccess(
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<ListItem
|
||||
title={t("Web")}
|
||||
title={t("Publish to web")}
|
||||
subtitle={<>{t("Allow anyone with the link to access")}</>}
|
||||
image={
|
||||
<Squircle color={theme.text} size={AvatarSize.Medium}>
|
||||
@@ -194,123 +150,24 @@ function InnerPublicAccess(
|
||||
<ResizingHeightContainer>
|
||||
{!!share?.published && (
|
||||
<>
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Search engine indexing")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Disable this setting to discourage search engines from indexing the page"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Search engine indexing")}
|
||||
checked={share?.allowIndexing ?? false}
|
||||
onChange={handleIndexingChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{env.EMAIL_ENABLED && (
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Email subscriptions")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Allow viewers to subscribe and receive email notifications when documents are updated"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
<Flex align="center" gap={2}>
|
||||
<ShareLinkInput
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
placeholder={share?.id}
|
||||
onChange={handleUrlChange}
|
||||
error={validationError}
|
||||
defaultValue={urlId}
|
||||
prefix={
|
||||
<DomainPrefix onClick={() => inputRef.current?.focus()}>
|
||||
{env.URL.replace(/https?:\/\//, "") + "/s/"}
|
||||
</DomainPrefix>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Email subscriptions")}
|
||||
checked={share?.allowSubscriptions ?? true}
|
||||
onChange={handleSubscriptionsChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Show last modified")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Display the last modified timestamp on the shared page"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Show last modified")}
|
||||
checked={share?.showLastUpdated ?? false}
|
||||
onChange={handleShowLastModifiedChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Show table of contents")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Display the table of contents on documents by default"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Show table of contents")}
|
||||
checked={share?.showTOC ?? false}
|
||||
onChange={handleShowTOCChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ShareLinkInput
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
placeholder={share?.id}
|
||||
onChange={handleUrlChange}
|
||||
error={validationError}
|
||||
defaultValue={urlId}
|
||||
prefix={
|
||||
<DomainPrefix onClick={() => inputRef.current?.focus()}>
|
||||
{env.URL.replace(/https?:\/\//, "") + "/s/"}
|
||||
</DomainPrefix>
|
||||
}
|
||||
>
|
||||
{copyButton}
|
||||
</ShareLinkInput>
|
||||
>
|
||||
{copyButton}
|
||||
</ShareLinkInput>
|
||||
<ShareSettingsPopover share={share} />
|
||||
</Flex>
|
||||
<Flex align="flex-start" gap={4}>
|
||||
<StyledInfoIcon color={theme.textTertiary} />
|
||||
<Text type="tertiary" size="xsmall">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import debounce from "lodash/debounce";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { observer } from "mobx-react";
|
||||
import { CopyIcon, GlobeIcon, QuestionMarkIcon } from "outline-icons";
|
||||
import { CopyIcon, GlobeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
@@ -22,6 +23,7 @@ import { ResizingHeightContainer } from "../../ResizingHeightContainer";
|
||||
import Text from "../../Text";
|
||||
import Tooltip from "../../Tooltip";
|
||||
import { ListItem } from "../components/ListItem";
|
||||
import ShareSettingsPopover from "../components/ShareSettingsPopover";
|
||||
import {
|
||||
DomainPrefix,
|
||||
ShareLinkInput,
|
||||
@@ -60,70 +62,24 @@ function PublicAccess(
|
||||
setUrlId(share?.urlId);
|
||||
}, [share?.urlId]);
|
||||
|
||||
const handleIndexingChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
allowIndexing: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleSubscriptionsChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
allowSubscriptions: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleShowLastModifiedChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
showLastUpdated: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleShowTOCChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
showTOC: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handlePublishedChange = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
if (checked && !share) {
|
||||
setCreating(true);
|
||||
await shares.create({
|
||||
const newShare = await shares.create({
|
||||
type: "document",
|
||||
documentId: document.id,
|
||||
published: true,
|
||||
});
|
||||
copy(newShare.url);
|
||||
toast.success(t("Public link copied to clipboard"));
|
||||
} else if (share) {
|
||||
await share.save({ published: checked });
|
||||
if (checked) {
|
||||
copy(share.url);
|
||||
toast.success(t("Public link copied to clipboard"));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
@@ -131,7 +87,7 @@ function PublicAccess(
|
||||
setCreating(false);
|
||||
}
|
||||
},
|
||||
[share, shares, document]
|
||||
[t, share, shares, document]
|
||||
);
|
||||
|
||||
const handleUrlChange = React.useMemo(
|
||||
@@ -187,7 +143,7 @@ function PublicAccess(
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<ListItem
|
||||
title={t("Web")}
|
||||
title={t("Publish to web")}
|
||||
subtitle={
|
||||
<>
|
||||
{sharedParent && !document.isDraft ? (
|
||||
@@ -236,133 +192,29 @@ function PublicAccess(
|
||||
/>
|
||||
|
||||
<ResizingHeightContainer>
|
||||
{share?.published && !sharedParent?.published && (
|
||||
<>
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Search engine indexing")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Disable this setting to discourage search engines from indexing the page"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Search engine indexing")}
|
||||
checked={share?.allowIndexing ?? false}
|
||||
onChange={handleIndexingChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{env.EMAIL_ENABLED && (
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Email subscriptions")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Allow viewers to subscribe and receive email notifications when this document is updated"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Email subscriptions")}
|
||||
checked={share?.allowSubscriptions ?? true}
|
||||
onChange={handleSubscriptionsChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Show last modified")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Display the last modified timestamp on the shared page"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Show last modified")}
|
||||
checked={share?.showLastUpdated ?? false}
|
||||
onChange={handleShowLastModifiedChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Show table of contents")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Display the table of contents on documents by default"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Show table of contents")}
|
||||
checked={share?.showTOC ?? false}
|
||||
onChange={handleShowTOCChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{sharedParent?.published && !document.isDraft ? (
|
||||
<ShareLinkInput type="text" disabled defaultValue={shareUrl}>
|
||||
{copyButton}
|
||||
</ShareLinkInput>
|
||||
) : share?.published ? (
|
||||
<ShareLinkInput
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
placeholder={share?.id}
|
||||
onChange={handleUrlChange}
|
||||
error={validationError}
|
||||
defaultValue={urlId}
|
||||
prefix={
|
||||
<DomainPrefix onClick={() => inputRef.current?.focus()}>
|
||||
{env.URL.replace(/https?:\/\//, "") + "/s/"}
|
||||
</DomainPrefix>
|
||||
}
|
||||
>
|
||||
{copyButton}
|
||||
</ShareLinkInput>
|
||||
<Flex align="center" gap={2}>
|
||||
<ShareLinkInput
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
placeholder={share?.id}
|
||||
onChange={handleUrlChange}
|
||||
error={validationError}
|
||||
defaultValue={urlId}
|
||||
prefix={
|
||||
<DomainPrefix onClick={() => inputRef.current?.focus()}>
|
||||
{env.URL.replace(/https?:\/\//, "") + "/s/"}
|
||||
</DomainPrefix>
|
||||
}
|
||||
>
|
||||
{copyButton}
|
||||
</ShareLinkInput>
|
||||
<ShareSettingsPopover share={share} />
|
||||
</Flex>
|
||||
) : null}
|
||||
|
||||
{share?.published && !share.includeChildDocuments ? (
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { ellipsis, s } from "@shared/styles";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import TeamLogo from "~/components/TeamLogo";
|
||||
import useShareBranding from "~/hooks/useShareBranding";
|
||||
import type Share from "~/models/Share";
|
||||
|
||||
type Props = {
|
||||
share: Share;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the team or share-customized branding (logo + name) for shared
|
||||
* documents that do not have a sidebar.
|
||||
*/
|
||||
function HeaderBranding({ share }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { displayName, displayLogoUrl, displayLogoModel, brandingAvailable } =
|
||||
useShareBranding(share);
|
||||
|
||||
if (!brandingAvailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper align="center" gap={8}>
|
||||
<TeamLogo
|
||||
model={displayLogoModel}
|
||||
src={displayLogoUrl ?? undefined}
|
||||
size={AvatarSize.Large}
|
||||
alt={t("Logo")}
|
||||
/>
|
||||
{displayName && <Name>{displayName}</Name>}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
min-width: 0;
|
||||
color: ${s("text")};
|
||||
`;
|
||||
|
||||
const Name = styled.span`
|
||||
${ellipsis()}
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
export default observer(HeaderBranding);
|
||||
@@ -0,0 +1,428 @@
|
||||
import debounce from "lodash/debounce";
|
||||
import uniqueId from "lodash/uniqueId";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
ImageIcon,
|
||||
QuestionMarkIcon,
|
||||
SettingsIcon,
|
||||
TrashIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { HStack } from "~/components/primitives/HStack";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
import type Share from "~/models/Share";
|
||||
import { createAction } from "~/actions";
|
||||
import { ShareSection } from "~/actions/sections";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import Input from "~/components/Input";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Switch from "~/components/Switch";
|
||||
import TeamLogo from "~/components/TeamLogo";
|
||||
import Text from "~/components/Text";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import env from "~/env";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { compressImage } from "~/utils/compressImage";
|
||||
import { uploadFile } from "~/utils/files";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "~/components/primitives/Popover";
|
||||
import { ListItem } from "./ListItem";
|
||||
|
||||
type Props = {
|
||||
/** The share model to configure settings for. */
|
||||
share: Share;
|
||||
/** Custom trigger element. If not provided, a default settings icon button is rendered. */
|
||||
children?: React.ReactElement;
|
||||
};
|
||||
|
||||
/**
|
||||
* A popover triggered by a settings icon that contains toggle options
|
||||
* for configuring a published share link (indexing, subscriptions, etc.),
|
||||
* as well as custom title and logo branding.
|
||||
*/
|
||||
function ShareSettingsPopover({ share, children }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { auth } = useStores();
|
||||
const theme = useTheme();
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const hasChangesRef = React.useRef(false);
|
||||
const [isUploading, setIsUploading] = React.useState(false);
|
||||
const idPrefix = React.useMemo(() => uniqueId("share-settings-"), []);
|
||||
const showLastUpdatedId = `${idPrefix}-show-last-updated`;
|
||||
const showTOCId = `${idPrefix}-show-toc`;
|
||||
const indexingId = `${idPrefix}-indexing`;
|
||||
const subscriptionsId = `${idPrefix}-subscriptions`;
|
||||
|
||||
const handleTitleChange = React.useMemo(
|
||||
() =>
|
||||
debounce(async (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = ev.target.value;
|
||||
try {
|
||||
await share.save({ title: val || null });
|
||||
hasChangesRef.current = true;
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
}, 500),
|
||||
[share]
|
||||
);
|
||||
|
||||
const triggerUpload = React.useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleLogoUpload = React.useCallback(
|
||||
async (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = ev.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const compressed = await compressImage(file, {
|
||||
maxHeight: 512,
|
||||
maxWidth: 512,
|
||||
});
|
||||
const attachment = await uploadFile(compressed, {
|
||||
name: file.name,
|
||||
preset: AttachmentPreset.Avatar,
|
||||
});
|
||||
await share.save({ iconUrl: attachment.url });
|
||||
hasChangesRef.current = true;
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleLogoRemove = React.useCallback(async () => {
|
||||
try {
|
||||
await share.save({ iconUrl: null });
|
||||
hasChangesRef.current = true;
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
}, [share]);
|
||||
|
||||
const handleIndexingChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share.save({ allowIndexing: checked });
|
||||
hasChangesRef.current = true;
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleSubscriptionsChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share.save({ allowSubscriptions: checked });
|
||||
hasChangesRef.current = true;
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleShowLastModifiedChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share.save({ showLastUpdated: checked });
|
||||
hasChangesRef.current = true;
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleShowTOCChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share.save({ showTOC: checked });
|
||||
hasChangesRef.current = true;
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const flushChangeToast = React.useCallback(() => {
|
||||
if (hasChangesRef.current) {
|
||||
toast.success(t("Sharing settings updated"));
|
||||
hasChangesRef.current = false;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
flushChangeToast();
|
||||
}
|
||||
},
|
||||
[flushChangeToast]
|
||||
);
|
||||
|
||||
// Also flush on unmount in case the parent popover closes us before
|
||||
// onOpenChange fires.
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
flushChangeToast();
|
||||
},
|
||||
[flushChangeToast]
|
||||
);
|
||||
|
||||
const iconActions = React.useMemo(
|
||||
() => [
|
||||
createAction({
|
||||
name: ({ t: translate }) => translate("Upload image"),
|
||||
analyticsName: "Upload share icon",
|
||||
section: ShareSection,
|
||||
icon: <ImageIcon />,
|
||||
perform: triggerUpload,
|
||||
}),
|
||||
createAction({
|
||||
name: ({ t: translate }) => translate("Remove image"),
|
||||
analyticsName: "Remove share icon",
|
||||
section: ShareSection,
|
||||
icon: <TrashIcon />,
|
||||
dangerous: true,
|
||||
perform: handleLogoRemove,
|
||||
}),
|
||||
],
|
||||
[triggerUpload, handleLogoRemove]
|
||||
);
|
||||
const iconRootAction = useMenuAction(iconActions);
|
||||
|
||||
return (
|
||||
<Popover modal onOpenChange={handleOpenChange}>
|
||||
<Tooltip content={t("Display settings")} placement="top">
|
||||
<PopoverTrigger>
|
||||
{children ?? (
|
||||
<SettingsTrigger type="button">
|
||||
<SettingsIcon color={theme.placeholder} size={24} />
|
||||
</SettingsTrigger>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
</Tooltip>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
minWidth={400}
|
||||
style={{ paddingTop: 20, paddingBottom: 20 }}
|
||||
>
|
||||
<Text as="h3" weight="bold">
|
||||
{t("Display settings")}
|
||||
</Text>
|
||||
<Text as="p" size="small" type="secondary">
|
||||
{t("Customize how the published document is displayed")}
|
||||
</Text>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={AttachmentValidation.avatarContentTypes.join(",")}
|
||||
onChange={handleLogoUpload}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<HStack spacing={8} style={{ marginBottom: 8 }}>
|
||||
{share.iconUrl ? (
|
||||
<DropdownMenu
|
||||
action={iconRootAction}
|
||||
align="start"
|
||||
ariaLabel={t("Image options")}
|
||||
>
|
||||
<LogoButton type="button" disabled={isUploading}>
|
||||
<TeamLogo
|
||||
src={share.iconUrl}
|
||||
size={AvatarSize.Large}
|
||||
alt={t("Icon")}
|
||||
/>
|
||||
</LogoButton>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<LogoButton
|
||||
type="button"
|
||||
onClick={triggerUpload}
|
||||
disabled={isUploading}
|
||||
aria-label={t("Upload")}
|
||||
>
|
||||
<TeamLogo
|
||||
model={auth.team ?? undefined}
|
||||
size={AvatarSize.Large}
|
||||
alt={t("Icon")}
|
||||
/>
|
||||
</LogoButton>
|
||||
)}
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Site title")}
|
||||
labelHidden
|
||||
placeholder={auth.team?.name ?? ""}
|
||||
defaultValue={share.title ?? ""}
|
||||
onChange={handleTitleChange}
|
||||
margin={0}
|
||||
flex
|
||||
/>
|
||||
</HStack>
|
||||
<ListItem
|
||||
title={
|
||||
<SwitchLabel htmlFor={showLastUpdatedId}>
|
||||
{t("Show last modified")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Display the last modified timestamp on the shared page"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</SwitchLabel>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
id={showLastUpdatedId}
|
||||
checked={share.showLastUpdated ?? false}
|
||||
onChange={handleShowLastModifiedChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title={
|
||||
<SwitchLabel htmlFor={showTOCId}>
|
||||
{t("Show table of contents")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Display the table of contents on documents by default"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</SwitchLabel>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
id={showTOCId}
|
||||
checked={share.showTOC ?? false}
|
||||
onChange={handleShowTOCChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Text as="h3" weight="bold" style={{ marginTop: 16 }}>
|
||||
{t("Behavior")}
|
||||
</Text>
|
||||
<ListItem
|
||||
title={
|
||||
<SwitchLabel htmlFor={indexingId}>
|
||||
{t("Search engine indexing")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Disable this setting to discourage search engines from indexing the page"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</SwitchLabel>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
id={indexingId}
|
||||
checked={share.allowIndexing ?? false}
|
||||
onChange={handleIndexingChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{env.EMAIL_ENABLED && (
|
||||
<ListItem
|
||||
title={
|
||||
<SwitchLabel htmlFor={subscriptionsId}>
|
||||
{t("Email subscriptions")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Allow viewers to subscribe and receive email notifications when documents are updated"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</SwitchLabel>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
id={subscriptionsId}
|
||||
checked={share.allowSubscriptions ?? true}
|
||||
onChange={handleSubscriptionsChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const SwitchLabel = styled.label`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: ${s("textSecondary")};
|
||||
cursor: var(--pointer);
|
||||
`;
|
||||
|
||||
const SettingsTrigger = styled(NudeButton)`
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
top: -2px;
|
||||
right: -4px;
|
||||
`;
|
||||
|
||||
const LogoButton = styled.button`
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: var(--pointer);
|
||||
flex-shrink: 0;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(ShareSettingsPopover);
|
||||
@@ -11,11 +11,11 @@ import type Share from "~/models/Share";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useShareBranding from "~/hooks/useShareBranding";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import { homePath, sharedModelPath } from "~/utils/routeHelpers";
|
||||
import { AvatarSize } from "../Avatar";
|
||||
import { useTeamContext } from "../TeamContext";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
import Sidebar from "./Sidebar";
|
||||
import Section from "./components/Section";
|
||||
@@ -28,13 +28,13 @@ type Props = {
|
||||
};
|
||||
|
||||
function SharedSidebar({ share }: Props) {
|
||||
const team = useTeamContext();
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const { ui, documents, collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const { query } = useKBar();
|
||||
|
||||
const teamAvailable = !!team?.name;
|
||||
const { displayName, displayLogoUrl, displayLogoModel, brandingAvailable } =
|
||||
useShareBranding(share);
|
||||
const rootNode = share.tree;
|
||||
const shareId = share.urlId || share.id;
|
||||
const collection = collections.get(rootNode?.id);
|
||||
@@ -56,11 +56,16 @@ function SharedSidebar({ share }: Props) {
|
||||
|
||||
return (
|
||||
<Sidebar canCollapse={false}>
|
||||
{teamAvailable && (
|
||||
{brandingAvailable && (
|
||||
<SidebarButton
|
||||
title={team.name}
|
||||
title={displayName}
|
||||
image={
|
||||
<TeamLogo model={team} size={AvatarSize.XLarge} alt={t("Logo")} />
|
||||
<TeamLogo
|
||||
model={displayLogoModel}
|
||||
src={displayLogoUrl ?? undefined}
|
||||
size={AvatarSize.XLarge}
|
||||
alt={t("Logo")}
|
||||
/>
|
||||
}
|
||||
disabled={hideRootNode}
|
||||
onClick={
|
||||
|
||||
@@ -44,7 +44,7 @@ const PopoverContent = React.forwardRef<
|
||||
const timeoutRef = React.useRef<NodeJS.Timeout>();
|
||||
const container = usePortalContext();
|
||||
const {
|
||||
width = 380,
|
||||
width,
|
||||
minWidth,
|
||||
minHeight,
|
||||
scrollable = true,
|
||||
@@ -53,6 +53,7 @@ const PopoverContent = React.forwardRef<
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
const resolvedWidth = width ?? (minWidth ? undefined : 380);
|
||||
|
||||
const enablePointerEvents = React.useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
@@ -78,7 +79,7 @@ const PopoverContent = React.forwardRef<
|
||||
<StyledContent
|
||||
ref={mergeRefs([ref, forwardedRef])}
|
||||
sideOffset={sideOffset}
|
||||
$width={width}
|
||||
$width={resolvedWidth}
|
||||
$minWidth={minWidth}
|
||||
$minHeight={minHeight}
|
||||
$scrollable={scrollable}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { PublicTeam } from "@shared/types";
|
||||
import { useTeamContext } from "~/components/TeamContext";
|
||||
import type Share from "~/models/Share";
|
||||
import type Team from "~/models/Team";
|
||||
|
||||
type ShareBranding = {
|
||||
displayName: string | null;
|
||||
displayLogoUrl: string | null;
|
||||
displayLogoModel: Team | PublicTeam | undefined;
|
||||
brandingAvailable: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the resolved branding (name + logo) to display for a share, falling
|
||||
* back to the team's name and avatar when the share has no custom values.
|
||||
*
|
||||
* @param share the share to derive branding for.
|
||||
* @returns the resolved name, logo URL, fallback model, and whether any
|
||||
* branding is available to display.
|
||||
*/
|
||||
export default function useShareBranding(share: Share): ShareBranding {
|
||||
const team = useTeamContext();
|
||||
const displayName = share.title ?? team?.name ?? null;
|
||||
const displayLogoUrl = share.iconUrl ?? team?.avatarUrl ?? null;
|
||||
const displayLogoModel = displayLogoUrl ? undefined : team;
|
||||
|
||||
return {
|
||||
displayName,
|
||||
displayLogoUrl,
|
||||
displayLogoModel,
|
||||
brandingAvailable: !!(displayName || displayLogoUrl),
|
||||
};
|
||||
}
|
||||
+11
-6
@@ -82,6 +82,16 @@ class Share extends Model implements Searchable {
|
||||
@observable
|
||||
showTOC: boolean;
|
||||
|
||||
/** Custom branding title to display on the shared page, supersedes team name. */
|
||||
@Field
|
||||
@observable
|
||||
title: string | null;
|
||||
|
||||
/** Custom branding icon URL to display on the shared page, supersedes team avatar. */
|
||||
@Field
|
||||
@observable
|
||||
iconUrl: string | null;
|
||||
|
||||
@observable
|
||||
views: number;
|
||||
|
||||
@@ -89,11 +99,6 @@ class Share extends Model implements Searchable {
|
||||
@Relation(() => User, { onDelete: "null" })
|
||||
createdBy: User;
|
||||
|
||||
@computed
|
||||
get title(): string {
|
||||
return this.sourceTitle ?? this.documentTitle;
|
||||
}
|
||||
|
||||
@computed
|
||||
get sourcePathWithFallback(): string {
|
||||
return this.sourcePath ?? this.documentUrl;
|
||||
@@ -101,7 +106,7 @@ class Share extends Model implements Searchable {
|
||||
|
||||
@computed
|
||||
get searchContent(): string[] {
|
||||
return [this.title];
|
||||
return [this.sourceTitle ?? this.documentTitle];
|
||||
}
|
||||
|
||||
@computed
|
||||
|
||||
@@ -38,6 +38,7 @@ import Header from "./Header";
|
||||
import Notices from "./Notices";
|
||||
import References from "./References";
|
||||
import RevisionViewer from "./RevisionViewer";
|
||||
import SharedHeader from "./SharedHeader";
|
||||
|
||||
type LocationState = {
|
||||
title?: string;
|
||||
@@ -316,19 +317,25 @@ function DocumentScene({
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Header
|
||||
editorRef={editorRef}
|
||||
document={document}
|
||||
revision={revision}
|
||||
isDraft={document.isDraft}
|
||||
isEditing={!readOnly && !!user?.separateEditMode}
|
||||
isSaving={isSaving}
|
||||
isPublishing={isPublishing}
|
||||
publishingIsDisabled={document.isSaving || isPublishing || isEmpty}
|
||||
savingIsDisabled={document.isSaving || isEmpty}
|
||||
onSelectTemplate={handleSelectTemplate}
|
||||
onSave={onSave}
|
||||
/>
|
||||
{isShare ? (
|
||||
<SharedHeader document={document} />
|
||||
) : (
|
||||
<Header
|
||||
editorRef={editorRef}
|
||||
document={document}
|
||||
revision={revision}
|
||||
isDraft={document.isDraft}
|
||||
isEditing={!readOnly && !!user?.separateEditMode}
|
||||
isSaving={isSaving}
|
||||
isPublishing={isPublishing}
|
||||
publishingIsDisabled={
|
||||
document.isSaving || isPublishing || isEmpty
|
||||
}
|
||||
savingIsDisabled={document.isSaving || isEmpty}
|
||||
onSelectTemplate={handleSelectTemplate}
|
||||
onSave={onSave}
|
||||
/>
|
||||
)}
|
||||
<Main
|
||||
fullWidth={document.fullWidth}
|
||||
tocPosition={tocPos}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Link } from "react-router-dom";
|
||||
import useMeasure from "react-use-measure";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import useShare from "@shared/hooks/useShare";
|
||||
import { altDisplay, metaDisplay } from "@shared/utils/keyboard";
|
||||
import { publishDocument } from "~/actions/definitions/documents";
|
||||
import { restoreRevision } from "~/actions/definitions/revisions";
|
||||
@@ -18,14 +17,9 @@ import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import Flex from "~/components/Flex";
|
||||
import Header from "~/components/Header";
|
||||
import {
|
||||
AppearanceAction,
|
||||
SubscribeAction,
|
||||
} from "~/components/Sharing/components/Actions";
|
||||
import Star from "~/components/Star";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { type Editor } from "~/editor";
|
||||
import env from "~/env";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useEditingFocus from "~/hooks/useEditingFocus";
|
||||
@@ -44,7 +38,6 @@ import type Template from "~/models/Template";
|
||||
import { documentEditPath } from "~/utils/routeHelpers";
|
||||
import { ChangesNavigation } from "./ChangesNavigation";
|
||||
import ObservingBanner from "./ObservingBanner";
|
||||
import PublicBreadcrumb from "./PublicBreadcrumb";
|
||||
import { SearchHighlightChip } from "./SearchHighlightChip";
|
||||
import ShareButton from "./ShareButton";
|
||||
|
||||
@@ -97,7 +90,6 @@ function DocumentHeader({
|
||||
const { hasHeadings, editor } = useDocumentContext();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const [measureRef, size] = useMeasure();
|
||||
const { isShare, shareId, sharedTree, allowSubscriptions } = useShare();
|
||||
const isMobile = isMobileMedia || (size.width > 0 && size.width < 700);
|
||||
|
||||
// We cache this value for as long as the component is mounted so that if you
|
||||
@@ -112,19 +104,13 @@ function DocumentHeader({
|
||||
}, [onSave]);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
// Public shares, by default, show ToC on load.
|
||||
if (isShare && ui.tocVisible === undefined) {
|
||||
ui.set({ tocVisible: false });
|
||||
} else {
|
||||
ui.set({ tocVisible: !ui.tocVisible });
|
||||
}
|
||||
}, [ui, isShare]);
|
||||
ui.set({ tocVisible: !ui.tocVisible });
|
||||
}, [ui]);
|
||||
|
||||
const can = usePolicy(document);
|
||||
const { isDeleted } = document;
|
||||
const canToggleEmbeds = team?.documentEmbeds;
|
||||
const showContents =
|
||||
ui.tocVisible === true || (isShare && ui.tocVisible !== false);
|
||||
const showContents = ui.tocVisible === true;
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile && showContents) {
|
||||
@@ -186,192 +172,137 @@ function DocumentHeader({
|
||||
}
|
||||
);
|
||||
|
||||
if (shareId) {
|
||||
return (
|
||||
<StyledHeader
|
||||
ref={measureRef}
|
||||
$hidden={isEditingFocus}
|
||||
title={
|
||||
<Flex gap={4}>
|
||||
{document.icon && (
|
||||
<Icon
|
||||
value={document.icon}
|
||||
initial={document.initial}
|
||||
color={document.color ?? undefined}
|
||||
/>
|
||||
)}
|
||||
{document.title}
|
||||
</Flex>
|
||||
}
|
||||
hasSidebar={sharedTree && sharedTree.children?.length > 0}
|
||||
left={
|
||||
isMobile ? (
|
||||
hasHeadings ? (
|
||||
<TableOfContentsMenu />
|
||||
) : null
|
||||
) : (
|
||||
<PublicBreadcrumb
|
||||
documentId={document.id}
|
||||
shareId={shareId}
|
||||
sharedTree={sharedTree}
|
||||
>
|
||||
{hasHeadings ? toc : null}
|
||||
</PublicBreadcrumb>
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
{allowSubscriptions !== false && !user && env.EMAIL_ENABLED && (
|
||||
<SubscribeAction shareId={shareId} documentId={document.id} />
|
||||
)}
|
||||
<AppearanceAction />
|
||||
{can.update && !isEditing ? editAction : <div />}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledHeader
|
||||
ref={measureRef}
|
||||
$hidden={isEditingFocus}
|
||||
hasSidebar
|
||||
left={
|
||||
isMobile ? (
|
||||
<TableOfContentsMenu />
|
||||
) : (
|
||||
<DocumentBreadcrumb document={document as Document}>
|
||||
{toc}{" "}
|
||||
<Star
|
||||
document={document as Document}
|
||||
color={theme.textSecondary}
|
||||
/>
|
||||
</DocumentBreadcrumb>
|
||||
)
|
||||
}
|
||||
title={
|
||||
<Flex gap={4} align="center">
|
||||
{document.icon && (
|
||||
<Icon
|
||||
value={document.icon}
|
||||
initial={document.initial}
|
||||
color={document.color ?? undefined}
|
||||
/>
|
||||
)}
|
||||
{document.title}
|
||||
{document.isArchived && <Badge>{t("Archived")}</Badge>}
|
||||
{document.isDraft && <Badge>{t("Draft")}</Badge>}
|
||||
</Flex>
|
||||
}
|
||||
actions={({ isCompact }) => (
|
||||
<>
|
||||
<ObservingBanner />
|
||||
<SearchHighlightChip />
|
||||
{!isDeleted && !isRevision && can.listViews && (
|
||||
<Collaborators
|
||||
<StyledHeader
|
||||
ref={measureRef}
|
||||
$hidden={isEditingFocus}
|
||||
hasSidebar
|
||||
left={
|
||||
isMobile ? (
|
||||
<TableOfContentsMenu />
|
||||
) : (
|
||||
<DocumentBreadcrumb document={document}>
|
||||
{toc} <Star document={document} color={theme.textSecondary} />
|
||||
</DocumentBreadcrumb>
|
||||
)
|
||||
}
|
||||
title={
|
||||
<Flex gap={4} align="center">
|
||||
{document.icon && (
|
||||
<Icon
|
||||
value={document.icon}
|
||||
initial={document.initial}
|
||||
color={document.color ?? undefined}
|
||||
/>
|
||||
)}
|
||||
{document.title}
|
||||
{document.isArchived && <Badge>{t("Archived")}</Badge>}
|
||||
{document.isDraft && <Badge>{t("Draft")}</Badge>}
|
||||
</Flex>
|
||||
}
|
||||
actions={({ isCompact }) => (
|
||||
<>
|
||||
<ObservingBanner />
|
||||
<SearchHighlightChip />
|
||||
{!isDeleted && !isRevision && can.listViews && (
|
||||
<Collaborators
|
||||
document={document}
|
||||
limit={isCompact ? 3 : undefined}
|
||||
/>
|
||||
)}
|
||||
{(isEditing || !user?.separateEditMode) && wasNew && can.update && (
|
||||
<Action>
|
||||
<TemplatesMenu
|
||||
isCompact={isCompact}
|
||||
document={document}
|
||||
limit={isCompact ? 3 : undefined}
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
/>
|
||||
)}
|
||||
{(isEditing || !user?.separateEditMode) && wasNew && can.update && (
|
||||
<Action>
|
||||
<TemplatesMenu
|
||||
isCompact={isCompact}
|
||||
document={document as Document}
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
/>
|
||||
</Action>
|
||||
)}
|
||||
{!isEditing && !isRevision && can.update && (
|
||||
<Action>
|
||||
<ShareButton document={document} />
|
||||
</Action>
|
||||
)}
|
||||
{isEditing && (
|
||||
<Action>
|
||||
<Tooltip
|
||||
content={isDraft ? t("Save draft") : t("Done editing")}
|
||||
shortcut={`${metaDisplay}+enter`}
|
||||
placement="bottom"
|
||||
</Action>
|
||||
)}
|
||||
{!isEditing && !isRevision && can.update && (
|
||||
<Action>
|
||||
<ShareButton document={document} />
|
||||
</Action>
|
||||
)}
|
||||
{isEditing && (
|
||||
<Action>
|
||||
<Tooltip
|
||||
content={isDraft ? t("Save draft") : t("Done editing")}
|
||||
shortcut={`${metaDisplay}+enter`}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={savingIsDisabled}
|
||||
neutral={isDraft}
|
||||
haptic="medium"
|
||||
hideIcon
|
||||
>
|
||||
{isDraft ? t("Save draft") : t("Done editing")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
)}
|
||||
{can.update &&
|
||||
!isEditing &&
|
||||
user?.separateEditMode &&
|
||||
!isRevision &&
|
||||
editAction}
|
||||
{can.update &&
|
||||
can.createChildDocument &&
|
||||
!isRevision &&
|
||||
!isCompact &&
|
||||
!isMobile && (
|
||||
<Action>
|
||||
<NewChildDocumentMenu document={document} />
|
||||
</Action>
|
||||
)}
|
||||
{revision && (
|
||||
<>
|
||||
<Action>
|
||||
<ChangesNavigation revision={revision} editorRef={editorRef} />
|
||||
</Action>
|
||||
<Action>
|
||||
<Tooltip content={t("Restore version")} placement="bottom">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={savingIsDisabled}
|
||||
neutral={isDraft}
|
||||
haptic="medium"
|
||||
hideIcon
|
||||
action={restoreRevision}
|
||||
disabled={revision.createdAt === document.updatedAt}
|
||||
neutral
|
||||
hideOnActionDisabled
|
||||
>
|
||||
{isDraft ? t("Save draft") : t("Done editing")}
|
||||
{t("Restore")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
)}
|
||||
{can.update &&
|
||||
!isEditing &&
|
||||
user?.separateEditMode &&
|
||||
!isRevision &&
|
||||
editAction}
|
||||
{can.update &&
|
||||
can.createChildDocument &&
|
||||
!isRevision &&
|
||||
!isCompact &&
|
||||
!isMobile && (
|
||||
<Action>
|
||||
<NewChildDocumentMenu document={document} />
|
||||
</Action>
|
||||
)}
|
||||
{revision && (
|
||||
<>
|
||||
<Action>
|
||||
<ChangesNavigation
|
||||
revision={revision}
|
||||
editorRef={editorRef}
|
||||
/>
|
||||
</Action>
|
||||
<Action>
|
||||
<Tooltip content={t("Restore version")} placement="bottom">
|
||||
<Button
|
||||
action={restoreRevision}
|
||||
disabled={revision.createdAt === document.updatedAt}
|
||||
neutral
|
||||
hideOnActionDisabled
|
||||
>
|
||||
{t("Restore")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
</>
|
||||
)}
|
||||
{can.publish && (
|
||||
<Action>
|
||||
<Button
|
||||
action={publishDocument}
|
||||
disabled={publishingIsDisabled}
|
||||
hideOnActionDisabled
|
||||
hideIcon
|
||||
>
|
||||
{t("Publish")}…
|
||||
</Button>
|
||||
</Action>
|
||||
)}
|
||||
{!isDeleted && <Separator />}
|
||||
</>
|
||||
)}
|
||||
{can.publish && (
|
||||
<Action>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
align="end"
|
||||
neutral
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
onFindAndReplace={editor?.commands.openFindAndReplace}
|
||||
showToggleEmbeds={canToggleEmbeds}
|
||||
showDisplayOptions
|
||||
/>
|
||||
<Button
|
||||
action={publishDocument}
|
||||
disabled={publishingIsDisabled}
|
||||
hideOnActionDisabled
|
||||
hideIcon
|
||||
>
|
||||
{t("Publish")}…
|
||||
</Button>
|
||||
</Action>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!isDeleted && <Separator />}
|
||||
<Action>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
align="end"
|
||||
neutral
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
onFindAndReplace={editor?.commands.openFindAndReplace}
|
||||
showToggleEmbeds={canToggleEmbeds}
|
||||
showDisplayOptions
|
||||
/>
|
||||
</Action>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { TableOfContentsIcon, EditIcon, SettingsIcon } from "outline-icons";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import useMeasure from "react-use-measure";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import useShare from "@shared/hooks/useShare";
|
||||
import { altDisplay } from "@shared/utils/keyboard";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Button from "~/components/Button";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import Flex from "~/components/Flex";
|
||||
import Header from "~/components/Header";
|
||||
import {
|
||||
AppearanceAction,
|
||||
SubscribeAction,
|
||||
} from "~/components/Sharing/components/Actions";
|
||||
import HeaderBranding from "~/components/Sharing/components/HeaderBranding";
|
||||
import ShareSettingsPopover from "~/components/Sharing/components/ShareSettingsPopover";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import env from "~/env";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useEditingFocus from "~/hooks/useEditingFocus";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import TableOfContentsMenu from "~/menus/TableOfContentsMenu";
|
||||
import type Document from "~/models/Document";
|
||||
import { documentEditPath } from "~/utils/routeHelpers";
|
||||
import PublicBreadcrumb from "./PublicBreadcrumb";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
};
|
||||
|
||||
function SharedDocumentHeader({ document }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { ui, shares } = useStores();
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const isMobileMedia = useMobile();
|
||||
const isEditingFocus = useEditingFocus();
|
||||
|
||||
// Set CSS variable for header offset (used by sticky table headers)
|
||||
useEffect(() => {
|
||||
window.document.documentElement.style.setProperty(
|
||||
"--header-offset",
|
||||
isEditingFocus ? "0px" : "64px"
|
||||
);
|
||||
}, [isEditingFocus]);
|
||||
|
||||
const { hasHeadings } = useDocumentContext();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const [measureRef, size] = useMeasure();
|
||||
const { shareId, sharedTree, allowSubscriptions } = useShare();
|
||||
const share = shareId ? shares.get(shareId) : undefined;
|
||||
const isMobile = isMobileMedia || (size.width > 0 && size.width < 700);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
// Public shares, by default, show ToC on load.
|
||||
if (ui.tocVisible === undefined) {
|
||||
ui.set({ tocVisible: false });
|
||||
} else {
|
||||
ui.set({ tocVisible: !ui.tocVisible });
|
||||
}
|
||||
}, [ui]);
|
||||
|
||||
const can = usePolicy(document);
|
||||
const showContents = ui.tocVisible !== false;
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile && showContents) {
|
||||
ui.set({ tocVisible: false });
|
||||
}
|
||||
}, [isMobile, showContents, ui]);
|
||||
|
||||
useKeyDown(
|
||||
(event) => event.ctrlKey && event.altKey && event.code === "KeyH",
|
||||
handleToggle,
|
||||
{
|
||||
allowInInput: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (!shareId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toc = (
|
||||
<Tooltip
|
||||
content={
|
||||
showContents
|
||||
? t("Hide contents")
|
||||
: hasHeadings
|
||||
? t("Show contents")
|
||||
: `${t("Show contents")} (${t("available when headings are added")})`
|
||||
}
|
||||
shortcut={`Ctrl+${altDisplay}+h`}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
aria-label={t("Show contents")}
|
||||
onClick={handleToggle}
|
||||
icon={<TableOfContentsIcon />}
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const editAction = can.update ? (
|
||||
<Action>
|
||||
<Tooltip
|
||||
content={t("Edit {{noun}}", { noun: document.noun })}
|
||||
shortcut="e"
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
as={Link}
|
||||
icon={<EditIcon />}
|
||||
to={{
|
||||
pathname: documentEditPath(document),
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
haptic="light"
|
||||
neutral
|
||||
>
|
||||
{isMobile ? null : t("Edit")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
) : (
|
||||
<div />
|
||||
);
|
||||
|
||||
const hasSidebar = !!(sharedTree && sharedTree.children?.length);
|
||||
|
||||
return (
|
||||
<StyledHeader
|
||||
ref={measureRef}
|
||||
$hidden={isEditingFocus}
|
||||
title={
|
||||
<Flex gap={4}>
|
||||
{document.icon && (
|
||||
<Icon
|
||||
value={document.icon}
|
||||
initial={document.initial}
|
||||
color={document.color ?? undefined}
|
||||
/>
|
||||
)}
|
||||
{document.title}
|
||||
</Flex>
|
||||
}
|
||||
hasSidebar={hasSidebar}
|
||||
left={
|
||||
isMobile ? (
|
||||
hasHeadings ? (
|
||||
<TableOfContentsMenu />
|
||||
) : null
|
||||
) : hasSidebar ? (
|
||||
<PublicBreadcrumb
|
||||
documentId={document.id}
|
||||
shareId={shareId}
|
||||
sharedTree={sharedTree}
|
||||
>
|
||||
{hasHeadings ? toc : null}
|
||||
</PublicBreadcrumb>
|
||||
) : (
|
||||
<Flex align="center" gap={8}>
|
||||
{share && <HeaderBranding share={share} />}
|
||||
{hasHeadings ? toc : null}
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
{allowSubscriptions !== false && !user && env.EMAIL_ENABLED && (
|
||||
<SubscribeAction shareId={shareId} documentId={document.id} />
|
||||
)}
|
||||
<AppearanceAction />
|
||||
{can.update && share && (
|
||||
<Action>
|
||||
<ShareSettingsPopover share={share}>
|
||||
<Button
|
||||
icon={<SettingsIcon />}
|
||||
aria-label={t("Display settings")}
|
||||
neutral
|
||||
borderOnHover
|
||||
/>
|
||||
</ShareSettingsPopover>
|
||||
</Action>
|
||||
)}
|
||||
{editAction}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledHeader = styled(Header)<{ $hidden: boolean }>`
|
||||
transition: opacity 500ms ease-in-out;
|
||||
${(props) => props.$hidden && "opacity: 0;"}
|
||||
`;
|
||||
|
||||
export default observer(SharedDocumentHeader);
|
||||
@@ -0,0 +1,19 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn("shares", "title", {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true,
|
||||
});
|
||||
await queryInterface.addColumn("shares", "iconUrl", {
|
||||
type: Sequelize.STRING(4096),
|
||||
allowNull: true,
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
await queryInterface.removeColumn("shares", "title");
|
||||
await queryInterface.removeColumn("shares", "iconUrl");
|
||||
},
|
||||
};
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
BeforeUpdate,
|
||||
} from "sequelize-typescript";
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
import { ShareValidation } from "@shared/validations";
|
||||
import env from "@server/env";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import type { APIContext } from "@server/types";
|
||||
@@ -25,6 +26,7 @@ import User from "./User";
|
||||
import IdModel from "./base/IdModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
import IsFQDN from "./validators/IsFQDN";
|
||||
import IsUrlOrRelativePath from "./validators/IsUrlOrRelativePath";
|
||||
import Length from "./validators/Length";
|
||||
|
||||
@DefaultScope(() => ({
|
||||
@@ -155,6 +157,23 @@ class Share extends IdModel<
|
||||
@Column
|
||||
showTOC: boolean;
|
||||
|
||||
@AllowNull
|
||||
@Length({
|
||||
max: ShareValidation.maxTitleLength,
|
||||
msg: `title must be ${ShareValidation.maxTitleLength} characters or less`,
|
||||
})
|
||||
@Column
|
||||
title: string | null;
|
||||
|
||||
@AllowNull
|
||||
@IsUrlOrRelativePath
|
||||
@Length({
|
||||
max: ShareValidation.maxIconUrlLength,
|
||||
msg: `iconUrl must be ${ShareValidation.maxIconUrlLength} characters or less`,
|
||||
})
|
||||
@Column
|
||||
iconUrl: string | null;
|
||||
|
||||
// hooks
|
||||
|
||||
@BeforeUpdate
|
||||
|
||||
@@ -19,6 +19,8 @@ export default function presentShare(share: Share, isAdmin = false) {
|
||||
allowSubscriptions: share.allowSubscriptions,
|
||||
showLastUpdated: share.showLastUpdated,
|
||||
showTOC: share.showTOC,
|
||||
title: share.title,
|
||||
iconUrl: share.iconUrl,
|
||||
lastAccessedAt: share.lastAccessedAt || undefined,
|
||||
views: share.views || 0,
|
||||
domain: share.domain,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { isURL } from "class-validator";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { z } from "zod";
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
import { ShareValidation } from "@shared/validations";
|
||||
import { Share } from "@server/models";
|
||||
import { ValidateURL } from "@server/validation";
|
||||
import { zodIdType } from "@server/utils/zod";
|
||||
import { BaseSchema } from "../schema";
|
||||
|
||||
@@ -56,6 +59,15 @@ export const SharesUpdateSchema = BaseSchema.extend({
|
||||
allowSubscriptions: z.boolean().optional(),
|
||||
showLastUpdated: z.boolean().optional(),
|
||||
showTOC: z.boolean().optional(),
|
||||
title: z.string().max(ShareValidation.maxTitleLength).nullish(),
|
||||
iconUrl: z
|
||||
.string()
|
||||
.max(ShareValidation.maxIconUrlLength)
|
||||
.refine(
|
||||
(val) => isURL(val, { require_host: false, require_protocol: false }),
|
||||
{ error: ValidateURL.message }
|
||||
)
|
||||
.nullish(),
|
||||
urlId: z
|
||||
.string()
|
||||
.regex(UrlHelper.SHARE_URL_SLUG_REGEX, {
|
||||
|
||||
@@ -1009,6 +1009,141 @@ describe("#shares.update", () => {
|
||||
expect(body.data.urlId).toBeNull();
|
||||
});
|
||||
|
||||
it("should update title and iconUrl", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const res = await server.post("/api/shares.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: share.id,
|
||||
title: "Custom Title",
|
||||
iconUrl: "https://example.com/icon.png",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.title).toEqual("Custom Title");
|
||||
expect(body.data.iconUrl).toEqual("https://example.com/icon.png");
|
||||
});
|
||||
|
||||
it("should allow clearing title and iconUrl with null", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
title: "Custom Title",
|
||||
iconUrl: "https://example.com/icon.png",
|
||||
});
|
||||
const res = await server.post("/api/shares.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: share.id,
|
||||
title: null,
|
||||
iconUrl: null,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.title).toBeNull();
|
||||
expect(body.data.iconUrl).toBeNull();
|
||||
});
|
||||
|
||||
it("should normalize empty title to null", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
title: "Custom Title",
|
||||
});
|
||||
const res = await server.post("/api/shares.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: share.id,
|
||||
title: "",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.title).toBeNull();
|
||||
});
|
||||
|
||||
it("should accept a relative iconUrl", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const res = await server.post("/api/shares.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: share.id,
|
||||
iconUrl: "/uploads/icon.png",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.iconUrl).toEqual("/uploads/icon.png");
|
||||
});
|
||||
|
||||
it("should reject malformed iconUrl", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const res = await server.post("/api/shares.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: share.id,
|
||||
iconUrl: "not a url",
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
|
||||
it("should reject iconUrl with disallowed protocol", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const res = await server.post("/api/shares.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: share.id,
|
||||
iconUrl: "javascript:alert(1)",
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
|
||||
it("should allow user to update a share", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
|
||||
@@ -350,6 +350,8 @@ router.post(
|
||||
allowSubscriptions,
|
||||
showLastUpdated,
|
||||
showTOC,
|
||||
title,
|
||||
iconUrl,
|
||||
} = ctx.input.body;
|
||||
|
||||
const { user } = ctx.state.auth;
|
||||
@@ -390,6 +392,14 @@ router.post(
|
||||
share.showTOC = showTOC;
|
||||
}
|
||||
|
||||
if (!isUndefined(title)) {
|
||||
share.title = title || null;
|
||||
}
|
||||
|
||||
if (!isUndefined(iconUrl)) {
|
||||
share.iconUrl = iconUrl || null;
|
||||
}
|
||||
|
||||
await share.saveWithCtx(ctx);
|
||||
|
||||
ctx.body = {
|
||||
|
||||
@@ -421,20 +421,12 @@
|
||||
"Everyone in the workspace": "Everyone in the workspace",
|
||||
"{{ count }} member": "{{ count }} member",
|
||||
"{{ count }} member_plural": "{{ count }} members",
|
||||
"Public link copied to clipboard": "Public link copied to clipboard",
|
||||
"Only lowercase letters, digits and dashes allowed": "Only lowercase letters, digits and dashes allowed",
|
||||
"Sorry, this link has already been used": "Sorry, this link has already been used",
|
||||
"Public link copied to clipboard": "Public link copied to clipboard",
|
||||
"Web": "Web",
|
||||
"Publish to web": "Publish to web",
|
||||
"Allow anyone with the link to access": "Allow anyone with the link to access",
|
||||
"Publish to internet": "Publish to internet",
|
||||
"Search engine indexing": "Search engine indexing",
|
||||
"Disable this setting to discourage search engines from indexing the page": "Disable this setting to discourage search engines from indexing the page",
|
||||
"Email subscriptions": "Email subscriptions",
|
||||
"Allow viewers to subscribe and receive email notifications when documents are updated": "Allow viewers to subscribe and receive email notifications when documents are updated",
|
||||
"Show last modified": "Show last modified",
|
||||
"Display the last modified timestamp on the shared page": "Display the last modified timestamp on the shared page",
|
||||
"Show table of contents": "Show table of contents",
|
||||
"Display the table of contents on documents by default": "Display the table of contents on documents by default",
|
||||
"All documents in this collection will be shared on the web, including any new documents added later": "All documents in this collection will be shared on the web, including any new documents added later",
|
||||
"Invite": "Invite",
|
||||
"{{ userName }} was added to the collection": "{{ userName }} was added to the collection",
|
||||
@@ -445,8 +437,24 @@
|
||||
"Switch to dark": "Switch to dark",
|
||||
"Switch to light": "Switch to light",
|
||||
"Subscribe to updates": "Subscribe to updates",
|
||||
"Logo": "Logo",
|
||||
"Add": "Add",
|
||||
"Add or invite": "Add or invite",
|
||||
"Sharing settings updated": "Sharing settings updated",
|
||||
"Display settings": "Display settings",
|
||||
"Customize how the published document is displayed": "Customize how the published document is displayed",
|
||||
"Image options": "Image options",
|
||||
"Upload": "Upload",
|
||||
"Site title": "Site title",
|
||||
"Show last modified": "Show last modified",
|
||||
"Display the last modified timestamp on the shared page": "Display the last modified timestamp on the shared page",
|
||||
"Show table of contents": "Show table of contents",
|
||||
"Display the table of contents on documents by default": "Display the table of contents on documents by default",
|
||||
"Behavior": "Behavior",
|
||||
"Search engine indexing": "Search engine indexing",
|
||||
"Disable this setting to discourage search engines from indexing the page": "Disable this setting to discourage search engines from indexing the page",
|
||||
"Email subscriptions": "Email subscriptions",
|
||||
"Allow viewers to subscribe and receive email notifications when documents are updated": "Allow viewers to subscribe and receive email notifications when documents are updated",
|
||||
"Something went wrong": "Something went wrong",
|
||||
"Check your email to confirm your subscription": "Check your email to confirm your subscription",
|
||||
"Get notified when this document is updated": "Get notified when this document is updated",
|
||||
@@ -475,14 +483,12 @@
|
||||
"Leave": "Leave",
|
||||
"Anyone with the link can access because the containing collection, <2>{sharedParent.sourceTitle}</2>, is shared": "Anyone with the link can access because the containing collection, <2>{sharedParent.sourceTitle}</2>, is shared",
|
||||
"Anyone with the link can access because the parent document, <2>{sharedParent.sourceTitle}</2>, is shared": "Anyone with the link can access because the parent document, <2>{sharedParent.sourceTitle}</2>, is shared",
|
||||
"Allow viewers to subscribe and receive email notifications when this document is updated": "Allow viewers to subscribe and receive email notifications when this document is updated",
|
||||
"Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future": "Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future",
|
||||
"{{ userName }} was added to the document": "{{ userName }} was added to the document",
|
||||
"{{ count }} people added to the document": "{{ count }} people added to the document",
|
||||
"{{ count }} people added to the document_plural": "{{ count }} people added to the document",
|
||||
"{{ count }} groups added to the document": "{{ count }} groups added to the document",
|
||||
"{{ count }} groups added to the document_plural": "{{ count }} groups added to the document",
|
||||
"Logo": "Logo",
|
||||
"Expand sidebar": "Expand sidebar",
|
||||
"Collapse sidebar": "Collapse sidebar",
|
||||
"Archived collections": "Archived collections",
|
||||
@@ -1249,7 +1255,6 @@
|
||||
"Show your workspace logo, description, and branding on publicly shared pages.": "Show your workspace logo, description, and branding on publicly shared pages.",
|
||||
"Table of contents position": "Table of contents position",
|
||||
"The side to display the table of contents in relation to the main content.": "The side to display the table of contents in relation to the main content.",
|
||||
"Behavior": "Behavior",
|
||||
"Subdomain": "Subdomain",
|
||||
"Your workspace will be accessible at": "Your workspace will be accessible at",
|
||||
"Choose a subdomain to enable a login page just for your team.": "Choose a subdomain to enable a login page just for your team.",
|
||||
|
||||
@@ -101,6 +101,14 @@ export const OAuthClientValidation = {
|
||||
clientTypes: ["confidential", "public"] as const,
|
||||
};
|
||||
|
||||
export const ShareValidation = {
|
||||
/** The maximum length of the share title */
|
||||
maxTitleLength: 255,
|
||||
|
||||
/** The maximum length of the share iconUrl */
|
||||
maxIconUrlLength: 4096,
|
||||
};
|
||||
|
||||
export const RevisionValidation = {
|
||||
minNameLength: 1,
|
||||
maxNameLength: 255,
|
||||
|
||||
Reference in New Issue
Block a user