Skip to content
File

Blob: src/client/components/workspace/share-dialog/publish-tab.tsx

typescript375 lines
1import { houseOff } from "@lucide/lab";
2import { Check, Copy, Home, Icon, Loader2 } from "lucide-react";
3 
4import { Skeleton } from "@/client/components/ui/skeleton";
5import { isActionEnabled, isActionVisible } from "@/client/lib/affordance/action-state";
6import type { SitePageStatus } from "@/shared/types";
7 
8import { useShareDialogShell, useSitePublish } from "./context";
9 
10export function PublishTabContent() {
11 const { publishAffordance } = useShareDialogShell();
12 const {
13 site,
14 pageStatus,
15 loading,
16 saving,
17 slugSavePending,
18 siteTogglePending,
19 setHomePending,
20 publishPending,
21 unpublishPending,
22 featureDisabled,
23 error,
24 slugDraft,
25 setSlugDraft,
26 saveSiteSlug,
27 toggleSitePublished,
28 publishPage,
29 unpublishPage,
30 isHome,
31 toggleHomePage,
32 copyPublicUrl,
33 copied,
34 } = useSitePublish();
35 
36 if (loading) {
37 return (
38 <div aria-busy="true" className="space-y-3">
39 <Skeleton className="h-4 w-1/2 rounded-sm" />
40 <Skeleton className="h-7 w-full rounded-md" />
41 <Skeleton className="h-8 w-full rounded-md" />
42 </div>
43 );
44 }
45 
46 if (featureDisabled) {
47 return <p className="px-2 py-3 text-sm text-zinc-400">Sites are not enabled on this instance.</p>;
48 }
49 
50 const siteRow = site?.site ?? null;
51 const baseDomain = site?.base_domain ?? null;
52 const sitePublished = !!siteRow?.published_at;
53 const showManageSite = isActionVisible(publishAffordance.manageSite);
54 const canManage = isActionEnabled(publishAffordance.manageSite);
55 const siteReady = !!siteRow && sitePublished;
56 
57 return (
58 <div className="space-y-4">
59 {error && <p className="text-sm text-red-400">{error}</p>}
60
61 {pageStatus ? (
62 <PagePublicationSection
63 status={pageStatus}
64 siteReady={siteReady}
65 showManageSite={showManageSite}
66 canManage={canManage}
67 saving={saving}
68 publishPending={publishPending}
69 unpublishPending={unpublishPending}
70 setHomePending={setHomePending}
71 isHome={isHome}
72 copied={copied}
73 onCopy={copyPublicUrl}
74 onPublish={() => void publishPage()}
75 onUnpublish={() => void unpublishPage()}
76 onToggleHome={() => void toggleHomePage()}
77 />
78 ) : null}
79
80 {showManageSite && baseDomain ? (
81 <SiteSettingsSection
82 baseDomain={baseDomain}
83 slugDraft={slugDraft}
84 setSlugDraft={setSlugDraft}
85 siteRow={siteRow}
86 sitePublished={sitePublished}
87 saving={saving}
88 slugSavePending={slugSavePending}
89 siteTogglePending={siteTogglePending}
90 canManage={canManage}
91 dividerAbove={!!pageStatus}
92 onSaveSlug={() => void saveSiteSlug(slugDraft.trim())}
93 onToggleSite={() => void toggleSitePublished()}
94 />
95 ) : null}
96 </div>
97 );
98}
99 
100interface PagePublicationSectionProps {
101 status: SitePageStatus;
102 siteReady: boolean;
103 showManageSite: boolean;
104 canManage: boolean;
105 saving: boolean;
106 publishPending: boolean;
107 unpublishPending: boolean;
108 setHomePending: boolean;
109 isHome: boolean;
110 copied: boolean;
111 onCopy: () => void;
112 onPublish: () => void;
113 onUnpublish: () => void;
114 onToggleHome: () => void;
115}
116 
117function PagePublicationSection({
118 status,
119 siteReady,
120 showManageSite,
121 canManage,
122 saving,
123 publishPending,
124 unpublishPending,
125 setHomePending,
126 isHome,
127 copied,
128 onCopy,
129 onPublish,
130 onUnpublish,
131 onToggleHome,
132}: PagePublicationSectionProps) {
133 if (status.canvas) {
134 return <p className="text-sm text-zinc-500">Canvas pages cannot be published.</p>;
135 }
136 
137 return (
138 <section className="space-y-2">
139 <StatusRow status={status} />
140 {status.public_url ? <PublicUrlPill url={status.public_url} copied={copied} onCopy={onCopy} /> : null}
141
142 {showManageSite ? (
143 <PageActions
144 status={status}
145 siteReady={siteReady}
146 canManage={canManage}
147 saving={saving}
148 publishPending={publishPending}
149 unpublishPending={unpublishPending}
150 setHomePending={setHomePending}
151 isHome={isHome}
152 onPublish={onPublish}
153 onUnpublish={onUnpublish}
154 onToggleHome={onToggleHome}
155 />
156 ) : (
157 <p className="text-xs text-zinc-500">Only admins can publish pages.</p>
158 )}
159 </section>
160 );
161}
162 
163function StatusRow({ status }: { status: SitePageStatus }) {
164 if (status.is_explicit_root) {
165 return (
166 <div className="flex items-center gap-2">
167 <span className="h-2 w-2 rounded-full bg-emerald-400" aria-hidden="true" />
168 <span className="text-sm font-medium text-zinc-200">Live on the web</span>
169 </div>
170 );
171 }
172 if (status.inherited_from) {
173 return (
174 <div className="flex items-center gap-2">
175 <span className="h-2 w-2 rounded-full bg-amber-400" aria-hidden="true" />
176 <span className="text-sm font-medium text-zinc-200">
177 Live via <span className="text-zinc-100">{status.inherited_from.title || "ancestor"}</span>
178 </span>
179 </div>
180 );
181 }
182 return (
183 <div className="flex items-center gap-2">
184 <span className="h-2 w-2 rounded-full bg-zinc-500" aria-hidden="true" />
185 <span className="text-sm font-medium text-zinc-300">Page is private</span>
186 </div>
187 );
188}
189 
190function PublicUrlPill({ url, copied, onCopy }: { url: string; copied: boolean; onCopy: () => void }) {
191 return (
192 <div className="flex items-center gap-1 rounded-md border border-zinc-800 bg-zinc-900 px-2 py-1">
193 <code className="min-w-0 flex-1 truncate font-mono text-xs text-zinc-300">{url}</code>
194 <button
195 type="button"
196 onClick={onCopy}
197 className="shrink-0 rounded p-1 text-zinc-500 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
198 aria-label="Copy public URL"
199 >
200 {copied ? <Check className="h-3.5 w-3.5 text-emerald-400" /> : <Copy className="h-3.5 w-3.5" />}
201 </button>
202 </div>
203 );
204}
205 
206interface PageActionsProps {
207 status: SitePageStatus;
208 siteReady: boolean;
209 canManage: boolean;
210 saving: boolean;
211 publishPending: boolean;
212 unpublishPending: boolean;
213 setHomePending: boolean;
214 isHome: boolean;
215 onPublish: () => void;
216 onUnpublish: () => void;
217 onToggleHome: () => void;
218}
219 
220function PageActions({
221 status,
222 siteReady,
223 canManage,
224 saving,
225 publishPending,
226 unpublishPending,
227 setHomePending,
228 isHome,
229 onPublish,
230 onUnpublish,
231 onToggleHome,
232}: PageActionsProps) {
233 // Inherited-from-ancestor: explain the relationship and offer a way to
234 // promote the page to its own root.
235 if (!status.is_explicit_root && status.inherited_from) {
236 return (
237 <div className="space-y-2">
238 <p className="text-xs text-zinc-400">
239 This page is live because{" "}
240 <span className="text-zinc-200">{status.inherited_from.title || "an ancestor"}</span> is published.
241 </p>
242 <button
243 type="button"
244 disabled={!canManage || saving || !siteReady}
245 onClick={onPublish}
246 className="inline-flex items-center gap-1 text-xs text-accent-400 transition-colors hover:text-accent-300 disabled:opacity-50"
247 >
248 {publishPending ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
249 Publish directly <span aria-hidden="true">→</span>
250 </button>
251 </div>
252 );
253 }
254 
255 // Explicit root: stop-publishing primary + set-as-home secondary
256 if (status.is_explicit_root) {
257 return (
258 <div className="flex items-center gap-2">
259 <button
260 type="button"
261 disabled={!canManage || saving}
262 onClick={onUnpublish}
263 className="inline-flex items-center justify-center gap-1.5 rounded-md border border-zinc-700 px-3 py-1.5 text-sm text-zinc-300 transition-colors hover:bg-zinc-700 hover:text-zinc-100 disabled:opacity-50"
264 >
265 {unpublishPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
266 Stop publishing
267 </button>
268 <button
269 type="button"
270 disabled={!canManage || saving}
271 onClick={onToggleHome}
272 className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-sm text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-zinc-100 disabled:opacity-50"
273 >
274 {setHomePending ? (
275 <Loader2 className="h-3.5 w-3.5 animate-spin" />
276 ) : isHome ? (
277 <Icon iconNode={houseOff} className="h-3.5 w-3.5" />
278 ) : (
279 <Home className="h-3.5 w-3.5" />
280 )}
281 {isHome ? "Unset as home" : "Set as home"}
282 </button>
283 </div>
284 );
285 }
286 
287 // Not published: primary "Publish this page" CTA, or note when no site exists yet
288 if (!siteReady) {
289 return <p className="text-xs text-zinc-500">Set up your site URL below to publish this page.</p>;
290 }
291 
292 return (
293 <button
294 type="button"
295 disabled={!canManage || saving}
296 onClick={onPublish}
297 className="inline-flex w-full items-center justify-center gap-1.5 rounded-md bg-accent-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-500 disabled:opacity-50"
298 >
299 {publishPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
300 Publish this page
301 </button>
302 );
303}
304 
305interface SiteSettingsSectionProps {
306 baseDomain: string;
307 slugDraft: string;
308 setSlugDraft: (next: string) => void;
309 siteRow: { published_at: string | null } | null;
310 sitePublished: boolean;
311 saving: boolean;
312 slugSavePending: boolean;
313 siteTogglePending: boolean;
314 canManage: boolean;
315 dividerAbove: boolean;
316 onSaveSlug: () => void;
317 onToggleSite: () => void;
318}
319 
320function SiteSettingsSection({
321 baseDomain,
322 slugDraft,
323 setSlugDraft,
324 siteRow,
325 sitePublished,
326 saving,
327 slugSavePending,
328 siteTogglePending,
329 canManage,
330 dividerAbove,
331 onSaveSlug,
332 onToggleSite,
333}: SiteSettingsSectionProps) {
334 return (
335 <section className={dividerAbove ? "space-y-2 border-t border-zinc-800/60 pt-3" : "space-y-2"}>
336 <p className="text-xs font-medium uppercase tracking-wide text-zinc-500">Site settings</p>
337 <div className="flex items-center gap-1.5">
338 <input
339 type="text"
340 value={slugDraft}
341 onChange={(e) => setSlugDraft(e.target.value)}
342 placeholder="your-slug"
343 disabled={!canManage || saving}
344 className="min-w-0 flex-1 rounded-md border border-zinc-700 bg-zinc-900 py-1 pl-2 text-sm text-zinc-300 outline-none focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/30 disabled:opacity-60"
345 aria-label="Site slug"
346 />
347 <span className="shrink-0 text-xs text-zinc-500">.{baseDomain}</span>
348 <button
349 type="button"
350 disabled={!canManage || saving || slugDraft.trim() === ""}
351 onClick={onSaveSlug}
352 className="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-zinc-700 px-2 py-1 text-sm text-zinc-300 transition-colors hover:bg-zinc-700 hover:text-zinc-100 disabled:opacity-50"
353 >
354 {slugSavePending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
355 {siteRow ? "Save" : "Create site"}
356 </button>
357 </div>
358 {siteRow ? (
359 <div className="flex justify-end">
360 <button
361 type="button"
362 disabled={!canManage || saving}
363 onClick={onToggleSite}
364 className="inline-flex items-center gap-1 text-xs text-zinc-500 transition-colors hover:text-zinc-300 hover:underline disabled:opacity-50"
365 style={{ textUnderlineOffset: "4px" }}
366 >
367 {siteTogglePending ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
368 {sitePublished ? "Take site offline" : "Bring site online"}
369 </button>
370 </div>
371 ) : null}
372 </section>
373 );
374}