Skip to content
File

Blob: src/client/components/workspace/share-dialog/site-publish-controller.ts

typescript219 lines
1import { useCallback, useMemo, useState } from "react";
2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3 
4import { useCopyFeedback } from "@/shared/browser/use-copy-feedback";
5import { api, toApiError } from "@/client/lib/api";
6import {
7 sitePageStatusQueryKey,
8 sitePageStatusQueryOptions,
9 siteQueryKey,
10 siteQueryOptions,
11} from "@/client/lib/queries/site";
12import type { PageKind } from "@/shared/types";
13 
14import type { SitePublishValue } from "./types";
15 
16interface UseSitePublishInput {
17 pageId: string;
18 workspaceId: string;
19 pageKind: PageKind;
20 canManageSite: boolean;
21 canViewPagePublishStatus: boolean;
22 open: boolean;
23}
24 
25type UpdateAction = "slug" | "site-toggle" | "set-home";
26 
27function isSitesDisabledError(err: unknown): boolean {
28 return err ? toApiError(err).error === "sites_disabled" : false;
29}
30 
31function queryErrorMessage(err: unknown): string | null {
32 if (!err || isSitesDisabledError(err)) return null;
33 return toApiError(err).message;
34}
35 
36export function useSitePublishController({
37 pageId,
38 workspaceId,
39 pageKind,
40 canManageSite,
41 canViewPagePublishStatus,
42 open,
43}: UseSitePublishInput): SitePublishValue {
44 const queryClient = useQueryClient();
45 const { copiedId, copy: copyToClipboard } = useCopyFeedback<string>();
46 const [slugDraft, setSlugDraft] = useState("");
47 const [localError, setLocalError] = useState<string | null>(null);
48 // The update endpoint serves three distinct intents (slug save, site
49 // enable/disable toggle, set-as-home). UI needs to spin only the button
50 // that triggered the update. A single discriminator keeps the controller
51 // small and avoids three parallel mutations against the same endpoint.
52 const [updateAction, setUpdateAction] = useState<UpdateAction | null>(null);
53 // Tracker fields for render-phase state adjustments. Keyed on workspaceId
54 // so a draft typed in workspace A cannot be PATCHed against workspace B
55 // after the dialog is reopened in a different workspace. The seed marker
56 // remembers which server slug we already accepted so we do not clobber a
57 // user edit once the server response lands.
58 const [keyedWorkspaceId, setKeyedWorkspaceId] = useState(workspaceId);
59 const [seedFromServer, setSeedFromServer] = useState("");
60 
61 // Site config is admin-only on the worker. Members would hit 403, so gate
62 // the query by entitlement and let the member surface render from page
63 // status alone.
64 const siteQuery = useQuery({ ...siteQueryOptions(workspaceId), enabled: open && canManageSite });
65 const statusQuery = useQuery({
66 ...sitePageStatusQueryOptions(workspaceId, pageId),
67 enabled: open && canViewPagePublishStatus && pageKind === "doc",
68 });
69 
70 const serverSlug = siteQuery.data?.site?.slug ?? "";
71 const siteRow = siteQuery.data?.site ?? null;
72 const sitePublished = !!siteRow?.published_at;
73 
74 // Render-phase adjustments (https://react.dev/reference/react/useState#storing-information-from-previous-renders).
75 // Cheaper than a useEffect cascade and avoids the lint rule against
76 // setState-inside-useEffect.
77 if (keyedWorkspaceId !== workspaceId) {
78 setKeyedWorkspaceId(workspaceId);
79 setSlugDraft("");
80 setSeedFromServer("");
81 setLocalError(null);
82 } else if (open && slugDraft === "" && serverSlug !== "" && seedFromServer !== serverSlug) {
83 setSlugDraft(serverSlug);
84 setSeedFromServer(serverSlug);
85 }
86 
87 const invalidatePublishState = useCallback(() => {
88 queryClient.invalidateQueries({ queryKey: siteQueryKey(workspaceId) });
89 queryClient.invalidateQueries({ queryKey: sitePageStatusQueryKey(workspaceId, pageId) });
90 }, [queryClient, workspaceId, pageId]);
91 
92 const updateMutation = useMutation({
93 mutationFn: (body: { slug?: string; published?: boolean; home_page_id?: string | null }) =>
94 api.site.update(workspaceId, body),
95 onMutate: () => setLocalError(null),
96 onSuccess: () => invalidatePublishState(),
97 onError: (err) => setLocalError(toApiError(err).message),
98 onSettled: () => setUpdateAction(null),
99 });
100 
101 const publishMutation = useMutation({
102 mutationFn: () => api.site.addRoot(workspaceId, pageId),
103 onMutate: () => setLocalError(null),
104 onSuccess: () => invalidatePublishState(),
105 onError: (err) => setLocalError(toApiError(err).message),
106 });
107 
108 const unpublishMutation = useMutation({
109 mutationFn: () => api.site.removeRoot(workspaceId, pageId),
110 onMutate: () => setLocalError(null),
111 onSuccess: () => invalidatePublishState(),
112 onError: (err) => setLocalError(toApiError(err).message),
113 });
114 
115 const saveSiteSlug = useCallback(
116 async (slug: string) => {
117 setUpdateAction("slug");
118 // First-time slug save also publishes the site so the user does not
119 // have to make two clicks to bring a new site online.
120 const published = sitePublished || !siteRow;
121 await updateMutation.mutateAsync({ slug, published });
122 },
123 [updateMutation, sitePublished, siteRow],
124 );
125 
126 const toggleSitePublished = useCallback(async () => {
127 setUpdateAction("site-toggle");
128 await updateMutation.mutateAsync({ published: !sitePublished });
129 }, [updateMutation, sitePublished]);
130 
131 const publishPage = useCallback(async () => {
132 await publishMutation.mutateAsync();
133 }, [publishMutation]);
134 
135 const unpublishPage = useCallback(async () => {
136 await unpublishMutation.mutateAsync();
137 }, [unpublishMutation]);
138 
139 const isHome = siteRow?.home_page_id === pageId;
140 const toggleHomePage = useCallback(async () => {
141 setUpdateAction("set-home");
142 await updateMutation.mutateAsync({ home_page_id: isHome ? null : pageId });
143 }, [updateMutation, pageId, isHome]);
144 
145 const publicUrl = statusQuery.data?.public_url ?? null;
146 const copyPublicUrl = useCallback(() => {
147 if (!publicUrl) return;
148 copyToClipboard(pageId, publicUrl);
149 }, [publicUrl, copyToClipboard, pageId]);
150 
151 const slugSavePending = updateMutation.isPending && updateAction === "slug";
152 const siteTogglePending = updateMutation.isPending && updateAction === "site-toggle";
153 const setHomePending = updateMutation.isPending && updateAction === "set-home";
154 const publishPending = publishMutation.isPending;
155 const unpublishPending = unpublishMutation.isPending;
156 const saving = slugSavePending || siteTogglePending || setHomePending || publishPending || unpublishPending;
157 
158 const loading = siteQuery.isLoading || (pageKind === "doc" && statusQuery.isLoading);
159 const featureDisabled = isSitesDisabledError(siteQuery.error) || isSitesDisabledError(statusQuery.error);
160 
161 const error = useMemo(() => {
162 if (localError) return localError;
163 if (featureDisabled) return null;
164 return queryErrorMessage(siteQuery.error) ?? queryErrorMessage(statusQuery.error);
165 }, [localError, featureDisabled, siteQuery.error, statusQuery.error]);
166 
167 return useMemo<SitePublishValue>(
168 () => ({
169 pageId,
170 workspaceId,
171 site: siteQuery.data,
172 pageStatus: statusQuery.data,
173 loading,
174 saving,
175 slugSavePending,
176 siteTogglePending,
177 setHomePending,
178 publishPending,
179 unpublishPending,
180 featureDisabled,
181 error,
182 slugDraft,
183 setSlugDraft,
184 saveSiteSlug,
185 toggleSitePublished,
186 publishPage,
187 unpublishPage,
188 isHome,
189 toggleHomePage,
190 copyPublicUrl,
191 copied: copiedId === pageId,
192 }),
193 [
194 pageId,
195 workspaceId,
196 siteQuery.data,
197 statusQuery.data,
198 loading,
199 saving,
200 slugSavePending,
201 siteTogglePending,
202 setHomePending,
203 publishPending,
204 unpublishPending,
205 featureDisabled,
206 error,
207 slugDraft,
208 saveSiteSlug,
209 toggleSitePublished,
210 publishPage,
211 unpublishPage,
212 isHome,
213 toggleHomePage,
214 copyPublicUrl,
215 copiedId,
216 ],
217 );
218}