diff --git a/.changeset/studio-live-links.md b/.changeset/studio-live-links.md
new file mode 100644
index 00000000..2c737b7f
--- /dev/null
+++ b/.changeset/studio-live-links.md
@@ -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".
diff --git a/apps/studio/src/components/LiveAddress.tsx b/apps/studio/src/components/LiveAddress.tsx
new file mode 100644
index 00000000..167313b4
--- /dev/null
+++ b/apps/studio/src/components/LiveAddress.tsx
@@ -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 (
+
+
+ {live ? t("liveSite.liveAt") : t("liveSite.willBeAt")}
+
+ {url ? (
+
+ ) : (
+
+ {path}
+
+ )}
+
+ );
+}
+
+/** 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(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 ;
+}
+
+/** 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(siteId, page.id);
+ const live = servesAt(liveNode, (n) => n.path, page.path);
+ return ;
+}
diff --git a/apps/studio/src/components/LiveLinks.tsx b/apps/studio/src/components/LiveLinks.tsx
new file mode 100644
index 00000000..faa96181
--- /dev/null
+++ b/apps/studio/src/components/LiveLinks.tsx
@@ -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 (
+
+ {t("liveSite.view")}
+
+ );
+}
+
+/** 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 (
+ openLive(`${origin}/`)}>
+ {t("liveSite.open")}
+
+ );
+}
diff --git a/apps/studio/src/components/LiveRowItems.tsx b/apps/studio/src/components/LiveRowItems.tsx
new file mode 100644
index 00000000..0a690319
--- /dev/null
+++ b/apps/studio/src/components/LiveRowItems.tsx
@@ -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 (
+ <>
+ openLive(url)}>
+ {t("liveSite.viewLive")}
+
+ void copy(url)}>
+ {t("liveSite.copyLink")}
+
+ >
+ );
+}
+
+/** 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 ;
+}
+
+/** 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 (
+
+ );
+}
diff --git a/apps/studio/src/components/PageStatus.tsx b/apps/studio/src/components/PageStatus.tsx
index 4e468d4a..61735ee9 100644
--- a/apps/studio/src/components/PageStatus.tsx
+++ b/apps/studio/src/components/PageStatus.tsx
@@ -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[] {
@@ -27,6 +28,7 @@ export function usePageStatusActions(siteId: string, reason: string | undefined)
const t = useT();
const qc = useQueryClient();
const toast = useToast();
+ const markedPublished = useMarkedPublishedNotice();
const [toPublish, setToPublish] = useState();
const change = useMutation({
mutationFn: (page: Page) => api.pages.update(page),
@@ -34,9 +36,9 @@ export function usePageStatusActions(siteId: string, reason: string | undefined)
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)),
});
diff --git a/apps/studio/src/components/Publish.tsx b/apps/studio/src/components/Publish.tsx
index ab19b6c0..7aa89732 100644
--- a/apps/studio/src/components/Publish.tsx
+++ b/apps/studio/src/components/Publish.tsx
@@ -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";
@@ -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 };
/**
@@ -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 =>
"snapshotId" in target
@@ -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;
diff --git a/apps/studio/src/components/SiteSwitcher.tsx b/apps/studio/src/components/SiteSwitcher.tsx
new file mode 100644
index 00000000..b1580545
--- /dev/null
+++ b/apps/studio/src/components/SiteSwitcher.tsx
@@ -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 (
+ <>
+
+ navigate({ to: "/sites" })}>
+ {t("nav.allSites")}
+
+ >
+ );
+}
diff --git a/apps/studio/src/components/Toast.ts b/apps/studio/src/components/Toast.ts
index 42d49ab9..3c994fff 100644
--- a/apps/studio/src/components/Toast.ts
+++ b/apps/studio/src/components/Toast.ts
@@ -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),
/**
@@ -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),
};
}
diff --git a/apps/studio/src/components/page-editor/usePageEditor.ts b/apps/studio/src/components/page-editor/usePageEditor.ts
index 191daabd..7211c74c 100644
--- a/apps/studio/src/components/page-editor/usePageEditor.ts
+++ b/apps/studio/src/components/page-editor/usePageEditor.ts
@@ -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";
@@ -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),
@@ -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({
diff --git a/apps/studio/src/components/site-overview/SiteStatusCard.tsx b/apps/studio/src/components/site-overview/SiteStatusCard.tsx
new file mode 100644
index 00000000..de8ea34d
--- /dev/null
+++ b/apps/studio/src/components/site-overview/SiteStatusCard.tsx
@@ -0,0 +1,89 @@
+import { Badge, InlineCopyText, Link, SectionCard, SkeletonLine, Text } from "@cloudhal/ui";
+import type { ReactNode } from "react";
+import { useT } from "../../i18n/index.tsx";
+import { originHost } from "../../lib/live-site.ts";
+import { RelativeTime } from "../RelativeTime.tsx";
+import { useLiveSite, useLiveSnapshot } from "../useLiveSite.ts";
+
+/**
+ * The overview's Status card: live or not, the counts, where the site is live (its primary domain,
+ * opening in a new tab), when the live snapshot was taken, and its id.
+ */
+export function SiteStatusCard({
+ siteId,
+ loading,
+ pages,
+ articles,
+ snapshots,
+}: {
+ siteId: string;
+ loading: boolean;
+ pages: number;
+ articles: number;
+ snapshots: number;
+}) {
+ const t = useT();
+ const { origin, liveSnapshotId } = useLiveSite(siteId);
+ const live = useLiveSnapshot(siteId).data;
+ return (
+
+ {t("overview.live")}
+
+ ) : (
+
+ {t("overview.notPublished")}
+
+ )
+ }
+ >
+ {loading ? (
+
+ ) : (
+
+ {liveSnapshotId && origin && (
+
+ {originHost(origin)}
+
+ }
+ />
+ )}
+ {live && (
+ }
+ />
+ )}
+
+
+
+ {liveSnapshotId && (
+ {liveSnapshotId}}
+ />
+ )}
+
+ )}
+
+ );
+}
+
+function StatRow({ label, value }: { label: string; value: ReactNode }) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
diff --git a/apps/studio/src/components/sitePublishOpener.ts b/apps/studio/src/components/sitePublishOpener.ts
new file mode 100644
index 00000000..6ea6f843
--- /dev/null
+++ b/apps/studio/src/components/sitePublishOpener.ts
@@ -0,0 +1,11 @@
+import { createContext, useContext } from "react";
+
+/**
+ * Opens the top bar's site Publish confirmation from inside a site screen (the site shell provides
+ * it; undefined outside one). The dialog stays where it is, controlled by `open`.
+ */
+export const SitePublishOpener = createContext<(() => void) | undefined>(undefined);
+
+export function useOpenSitePublish(): (() => void) | undefined {
+ return useContext(SitePublishOpener);
+}
diff --git a/apps/studio/src/components/useLiveSite.ts b/apps/studio/src/components/useLiveSite.ts
new file mode 100644
index 00000000..20d0e682
--- /dev/null
+++ b/apps/studio/src/components/useLiveSite.ts
@@ -0,0 +1,143 @@
+/**
+ * Links to the live site (its primary domain): the origin, the live snapshot, an item as the live
+ * snapshot holds it, and the toast actions and notices that point there. Every read shares the
+ * `["site", siteId]` and `["snapshots", siteId]` queries Studio already has.
+ */
+import type { Site, Snapshot } from "@cloudhal/core-contracts";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useT } from "../i18n/index.tsx";
+import { api } from "../lib/api.ts";
+import { siteOrigin } from "../lib/live-site.ts";
+import { useOpenSitePublish } from "./sitePublishOpener.ts";
+import { type ToastAction, useToast } from "./Toast.ts";
+import { useGate } from "./useMe.ts";
+
+function useSite(siteId: string) {
+ return useQuery({
+ queryKey: ["site", siteId],
+ queryFn: () => api.sites.get(siteId),
+ enabled: siteId !== "",
+ });
+}
+
+/** `https://`, or undefined while the site has no domain (or is loading). */
+export function useSiteOrigin(siteId: string): string | undefined {
+ return siteOrigin(useSite(siteId).data?.domains);
+}
+
+/** The origin, the id of the live snapshot (undefined: never published), and the site itself. */
+export function useLiveSite(siteId: string): {
+ origin: string | undefined;
+ liveSnapshotId: string | undefined;
+ loading: boolean;
+ site: Site | undefined;
+} {
+ const site = useSite(siteId);
+ return {
+ origin: siteOrigin(site.data?.domains),
+ liveSnapshotId: site.data?.publishedSnapshotId,
+ loading: site.isPending,
+ site: site.data,
+ };
+}
+
+/** The live snapshot (snapshots never change): from the list when it is there, else fetched. */
+export function useLiveSnapshot(siteId: string) {
+ const qc = useQueryClient();
+ const id = useSite(siteId).data?.publishedSnapshotId;
+ return useQuery({
+ queryKey: ["snapshot", siteId, id],
+ queryFn: () => api.snapshots.get(siteId, id as string),
+ enabled: id !== undefined,
+ staleTime: Number.POSITIVE_INFINITY,
+ initialData: () => qc.getQueryData(["snapshots", siteId])?.find((s) => s.id === id),
+ });
+}
+
+/**
+ * A page or article as the live snapshot holds it (drafts included), or undefined when the site
+ * was never published or the item is not in there (404). One request per item, cached for good.
+ */
+export function useLiveNode(siteId: string, nodeId: string): T | undefined {
+ const id = useSite(siteId).data?.publishedSnapshotId;
+ return useQuery({
+ queryKey: ["snapshot-node", siteId, id, nodeId],
+ queryFn: () => api.snapshots.node(siteId, id as string, nodeId),
+ enabled: id !== undefined && nodeId !== "",
+ staleTime: Number.POSITIVE_INFINITY,
+ retry: false,
+ }).data;
+}
+
+/** Opens a live URL in a new tab, without giving it this window. */
+export function openLive(url: string) {
+ window.open(url, "_blank", "noopener,noreferrer");
+}
+
+/** Copies a URL and says so (as Studio copies ids elsewhere). */
+export function useCopyLink(): (url: string) => Promise {
+ const t = useT();
+ const toast = useToast();
+ return async (url) => {
+ await navigator.clipboard?.writeText(url);
+ toast.success(t("common.copied"), url);
+ };
+}
+
+/**
+ * Toast actions to the live site: `view` ("View site"), `viewAndCopy` (plus "Copy link"). Empty
+ * without a domain, so the toast stays plain.
+ */
+export function useLiveSiteActions(siteId: string): {
+ view: ToastAction[];
+ viewAndCopy: ToastAction[];
+} {
+ const t = useT();
+ const origin = useSiteOrigin(siteId);
+ const copy = useCopyLink();
+ if (!origin) return { view: [], viewAndCopy: [] };
+ const url = `${origin}/`;
+ const view = { label: t("liveSite.view"), onClick: () => openLive(url) };
+ const copyLink = { label: t("liveSite.copyLink"), onClick: () => void copy(url) };
+ return { view: [view], viewAndCopy: [view, copyLink] };
+}
+
+/**
+ * Setting a page or article to Published only marks it: it goes live with the next site publish
+ * (`editor.publishPageDescription`). The notice says so and offers "Publish site" — the top bar's
+ * confirmation — to people who may publish.
+ */
+export function useMarkedPublishedNotice(): (item: { id: string; rev: number }) => void {
+ const t = useT();
+ const toast = useToast();
+ const openPublish = useOpenSitePublish();
+ const gate = useGate("publish.own");
+ return (item) =>
+ toast.notice({
+ id: `marked-published-${item.id}-${item.rev}`,
+ title: t("liveSite.markedPublished"),
+ description: t("liveSite.markedPublishedDescription"),
+ tone: "info",
+ actions:
+ openPublish && gate.allowed
+ ? [{ label: t("liveSite.publishSite"), onClick: openPublish }]
+ : [],
+ });
+}
+
+/**
+ * An editor's save toast: "Saved · Revision n", or — when the save is what marked the item
+ * Published — the notice that it goes live with the next site publish.
+ */
+export function useSavedToast(): (
+ saved: { id: string; rev: number; status: string },
+ before: string | undefined,
+) => void {
+ const t = useT();
+ const toast = useToast();
+ const markedPublished = useMarkedPublishedNotice();
+ return (saved, before) => {
+ if (saved.status === "published" && before !== "published") markedPublished(saved);
+ else toast.success(t("editor.saved"), t("common.rev", { rev: saved.rev }));
+ };
+}
diff --git a/apps/studio/src/components/useSitePalette.ts b/apps/studio/src/components/useSitePalette.ts
index 40248487..374b7ad3 100644
--- a/apps/studio/src/components/useSitePalette.ts
+++ b/apps/studio/src/components/useSitePalette.ts
@@ -9,6 +9,7 @@ import type { PaletteCommand } from "./AppFrame.tsx";
import type { PaletteSearch } from "./CommandMenu.tsx";
import { usePublishMode } from "./Publish.tsx";
import { useToast } from "./Toast.ts";
+import { openLive, useLiveSite } from "./useLiveSite.ts";
/** The site's screens the palette goes to, in its order (a table, not a list of closures). */
const SITE_SCREENS = [
@@ -63,6 +64,7 @@ export function useSitePalette({
const toast = useToast();
const [publishOpen, setPublishOpen] = useState(false);
const publishing = usePublishMode(siteId);
+ const live = useLiveSite(siteId);
const createArticle = useMutation({
mutationFn: () =>
api.entries.create(siteId, {
@@ -138,6 +140,10 @@ export function useSitePalette({
run: () => setPublishOpen(true),
},
]),
+ // The live site in a new tab, once it has a domain and has been published.
+ ...(live.origin && live.liveSnapshotId
+ ? [{ id: "open-site", label: t("liveSite.open"), run: () => openLive(`${live.origin}/`) }]
+ : []),
{ id: "all-sites", label: t("nav.allSites"), run: () => navigate({ to: "/sites" }) },
];
const search: PaletteSearch = {
diff --git a/apps/studio/src/i18n/ar.ts b/apps/studio/src/i18n/ar.ts
index dfc24f5d..78ae7f50 100644
--- a/apps/studio/src/i18n/ar.ts
+++ b/apps/studio/src/i18n/ar.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): ar core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: be115bdef78fb7c2
+// hash: 4197f5e07bd13c5e
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -263,7 +263,6 @@ export const messages: Messages = {
},
pageStatus: {
publishTitle: "هل تريد نشر «{title}»؟",
- published: "نُشرت الصفحة",
unpublished: "أُعيدت الصفحة إلى مسودة",
failed: "لم تتغير حالة الصفحة",
leftOut:
@@ -2732,6 +2731,20 @@ export const messages: Messages = {
openRequest: "افتح الموافقات",
refused: "لا يمكنك النشر",
},
+ liveSite: {
+ view: "عرض الموقع",
+ open: "افتح الموقع",
+ viewLive: "عرض النسخة المباشرة",
+ copyLink: "انسخ الرابط",
+ noDomain: "أضف نطاقًا في الإعدادات",
+ notPublished: "غير منشور بعد",
+ markedPublished: "عُلّم كمنشور",
+ markedPublishedDescription: "يصبح مباشرًا في المرة القادمة التي يُنشر فيها الموقع.",
+ publishSite: "انشر الموقع",
+ liveAt: "مباشر على",
+ willBeAt: "سيكون على",
+ lastPublished: "آخر نشر",
+ },
members: {
invite: "ادعُ أشخاصًا",
search: "ابحث في الأشخاص",
diff --git a/apps/studio/src/i18n/de.ts b/apps/studio/src/i18n/de.ts
index 21855a81..eb38d6e7 100644
--- a/apps/studio/src/i18n/de.ts
+++ b/apps/studio/src/i18n/de.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): de core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 93337839544425aa
+// hash: 26ca067e6f1fbc17
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -268,7 +268,6 @@ export const messages: Messages = {
},
pageStatus: {
publishTitle: "„{title}“ veröffentlichen?",
- published: "Seite veröffentlicht",
unpublished: "Seite wieder als Entwurf",
failed: "Der Status der Seite hat sich nicht geändert",
leftOut:
@@ -2860,6 +2859,21 @@ export const messages: Messages = {
openRequest: "Freigaben öffnen",
refused: "Du kannst nicht veröffentlichen",
},
+ liveSite: {
+ view: "Website ansehen",
+ open: "Website öffnen",
+ viewLive: "Live ansehen",
+ copyLink: "Link kopieren",
+ noDomain: "Füge in den Einstellungen eine Domain hinzu",
+ notPublished: "Noch nicht veröffentlicht",
+ markedPublished: "Als veröffentlicht markiert",
+ markedPublishedDescription:
+ "Es geht live, wenn die Website das nächste Mal veröffentlicht wird.",
+ publishSite: "Website veröffentlichen",
+ liveAt: "Live unter",
+ willBeAt: "Wird erreichbar unter",
+ lastPublished: "Zuletzt veröffentlicht",
+ },
members: {
invite: "Personen einladen",
search: "Personen suchen",
diff --git a/apps/studio/src/i18n/en.ts b/apps/studio/src/i18n/en.ts
index ef10cfba..00ea300e 100644
--- a/apps/studio/src/i18n/en.ts
+++ b/apps/studio/src/i18n/en.ts
@@ -259,7 +259,6 @@ export const en = {
},
pageStatus: {
publishTitle: "Publish “{title}”?",
- published: "Page published",
unpublished: "Page moved back to draft",
failed: "The page's status did not change",
leftOut: "{count, plural, one {# page is not published} other {# pages are not published}}",
@@ -2656,6 +2655,20 @@ export const en = {
openRequest: "Open Approvals",
refused: "You can't publish",
},
+ liveSite: {
+ view: "View site",
+ open: "Open site",
+ viewLive: "View live",
+ copyLink: "Copy link",
+ noDomain: "Add a domain in Settings",
+ notPublished: "Not published yet",
+ markedPublished: "Marked published",
+ markedPublishedDescription: "It goes live the next time the site is published.",
+ publishSite: "Publish site",
+ liveAt: "Live at",
+ willBeAt: "Will be at",
+ lastPublished: "Last published",
+ },
members: {
invite: "Invite people",
search: "Search people",
diff --git a/apps/studio/src/i18n/es.ts b/apps/studio/src/i18n/es.ts
index 824a698d..6adc17e1 100644
--- a/apps/studio/src/i18n/es.ts
+++ b/apps/studio/src/i18n/es.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): es core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 849ee636ff4ae543
+// hash: 716940210f07c125
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -268,7 +268,6 @@ export const messages: Messages = {
},
pageStatus: {
publishTitle: "¿Publicar «{title}»?",
- published: "Página publicada",
unpublished: "La página volvió a borrador",
failed: "El estado de la página no cambió",
leftOut:
@@ -2841,6 +2840,20 @@ export const messages: Messages = {
openRequest: "Abrir Aprobaciones",
refused: "No puedes publicar",
},
+ liveSite: {
+ view: "Ver sitio",
+ open: "Abrir sitio",
+ viewLive: "Ver en línea",
+ copyLink: "Copiar enlace",
+ noDomain: "Añade un dominio en Configuración",
+ notPublished: "Aún sin publicar",
+ markedPublished: "Marcado como publicado",
+ markedPublishedDescription: "Se publica la próxima vez que se publique el sitio.",
+ publishSite: "Publicar sitio",
+ liveAt: "En línea en",
+ willBeAt: "Estará en",
+ lastPublished: "Última publicación",
+ },
members: {
invite: "Invitar personas",
search: "Buscar personas",
diff --git a/apps/studio/src/i18n/fa.ts b/apps/studio/src/i18n/fa.ts
index 9843af7f..df35a3f9 100644
--- a/apps/studio/src/i18n/fa.ts
+++ b/apps/studio/src/i18n/fa.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): fa core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 8ec073f87312d817
+// hash: b5bc1a7e882dfb4f
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -266,7 +266,6 @@ export const messages: Messages = {
},
pageStatus: {
publishTitle: "«{title}» منتشر شود؟",
- published: "صفحه منتشر شد",
unpublished: "صفحه به پیشنویس برگشت",
failed: "وضعیت صفحه تغییر نکرد",
leftOut: "{count, plural, one {# صفحه منتشر نشده است} other {# صفحه منتشر نشدهاند}}",
@@ -2800,6 +2799,20 @@ export const messages: Messages = {
openRequest: "باز کردن تأییدها",
refused: "شما نمیتوانید منتشر کنید",
},
+ liveSite: {
+ view: "مشاهدهٔ سایت",
+ open: "باز کردن سایت",
+ viewLive: "مشاهدهٔ نسخهٔ زنده",
+ copyLink: "کپی پیوند",
+ noDomain: "در تنظیمات یک دامنه اضافه کن",
+ notPublished: "هنوز منتشر نشده",
+ markedPublished: "منتشرشده علامت خورد",
+ markedPublishedDescription: "دفعهٔ بعد که سایت منتشر شود، زنده میشود.",
+ publishSite: "انتشار سایت",
+ liveAt: "زنده در",
+ willBeAt: "خواهد بود در",
+ lastPublished: "آخرین انتشار",
+ },
members: {
invite: "دعوت افراد",
search: "جستجوی افراد",
diff --git a/apps/studio/src/i18n/fr.ts b/apps/studio/src/i18n/fr.ts
index 40927d7c..7c97d07c 100644
--- a/apps/studio/src/i18n/fr.ts
+++ b/apps/studio/src/i18n/fr.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): fr core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 4d57345726542d46
+// hash: 0bf11932d888cc28
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -270,7 +270,6 @@ export const messages: Messages = {
},
pageStatus: {
publishTitle: "Publier « {title} » ?",
- published: "Page publiée",
unpublished: "Page repassée en brouillon",
failed: "Le statut de la page n’a pas changé",
leftOut:
@@ -2881,6 +2880,20 @@ export const messages: Messages = {
openRequest: "Ouvrir Approbations",
refused: "Vous ne pouvez pas publier",
},
+ liveSite: {
+ view: "Voir le site",
+ open: "Ouvrir le site",
+ viewLive: "Voir en ligne",
+ copyLink: "Copier le lien",
+ noDomain: "Ajoutez un domaine dans les paramètres",
+ notPublished: "Pas encore publié",
+ markedPublished: "Marqué comme publié",
+ markedPublishedDescription: "Il sera en ligne à la prochaine publication du site.",
+ publishSite: "Publier le site",
+ liveAt: "En ligne à",
+ willBeAt: "Sera à",
+ lastPublished: "Dernière publication",
+ },
members: {
invite: "Inviter des personnes",
search: "Rechercher des personnes",
diff --git a/apps/studio/src/i18n/he.ts b/apps/studio/src/i18n/he.ts
index a785dbaa..c437b614 100644
--- a/apps/studio/src/i18n/he.ts
+++ b/apps/studio/src/i18n/he.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): he core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 1f619bd9babf939f
+// hash: 35b78f5af434fd25
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -263,7 +263,6 @@ export const messages: Messages = {
},
pageStatus: {
publishTitle: "לפרסם את „{title}”?",
- published: "הדף פורסם",
unpublished: "הדף הוחזר לטיוטה",
failed: "סטטוס הדף לא השתנה",
leftOut:
@@ -2726,6 +2725,20 @@ export const messages: Messages = {
openRequest: "פתיחת האישורים",
refused: "אין לך הרשאה לפרסם",
},
+ liveSite: {
+ view: "הצגת האתר",
+ open: "פתיחת האתר",
+ viewLive: "הצגה באוויר",
+ copyLink: "העתקת הקישור",
+ noDomain: "יש להוסיף דומיין בהגדרות",
+ notPublished: "עדיין לא פורסם",
+ markedPublished: "סומן כפורסם",
+ markedPublishedDescription: "זה יעלה לאוויר בפעם הבאה שהאתר יפורסם.",
+ publishSite: "פרסום האתר",
+ liveAt: "באוויר בכתובת",
+ willBeAt: "יהיה בכתובת",
+ lastPublished: "פורסם לאחרונה",
+ },
members: {
invite: "הזמנת אנשים",
search: "חיפוש אנשים",
diff --git a/apps/studio/src/i18n/hi.ts b/apps/studio/src/i18n/hi.ts
index 38e6832e..39e94b3d 100644
--- a/apps/studio/src/i18n/hi.ts
+++ b/apps/studio/src/i18n/hi.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): hi core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 676c28d94a84d681
+// hash: 3f9ece711d48c79b
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -263,7 +263,6 @@ export const messages: Messages = {
},
pageStatus: {
publishTitle: "“{title}” प्रकाशित करें?",
- published: "पेज प्रकाशित हुआ",
unpublished: "पेज वापस ड्राफ़्ट में",
failed: "पेज की स्थिति नहीं बदली",
leftOut: "{count, plural, one {# पेज प्रकाशित नहीं है} other {# पेज प्रकाशित नहीं हैं}}",
@@ -2733,6 +2732,20 @@ export const messages: Messages = {
openRequest: "अनुमोदन खोलें",
refused: "आप प्रकाशित नहीं कर सकते",
},
+ liveSite: {
+ view: "साइट देखें",
+ open: "साइट खोलें",
+ viewLive: "लाइव देखें",
+ copyLink: "लिंक कॉपी करें",
+ noDomain: "सेटिंग्स में डोमेन जोड़ें",
+ notPublished: "अभी प्रकाशित नहीं",
+ markedPublished: "प्रकाशित के रूप में चिह्नित",
+ markedPublishedDescription: "यह अगली बार साइट प्रकाशित होने पर लाइव होगा।",
+ publishSite: "साइट प्रकाशित करें",
+ liveAt: "यहाँ लाइव",
+ willBeAt: "यहाँ होगा",
+ lastPublished: "अंतिम बार प्रकाशित",
+ },
members: {
invite: "लोगों को आमंत्रित करें",
search: "लोग खोजें",
diff --git a/apps/studio/src/i18n/id.ts b/apps/studio/src/i18n/id.ts
index 19c6ec65..79f8a376 100644
--- a/apps/studio/src/i18n/id.ts
+++ b/apps/studio/src/i18n/id.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): id core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 407fe5a24c0cf3e5
+// hash: d363ba45d890cf15
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -266,7 +266,6 @@ export const messages: Messages = {
},
pageStatus: {
publishTitle: "Terbitkan “{title}”?",
- published: "Halaman diterbitkan",
unpublished: "Halaman kembali menjadi draf",
failed: "Status halaman tidak berubah",
leftOut: "{count, plural, other {# halaman belum diterbitkan}}",
@@ -2831,6 +2830,20 @@ export const messages: Messages = {
openRequest: "Buka Persetujuan",
refused: "Anda tidak dapat menerbitkan",
},
+ liveSite: {
+ view: "Lihat situs",
+ open: "Buka situs",
+ viewLive: "Lihat versi live",
+ copyLink: "Salin tautan",
+ noDomain: "Tambahkan domain di Pengaturan",
+ notPublished: "Belum diterbitkan",
+ markedPublished: "Ditandai terbit",
+ markedPublishedDescription: "Akan live saat situs diterbitkan berikutnya.",
+ publishSite: "Terbitkan situs",
+ liveAt: "Live di",
+ willBeAt: "Akan berada di",
+ lastPublished: "Terakhir diterbitkan",
+ },
members: {
invite: "Undang orang",
search: "Cari orang",
diff --git a/apps/studio/src/i18n/it.ts b/apps/studio/src/i18n/it.ts
index 12aa4afb..3aecd423 100644
--- a/apps/studio/src/i18n/it.ts
+++ b/apps/studio/src/i18n/it.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): it core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 2392501373532dca
+// hash: 3d927394ab6cfe30
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -267,7 +267,6 @@ export const messages: Messages = {
},
pageStatus: {
publishTitle: "Pubblicare «{title}»?",
- published: "Pagina pubblicata",
unpublished: "Pagina tornata in bozza",
failed: "Lo stato della pagina non è cambiato",
leftOut:
@@ -2836,6 +2835,20 @@ export const messages: Messages = {
openRequest: "Apri Approvazioni",
refused: "Non puoi pubblicare",
},
+ liveSite: {
+ view: "Visualizza sito",
+ open: "Apri sito",
+ viewLive: "Visualizza online",
+ copyLink: "Copia link",
+ noDomain: "Aggiungi un dominio nelle Impostazioni",
+ notPublished: "Non ancora pubblicato",
+ markedPublished: "Segnato come pubblicato",
+ markedPublishedDescription: "Andrà online alla prossima pubblicazione del sito.",
+ publishSite: "Pubblica sito",
+ liveAt: "Online su",
+ willBeAt: "Sarà su",
+ lastPublished: "Ultima pubblicazione",
+ },
members: {
invite: "Invita persone",
search: "Cerca persone",
diff --git a/apps/studio/src/i18n/ja.ts b/apps/studio/src/i18n/ja.ts
index 01fcad69..44c609cf 100644
--- a/apps/studio/src/i18n/ja.ts
+++ b/apps/studio/src/i18n/ja.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): ja core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 1929d0e3930a43eb
+// hash: 835dcf377d7e6247
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -265,7 +265,6 @@ export const messages: Messages = {
},
pageStatus: {
publishTitle: "「{title}」を公開しますか?",
- published: "ページを公開しました",
unpublished: "ページを下書きに戻しました",
failed: "ページのステータスは変更されませんでした",
leftOut: "{count, plural, other {#件のページが未公開です}}",
@@ -2801,6 +2800,20 @@ export const messages: Messages = {
openRequest: "承認を開く",
refused: "公開できません",
},
+ liveSite: {
+ view: "サイトを表示",
+ open: "サイトを開く",
+ viewLive: "公開中のページを表示",
+ copyLink: "リンクをコピー",
+ noDomain: "設定でドメインを追加してください",
+ notPublished: "まだ公開されていません",
+ markedPublished: "公開済みにしました",
+ markedPublishedDescription: "次にサイトを公開したときに反映されます。",
+ publishSite: "サイトを公開",
+ liveAt: "公開URL",
+ willBeAt: "公開予定のURL",
+ lastPublished: "最終公開",
+ },
members: {
invite: "メンバーを招待",
search: "メンバーを検索",
diff --git a/apps/studio/src/i18n/ko.ts b/apps/studio/src/i18n/ko.ts
index 79474619..02d837df 100644
--- a/apps/studio/src/i18n/ko.ts
+++ b/apps/studio/src/i18n/ko.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): ko core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 519ff20ac8c13982
+// hash: 4ce5543a39dae512
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -263,7 +263,6 @@ export const messages: Messages = {
},
pageStatus: {
publishTitle: "“{title}”을(를) 게시할까요?",
- published: "페이지를 게시했습니다",
unpublished: "페이지를 초안으로 되돌렸습니다",
failed: "페이지 상태가 바뀌지 않았습니다",
leftOut: "{count, plural, other {페이지 #개가 게시되지 않았습니다}}",
@@ -2748,6 +2747,20 @@ export const messages: Messages = {
openRequest: "승인 열기",
refused: "게시할 수 없어요",
},
+ liveSite: {
+ view: "사이트 보기",
+ open: "사이트 열기",
+ viewLive: "라이브 보기",
+ copyLink: "링크 복사",
+ noDomain: "설정에서 도메인을 추가하세요",
+ notPublished: "아직 게시 안 됨",
+ markedPublished: "게시됨으로 표시됨",
+ markedPublishedDescription: "다음에 사이트를 게시할 때 라이브가 됩니다.",
+ publishSite: "사이트 게시",
+ liveAt: "라이브 주소",
+ willBeAt: "게시될 주소",
+ lastPublished: "마지막 게시",
+ },
members: {
invite: "사람 초대",
search: "사람 검색",
diff --git a/apps/studio/src/i18n/nl.ts b/apps/studio/src/i18n/nl.ts
index df1572d8..c5beea81 100644
--- a/apps/studio/src/i18n/nl.ts
+++ b/apps/studio/src/i18n/nl.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): nl core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: c5d5bf988b3add75
+// hash: 8982577332ffc91b
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -268,7 +268,6 @@ export const messages: Messages = {
},
pageStatus: {
publishTitle: "‘{title}’ publiceren?",
- published: "Pagina gepubliceerd",
unpublished: "Pagina terug naar concept",
failed: "De status van de pagina is niet gewijzigd",
leftOut:
@@ -2828,6 +2827,21 @@ export const messages: Messages = {
openRequest: "Goedkeuringen openen",
refused: "Je kunt niet publiceren",
},
+ liveSite: {
+ view: "Site bekijken",
+ open: "Site openen",
+ viewLive: "Live bekijken",
+ copyLink: "Link kopiëren",
+ noDomain: "Voeg een domein toe in Instellingen",
+ notPublished: "Nog niet gepubliceerd",
+ markedPublished: "Gemarkeerd als gepubliceerd",
+ markedPublishedDescription:
+ "Het gaat live wanneer de site de volgende keer wordt gepubliceerd.",
+ publishSite: "Site publiceren",
+ liveAt: "Live op",
+ willBeAt: "Komt op",
+ lastPublished: "Laatst gepubliceerd",
+ },
members: {
invite: "Mensen uitnodigen",
search: "Mensen zoeken",
diff --git a/apps/studio/src/i18n/pl.ts b/apps/studio/src/i18n/pl.ts
index fd289602..425ddab6 100644
--- a/apps/studio/src/i18n/pl.ts
+++ b/apps/studio/src/i18n/pl.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): pl core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 7429dfe5972d305f
+// hash: cbae6fb4d1cc5937
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -265,7 +265,6 @@ export const messages: Messages = {
},
pageStatus: {
publishTitle: "Opublikować „{title}”?",
- published: "Strona opublikowana",
unpublished: "Strona wróciła do szkicu",
failed: "Status strony się nie zmienił",
leftOut:
@@ -2804,6 +2803,20 @@ export const messages: Messages = {
openRequest: "Otwórz Zatwierdzenia",
refused: "Nie możesz publikować",
},
+ liveSite: {
+ view: "Zobacz witrynę",
+ open: "Otwórz witrynę",
+ viewLive: "Zobacz na żywo",
+ copyLink: "Kopiuj link",
+ noDomain: "Dodaj domenę w Ustawieniach",
+ notPublished: "Jeszcze nieopublikowana",
+ markedPublished: "Oznaczono jako opublikowane",
+ markedPublishedDescription: "Pojawi się na żywo przy następnej publikacji witryny.",
+ publishSite: "Opublikuj witrynę",
+ liveAt: "Na żywo pod adresem",
+ willBeAt: "Będzie pod adresem",
+ lastPublished: "Ostatnia publikacja",
+ },
members: {
invite: "Zaproś osoby",
search: "Szukaj osób",
diff --git a/apps/studio/src/i18n/pt-BR.ts b/apps/studio/src/i18n/pt-BR.ts
index 4c675775..15816bdf 100644
--- a/apps/studio/src/i18n/pt-BR.ts
+++ b/apps/studio/src/i18n/pt-BR.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): pt-BR core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: bac0d5899741ff5e
+// hash: c13786d0467eb894
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -268,7 +268,6 @@ export const messages: Messages = {
},
pageStatus: {
publishTitle: "Publicar “{title}”?",
- published: "Página publicada",
unpublished: "Página voltou para rascunho",
failed: "O status da página não mudou",
leftOut:
@@ -2810,6 +2809,20 @@ export const messages: Messages = {
openRequest: "Abrir Aprovações",
refused: "Você não pode publicar",
},
+ liveSite: {
+ view: "Ver site",
+ open: "Abrir site",
+ viewLive: "Ver no ar",
+ copyLink: "Copiar link",
+ noDomain: "Adicione um domínio em Configurações",
+ notPublished: "Ainda não publicado",
+ markedPublished: "Marcado como publicado",
+ markedPublishedDescription: "Vai ao ar na próxima vez que o site for publicado.",
+ publishSite: "Publicar site",
+ liveAt: "No ar em",
+ willBeAt: "Estará em",
+ lastPublished: "Última publicação",
+ },
members: {
invite: "Convidar pessoas",
search: "Pesquisar pessoas",
diff --git a/apps/studio/src/i18n/ru.ts b/apps/studio/src/i18n/ru.ts
index 27fb8de9..fe05ea7d 100644
--- a/apps/studio/src/i18n/ru.ts
+++ b/apps/studio/src/i18n/ru.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): ru core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 2b9b91e3ba845f7c
+// hash: 797d824c356d53e2
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -268,7 +268,6 @@ export const messages: Messages = {
},
pageStatus: {
publishTitle: "Опубликовать «{title}»?",
- published: "Страница опубликована",
unpublished: "Страница возвращена в черновики",
failed: "Статус страницы не изменился",
leftOut:
@@ -2842,6 +2841,20 @@ export const messages: Messages = {
openRequest: "Открыть «Одобрения»",
refused: "Вы не можете публиковать",
},
+ liveSite: {
+ view: "Посмотреть сайт",
+ open: "Открыть сайт",
+ viewLive: "Открыть на сайте",
+ copyLink: "Копировать ссылку",
+ noDomain: "Добавьте домен в настройках",
+ notPublished: "Ещё не опубликован",
+ markedPublished: "Отмечено как опубликованное",
+ markedPublishedDescription: "Появится на сайте при следующей публикации сайта.",
+ publishSite: "Опубликовать сайт",
+ liveAt: "На сайте по адресу",
+ willBeAt: "Будет по адресу",
+ lastPublished: "Последняя публикация",
+ },
members: {
invite: "Пригласить людей",
search: "Поиск людей",
diff --git a/apps/studio/src/i18n/tr.ts b/apps/studio/src/i18n/tr.ts
index b0e78e8c..4497c50f 100644
--- a/apps/studio/src/i18n/tr.ts
+++ b/apps/studio/src/i18n/tr.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): tr core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: a2224e343d98566f
+// hash: 7c878d6dae72d9be
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -265,7 +265,6 @@ export const messages: Messages = {
},
pageStatus: {
publishTitle: "“{title}” yayınlansın mı?",
- published: "Sayfa yayınlandı",
unpublished: "Sayfa taslağa döndü",
failed: "Sayfanın durumu değişmedi",
leftOut: "{count, number} sayfa yayınlanmadı",
@@ -2800,6 +2799,20 @@ export const messages: Messages = {
openRequest: "Onayları aç",
refused: "Yayınlayamazsın",
},
+ liveSite: {
+ view: "Siteyi görüntüle",
+ open: "Siteyi aç",
+ viewLive: "Canlıda görüntüle",
+ copyLink: "Bağlantıyı kopyala",
+ noDomain: "Ayarlar'da bir alan adı ekle",
+ notPublished: "Henüz yayınlanmadı",
+ markedPublished: "Yayınlandı olarak işaretlendi",
+ markedPublishedDescription: "Site bir dahaki yayınlandığında canlıya çıkar.",
+ publishSite: "Siteyi yayınla",
+ liveAt: "Canlı adres",
+ willBeAt: "Şu adreste olacak",
+ lastPublished: "Son yayın",
+ },
members: {
invite: "Kişi davet et",
search: "Kişi ara",
diff --git a/apps/studio/src/i18n/uk.ts b/apps/studio/src/i18n/uk.ts
index 6c9ddaf8..3d9e485f 100644
--- a/apps/studio/src/i18n/uk.ts
+++ b/apps/studio/src/i18n/uk.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): uk core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: c59751eaf3e4657b
+// hash: f92a266407d2db3b
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -267,7 +267,6 @@ export const messages: Messages = {
},
pageStatus: {
publishTitle: "Опублікувати «{title}»?",
- published: "Сторінку опубліковано",
unpublished: "Сторінку повернуто в чернетки",
failed: "Статус сторінки не змінився",
leftOut:
@@ -2817,6 +2816,20 @@ export const messages: Messages = {
openRequest: "Відкрити «Схвалення»",
refused: "Ви не можете публікувати",
},
+ liveSite: {
+ view: "Переглянути сайт",
+ open: "Відкрити сайт",
+ viewLive: "Переглянути на сайті",
+ copyLink: "Копіювати посилання",
+ noDomain: "Додайте домен у налаштуваннях",
+ notPublished: "Ще не опубліковано",
+ markedPublished: "Позначено як опубліковане",
+ markedPublishedDescription: "З'явиться на сайті під час наступної публікації сайту.",
+ publishSite: "Опублікувати сайт",
+ liveAt: "На сайті за адресою",
+ willBeAt: "Буде за адресою",
+ lastPublished: "Остання публікація",
+ },
members: {
invite: "Запросити людей",
search: "Пошук людей",
diff --git a/apps/studio/src/i18n/zh-Hans.ts b/apps/studio/src/i18n/zh-Hans.ts
index 72d03651..35014b9b 100644
--- a/apps/studio/src/i18n/zh-Hans.ts
+++ b/apps/studio/src/i18n/zh-Hans.ts
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): zh-Hans core messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: bc5750ee8cbc24aa
+// hash: a6634216372eefff
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -259,7 +259,6 @@ export const messages: Messages = {
},
pageStatus: {
publishTitle: "发布“{title}”?",
- published: "页面已发布",
unpublished: "页面已退回草稿",
failed: "页面状态未更改",
leftOut: "{count, plural, other {# 个页面未发布}}",
@@ -2652,6 +2651,20 @@ export const messages: Messages = {
openRequest: "打开审批",
refused: "你无法发布",
},
+ liveSite: {
+ view: "查看站点",
+ open: "打开站点",
+ viewLive: "查看线上版本",
+ copyLink: "复制链接",
+ noDomain: "请在设置中添加域名",
+ notPublished: "尚未发布",
+ markedPublished: "已标记为已发布",
+ markedPublishedDescription: "下次发布站点时上线。",
+ publishSite: "发布站点",
+ liveAt: "线上地址",
+ willBeAt: "将位于",
+ lastPublished: "上次发布",
+ },
members: {
invite: "邀请成员",
search: "搜索成员",
diff --git a/apps/studio/src/lib/live-site.ts b/apps/studio/src/lib/live-site.ts
new file mode 100644
index 00000000..94c8849d
--- /dev/null
+++ b/apps/studio/src/lib/live-site.ts
@@ -0,0 +1,36 @@
+/**
+ * Pure helpers for links to the live site: its origin (the primary domain; Studio does not know a
+ * workers.dev address), full URLs, and whether an item's address is what the live snapshot serves.
+ */
+import { type Domain, primaryHostname } from "./site-settings.ts";
+
+/** `https://`, or undefined while the site has no domain. */
+export function siteOrigin(domains: readonly Domain[] | undefined): string | undefined {
+ const host = primaryHostname(domains ?? []);
+ return host ? `https://${host}` : undefined;
+}
+
+/** The full URL of a site path (`/about`), or undefined without an origin. */
+export function liveUrl(origin: string | undefined, path: string): string | undefined {
+ if (!origin) return undefined;
+ return path === "/" || path === ""
+ ? `${origin}/`
+ : `${origin}${path.startsWith("/") ? "" : "/"}${path}`;
+}
+
+/** The host part of an origin, for labels (`https://blog.example.com` → `blog.example.com`). */
+export function originHost(origin: string): string {
+ return origin.replace(/^https?:\/\//, "");
+}
+
+/**
+ * Whether an address is live: the live snapshot holds the item as published, at the same address.
+ * `liveNode` is the item as the live snapshot holds it (undefined when it is not in there).
+ */
+export function servesAt(
+ liveNode: T | undefined,
+ addressOf: (node: T) => string,
+ address: string,
+): boolean {
+ return liveNode?.status === "published" && addressOf(liveNode) === address;
+}
diff --git a/apps/studio/src/routes/ApprovalsPage.tsx b/apps/studio/src/routes/ApprovalsPage.tsx
index 7f328ed6..9e4c9ee4 100644
--- a/apps/studio/src/routes/ApprovalsPage.tsx
+++ b/apps/studio/src/routes/ApprovalsPage.tsx
@@ -45,6 +45,7 @@ import { ErrorBanner } from "../components/ErrorBanner.tsx";
import { RelativeTime } from "../components/RelativeTime.tsx";
import { TableSkeleton } from "../components/TableSkeleton.tsx";
import { useToast } from "../components/Toast.ts";
+import { useLiveSiteActions } from "../components/useLiveSite.ts";
import { useMe, useMemberRoles, useRoleRules, useSelf } from "../components/useMe.ts";
import { useNow } from "../components/useNow.ts";
import { approvalsKey } from "../components/usePendingApprovals.ts";
@@ -501,11 +502,24 @@ function HistoryTable({
}
/** Site › Activity › Approvals: agents' gated calls waiting for a person (ADR-0014, DESIGN.md §2m). */
+/** Whose request this is, from the signed-in person's side. */
+function requesterOf(
+ request: Approval,
+ meId: string | undefined,
+ agentIds: ReadonlySet,
+): Requester {
+ return {
+ mine: request.actor.kind === "human" && request.actor.id === meId,
+ myAgent: request.actor.kind === "agent" && agentIds.has(request.actor.id),
+ };
+}
+
export function ApprovalsPage() {
const { t } = useI18n("comments");
const { siteId } = useParams({ from: "/sites/$siteId" });
const qc = useQueryClient();
const toast = useToast();
+ const live = useLiveSiteActions(siteId);
const all = useQuery({
queryKey: [...approvalsKey(siteId), "all"],
queryFn: () => api.approvals.list(siteId),
@@ -514,10 +528,7 @@ export function ApprovalsPage() {
const me = useQuery({ queryKey: ["me"], queryFn: api.me });
const self = useSelf();
const roles = useMemberRoles();
- const whoFor = (request: Approval): Requester => ({
- mine: request.actor.kind === "human" && request.actor.id === me.data?.actor.id,
- myAgent: request.actor.kind === "agent" && self.agentIds.has(request.actor.id),
- });
+ const whoFor = (request: Approval) => requesterOf(request, me.data?.actor.id, self.agentIds);
const [confirming, setConfirming] = useState();
const [granting, setGranting] = useState();
const [commenting, setCommenting] = useState();
@@ -562,6 +573,7 @@ export function ApprovalsPage() {
toast.success(
t("approvals.role.published"),
t("approvals.role.publishedDescription", { name: agent }),
+ live.view,
);
else if (decision === "approve")
toast.success(t("approvals.approved"), t("approvals.approvedDescription", { agent }));
diff --git a/apps/studio/src/routes/ArticleEditorPage.tsx b/apps/studio/src/routes/ArticleEditorPage.tsx
index 555a2a6c..f826e57e 100644
--- a/apps/studio/src/routes/ArticleEditorPage.tsx
+++ b/apps/studio/src/routes/ArticleEditorPage.tsx
@@ -47,6 +47,7 @@ import {
import { EntryPath } from "../components/EntryPath.tsx";
import { ErrorBanner } from "../components/ErrorBanner.tsx";
import { ViewOnlyBanner } from "../components/Gating.tsx";
+import { EntryAddress } from "../components/LiveAddress.tsx";
import { MenuDeleteNotice, useMenuReferences } from "../components/Menus.tsx";
import { RelativeTime } from "../components/RelativeTime.tsx";
import { ArticleSeoPreviewCard, CountedHint, CoverImageCard } from "../components/Seo.tsx";
@@ -55,6 +56,7 @@ import { StatusActions } from "../components/StatusActions.tsx";
import { TagsMigrationBanner } from "../components/TagsMigration.tsx";
import { useTaxonomyData } from "../components/Taxonomy.tsx";
import { useToast } from "../components/Toast.ts";
+import { useSavedToast } from "../components/useLiveSite.ts";
import { useCan, useGate, useSelf } from "../components/useMe.ts";
import { useT } from "../i18n/index.tsx";
import { api, type NewEntry } from "../lib/api.ts";
@@ -91,12 +93,19 @@ function forSave(entry: ContentEntry): ContentEntry {
return entry.coverImage ? { ...entry, coverImage: cleanImageRef(entry.coverImage) } : entry;
}
+/** Why the form is read-only for someone who may not change this article (§2s). */
+const READ_ONLY_REASON = {
+ notOwn: "gating.entryNotOwn",
+ published: "gating.entryPublished",
+} as const;
+
export function ArticleEditorPage() {
const t = useT();
const { siteId, entryId } = useParams({ from: "/sites/$siteId/articles/$entryId" });
const qc = useQueryClient();
const navigate = useNavigate();
const toast = useToast();
+ const savedToast = useSavedToast();
const query = useQuery({
queryKey: ["entry", siteId, entryId],
queryFn: () => api.entries.get(siteId, entryId),
@@ -135,7 +144,7 @@ export function ArticleEditorPage() {
setMissing([]);
qc.setQueryData(["entry", siteId, entryId], saved);
await qc.invalidateQueries({ queryKey: ["entries", siteId] });
- toast.success(t("editor.saved"), t("common.rev", { rev: saved.rev }));
+ savedToast(saved, query.data?.status);
},
});
const duplicate = useMutation({
@@ -221,12 +230,7 @@ export function ArticleEditorPage() {
};
const migrated = (taxonomy.taxonomies.data?.length ?? 0) > 0;
const access = entryAccess(draft, { editAny, editOwnPublished }, self);
- const readOnlyReason =
- access === "notOwn"
- ? t("gating.entryNotOwn")
- : access === "published"
- ? t("gating.entryPublished")
- : undefined;
+ const readOnlyReason = access === "edit" ? undefined : t(READ_ONLY_REASON[access]);
const lockedReason = locked ? t("editor.locked") : undefined;
// Contributors move their drafts to review; Authors and above set every status.
const allowedStatuses: PublishStatus[] | undefined = editOwnPublished
@@ -415,6 +419,7 @@ export function ArticleEditorPage() {
/>
+
}
onDelete={onDelete}
deleteDisabledReason={deleteDisabledReason}
/>
diff --git a/apps/studio/src/routes/PageEditorPage.tsx b/apps/studio/src/routes/PageEditorPage.tsx
index 5be1f8a0..563ca8bb 100644
--- a/apps/studio/src/routes/PageEditorPage.tsx
+++ b/apps/studio/src/routes/PageEditorPage.tsx
@@ -7,6 +7,7 @@ import { EditorLayout } from "../components/EditorLayout.tsx";
import { isScheduleError, ScheduleLock } from "../components/EditorSchedule.tsx";
import { ErrorBanner } from "../components/ErrorBanner.tsx";
import { ViewOnlyBanner } from "../components/Gating.tsx";
+import { PageAddress } from "../components/LiveAddress.tsx";
import { PageEditorActions } from "../components/page-editor/PageEditorActions.tsx";
import { PageEditorAside } from "../components/page-editor/PageEditorAside.tsx";
import { PageEditorDialogs } from "../components/page-editor/PageEditorDialogs.tsx";
@@ -137,6 +138,7 @@ export function PageEditorPage() {
className="w-full font-mono"
/>
+
navigate({ to: "/sites/$siteId/pages/$pageId", params: { siteId, pageId: p.id } })
}
- items={statusItems}
+ items={
+ <>
+
+ {statusItems}
+ >
+ }
onDelete={onDelete}
deleteDisabledReason={gate.reason}
/>
diff --git a/apps/studio/src/routes/SiteOverviewPage.tsx b/apps/studio/src/routes/SiteOverviewPage.tsx
index 71c5032d..76f09948 100644
--- a/apps/studio/src/routes/SiteOverviewPage.tsx
+++ b/apps/studio/src/routes/SiteOverviewPage.tsx
@@ -1,22 +1,19 @@
import {
- Badge,
Button,
Code,
Columns,
- InlineCopyText,
Link,
LinkButton,
PageLayout,
SectionCard,
SectionEmpty,
- SkeletonLine,
Table,
Text,
} from "@cloudhal/ui";
import { Article, FileText, Plus, TerminalWindow } from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useParams } from "@tanstack/react-router";
-import { lazy, type ReactNode, Suspense } from "react";
+import { lazy, Suspense } from "react";
import { CommentsWaitingBanner } from "../components/CommentsWaitingBanner.tsx";
import { SiteCrumbs } from "../components/Crumbs.tsx";
import { ErrorBanner } from "../components/ErrorBanner.tsx";
@@ -24,6 +21,7 @@ import { PendingApprovalsBanner } from "../components/PendingApprovalsBanner.tsx
import { OverviewAnalyticsCard } from "../components/Readers.tsx";
import { RecentActivity } from "../components/RecentActivity.tsx";
import { Status } from "../components/Status.tsx";
+import { SiteStatusCard } from "../components/site-overview/SiteStatusCard.tsx";
import { TableSkeleton } from "../components/TableSkeleton.tsx";
import { useT, withMessages } from "../i18n/index.tsx";
import { api } from "../lib/api.ts";
@@ -227,36 +225,13 @@ export function SiteOverviewPage() {
-
- {t("overview.live")}
-
- ) : (
-
- {t("overview.notPublished")}
-
- )
- }
- >
- {site.isPending || pages.isPending ? (
-
- ) : (
-
-
-
-
- {site.data?.publishedSnapshotId && (
- {site.data.publishedSnapshotId}}
- />
- )}
-
- )}
-
+
);
}
-
-function StatRow({ label, value }: { label: string; value: ReactNode }) {
- return (
-
-
- {label}
-
-
- {value}
-
-
- );
-}
diff --git a/apps/studio/src/routes/SiteShell.tsx b/apps/studio/src/routes/SiteShell.tsx
index 0244ef7a..36031cc3 100644
--- a/apps/studio/src/routes/SiteShell.tsx
+++ b/apps/studio/src/routes/SiteShell.tsx
@@ -1,4 +1,4 @@
-import { DropdownMenu, Sidebar, SidebarSwitcher } from "@cloudhal/ui";
+import { Sidebar, SidebarSwitcher } from "@cloudhal/ui";
import {
Article,
CalendarDots,
@@ -14,15 +14,18 @@ import {
Images,
Robot,
Signpost,
- SquaresFour,
Tag,
Translate,
TreeStructure,
} from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { Outlet, useLocation, useNavigate, useParams } from "@tanstack/react-router";
+import { useCallback } from "react";
import { AppFrame } from "../components/AppFrame.tsx";
+import { ViewSiteButton } from "../components/LiveLinks.tsx";
import { SitePublish } from "../components/SitePublish.tsx";
+import { SiteSwitcherExtra } from "../components/SiteSwitcher.tsx";
+import { SitePublishOpener } from "../components/sitePublishOpener.ts";
import { useTaxonomyData } from "../components/Taxonomy.tsx";
import { useCan } from "../components/useMe.ts";
import { useNotifications } from "../components/useNotifications.ts";
@@ -67,15 +70,7 @@ export function SiteShell() {
const pendingComments = pendingCommentsCount(notifications.data?.items, siteId);
// Releases needing someone (RFC-0008 §15.1): conflict, missed, failed, approval due — from the
// bell's own poll, so the badge costs no request of its own.
- const scheduleAttention = (notifications.data?.items ?? []).filter(
- (i) =>
- "releaseId" in i &&
- i.siteId === siteId &&
- (i.kind === "schedule-conflict" ||
- i.kind === "schedule-missed" ||
- i.kind === "schedule-failed" ||
- i.kind === "schedule-approval-due"),
- ).length;
+ const scheduleAttention = scheduleAttentionCount(notifications.data?.items, siteId);
const base = `/sites/${siteId}`;
const is = (path: string, exact = false) =>
exact ? pathname === path : pathname.startsWith(path);
@@ -86,15 +81,19 @@ export function SiteShell() {
defaultLocale: site.data?.defaultLocale,
comments,
});
+ const openPublish = useCallback(() => palette.setPublishOpen(true), [palette.setPublishOpen]);
return (
+ <>
+
+
+ >
}
search={palette.search}
loading={site.isPending}
@@ -104,11 +103,7 @@ export function SiteShell() {
current={{ id: siteId, name: site.data?.name ?? "…", icon: Globe }}
items={(sites.data ?? []).map((s) => ({ id: s.id, name: s.name, icon: Globe }))}
onSelect={(item) => navigate({ to: "/sites/$siteId", params: { siteId: item.id } })}
- extra={
- navigate({ to: "/sites" })}>
- {t("nav.allSites")}
-
- }
+ extra={ }
/>
}
nav={
@@ -208,7 +203,9 @@ export function SiteShell() {
}
commands={palette.commands}
>
-
+
+
+
);
}
@@ -342,3 +339,18 @@ function AutomationGroup({ base, is, attention }: GroupProps & { attention: numb
);
}
+
+type NotificationItems = NonNullable["data"]>["items"];
+
+/** Releases needing someone (RFC-0008 §15.1): conflict, missed, failed, approval due. */
+function scheduleAttentionCount(items: NotificationItems | undefined, siteId: string): number {
+ return (items ?? []).filter(
+ (i) =>
+ "releaseId" in i &&
+ i.siteId === siteId &&
+ (i.kind === "schedule-conflict" ||
+ i.kind === "schedule-missed" ||
+ i.kind === "schedule-failed" ||
+ i.kind === "schedule-approval-due"),
+ ).length;
+}
diff --git a/apps/studio/src/routes/SnapshotsPage.tsx b/apps/studio/src/routes/SnapshotsPage.tsx
index 4d182fc4..41af6688 100644
--- a/apps/studio/src/routes/SnapshotsPage.tsx
+++ b/apps/studio/src/routes/SnapshotsPage.tsx
@@ -19,11 +19,12 @@ import { useState } from "react";
import { SiteCrumbs } from "../components/Crumbs.tsx";
import { ErrorBanner } from "../components/ErrorBanner.tsx";
import { ListToolbar } from "../components/ListToolbar.tsx";
-import { usePublishMode } from "../components/Publish.tsx";
+import { usePublishMode, useRequestedNotice } from "../components/Publish.tsx";
import { RelativeTime } from "../components/RelativeTime.tsx";
import { SnapshotDiffDialog } from "../components/SnapshotDiffDialog.tsx";
import { TableSkeleton } from "../components/TableSkeleton.tsx";
import { useToast } from "../components/Toast.ts";
+import { useLiveSiteActions } from "../components/useLiveSite.ts";
import { useGate } from "../components/useMe.ts";
import { usePaging } from "../components/usePaging.ts";
import { approvalsKey } from "../components/usePendingApprovals.ts";
@@ -48,6 +49,8 @@ export function SnapshotsPage() {
const { siteId } = useParams({ from: "/sites/$siteId" });
const qc = useQueryClient();
const toast = useToast();
+ const live = useLiveSiteActions(siteId);
+ const requested = useRequestedNotice(siteId);
const site = useQuery({ queryKey: ["site", siteId], queryFn: () => api.sites.get(siteId) });
const snapshots = useQuery({
queryKey: ["snapshots", siteId],
@@ -110,18 +113,7 @@ export function SnapshotsPage() {
// An Author's publish that includes others' work became a request (202, RFC-0005 §6).
if (result?.kind === "requested") {
await qc.invalidateQueries({ queryKey: approvalsKey(siteId) });
- 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);
return;
}
toast.success(
@@ -132,6 +124,9 @@ export function SnapshotsPage() {
: p.kind === "publish"
? t("snapshots.published")
: t("snapshots.rolledBack"),
+ undefined,
+ // A publish offers the live site and its link; a rollback the live site.
+ p.kind === "rollback" ? live.view : result ? live.viewAndCopy : undefined,
);
},
onError: (e) => toast.error(t("snapshots.failed"), apiErrorText(t, e)),
diff --git a/apps/studio/test/design-rules.test.ts b/apps/studio/test/design-rules.test.ts
index f6a8b997..c16e4573 100644
--- a/apps/studio/test/design-rules.test.ts
+++ b/apps/studio/test/design-rules.test.ts
@@ -44,6 +44,7 @@ const files = walk(SRC).map((f) => ({
const SCREEN_PARTS: Readonly> = {
"routes/PageEditorPage.tsx": "components/page-editor",
"routes/ArticleEditorPage.tsx": "components/article-editor",
+ "routes/SiteOverviewPage.tsx": "components/site-overview",
};
function screenText(file: string): string {
const own = (files.find((f) => f.file === file) as { text: string }).text;
diff --git a/apps/studio/test/live-site.test.tsx b/apps/studio/test/live-site.test.tsx
new file mode 100644
index 00000000..dca8f047
--- /dev/null
+++ b/apps/studio/test/live-site.test.tsx
@@ -0,0 +1,336 @@
+/**
+ * Links to the live site: the origin and URL helpers, the top bar's "View site" and its disabled
+ * states, the toasts after a site publish ("View site", "Copy link"), the notice after marking an
+ * item Published, and an editor's "Live at" / "Will be at" address.
+ */
+import type { Page } from "@cloudhal/core-contracts";
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ renderHook,
+ screen,
+ waitFor,
+ within,
+} from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { PageAddress } from "../src/components/LiveAddress.tsx";
+import { ViewSiteButton } from "../src/components/LiveLinks.tsx";
+import { SitePublish } from "../src/components/SitePublish.tsx";
+import { SitePublishOpener } from "../src/components/sitePublishOpener.ts";
+import { useMarkedPublishedNotice, useSiteOrigin } from "../src/components/useLiveSite.ts";
+import { liveUrl, originHost, servesAt, siteOrigin } from "../src/lib/live-site.ts";
+import { PagesPage } from "../src/routes/PagesPage.tsx";
+import { asRole, on, serve, wrap } from "./roles-fixtures.tsx";
+
+vi.mock("@tanstack/react-router", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useNavigate: () => vi.fn(),
+ useParams: () => ({ siteId: "site_1" }),
+ useSearch: () => ({}),
+}));
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+const DOMAINS = [
+ { hostname: "alias.example.com", primary: false },
+ { hostname: "blog.example.com", primary: true },
+];
+
+const siteRoute = (patch: Record = {}) =>
+ on("GET", "/api/sites/site_1", {
+ status: 200,
+ body: {
+ id: "site_1",
+ name: "Demo",
+ defaultLocale: "en",
+ locales: ["en"],
+ domains: DOMAINS,
+ publishedSnapshotId: "snap_live",
+ ...patch,
+ },
+ });
+
+describe("live-site helpers", () => {
+ it("builds the origin from the primary domain", () => {
+ expect(siteOrigin(DOMAINS)).toBe("https://blog.example.com");
+ expect(siteOrigin([{ hostname: "only.example.com", primary: false }])).toBe(
+ "https://only.example.com",
+ );
+ expect(siteOrigin([])).toBeUndefined();
+ expect(siteOrigin(undefined)).toBeUndefined();
+ });
+
+ it("joins an origin and a path", () => {
+ expect(liveUrl("https://a.example", "/blog/hello")).toBe("https://a.example/blog/hello");
+ expect(liveUrl("https://a.example", "/")).toBe("https://a.example/");
+ expect(liveUrl("https://a.example", "about")).toBe("https://a.example/about");
+ expect(liveUrl(undefined, "/about")).toBeUndefined();
+ expect(originHost("https://a.example")).toBe("a.example");
+ });
+
+ it("calls an address live only when the live snapshot serves it published, right there", () => {
+ type Node = { status: string; path: string };
+ const at = (n: Node) => n.path;
+ expect(servesAt({ status: "published", path: "/a" }, at, "/a")).toBe(true);
+ expect(servesAt({ status: "draft", path: "/a" }, at, "/a")).toBe(false);
+ expect(servesAt({ status: "published", path: "/old" }, at, "/a")).toBe(false);
+ expect(servesAt(undefined, at, "/a")).toBe(false);
+ });
+});
+
+describe("useSiteOrigin", () => {
+ it("reads the site query", async () => {
+ serve(siteRoute());
+ const { result } = renderHook(() => useSiteOrigin("site_1"), {
+ wrapper: ({ children }) => wrap(children),
+ });
+ await waitFor(() => expect(result.current).toBe("https://blog.example.com"));
+ });
+});
+
+describe("View site (top bar)", () => {
+ it("opens the primary domain in a new tab once the site is live", async () => {
+ serve(siteRoute());
+ render(wrap( ));
+ await waitFor(() =>
+ expect(screen.getByTestId("view-site")).toHaveAttribute("href", "https://blog.example.com/"),
+ );
+ const link = screen.getByTestId("view-site");
+ expect(link).toHaveAttribute("target", "_blank");
+ expect(link).toHaveAttribute("rel", "noopener noreferrer");
+ expect(link).toHaveAccessibleName("View site");
+ });
+
+ it("is disabled, saying so, without a domain", async () => {
+ serve(siteRoute({ domains: [] }));
+ render(wrap( ));
+ // The reason is the tooltip on the disabled button's wrapper.
+ await waitFor(() =>
+ expect(screen.getByTestId("view-site")).toHaveAttribute(
+ "data-reason",
+ "Add a domain in Settings",
+ ),
+ );
+ const button = screen.getByTestId("view-site");
+ expect(button).toBeDisabled();
+ expect(button.parentElement).toHaveAttribute("data-base-ui-tooltip-trigger");
+ });
+
+ it("is disabled before the first publish", async () => {
+ serve(siteRoute({ publishedSnapshotId: undefined }));
+ render(wrap( ));
+ // The reason is the tooltip on the disabled button's wrapper.
+ await waitFor(() =>
+ expect(screen.getByTestId("view-site")).toHaveAttribute("data-reason", "Not published yet"),
+ );
+ const button = screen.getByTestId("view-site");
+ expect(button).toBeDisabled();
+ expect(button.parentElement).toHaveAttribute("data-base-ui-tooltip-trigger");
+ });
+});
+
+describe("toasts after a site publish", { timeout: 30_000 }, () => {
+ const publishRoutes = [
+ on("GET", "/api/sites/site_1/snapshots", { status: 200, body: [] }),
+ on("GET", "/api/sites/site_1/pages", { status: 200, body: [] }),
+ on("POST", "/api/sites/site_1/snapshots", {
+ status: 200,
+ body: { snapshot: { id: "snap_2" }, site: { id: "site_1" } },
+ }),
+ ];
+ const publish = async () => {
+ fireEvent.click(await screen.findByRole("button", { name: "Publish" }));
+ fireEvent.click(await screen.findByTestId("approve"));
+ };
+
+ it("offers View site and Copy link, and Copy link copies the address", async () => {
+ const writeText = vi.fn(async () => undefined);
+ vi.stubGlobal("navigator", { ...navigator, clipboard: { writeText } });
+ const open = vi.fn();
+ vi.stubGlobal("open", open);
+ serve(...asRole("owner"), siteRoute(), ...publishRoutes);
+ render(wrap( ));
+ await waitFor(() => expect(fetch).toHaveBeenCalled());
+ await publish();
+ const view = await screen.findByRole("button", { name: "View site" });
+ expect(screen.getByRole("button", { name: "Copy link" })).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "Copy link" }));
+ await waitFor(() => expect(writeText).toHaveBeenCalledWith("https://blog.example.com/"));
+ expect(await screen.findByText("Copied")).toBeInTheDocument();
+ // An action closes its toast.
+ await waitFor(() => expect(view).not.toBeInTheDocument());
+ });
+
+ it("stays plain without a domain", async () => {
+ serve(...asRole("owner"), siteRoute({ domains: [] }), ...publishRoutes);
+ render(wrap( ));
+ await publish();
+ expect(await screen.findByText("Snapshot published")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "View site" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Copy link" })).not.toBeInTheDocument();
+ });
+});
+
+describe("marked published notice", () => {
+ const Trigger = () => {
+ const notice = useMarkedPublishedNotice();
+ return (
+ notice({ id: "page_1", rev: 3 })}>
+ mark
+
+ );
+ };
+
+ it("offers Publish site to people who may publish, opening the site's confirmation", async () => {
+ serve(...asRole("editor"), siteRoute());
+ const openPublish = vi.fn();
+ render(
+ wrap(
+
+
+ ,
+ ),
+ );
+ await waitFor(() => expect(fetch).toHaveBeenCalled());
+ // The gate reads /api/me first.
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 50));
+ });
+ fireEvent.click(screen.getByRole("button", { name: "mark" }));
+ expect(await screen.findByText("Marked published")).toBeInTheDocument();
+ expect(
+ screen.getByText("It goes live the next time the site is published."),
+ ).toBeInTheDocument();
+ fireEvent.click(await screen.findByRole("button", { name: "Publish site" }));
+ expect(openPublish).toHaveBeenCalledOnce();
+ });
+
+ it("shows no action to someone who may not publish", async () => {
+ serve(...asRole("contributor"), siteRoute());
+ render(
+ wrap(
+
+
+ ,
+ ),
+ );
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 50));
+ });
+ fireEvent.click(screen.getByRole("button", { name: "mark" }));
+ expect(await screen.findByText("Marked published")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Publish site" })).not.toBeInTheDocument();
+ });
+});
+
+describe("an editor's address", () => {
+ const page = (patch: Partial = {}): Page => ({
+ schemaVersion: 1,
+ id: "page_1",
+ siteId: "site_1",
+ path: "/about",
+ title: "About",
+ locale: "en",
+ status: "published",
+ sections: [],
+ rev: 4,
+ createdAt: "2026-10-08T08:00:00Z",
+ updatedAt: "2026-10-08T08:00:00Z",
+ ...patch,
+ });
+ const liveNode = (node: Page | undefined) =>
+ on(
+ "GET",
+ "/api/sites/site_1/snapshots/snap_live/nodes/page_1",
+ node ? { status: 200, body: node } : { status: 404, body: { error: "not_found" } },
+ );
+
+ it("says Live at, with the full URL, when the live snapshot serves the page there", async () => {
+ serve(siteRoute(), liveNode(page({ rev: 3 })));
+ render(wrap( ));
+ await waitFor(() =>
+ expect(screen.getByTestId("live-address")).toHaveAttribute("data-live", "true"),
+ );
+ expect(screen.getByText("Live at")).toBeInTheDocument();
+ expect(screen.getByText("https://blog.example.com/about")).toBeInTheDocument();
+ });
+
+ it("says Will be at for a new address or a page the live site does not have", async () => {
+ serve(siteRoute(), liveNode(page({ path: "/old-about" })));
+ render(wrap( ));
+ expect(await screen.findByText("https://blog.example.com/about")).toBeInTheDocument();
+ expect(screen.getByText("Will be at")).toBeInTheDocument();
+ });
+
+ it("shows only the path without a domain", async () => {
+ serve(siteRoute({ domains: [] }), liveNode(undefined));
+ render(wrap( ));
+ expect(await screen.findByText("/about")).toBeInTheDocument();
+ expect(screen.getByText("Will be at")).toBeInTheDocument();
+ expect(screen.queryByText(/https:/)).not.toBeInTheDocument();
+ });
+});
+
+describe("Pages list row menu", { timeout: 30_000 }, () => {
+ const rows: Page[] = [
+ {
+ schemaVersion: 1,
+ id: "page_3",
+ siteId: "site_1",
+ path: "/",
+ title: "Home",
+ locale: "en",
+ status: "published",
+ sections: [],
+ rev: 2,
+ createdAt: "2026-10-08T08:00:00Z",
+ updatedAt: "2026-10-08T08:00:00Z",
+ },
+ {
+ schemaVersion: 1,
+ id: "page_1",
+ siteId: "site_1",
+ path: "/about",
+ title: "About",
+ locale: "en",
+ status: "draft",
+ sections: [],
+ rev: 2,
+ createdAt: "2026-10-08T08:00:00Z",
+ updatedAt: "2026-10-08T08:00:00Z",
+ },
+ ];
+ const listRoutes = [
+ on("GET", "/api/sites/site_1/pages", { status: 200, body: rows }),
+ on("GET", "/api/sites/site_1/snapshots", { status: 200, body: [] }),
+ on("GET", "/api/sites/site_1/menus", { status: 200, body: [] }),
+ ];
+ const openMenu = async (title: string) => {
+ const row = (await screen.findByText(title)).closest("tr") as HTMLElement;
+ fireEvent.click(within(row).getByRole("button", { name: "More actions" }));
+ };
+
+ it("offers View live and Copy link for a published page", async () => {
+ const writeText = vi.fn(async () => undefined);
+ vi.stubGlobal("navigator", { ...navigator, clipboard: { writeText } });
+ serve(...asRole("owner"), siteRoute(), ...listRoutes);
+ render(wrap( ));
+ await openMenu("Home");
+ expect(await screen.findByRole("menuitem", { name: /View live/ })).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("menuitem", { name: /Copy link/ }));
+ await waitFor(() => expect(writeText).toHaveBeenCalledWith("https://blog.example.com/"));
+ });
+
+ it("leaves them out for a draft", async () => {
+ serve(...asRole("owner"), siteRoute(), ...listRoutes);
+ render(wrap( ));
+ await openMenu("About");
+ expect(await screen.findByTestId("row-publish")).toBeInTheDocument();
+ expect(screen.queryByRole("menuitem", { name: /View live/ })).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/studio/test/page-status.test.tsx b/apps/studio/test/page-status.test.tsx
index ce426602..61a9be98 100644
--- a/apps/studio/test/page-status.test.tsx
+++ b/apps/studio/test/page-status.test.tsx
@@ -112,7 +112,7 @@ describe("Pages list row menu", { timeout: 45_000 }, () => {
fireEvent.click(await screen.findByTestId("row-publish"));
expect(await screen.findByText("Publish “Hakkında”?")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("approve"));
- expect(await screen.findByText("Page published")).toBeInTheDocument();
+ expect(await screen.findByText("Marked published")).toBeInTheDocument();
expect(bodyOf(fetch, "PUT", "/api/sites/site_1/pages/page_2")).toMatchObject({
id: "page_2",
rev: 2,
diff --git a/apps/studio/test/role-gating.test.tsx b/apps/studio/test/role-gating.test.tsx
index 8831ce88..5efc241a 100644
--- a/apps/studio/test/role-gating.test.tsx
+++ b/apps/studio/test/role-gating.test.tsx
@@ -68,7 +68,7 @@ describe("publishing follows the role (RFC-0005 §4, §6)", { timeout: 45_000 },
});
it("an Author publishes when only their own articles changed", async () => {
- serve(
+ const fetch = serve(
...asRole("author"),
...siteRoutes,
on("GET", "/api/sites/site_1/entries", {
@@ -77,13 +77,15 @@ describe("publishing follows the role (RFC-0005 §4, §6)", { timeout: 45_000 },
}),
);
render(wrap( ));
- await waitFor(() =>
+ // Until the lists arrive the guess is "request"; wait for the answer the lists give.
+ await waitFor(() => {
+ expect(fetch.mock.calls.some(([u]) => String(u).includes("/entries"))).toBe(true);
expect(document.querySelector("[data-publish-mode]")).toHaveAttribute(
"data-publish-mode",
"publish",
- ),
- );
- expect(screen.getByRole("button", { name: "Publish" })).toBeEnabled();
+ );
+ expect(screen.getByRole("button", { name: "Publish" })).toBeEnabled();
+ });
});
it("an Author whose changes include others' work requests the publish (202)", async () => {
diff --git a/scripts/size-baseline.json b/scripts/size-baseline.json
index dd4a060e..157c47e3 100644
--- a/scripts/size-baseline.json
+++ b/scripts/size-baseline.json
@@ -175,13 +175,13 @@
"lines": 326
},
"apps/studio/src/routes/ApprovalsPage.tsx::ApprovalsPage": {
- "lines": 260
+ "lines": 259
},
"apps/studio/src/routes/ApprovalsPage.tsx::PendingRequest": {
"lines": 191
},
"apps/studio/src/routes/ArticleEditorPage.tsx::ArticleEditorPage": {
- "lines": 475
+ "lines": 472
},
"apps/studio/src/routes/ArticlesPage.tsx::ArticlesPage": {
"lines": 214
@@ -256,7 +256,7 @@
"lines": 258
},
"apps/studio/src/routes/SiteOverviewPage.tsx::SiteOverviewPage": {
- "lines": 239
+ "lines": 216
},
"apps/studio/src/routes/SiteSettingsPage.tsx::DomainsSection": {
"lines": 216
@@ -268,13 +268,13 @@
"lines": 164
},
"apps/studio/src/routes/SiteShell.tsx::SiteShell": {
- "lines": 178
+ "lines": 172
},
"apps/studio/src/routes/SkillsPage.tsx::SkillsPage": {
"lines": 170
},
"apps/studio/src/routes/SnapshotsPage.tsx::SnapshotsPage": {
- "lines": 296
+ "lines": 290
},
"apps/studio/src/routes/TaxonomyPage.tsx::TaxonomyPage": {
"lines": 457