diff --git a/app/components/Sharing/Collection/PublicAccess.tsx b/app/components/Sharing/Collection/PublicAccess.tsx
index 98a7173866..a489baaa74 100644
--- a/app/components/Sharing/Collection/PublicAccess.tsx
+++ b/app/components/Sharing/Collection/PublicAccess.tsx
@@ -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 (
{t("Allow anyone with the link to access")}>}
image={
@@ -194,123 +150,24 @@ function InnerPublicAccess(
{!!share?.published && (
<>
-
- {t("Search engine indexing")}
-
-
-
-
-
-
- }
- actions={
-
- }
- />
- {env.EMAIL_ENABLED && (
-
- {t("Email subscriptions")}
-
-
-
-
-
-
+
+ inputRef.current?.focus()}>
+ {env.URL.replace(/https?:\/\//, "") + "/s/"}
+
}
- actions={
-
- }
- />
- )}
-
- {t("Show last modified")}
-
-
-
-
-
-
- }
- actions={
-
- }
- />
-
- {t("Show table of contents")}
-
-
-
-
-
-
- }
- actions={
-
- }
- />
- inputRef.current?.focus()}>
- {env.URL.replace(/https?:\/\//, "") + "/s/"}
-
- }
- >
- {copyButton}
-
+ >
+ {copyButton}
+
+
+
diff --git a/app/components/Sharing/Document/PublicAccess.tsx b/app/components/Sharing/Document/PublicAccess.tsx
index 59dcdd29d6..5b5812c157 100644
--- a/app/components/Sharing/Document/PublicAccess.tsx
+++ b/app/components/Sharing/Document/PublicAccess.tsx
@@ -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 (
{sharedParent && !document.isDraft ? (
@@ -236,133 +192,29 @@ function PublicAccess(
/>
- {share?.published && !sharedParent?.published && (
- <>
-
- {t("Search engine indexing")}
-
-
-
-
-
-
- }
- actions={
-
- }
- />
- {env.EMAIL_ENABLED && (
-
- {t("Email subscriptions")}
-
-
-
-
-
-
- }
- actions={
-
- }
- />
- )}
-
- {t("Show last modified")}
-
-
-
-
-
-
- }
- actions={
-
- }
- />
-
- {t("Show table of contents")}
-
-
-
-
-
-
- }
- actions={
-
- }
- />
- >
- )}
-
{sharedParent?.published && !document.isDraft ? (
{copyButton}
) : share?.published ? (
- inputRef.current?.focus()}>
- {env.URL.replace(/https?:\/\//, "") + "/s/"}
-
- }
- >
- {copyButton}
-
+
+ inputRef.current?.focus()}>
+ {env.URL.replace(/https?:\/\//, "") + "/s/"}
+
+ }
+ >
+ {copyButton}
+
+
+
) : null}
{share?.published && !share.includeChildDocuments ? (
diff --git a/app/components/Sharing/components/HeaderBranding.tsx b/app/components/Sharing/components/HeaderBranding.tsx
new file mode 100644
index 0000000000..958c8fb06e
--- /dev/null
+++ b/app/components/Sharing/components/HeaderBranding.tsx
@@ -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 (
+
+
+ {displayName && {displayName}}
+
+ );
+}
+
+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);
diff --git a/app/components/Sharing/components/ShareSettingsPopover.tsx b/app/components/Sharing/components/ShareSettingsPopover.tsx
new file mode 100644
index 0000000000..6cf32c8ed5
--- /dev/null
+++ b/app/components/Sharing/components/ShareSettingsPopover.tsx
@@ -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(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) => {
+ 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) => {
+ 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: ,
+ perform: triggerUpload,
+ }),
+ createAction({
+ name: ({ t: translate }) => translate("Remove image"),
+ analyticsName: "Remove share icon",
+ section: ShareSection,
+ icon: ,
+ dangerous: true,
+ perform: handleLogoRemove,
+ }),
+ ],
+ [triggerUpload, handleLogoRemove]
+ );
+ const iconRootAction = useMenuAction(iconActions);
+
+ return (
+
+
+
+ {children ?? (
+
+
+
+ )}
+
+
+
+
+ {t("Display settings")}
+
+
+ {t("Customize how the published document is displayed")}
+
+
+
+ {share.iconUrl ? (
+
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {t("Show last modified")}
+
+
+
+
+
+
+ }
+ actions={
+
+ }
+ />
+
+ {t("Show table of contents")}
+
+
+
+
+
+
+ }
+ actions={
+
+ }
+ />
+
+ {t("Behavior")}
+
+
+ {t("Search engine indexing")}
+
+
+
+
+
+
+ }
+ actions={
+
+ }
+ />
+ {env.EMAIL_ENABLED && (
+
+ {t("Email subscriptions")}
+
+
+
+
+
+
+ }
+ actions={
+
+ }
+ />
+ )}
+
+
+ );
+}
+
+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);
diff --git a/app/components/Sidebar/Shared.tsx b/app/components/Sidebar/Shared.tsx
index aa778b73d5..e6266d9afa 100644
--- a/app/components/Sidebar/Shared.tsx
+++ b/app/components/Sidebar/Shared.tsx
@@ -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 (
- {teamAvailable && (
+ {brandingAvailable && (
+
}
disabled={hideRootNode}
onClick={
diff --git a/app/components/primitives/Popover.tsx b/app/components/primitives/Popover.tsx
index cca40e4947..c1b4510127 100644
--- a/app/components/primitives/Popover.tsx
+++ b/app/components/primitives/Popover.tsx
@@ -44,7 +44,7 @@ const PopoverContent = React.forwardRef<
const timeoutRef = React.useRef();
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<
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
diff --git a/app/scenes/Document/components/Document.tsx b/app/scenes/Document/components/Document.tsx
index fb21a1481f..f2273cbe69 100644
--- a/app/scenes/Document/components/Document.tsx
+++ b/app/scenes/Document/components/Document.tsx
@@ -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({
)}
/>
)}
-
+ {isShare ? (
+
+ ) : (
+
+ )}
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 (
-
- {document.icon && (
-
- )}
- {document.title}
-
- }
- hasSidebar={sharedTree && sharedTree.children?.length > 0}
- left={
- isMobile ? (
- hasHeadings ? (
-
- ) : null
- ) : (
-
- {hasHeadings ? toc : null}
-
- )
- }
- actions={
- <>
- {allowSubscriptions !== false && !user && env.EMAIL_ENABLED && (
-
- )}
-
- {can.update && !isEditing ? editAction : }
- >
- }
- />
- );
- }
-
return (
- <>
-
- ) : (
-
- {toc}{" "}
-
-
- )
- }
- title={
-
- {document.icon && (
-
- )}
- {document.title}
- {document.isArchived && {t("Archived")}}
- {document.isDraft && {t("Draft")}}
-
- }
- actions={({ isCompact }) => (
- <>
-
-
- {!isDeleted && !isRevision && can.listViews && (
-
+ ) : (
+
+ {toc}
+
+ )
+ }
+ title={
+
+ {document.icon && (
+
+ )}
+ {document.title}
+ {document.isArchived && {t("Archived")}}
+ {document.isDraft && {t("Draft")}}
+
+ }
+ actions={({ isCompact }) => (
+ <>
+
+
+ {!isDeleted && !isRevision && can.listViews && (
+
+ )}
+ {(isEditing || !user?.separateEditMode) && wasNew && can.update && (
+
+
- )}
- {(isEditing || !user?.separateEditMode) && wasNew && can.update && (
-
-
-
- )}
- {!isEditing && !isRevision && can.update && (
-
-
-
- )}
- {isEditing && (
-
-
+ )}
+ {!isEditing && !isRevision && can.update && (
+
+
+
+ )}
+ {isEditing && (
+
+
+
+
+
+ )}
+ {can.update &&
+ !isEditing &&
+ user?.separateEditMode &&
+ !isRevision &&
+ editAction}
+ {can.update &&
+ can.createChildDocument &&
+ !isRevision &&
+ !isCompact &&
+ !isMobile && (
+
+
+
+ )}
+ {revision && (
+ <>
+
+
+
+
+
- )}
- {can.update &&
- !isEditing &&
- user?.separateEditMode &&
- !isRevision &&
- editAction}
- {can.update &&
- can.createChildDocument &&
- !isRevision &&
- !isCompact &&
- !isMobile && (
-
-
-
- )}
- {revision && (
- <>
-
-
-
-
-
-
-
-
- >
- )}
- {can.publish && (
-
-
-
- )}
- {!isDeleted && }
+ >
+ )}
+ {can.publish && (
-
+
- >
- )}
- />
- >
+ )}
+ {!isDeleted && }
+
+
+
+ >
+ )}
+ />
);
}
diff --git a/app/scenes/Document/components/SharedHeader.tsx b/app/scenes/Document/components/SharedHeader.tsx
new file mode 100644
index 0000000000..41916f2e18
--- /dev/null
+++ b/app/scenes/Document/components/SharedHeader.tsx
@@ -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 = (
+
+ }
+ borderOnHover
+ neutral
+ />
+
+ );
+
+ const editAction = can.update ? (
+
+
+ }
+ to={{
+ pathname: documentEditPath(document),
+ state: { sidebarContext },
+ }}
+ haptic="light"
+ neutral
+ >
+ {isMobile ? null : t("Edit")}
+
+
+
+ ) : (
+
+ );
+
+ const hasSidebar = !!(sharedTree && sharedTree.children?.length);
+
+ return (
+
+ {document.icon && (
+
+ )}
+ {document.title}
+
+ }
+ hasSidebar={hasSidebar}
+ left={
+ isMobile ? (
+ hasHeadings ? (
+
+ ) : null
+ ) : hasSidebar ? (
+
+ {hasHeadings ? toc : null}
+
+ ) : (
+
+ {share && }
+ {hasHeadings ? toc : null}
+
+ )
+ }
+ actions={
+ <>
+ {allowSubscriptions !== false && !user && env.EMAIL_ENABLED && (
+
+ )}
+
+ {can.update && share && (
+
+
+ }
+ aria-label={t("Display settings")}
+ neutral
+ borderOnHover
+ />
+
+
+ )}
+ {editAction}
+ >
+ }
+ />
+ );
+}
+
+const StyledHeader = styled(Header)<{ $hidden: boolean }>`
+ transition: opacity 500ms ease-in-out;
+ ${(props) => props.$hidden && "opacity: 0;"}
+`;
+
+export default observer(SharedDocumentHeader);
diff --git a/server/migrations/20260411000000-add-title-and-iconUrl-to-shares.js b/server/migrations/20260411000000-add-title-and-iconUrl-to-shares.js
new file mode 100644
index 0000000000..2fe64a364b
--- /dev/null
+++ b/server/migrations/20260411000000-add-title-and-iconUrl-to-shares.js
@@ -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");
+ },
+};
diff --git a/server/models/Share.ts b/server/models/Share.ts
index a804ca886d..7fd077a729 100644
--- a/server/models/Share.ts
+++ b/server/models/Share.ts
@@ -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
diff --git a/server/presenters/share.ts b/server/presenters/share.ts
index 60804eb689..e126e93573 100644
--- a/server/presenters/share.ts
+++ b/server/presenters/share.ts
@@ -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,
diff --git a/server/routes/api/shares/schema.ts b/server/routes/api/shares/schema.ts
index 5d2513ef32..d36df15c48 100644
--- a/server/routes/api/shares/schema.ts
+++ b/server/routes/api/shares/schema.ts
@@ -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, {
diff --git a/server/routes/api/shares/shares.test.ts b/server/routes/api/shares/shares.test.ts
index 983e690e99..99a0486f59 100644
--- a/server/routes/api/shares/shares.test.ts
+++ b/server/routes/api/shares/shares.test.ts
@@ -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({
diff --git a/server/routes/api/shares/shares.ts b/server/routes/api/shares/shares.ts
index 2c814b883c..926532e979 100644
--- a/server/routes/api/shares/shares.ts
+++ b/server/routes/api/shares/shares.ts
@@ -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 = {
diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json
index 9724f9b27c..3eb907c058 100644
--- a/shared/i18n/locales/en_US/translation.json
+++ b/shared/i18n/locales/en_US/translation.json
@@ -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.",
diff --git a/shared/validations.ts b/shared/validations.ts
index cda9b252a2..d97da1b1e7 100644
--- a/shared/validations.ts
+++ b/shared/validations.ts
@@ -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,