From 7ed41eadc644388a1b10cfb59080eade4a81fa1c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:23:13 -0400 Subject: [PATCH] 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 * 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 Co-authored-by: Claude Opus 4.7 --- .../Sharing/Collection/PublicAccess.tsx | 201 ++------ .../Sharing/Document/PublicAccess.tsx | 208 ++------- .../Sharing/components/HeaderBranding.tsx | 52 +++ .../components/ShareSettingsPopover.tsx | 428 ++++++++++++++++++ app/components/Sidebar/Shared.tsx | 17 +- app/components/primitives/Popover.tsx | 5 +- app/hooks/useShareBranding.ts | 33 ++ app/models/Share.ts | 17 +- app/scenes/Document/components/Document.tsx | 33 +- app/scenes/Document/components/Header.tsx | 315 +++++-------- .../Document/components/SharedHeader.tsx | 208 +++++++++ ...1000000-add-title-and-iconUrl-to-shares.js | 19 + server/models/Share.ts | 19 + server/presenters/share.ts | 2 + server/routes/api/shares/schema.ts | 12 + server/routes/api/shares/shares.test.ts | 135 ++++++ server/routes/api/shares/shares.ts | 10 + shared/i18n/locales/en_US/translation.json | 31 +- shared/validations.ts | 8 + 19 files changed, 1171 insertions(+), 582 deletions(-) create mode 100644 app/components/Sharing/components/HeaderBranding.tsx create mode 100644 app/components/Sharing/components/ShareSettingsPopover.tsx create mode 100644 app/hooks/useShareBranding.ts create mode 100644 app/scenes/Document/components/SharedHeader.tsx create mode 100644 server/migrations/20260411000000-add-title-and-iconUrl-to-shares.js 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 = ( + + + + + ) : ( +
+ ); + + 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 && ( + + +