File
Blob: src/client/components/workspace/share-dialog/publish-tab.tsx
| 1 | import { houseOff } from "@lucide/lab"; |
| 2 | import { Check, Copy, Home, Icon, Loader2 } from "lucide-react"; |
| 3 | |
| 4 | import { Skeleton } from "@/client/components/ui/skeleton"; |
| 5 | import { isActionEnabled, isActionVisible } from "@/client/lib/affordance/action-state"; |
| 6 | import type { SitePageStatus } from "@/shared/types"; |
| 7 | |
| 8 | import { useShareDialogShell, useSitePublish } from "./context"; |
| 9 | |
| 10 | export 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 | |
| 100 | interface 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 | |
| 117 | function 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 | |
| 163 | function 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 | |
| 190 | function 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 | |
| 206 | interface 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 | |
| 220 | function 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 | |
| 305 | interface 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 | |
| 320 | function 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 | } |