Files
outline/app/scenes/Document/components/SharedHeader.tsx
T
Copilot 7ed41eadc6 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>
2026-04-26 21:23:13 -04:00

209 lines
5.9 KiB
TypeScript

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);