Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/studio-live-links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudhal/cli": patch
---

Studio links to the live site: a "View site" button in the top bar (also in the site switcher and the ⌘K palette), "Live at" and "Last published" on the overview, "View live" and "Copy link" on published pages and articles, and the full address under the slug and path fields ("Live at" when the live snapshot serves it, "Will be at" until then). After a site publish the toast offers "View site" and "Copy link", as do rollback and approval toasts. Marking a page or article published now says it goes live with the next site publish, with a "Publish site" action, instead of "Page published".
65 changes: 65 additions & 0 deletions apps/studio/src/components/LiveAddress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* An editor's full address with copy: "Live at" / "Will be at" (`LiveLinks.tsx` has the shell's
* and lists' links). Without a domain only the path shows.
*/
import type { ContentEntry, Page } from "@cloudhal/core-contracts";
import { entryPath } from "@cloudhal/core-ts/permalinks";
import { ClipboardText, Text } from "@cloudhal/ui";
import { useT } from "../i18n/index.tsx";
import { liveUrl, servesAt } from "../lib/live-site.ts";
import { useLiveNode, useLiveSite } from "./useLiveSite.ts";

/**
* An editor's address: "Live at" when the live snapshot serves this item, published, at this very
* address; "Will be at" otherwise. The full URL is copyable; without a domain (or for an address
* Studio cannot build yet) only the path shows.
*/
export function LiveAddress({
path,
origin,
live,
}: {
path: string;
origin: string | undefined;
live: boolean;
}) {
const t = useT();
const url = liveUrl(origin, path);
return (
<div className="grid min-w-0 gap-1.5" data-testid="live-address" data-live={live}>
<Text size="sm" bold as="span">
{live ? t("liveSite.liveAt") : t("liveSite.willBeAt")}
</Text>
{url ? (
<ClipboardText
size="base"
text={url}
tooltip={{ text: t("common.copy"), copiedText: t("common.copied") }}
/>
) : (
<Text variant="mono-secondary" as="span">
{path}
</Text>
)}
</div>
);
}

/** The article editor's address, from the draft's slug and the collection's permalink. */
export function EntryAddress({ siteId, entry }: { siteId: string; entry: ContentEntry }) {
const { origin, site } = useLiveSite(siteId);
const liveNode = useLiveNode<ContentEntry>(siteId, entry.id);
const path = entryPath(site, entry);
const live = servesAt(liveNode, (n) => entryPath(site, n), path);
// Locale prefixes for articles come later: a non-default language shows only its path.
const known = site !== undefined && entry.locale === site.defaultLocale;
return <LiveAddress path={path} origin={known ? origin : undefined} live={live} />;
}

/** The page editor's address (the page's path is its full site path). */
export function PageAddress({ siteId, page }: { siteId: string; page: Page }) {
const { origin } = useLiveSite(siteId);
const liveNode = useLiveNode<Page>(siteId, page.id);
const live = servesAt(liveNode, (n) => n.path, page.path);
return <LiveAddress path={page.path} origin={origin} live={live} />;
}
58 changes: 58 additions & 0 deletions apps/studio/src/components/LiveLinks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* The shell's links to the live site (its primary domain, `useLiveSite`): the top bar's "View
* site" and the site switcher's "Open site". The list rows' links are in `LiveRowItems.tsx` and the
* editors' address in `LiveAddress.tsx`, so they load with their screens, not with the shell.
*/
import { DropdownMenu, directional, LinkButton } from "@cloudhal/ui";
import { ArrowSquareOut } from "@phosphor-icons/react";
import { useT } from "../i18n/index.tsx";
import { originHost } from "../lib/live-site.ts";
import { openLive, useLiveSite } from "./useLiveSite.ts";

const OpenOutside = directional(ArrowSquareOut);

/**
* The top bar's "View site" (left of Publish): opens the primary domain in a new tab, its host in
* the tooltip. Disabled, saying why, without a domain or before the first publish. Icon-only
* below `xl`, where the top bar is short of room.
*/
export function ViewSiteButton({ siteId }: { siteId: string }) {
const t = useT();
const { origin, liveSnapshotId, loading } = useLiveSite(siteId);
const reason = loading
? undefined
: !origin
? t("liveSite.noDomain")
: !liveSnapshotId
? t("liveSite.notPublished")
: undefined;
const ready = !loading && reason === undefined && origin !== undefined;
return (
<LinkButton
href={ready ? `${origin}/` : undefined}
external
variant="secondary"
size="sm"
icon={OpenOutside}
disabled={!ready}
title={reason ?? (origin ? originHost(origin) : undefined)}
aria-label={t("liveSite.view")}
data-testid="view-site"
data-reason={reason}
>
<span className="hidden xl:inline">{t("liveSite.view")}</span>
</LinkButton>
);
}

/** The site switcher's "Open site", once the site has a domain and is live. */
export function OpenSiteItem({ siteId }: { siteId: string }) {
const t = useT();
const { origin, liveSnapshotId } = useLiveSite(siteId);
if (!origin || !liveSnapshotId) return null;
return (
<DropdownMenu.Item icon={OpenOutside} onClick={() => openLive(`${origin}/`)}>
{t("liveSite.open")}
</DropdownMenu.Item>
);
}
59 changes: 59 additions & 0 deletions apps/studio/src/components/LiveRowItems.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/** The list rows' links to the live site: "View live" and "Copy link" (Pages, Articles). */
import type { ContentEntry, Page } from "@cloudhal/core-contracts";
import { entryPath } from "@cloudhal/core-ts/permalinks";
import { DropdownMenu, directional } from "@cloudhal/ui";
import { ArrowSquareOut, Copy } from "@phosphor-icons/react";
import { useT } from "../i18n/index.tsx";
import { liveUrl } from "../lib/live-site.ts";
import { openLive, useCopyLink, useLiveSite } from "./useLiveSite.ts";

const OpenOutside = directional(ArrowSquareOut);

/**
* A list row's "View live" and "Copy link": for a published item on a site that has a domain and
* has been published. `path` undefined: the address is not known (see `EntryLiveItems`).
*/
export function LiveLinkItems({
siteId,
path,
published,
}: {
siteId: string;
path: string | undefined;
published: boolean;
}) {
const t = useT();
const copy = useCopyLink();
const { origin, liveSnapshotId } = useLiveSite(siteId);
const url = path === undefined ? undefined : liveUrl(origin, path);
if (!published || !liveSnapshotId || !url) return null;
return (
<>
<DropdownMenu.Item icon={OpenOutside} onClick={() => openLive(url)}>
{t("liveSite.viewLive")}
</DropdownMenu.Item>
<DropdownMenu.Item icon={Copy} onClick={() => void copy(url)}>
{t("liveSite.copyLink")}
</DropdownMenu.Item>
</>
);
}

/** A page row's live links (a page's path is already its full site path). */
export function PageLiveItems({ siteId, page }: { siteId: string; page: Page }) {
return <LiveLinkItems siteId={siteId} path={page.path} published={page.status === "published"} />;
}

/** An article row's live links. */
export function EntryLiveItems({ siteId, entry }: { siteId: string; entry: ContentEntry }) {
const { site } = useLiveSite(siteId);
// Locale prefixes for articles come later: a non-default language's address is not known yet.
const known = site !== undefined && entry.locale === site.defaultLocale;
return (
<LiveLinkItems
siteId={siteId}
path={known ? entryPath(site, entry) : undefined}
published={entry.status === "published"}
/>
);
}
8 changes: 5 additions & 3 deletions apps/studio/src/components/PageStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useT } from "../i18n/index.tsx";
import { api } from "../lib/api.ts";
import { apiErrorText } from "../lib/api-errors.ts";
import { useToast } from "./Toast.ts";
import { useMarkedPublishedNotice } from "./useLiveSite.ts";

/** Pages a site publish leaves off the live site: drafts and pages in review. */
export function unpublishedPages(pages: readonly Page[]): Page[] {
Expand All @@ -27,16 +28,17 @@ export function usePageStatusActions(siteId: string, reason: string | undefined)
const t = useT();
const qc = useQueryClient();
const toast = useToast();
const markedPublished = useMarkedPublishedNotice();
const [toPublish, setToPublish] = useState<Page | undefined>();
const change = useMutation({
mutationFn: (page: Page) => api.pages.update(page),
onSuccess: async (saved) => {
setToPublish(undefined);
await qc.invalidateQueries({ queryKey: ["pages", siteId] });
await qc.invalidateQueries({ queryKey: ["page", siteId, saved.id] });
toast.success(
saved.status === "published" ? t("pageStatus.published") : t("pageStatus.unpublished"),
);
// Published only marks it: the notice says it goes live with the next site publish.
if (saved.status === "published") markedPublished(saved);
else toast.success(t("pageStatus.unpublished"));
},
onError: (e) => toast.error(t("pageStatus.failed"), apiErrorText(t, e)),
});
Expand Down
42 changes: 29 additions & 13 deletions apps/studio/src/components/Publish.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { apiErrorText } from "../lib/api-errors.ts";
import { ownOnlyGuess } from "../lib/roles.ts";
import { taxonomyKeys } from "./Taxonomy.tsx";
import { useToast } from "./Toast.ts";
import { useLiveSiteActions } from "./useLiveSite.ts";
import { useGate, useMe, useSelf } from "./useMe.ts";
import { approvalsKey } from "./usePendingApprovals.ts";

Expand Down Expand Up @@ -76,6 +77,29 @@ export function usePublishMode(siteId: string): {
return { mode: ownOnly ? "publish" : "request", reason: undefined };
}

/**
* An Author's publish that includes others' work became a request (202, RFC-0005 §6): an info
* notice that an Editor must approve, with the way to Approvals.
*/
export function useRequestedNotice(siteId: string): (requestId: string) => void {
const t = useT();
const toast = useToast();
const navigate = useNavigate();
return (requestId) =>
toast.notice({
id: requestId,
title: t("publishing.requested"),
description: t("publishing.requestedDescription"),
tone: "info",
actions: [
{
label: t("publishing.openRequest"),
onClick: () => void navigate({ to: "/sites/$siteId/approvals", params: { siteId } }),
},
],
});
}

export type PublishTarget = { snapshotId: string } | { label: string };

/**
Expand All @@ -87,8 +111,9 @@ export function usePublishSite(siteId: string, options: { onDone?: () => void }
const t = useT();
const qc = useQueryClient();
const toast = useToast();
const navigate = useNavigate();
const gate = useGate("publish.own");
const live = useLiveSiteActions(siteId);
const requested = useRequestedNotice(siteId);
return useMutation({
mutationFn: async (target: PublishTarget): Promise<PublishResult> =>
"snapshotId" in target
Expand All @@ -106,21 +131,12 @@ export function usePublishSite(siteId: string, options: { onDone?: () => void }
if (result.kind === "published") {
toast.success(
"snapshotId" in target ? t("snapshots.published") : t("snapshots.publishedSnapshot"),
undefined,
live.viewAndCopy,
);
return;
}
toast.notice({
id: result.request.id,
title: t("publishing.requested"),
description: t("publishing.requestedDescription"),
tone: "info",
actions: [
{
label: t("publishing.openRequest"),
onClick: () => void navigate({ to: "/sites/$siteId/approvals", params: { siteId } }),
},
],
});
requested(result.request.id);
},
onError: (e) => {
const refused = e instanceof ApiRequestError && e.status === 403;
Expand Down
19 changes: 19 additions & 0 deletions apps/studio/src/components/SiteSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { DropdownMenu } from "@cloudhal/ui";
import { SquaresFour } from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router";
import { useT } from "../i18n/index.tsx";
import { OpenSiteItem } from "./LiveLinks.tsx";

/** The site switcher's rows after the sites: "Open site" (once live), then "All sites". */
export function SiteSwitcherExtra({ siteId }: { siteId: string }) {
const t = useT();
const navigate = useNavigate();
return (
<>
<OpenSiteItem siteId={siteId} />
<DropdownMenu.Item icon={SquaresFour} onClick={() => navigate({ to: "/sites" })}>
{t("nav.allSites")}
</DropdownMenu.Item>
</>
);
}
37 changes: 26 additions & 11 deletions apps/studio/src/components/Toast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,35 @@ export interface ToastAction {
/** How long a notice (something that needs the person) stays before it slides away. */
const NOTICE_TIMEOUT_MS = 15_000;

/** At most two actions per toast (DESIGN.md §2n). */
const MAX_ACTIONS = 2;

/** Small wrapper so screens don't depend on Kumo's toast API shape. */
export function useToast() {
const manager = useKumoToastManager();
/** Kumo's action buttons; an action closes the toast it sits on. */
const buttons = (close: () => void, actions: readonly ToastAction[] | undefined) =>
actions?.slice(0, MAX_ACTIONS).map((action) => ({
children: action.label,
variant: "secondary",
size: "sm",
onClick: () => {
close();
action.onClick();
},
}));
return {
success: (title: string, description?: string) =>
manager.add({ title, description, type: "success" } as never),
/** Done; `actions` (at most two) offer the next step, e.g. "View site" after a publish. */
success: (title: string, description?: string, actions?: readonly ToastAction[]) => {
let id = "";
id = manager.add({
title,
description,
type: "success",
actions: buttons(() => manager.close(id), actions),
} as never) as unknown as string;
return id;
},
error: (title: string, description?: string) =>
manager.add({ title, description, type: "error" } as never),
/**
Expand All @@ -36,15 +59,7 @@ export function useToast() {
description: options.description,
variant: options.tone,
timeout: options.timeout ?? NOTICE_TIMEOUT_MS,
actions: options.actions?.map((action) => ({
children: action.label,
variant: "secondary",
size: "sm",
onClick: () => {
manager.close(options.id);
action.onClick();
},
})),
actions: buttons(() => manager.close(options.id), options.actions),
} as never),
};
}
4 changes: 3 additions & 1 deletion apps/studio/src/components/page-editor/usePageEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useBlockTitle, useBlockTypes } from "../BlockSection.tsx";
import { usePreviewVisible } from "../EditorLayout.tsx";
import { useMenuReferences } from "../Menus.tsx";
import { useToast } from "../Toast.ts";
import { useSavedToast } from "../useLiveSite.ts";
import { useGate } from "../useMe.ts";
import { duplicateOf } from "./sections.ts";

Expand All @@ -22,6 +23,7 @@ export function usePageEditor(siteId: string, pageId: string) {
const qc = useQueryClient();
const navigate = useNavigate();
const toast = useToast();
const savedToast = useSavedToast();
const query = useQuery({
queryKey: ["page", siteId, pageId],
queryFn: () => api.pages.get(siteId, pageId),
Expand Down Expand Up @@ -52,7 +54,7 @@ export function usePageEditor(siteId: string, pageId: string) {
setShowErrors(false);
qc.setQueryData(["page", siteId, pageId], saved);
await qc.invalidateQueries({ queryKey: ["pages", siteId] });
toast.success(t("editor.saved"), t("common.rev", { rev: saved.rev }));
savedToast(saved, query.data?.status);
},
});
const duplicate = useMutation({
Expand Down
Loading
Loading