create-nextblock 0.15.10 → 0.16.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docker-template/.dockerignore +1 -1
- package/libs/db/tsconfig.lib.json +3 -3
- package/libs/editor/tsconfig.lib.json +3 -3
- package/libs/ui/tsconfig.lib.json +3 -3
- package/libs/utils/tsconfig.json +3 -3
- package/package.json +50 -50
- package/project.json +19 -19
- package/templates/nextblock-template/.dockerignore +1 -1
- package/templates/nextblock-template/.swcrc +30 -30
- package/templates/nextblock-template/AGENTS.md +9 -9
- package/templates/nextblock-template/CLAUDE.md +1 -1
- package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -9
- package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -27
- package/templates/nextblock-template/app/.well-known/ucp/route.ts +16 -16
- package/templates/nextblock-template/app/ToasterProvider.tsx +26 -26
- package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -7
- package/templates/nextblock-template/app/actions/contactSellerActions.test.ts +280 -280
- package/templates/nextblock-template/app/actions/contactSellerActions.ts +222 -222
- package/templates/nextblock-template/app/actions/email-retry.test.ts +62 -62
- package/templates/nextblock-template/app/actions/email.ts +241 -241
- package/templates/nextblock-template/app/actions/formActions.ts +245 -245
- package/templates/nextblock-template/app/actions/interactions.ts +489 -489
- package/templates/nextblock-template/app/actions/threadActions.ts +166 -166
- package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -210
- package/templates/nextblock-template/app/api/ai/seo/alt-text/route.ts +221 -0
- package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -0
- package/templates/nextblock-template/app/api/checkout/freemius/sync/route.ts +29 -29
- package/templates/nextblock-template/app/api/checkout/route.ts +162 -162
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +3092 -2900
- package/templates/nextblock-template/app/api/mcp/route.ts +415 -415
- package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
- package/templates/nextblock-template/app/api/search/route.ts +43 -43
- package/templates/nextblock-template/app/api/view/route.ts +114 -114
- package/templates/nextblock-template/app/api/visual-editing/block-draft/route.ts +47 -47
- package/templates/nextblock-template/app/api/visual-editing/product-draft/route.ts +47 -47
- package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
- package/templates/nextblock-template/app/cart/page.tsx +7 -7
- package/templates/nextblock-template/app/checkout/UcpCartHydrator.tsx +20 -20
- package/templates/nextblock-template/app/checkout/page.tsx +57 -57
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +558 -552
- package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +27 -0
- package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +241 -241
- package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
- package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +304 -304
- package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +180 -3
- package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +171 -6
- package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -75
- package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -23
- package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
- package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +12 -3
- package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
- package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -58
- package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -62
- package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -542
- package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -16
- package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -16
- package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
- package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -200
- package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -66
- package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/layout.tsx +101 -101
- package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
- package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
- package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +177 -2
- package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
- package/templates/nextblock-template/app/cms/messages/MessagesClient.tsx +661 -661
- package/templates/nextblock-template/app/cms/messages/actions.ts +404 -404
- package/templates/nextblock-template/app/cms/messages/loadInbox.ts +333 -333
- package/templates/nextblock-template/app/cms/messages/page.tsx +87 -87
- package/templates/nextblock-template/app/cms/messages/require-admin.ts +37 -37
- package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
- package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +28 -0
- package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +248 -5
- package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +33 -0
- package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +240 -5
- package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -370
- package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
- package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
- package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
- package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -332
- package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -498
- package/templates/nextblock-template/app/cms/revisions/service.ts +569 -569
- package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -304
- package/templates/nextblock-template/app/cms/settings/cortex-ai/CortexAiSettingsClient.tsx +948 -948
- package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +628 -628
- package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +230 -230
- package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -34
- package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
- package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
- package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -227
- package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
- package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +276 -276
- package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
- package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -382
- package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -267
- package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -40
- package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -259
- package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
- package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
- package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
- package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -0
- package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -0
- package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -0
- package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -0
- package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -0
- package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -0
- package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -0
- package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -51
- package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
- package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
- package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
- package/templates/nextblock-template/app/layout.tsx +671 -671
- package/templates/nextblock-template/app/lib/seo.ts +319 -319
- package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -190
- package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -56
- package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -502
- package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
- package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
- package/templates/nextblock-template/app/profile/account-links.ts +22 -22
- package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
- package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
- package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
- package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
- package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
- package/templates/nextblock-template/app/providers.tsx +96 -96
- package/templates/nextblock-template/app/robots.ts +123 -0
- package/templates/nextblock-template/app/sitemap.ts +1 -1
- package/templates/nextblock-template/app/thread/ThreadView.tsx +164 -164
- package/templates/nextblock-template/app/thread/[token]/route.ts +57 -57
- package/templates/nextblock-template/app/thread/layout.tsx +15 -15
- package/templates/nextblock-template/app/thread/page.tsx +98 -98
- package/templates/nextblock-template/app/ucp/v1/carts/[id]/cancel/route.ts +38 -38
- package/templates/nextblock-template/app/ucp/v1/carts/[id]/route.ts +68 -68
- package/templates/nextblock-template/app/ucp/v1/carts/route.ts +35 -35
- package/templates/nextblock-template/app/ucp/v1/catalog/lookup/route.ts +35 -35
- package/templates/nextblock-template/app/ucp/v1/catalog/product/route.ts +35 -35
- package/templates/nextblock-template/app/ucp/v1/catalog/search/route.ts +34 -34
- package/templates/nextblock-template/components/BlockRenderer.tsx +312 -312
- package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
- package/templates/nextblock-template/components/CartTranslator.tsx +210 -210
- package/templates/nextblock-template/components/ContactSellerSection.tsx +188 -188
- package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
- package/templates/nextblock-template/components/DeferredCartTranslator.tsx +51 -51
- package/templates/nextblock-template/components/DeferredGlobalSearch.tsx +68 -68
- package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
- package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
- package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
- package/templates/nextblock-template/components/GlobalSearch.tsx +557 -557
- package/templates/nextblock-template/components/Header.tsx +38 -38
- package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
- package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
- package/templates/nextblock-template/components/PostCommentsSection.tsx +378 -378
- package/templates/nextblock-template/components/ProductReviewsSection.tsx +426 -426
- package/templates/nextblock-template/components/SiteScripts.tsx +56 -56
- package/templates/nextblock-template/components/StaffReplies.tsx +102 -102
- package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
- package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
- package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +48 -48
- package/templates/nextblock-template/components/blocks/TestimonialBlock.tsx +9 -9
- package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
- package/templates/nextblock-template/components/blocks/publicRendererLoaders.ts +25 -25
- package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -92
- package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +18 -18
- package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +20 -20
- package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +25 -25
- package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +385 -385
- package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +24 -24
- package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +157 -157
- package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +34 -34
- package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +612 -612
- package/templates/nextblock-template/components/blocks/renderers/TestimonialBlockRenderer.tsx +57 -57
- package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +2 -2
- package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +2 -2
- package/templates/nextblock-template/components/blocks/types.ts +7 -7
- package/templates/nextblock-template/components/commerce/PaymentReadinessBoundary.tsx +32 -32
- package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
- package/templates/nextblock-template/components/form-message.tsx +32 -32
- package/templates/nextblock-template/components/seo/GenerateMetaButton.tsx +137 -0
- package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +244 -0
- package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -0
- package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -0
- package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -0
- package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -0
- package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -0
- package/templates/nextblock-template/components/submit-button.tsx +23 -23
- package/templates/nextblock-template/components/theme-icon.tsx +78 -78
- package/templates/nextblock-template/components/theme-switcher.tsx +85 -85
- package/templates/nextblock-template/context/AuthContext.tsx +23 -23
- package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -44
- package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
- package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -77
- package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +372 -372
- package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +309 -309
- package/templates/nextblock-template/docs/README.md +42 -42
- package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +12506 -12506
- package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
- package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
- package/templates/nextblock-template/index.d.ts +7 -7
- package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
- package/templates/nextblock-template/lib/blocks/blockColors.test.ts +134 -134
- package/templates/nextblock-template/lib/blocks/blockColors.ts +175 -175
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +761 -761
- package/templates/nextblock-template/lib/blocks/inlineScriptNonce.ts +20 -20
- package/templates/nextblock-template/lib/cms/contact-reminder.ts +64 -64
- package/templates/nextblock-template/lib/cms/payments-reminder.test.ts +135 -0
- package/templates/nextblock-template/lib/cms/payments-reminder.ts +30 -23
- package/templates/nextblock-template/lib/cms/unread-messages.ts +42 -42
- package/templates/nextblock-template/lib/commerce/seller-contact.ts +162 -162
- package/templates/nextblock-template/lib/config/email-settings.ts +323 -323
- package/templates/nextblock-template/lib/config/email-tls.test.ts +57 -57
- package/templates/nextblock-template/lib/cortex-ai/alt-text-request.ts +86 -0
- package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -0
- package/templates/nextblock-template/lib/email/placeholder-address.test.ts +59 -59
- package/templates/nextblock-template/lib/email/placeholder-address.ts +39 -39
- package/templates/nextblock-template/lib/messages/thread-reference.test.ts +70 -70
- package/templates/nextblock-template/lib/messages/thread-token.test.ts +93 -93
- package/templates/nextblock-template/lib/messages/thread-token.ts +157 -157
- package/templates/nextblock-template/lib/messages/threads.ts +579 -579
- package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
- package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -26
- package/templates/nextblock-template/lib/search/types.ts +27 -27
- package/templates/nextblock-template/lib/seo/alt-text-write-back.test.ts +154 -0
- package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -0
- package/templates/nextblock-template/lib/seo/block-content.ts +123 -0
- package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -0
- package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -0
- package/templates/nextblock-template/lib/seo/page-audit-context.tsx +140 -0
- package/templates/nextblock-template/lib/seo/page-document.test.ts +350 -0
- package/templates/nextblock-template/lib/seo/page-document.ts +412 -0
- package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -0
- package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -0
- package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -0
- package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -0
- package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -0
- package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -0
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +177 -167
- package/templates/nextblock-template/lib/site-scripts/revisions.ts +71 -71
- package/templates/nextblock-template/lib/site-scripts/types.ts +46 -46
- package/templates/nextblock-template/lib/site-url.test.ts +89 -89
- package/templates/nextblock-template/lib/site-url.ts +102 -102
- package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -124
- package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -31
- package/templates/nextblock-template/lib/themes/tokens.ts +143 -143
- package/templates/nextblock-template/lib/visual-editing/draft-content.test.ts +105 -105
- package/templates/nextblock-template/lib/visual-editing/draft-route.test.ts +42 -42
- package/templates/nextblock-template/lib/visual-editing/edit-info.test.ts +143 -143
- package/templates/nextblock-template/lib/visual-editing/edit-info.ts +94 -94
- package/templates/nextblock-template/lib/visual-editing/product-drafts.test.ts +81 -81
- package/templates/nextblock-template/lib/zod-config.ts +5 -5
- package/templates/nextblock-template/next-env.d.ts +0 -1
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/postcss.config.js +6 -6
- package/templates/nextblock-template/proxy.ts +240 -20
- package/templates/nextblock-template/scripts/backup.js +115 -115
- package/templates/nextblock-template/scripts/restore.js +385 -385
- package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -112
- package/templates/nextblock-template/tailwind.config.js +25 -25
- package/templates/nextblock-template/tools/build-migrate.mjs +102 -102
- package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
- package/templates/nextblock-template/tools/deploy-supabase.js +159 -159
- package/templates/nextblock-template/tools/lib/migrate-core.mjs +569 -569
- package/templates/nextblock-template/tools/update.mjs +1303 -1303
- package/tsconfig.base.json +3 -3
- package/templates/nextblock-template/app/robots.txt/route.ts +0 -32
|
@@ -1,542 +1,542 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Top-bar visibility control for pages, posts and products.
|
|
5
|
-
*
|
|
6
|
-
* This is the only place in the CMS that can change what the public sees. It is
|
|
7
|
-
* deliberately NOT part of the content form: editing settings autosaves into the
|
|
8
|
-
* Live Draft, while publishing writes straight to the row. Two decisions, two
|
|
9
|
-
* controls — see `app/actions/visibilityActions.ts`.
|
|
10
|
-
*
|
|
11
|
-
* The pill is read-only on purpose. A <select> implies every option is equivalent
|
|
12
|
-
* and instantly applied, which is exactly the behaviour this replaces; naming the
|
|
13
|
-
* transitions as verbs in a menu lets each one carry its own confirmation.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import * as React from "react";
|
|
17
|
-
import { useRouter } from "next/navigation";
|
|
18
|
-
import { toast } from "react-hot-toast";
|
|
19
|
-
import {
|
|
20
|
-
Button,
|
|
21
|
-
Checkbox,
|
|
22
|
-
Dialog,
|
|
23
|
-
DialogContent,
|
|
24
|
-
DialogDescription,
|
|
25
|
-
DialogFooter,
|
|
26
|
-
DialogHeader,
|
|
27
|
-
DialogTitle,
|
|
28
|
-
DropdownMenu,
|
|
29
|
-
DropdownMenuContent,
|
|
30
|
-
DropdownMenuItem,
|
|
31
|
-
DropdownMenuLabel,
|
|
32
|
-
DropdownMenuSeparator,
|
|
33
|
-
DropdownMenuTrigger,
|
|
34
|
-
Input,
|
|
35
|
-
Label,
|
|
36
|
-
} from "@nextblock-cms/ui";
|
|
37
|
-
import {
|
|
38
|
-
LIVE_STATUS,
|
|
39
|
-
resolveVisibilityState,
|
|
40
|
-
type PublishableType,
|
|
41
|
-
type VisibilityState,
|
|
42
|
-
} from "@nextblock-cms/utils";
|
|
43
|
-
import {
|
|
44
|
-
AlertTriangle,
|
|
45
|
-
CalendarClock,
|
|
46
|
-
ChevronDown,
|
|
47
|
-
Link2,
|
|
48
|
-
Loader2,
|
|
49
|
-
Archive,
|
|
50
|
-
EyeOff,
|
|
51
|
-
Globe,
|
|
52
|
-
} from "lucide-react";
|
|
53
|
-
import {
|
|
54
|
-
getSiblingVisibility,
|
|
55
|
-
setContentVisibility,
|
|
56
|
-
type SiblingVisibility,
|
|
57
|
-
type VisibilityIntent,
|
|
58
|
-
} from "../../actions/visibilityActions";
|
|
59
|
-
import { publishVisualEditingDraft, publishVisualEditingProductDraft } from "../../actions/visualEditingActions";
|
|
60
|
-
|
|
61
|
-
interface LanguageOption {
|
|
62
|
-
id: number;
|
|
63
|
-
name: string;
|
|
64
|
-
code: string;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export interface VisibilityControlProps {
|
|
68
|
-
type: PublishableType;
|
|
69
|
-
id: number | string;
|
|
70
|
-
/** Live row status — never the draft overlay. */
|
|
71
|
-
status: string;
|
|
72
|
-
/** Live row published_at; a future value means "scheduled". */
|
|
73
|
-
publishedAt: string | null;
|
|
74
|
-
/** Root-relative public URL, e.g. "/about" or "/article/hello". */
|
|
75
|
-
publicPath: string;
|
|
76
|
-
/** Display name of the language this row is written in. */
|
|
77
|
-
languageName?: string;
|
|
78
|
-
translationGroupId?: string | null;
|
|
79
|
-
languages?: LanguageOption[];
|
|
80
|
-
/** True when unpublished content edits exist in the Live Draft. */
|
|
81
|
-
hasDraft?: boolean;
|
|
82
|
-
/**
|
|
83
|
-
* A caution to show before this content goes public — e.g. a product whose payment
|
|
84
|
-
* provider isn't configured, which publishes fine but cannot be bought yet. Shown
|
|
85
|
-
* verbatim as an amber callout in the publish and schedule dialogs; it never blocks
|
|
86
|
-
* the action, so write it as a consequence the editor can weigh, not a refusal.
|
|
87
|
-
*/
|
|
88
|
-
publishWarning?: string | null;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const STATE_LABEL: Record<VisibilityState, string> = {
|
|
92
|
-
draft: "Draft",
|
|
93
|
-
scheduled: "Scheduled",
|
|
94
|
-
published: "Published",
|
|
95
|
-
archived: "Archived",
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
const STATE_STYLE: Record<VisibilityState, string> = {
|
|
99
|
-
draft:
|
|
100
|
-
"border-border bg-muted text-muted-foreground",
|
|
101
|
-
scheduled:
|
|
102
|
-
"border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
|
103
|
-
published:
|
|
104
|
-
"border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
|
|
105
|
-
archived:
|
|
106
|
-
"border-dashed border-border bg-transparent text-muted-foreground",
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
const DOT_STYLE: Record<VisibilityState, string> = {
|
|
110
|
-
draft: "bg-muted-foreground/70",
|
|
111
|
-
scheduled: "bg-amber-500",
|
|
112
|
-
published: "bg-emerald-500",
|
|
113
|
-
archived: "bg-muted-foreground/50",
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
/** `datetime-local` wants "YYYY-MM-DDTHH:mm" in LOCAL time, not an ISO string. */
|
|
117
|
-
function toDateTimeLocal(value: string | null): string {
|
|
118
|
-
if (!value) return "";
|
|
119
|
-
const date = new Date(value);
|
|
120
|
-
if (Number.isNaN(date.getTime())) return "";
|
|
121
|
-
const pad = (n: number) => String(n).padStart(2, "0");
|
|
122
|
-
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(
|
|
123
|
-
date.getHours()
|
|
124
|
-
)}:${pad(date.getMinutes())}`;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function defaultScheduleValue(): string {
|
|
128
|
-
const soon = new Date();
|
|
129
|
-
soon.setDate(soon.getDate() + 1);
|
|
130
|
-
soon.setMinutes(0, 0, 0);
|
|
131
|
-
return toDateTimeLocal(soon.toISOString());
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export default function VisibilityControl({
|
|
135
|
-
type,
|
|
136
|
-
id,
|
|
137
|
-
status,
|
|
138
|
-
publishedAt,
|
|
139
|
-
publicPath,
|
|
140
|
-
languageName,
|
|
141
|
-
translationGroupId,
|
|
142
|
-
languages = [],
|
|
143
|
-
hasDraft = false,
|
|
144
|
-
publishWarning = null,
|
|
145
|
-
}: VisibilityControlProps) {
|
|
146
|
-
const router = useRouter();
|
|
147
|
-
const liveStatus = LIVE_STATUS[type];
|
|
148
|
-
const state = resolveVisibilityState({ status, publishedAt, liveStatus });
|
|
149
|
-
|
|
150
|
-
const [dialog, setDialog] = React.useState<null | "publish" | "schedule" | "unpublish" | "archive">(null);
|
|
151
|
-
const [isBusy, setIsBusy] = React.useState(false);
|
|
152
|
-
const [error, setError] = React.useState<string | null>(null);
|
|
153
|
-
const [alsoPublishDraft, setAlsoPublishDraft] = React.useState(true);
|
|
154
|
-
const [scheduleValue, setScheduleValue] = React.useState(() => toDateTimeLocal(publishedAt) || defaultScheduleValue());
|
|
155
|
-
const [siblings, setSiblings] = React.useState<SiblingVisibility[] | null>(null);
|
|
156
|
-
|
|
157
|
-
// The schedule date is formatted in the viewer's timezone, which the server
|
|
158
|
-
// doesn't know — render it only after mount so the two never disagree.
|
|
159
|
-
const [mounted, setMounted] = React.useState(false);
|
|
160
|
-
React.useEffect(() => setMounted(true), []);
|
|
161
|
-
|
|
162
|
-
const scheduleLabel = React.useMemo(() => {
|
|
163
|
-
if (!mounted || !publishedAt) return null;
|
|
164
|
-
const date = new Date(publishedAt);
|
|
165
|
-
if (Number.isNaN(date.getTime())) return null;
|
|
166
|
-
return new Intl.DateTimeFormat(undefined, {
|
|
167
|
-
month: "short",
|
|
168
|
-
day: "numeric",
|
|
169
|
-
hour: "numeric",
|
|
170
|
-
minute: "2-digit",
|
|
171
|
-
timeZoneName: "short",
|
|
172
|
-
}).format(date);
|
|
173
|
-
}, [mounted, publishedAt]);
|
|
174
|
-
|
|
175
|
-
const label = state === "scheduled" && scheduleLabel ? `Goes live ${scheduleLabel}` : STATE_LABEL[state];
|
|
176
|
-
|
|
177
|
-
const languageNameById = React.useMemo(
|
|
178
|
-
() => new Map(languages.map((language) => [language.id, `${language.name} (${language.code.toUpperCase()})`])),
|
|
179
|
-
[languages]
|
|
180
|
-
);
|
|
181
|
-
|
|
182
|
-
const openPublishDialog = React.useCallback(() => {
|
|
183
|
-
// A warning is not an error: seeding it into `error` renders it destructive-red and
|
|
184
|
-
// `run()` clears it on the next attempt. It belongs in the dialog body instead.
|
|
185
|
-
setError(null);
|
|
186
|
-
setAlsoPublishDraft(true);
|
|
187
|
-
setSiblings(null);
|
|
188
|
-
setDialog("publish");
|
|
189
|
-
|
|
190
|
-
if (translationGroupId) {
|
|
191
|
-
void getSiblingVisibility(type, translationGroupId, id).then(setSiblings);
|
|
192
|
-
}
|
|
193
|
-
}, [id, translationGroupId, type]);
|
|
194
|
-
|
|
195
|
-
const run = async (intent: VisibilityIntent, opts?: { publishDraft?: boolean }) => {
|
|
196
|
-
setIsBusy(true);
|
|
197
|
-
setError(null);
|
|
198
|
-
try {
|
|
199
|
-
const result = await setContentVisibility(type, id, intent);
|
|
200
|
-
if (result.error) {
|
|
201
|
-
setError(result.error);
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Publishing the row and publishing pending edits are separate operations;
|
|
206
|
-
// the dialog offers to do both so a never-live page doesn't go public
|
|
207
|
-
// showing content the editor has already replaced.
|
|
208
|
-
if (opts?.publishDraft && hasDraft) {
|
|
209
|
-
const draftResult =
|
|
210
|
-
type === "product"
|
|
211
|
-
? await publishVisualEditingProductDraft(String(id))
|
|
212
|
-
: await publishVisualEditingDraft(type, Number(id));
|
|
213
|
-
|
|
214
|
-
if (draftResult && "error" in draftResult && draftResult.error) {
|
|
215
|
-
toast.error(`Visibility updated, but the edits didn't publish: ${draftResult.error}`);
|
|
216
|
-
setDialog(null);
|
|
217
|
-
router.refresh();
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// Partial success: the edits went live but the revision didn't record.
|
|
222
|
-
if (draftResult && "success" in draftResult && draftResult.warning) {
|
|
223
|
-
toast.error(draftResult.warning, { duration: 8000 });
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
toast.success(
|
|
228
|
-
intent.action === "publish"
|
|
229
|
-
? "Published."
|
|
230
|
-
: intent.action === "schedule"
|
|
231
|
-
? "Scheduled."
|
|
232
|
-
: intent.action === "unpublish"
|
|
233
|
-
? "Unpublished."
|
|
234
|
-
: "Archived."
|
|
235
|
-
);
|
|
236
|
-
setDialog(null);
|
|
237
|
-
router.refresh();
|
|
238
|
-
} catch (err) {
|
|
239
|
-
setError(err instanceof Error ? err.message : "Something went wrong.");
|
|
240
|
-
} finally {
|
|
241
|
-
setIsBusy(false);
|
|
242
|
-
}
|
|
243
|
-
};
|
|
244
|
-
|
|
245
|
-
const copyPublicUrl = async () => {
|
|
246
|
-
const url = typeof window === "undefined" ? publicPath : new URL(publicPath, window.location.origin).toString();
|
|
247
|
-
try {
|
|
248
|
-
await navigator.clipboard.writeText(url);
|
|
249
|
-
toast.success("Public URL copied.");
|
|
250
|
-
} catch {
|
|
251
|
-
toast.error("Couldn't copy the URL.");
|
|
252
|
-
}
|
|
253
|
-
};
|
|
254
|
-
|
|
255
|
-
const typeLabel = type === "product" ? "product" : type;
|
|
256
|
-
const busySpinner = <Loader2 className="mr-2 h-4 w-4 animate-spin" />;
|
|
257
|
-
|
|
258
|
-
// Amber, not destructive: publishing is still allowed. Same treatment as the
|
|
259
|
-
// "publish your unpublished edits too" opt-in below, which is the other caution
|
|
260
|
-
// shown in this dialog.
|
|
261
|
-
const publishWarningCallout = (
|
|
262
|
-
<div className="flex items-start gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
|
|
263
|
-
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
|
264
|
-
<span>{publishWarning}</span>
|
|
265
|
-
</div>
|
|
266
|
-
);
|
|
267
|
-
|
|
268
|
-
return (
|
|
269
|
-
<div className="flex items-center gap-2">
|
|
270
|
-
<span
|
|
271
|
-
className={`inline-flex items-center gap-2 whitespace-nowrap rounded-full border px-2.5 py-1 text-xs font-semibold ${STATE_STYLE[state]}`}
|
|
272
|
-
title={
|
|
273
|
-
state === "published"
|
|
274
|
-
? `Live at ${publicPath}`
|
|
275
|
-
: state === "scheduled"
|
|
276
|
-
? "Not public yet — goes live automatically"
|
|
277
|
-
: "Not visible to the public"
|
|
278
|
-
}
|
|
279
|
-
>
|
|
280
|
-
<span className={`h-1.5 w-1.5 rounded-full ${DOT_STYLE[state]}`} aria-hidden="true" />
|
|
281
|
-
{label}
|
|
282
|
-
</span>
|
|
283
|
-
|
|
284
|
-
{hasDraft && (
|
|
285
|
-
<span className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-full border border-dashed border-amber-500/50 px-2.5 py-1 text-xs font-medium text-amber-700 dark:text-amber-300">
|
|
286
|
-
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" aria-hidden="true" />
|
|
287
|
-
Unpublished edits
|
|
288
|
-
</span>
|
|
289
|
-
)}
|
|
290
|
-
|
|
291
|
-
<div className="flex items-center">
|
|
292
|
-
{state !== "published" && (
|
|
293
|
-
<Button
|
|
294
|
-
size="sm"
|
|
295
|
-
className="rounded-r-none"
|
|
296
|
-
onClick={openPublishDialog}
|
|
297
|
-
disabled={isBusy}
|
|
298
|
-
>
|
|
299
|
-
<Globe className="mr-2 h-4 w-4" />
|
|
300
|
-
{state === "scheduled" ? "Publish now" : "Publish…"}
|
|
301
|
-
</Button>
|
|
302
|
-
)}
|
|
303
|
-
<DropdownMenu>
|
|
304
|
-
<DropdownMenuTrigger asChild>
|
|
305
|
-
<Button
|
|
306
|
-
size="sm"
|
|
307
|
-
variant={state === "published" ? "outline" : "default"}
|
|
308
|
-
className={state === "published" ? "" : "-ml-px rounded-l-none px-2"}
|
|
309
|
-
aria-label="Visibility options"
|
|
310
|
-
disabled={isBusy}
|
|
311
|
-
>
|
|
312
|
-
{state === "published" && <span className="mr-1">Visibility</span>}
|
|
313
|
-
<ChevronDown className="h-4 w-4" />
|
|
314
|
-
</Button>
|
|
315
|
-
</DropdownMenuTrigger>
|
|
316
|
-
<DropdownMenuContent align="end" className="w-64">
|
|
317
|
-
<DropdownMenuLabel>Visibility</DropdownMenuLabel>
|
|
318
|
-
<DropdownMenuSeparator />
|
|
319
|
-
<DropdownMenuItem
|
|
320
|
-
onSelect={() => {
|
|
321
|
-
setError(null);
|
|
322
|
-
setScheduleValue(toDateTimeLocal(publishedAt) || defaultScheduleValue());
|
|
323
|
-
setDialog("schedule");
|
|
324
|
-
}}
|
|
325
|
-
>
|
|
326
|
-
<CalendarClock className="mr-2 h-4 w-4" />
|
|
327
|
-
{state === "scheduled" ? "Reschedule…" : "Schedule…"}
|
|
328
|
-
</DropdownMenuItem>
|
|
329
|
-
<DropdownMenuItem onSelect={() => void copyPublicUrl()}>
|
|
330
|
-
<Link2 className="mr-2 h-4 w-4" />
|
|
331
|
-
Copy public URL
|
|
332
|
-
</DropdownMenuItem>
|
|
333
|
-
{(state === "published" || state === "scheduled") && (
|
|
334
|
-
<>
|
|
335
|
-
<DropdownMenuSeparator />
|
|
336
|
-
<DropdownMenuItem
|
|
337
|
-
className="text-destructive focus:text-destructive"
|
|
338
|
-
onSelect={() => {
|
|
339
|
-
setError(null);
|
|
340
|
-
setDialog("unpublish");
|
|
341
|
-
}}
|
|
342
|
-
>
|
|
343
|
-
<EyeOff className="mr-2 h-4 w-4" />
|
|
344
|
-
Unpublish
|
|
345
|
-
</DropdownMenuItem>
|
|
346
|
-
</>
|
|
347
|
-
)}
|
|
348
|
-
{state !== "archived" && (
|
|
349
|
-
<DropdownMenuItem
|
|
350
|
-
className="text-destructive focus:text-destructive"
|
|
351
|
-
onSelect={() => {
|
|
352
|
-
setError(null);
|
|
353
|
-
setDialog("archive");
|
|
354
|
-
}}
|
|
355
|
-
>
|
|
356
|
-
<Archive className="mr-2 h-4 w-4" />
|
|
357
|
-
Archive
|
|
358
|
-
</DropdownMenuItem>
|
|
359
|
-
)}
|
|
360
|
-
</DropdownMenuContent>
|
|
361
|
-
</DropdownMenu>
|
|
362
|
-
</div>
|
|
363
|
-
|
|
364
|
-
{/* ---- Publish ---- */}
|
|
365
|
-
<Dialog open={dialog === "publish"} onOpenChange={(open) => !open && setDialog(null)}>
|
|
366
|
-
<DialogContent>
|
|
367
|
-
<DialogHeader>
|
|
368
|
-
<DialogTitle>Make this {typeLabel} public?</DialogTitle>
|
|
369
|
-
<DialogDescription>
|
|
370
|
-
Anyone with the link will be able to see it, and search engines can index it.
|
|
371
|
-
</DialogDescription>
|
|
372
|
-
</DialogHeader>
|
|
373
|
-
|
|
374
|
-
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 rounded-md border bg-muted/40 p-3 text-sm">
|
|
375
|
-
<dt className="text-xs uppercase tracking-wide text-muted-foreground">URL</dt>
|
|
376
|
-
<dd className="font-mono text-xs break-all">{publicPath}</dd>
|
|
377
|
-
{languageName && (
|
|
378
|
-
<>
|
|
379
|
-
<dt className="text-xs uppercase tracking-wide text-muted-foreground">Language</dt>
|
|
380
|
-
<dd>{languageName}</dd>
|
|
381
|
-
</>
|
|
382
|
-
)}
|
|
383
|
-
<dt className="text-xs uppercase tracking-wide text-muted-foreground">Goes live</dt>
|
|
384
|
-
<dd>Immediately</dd>
|
|
385
|
-
</dl>
|
|
386
|
-
|
|
387
|
-
{hasDraft && (
|
|
388
|
-
<label className="flex items-start gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
|
|
389
|
-
<Checkbox
|
|
390
|
-
checked={alsoPublishDraft}
|
|
391
|
-
onCheckedChange={(checked) => setAlsoPublishDraft(checked === true)}
|
|
392
|
-
className="mt-0.5"
|
|
393
|
-
/>
|
|
394
|
-
<span>
|
|
395
|
-
Publish your unpublished edits at the same time — otherwise the older live version
|
|
396
|
-
is what goes public.
|
|
397
|
-
</span>
|
|
398
|
-
</label>
|
|
399
|
-
)}
|
|
400
|
-
|
|
401
|
-
{siblings && siblings.some((sibling) => sibling.state !== "published") && (
|
|
402
|
-
<div className="space-y-1.5 text-sm text-muted-foreground">
|
|
403
|
-
{siblings
|
|
404
|
-
.filter((sibling) => sibling.state !== "published")
|
|
405
|
-
.map((sibling) => (
|
|
406
|
-
<p key={String(sibling.id)}>
|
|
407
|
-
<span className="font-semibold text-amber-600 dark:text-amber-400">!</span>{" "}
|
|
408
|
-
The {languageNameById.get(sibling.languageId) ?? "other language"} version is
|
|
409
|
-
still {STATE_LABEL[sibling.state].toLowerCase()}.
|
|
410
|
-
</p>
|
|
411
|
-
))}
|
|
412
|
-
</div>
|
|
413
|
-
)}
|
|
414
|
-
|
|
415
|
-
{publishWarning && publishWarningCallout}
|
|
416
|
-
|
|
417
|
-
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
418
|
-
|
|
419
|
-
<DialogFooter className="mt-2 gap-2">
|
|
420
|
-
<Button variant="outline" onClick={() => setDialog(null)} disabled={isBusy}>
|
|
421
|
-
Cancel
|
|
422
|
-
</Button>
|
|
423
|
-
<Button
|
|
424
|
-
onClick={() => void run({ action: "publish" }, { publishDraft: alsoPublishDraft })}
|
|
425
|
-
disabled={isBusy}
|
|
426
|
-
>
|
|
427
|
-
{isBusy && busySpinner}
|
|
428
|
-
{publishWarning ? `Publish anyway` : `Publish ${typeLabel}`}
|
|
429
|
-
</Button>
|
|
430
|
-
</DialogFooter>
|
|
431
|
-
</DialogContent>
|
|
432
|
-
</Dialog>
|
|
433
|
-
|
|
434
|
-
{/* ---- Schedule ---- */}
|
|
435
|
-
<Dialog open={dialog === "schedule"} onOpenChange={(open) => !open && setDialog(null)}>
|
|
436
|
-
<DialogContent>
|
|
437
|
-
<DialogHeader>
|
|
438
|
-
<DialogTitle>Schedule this {typeLabel}</DialogTitle>
|
|
439
|
-
<DialogDescription>
|
|
440
|
-
It stays private until the moment you pick, then goes live on its own. Times are in
|
|
441
|
-
your own timezone.
|
|
442
|
-
</DialogDescription>
|
|
443
|
-
</DialogHeader>
|
|
444
|
-
|
|
445
|
-
<div className="space-y-2">
|
|
446
|
-
<Label htmlFor="visibility-schedule">Go live on</Label>
|
|
447
|
-
<Input
|
|
448
|
-
id="visibility-schedule"
|
|
449
|
-
type="datetime-local"
|
|
450
|
-
value={scheduleValue}
|
|
451
|
-
onChange={(event) => setScheduleValue(event.target.value)}
|
|
452
|
-
/>
|
|
453
|
-
</div>
|
|
454
|
-
|
|
455
|
-
{publishWarning && publishWarningCallout}
|
|
456
|
-
|
|
457
|
-
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
458
|
-
|
|
459
|
-
<DialogFooter className="mt-2 gap-2">
|
|
460
|
-
<Button variant="outline" onClick={() => setDialog(null)} disabled={isBusy}>
|
|
461
|
-
Cancel
|
|
462
|
-
</Button>
|
|
463
|
-
<Button
|
|
464
|
-
onClick={() => {
|
|
465
|
-
const local = new Date(scheduleValue);
|
|
466
|
-
if (Number.isNaN(local.getTime())) {
|
|
467
|
-
setError("That isn't a valid date and time.");
|
|
468
|
-
return;
|
|
469
|
-
}
|
|
470
|
-
void run({ action: "schedule", publishedAt: local.toISOString() });
|
|
471
|
-
}}
|
|
472
|
-
disabled={isBusy || !scheduleValue}
|
|
473
|
-
>
|
|
474
|
-
{isBusy && busySpinner}
|
|
475
|
-
{publishWarning ? "Schedule anyway" : "Schedule"}
|
|
476
|
-
</Button>
|
|
477
|
-
</DialogFooter>
|
|
478
|
-
</DialogContent>
|
|
479
|
-
</Dialog>
|
|
480
|
-
|
|
481
|
-
{/* ---- Unpublish ---- */}
|
|
482
|
-
<Dialog open={dialog === "unpublish"} onOpenChange={(open) => !open && setDialog(null)}>
|
|
483
|
-
<DialogContent>
|
|
484
|
-
<DialogHeader>
|
|
485
|
-
<DialogTitle>Take this {typeLabel} off the live site?</DialogTitle>
|
|
486
|
-
<DialogDescription>
|
|
487
|
-
Visitors will get a 404 at{" "}
|
|
488
|
-
<span className="font-mono text-xs">{publicPath}</span>. Your content and any
|
|
489
|
-
unpublished edits are kept — you can publish it again at any time. Check your
|
|
490
|
-
navigation menus for links pointing here.
|
|
491
|
-
</DialogDescription>
|
|
492
|
-
</DialogHeader>
|
|
493
|
-
|
|
494
|
-
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
495
|
-
|
|
496
|
-
<DialogFooter className="mt-2 gap-2">
|
|
497
|
-
<Button variant="outline" onClick={() => setDialog(null)} disabled={isBusy}>
|
|
498
|
-
Cancel
|
|
499
|
-
</Button>
|
|
500
|
-
<Button
|
|
501
|
-
variant="destructive"
|
|
502
|
-
onClick={() => void run({ action: "unpublish" })}
|
|
503
|
-
disabled={isBusy}
|
|
504
|
-
>
|
|
505
|
-
{isBusy && busySpinner}
|
|
506
|
-
Unpublish
|
|
507
|
-
</Button>
|
|
508
|
-
</DialogFooter>
|
|
509
|
-
</DialogContent>
|
|
510
|
-
</Dialog>
|
|
511
|
-
|
|
512
|
-
{/* ---- Archive ---- */}
|
|
513
|
-
<Dialog open={dialog === "archive"} onOpenChange={(open) => !open && setDialog(null)}>
|
|
514
|
-
<DialogContent>
|
|
515
|
-
<DialogHeader>
|
|
516
|
-
<DialogTitle>Archive this {typeLabel}?</DialogTitle>
|
|
517
|
-
<DialogDescription>
|
|
518
|
-
It comes off the live site and drops out of the default {typeLabel} list. Nothing is
|
|
519
|
-
deleted, and you can publish it again later.
|
|
520
|
-
</DialogDescription>
|
|
521
|
-
</DialogHeader>
|
|
522
|
-
|
|
523
|
-
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
524
|
-
|
|
525
|
-
<DialogFooter className="mt-2 gap-2">
|
|
526
|
-
<Button variant="outline" onClick={() => setDialog(null)} disabled={isBusy}>
|
|
527
|
-
Cancel
|
|
528
|
-
</Button>
|
|
529
|
-
<Button
|
|
530
|
-
variant="destructive"
|
|
531
|
-
onClick={() => void run({ action: "archive" })}
|
|
532
|
-
disabled={isBusy}
|
|
533
|
-
>
|
|
534
|
-
{isBusy && busySpinner}
|
|
535
|
-
Archive
|
|
536
|
-
</Button>
|
|
537
|
-
</DialogFooter>
|
|
538
|
-
</DialogContent>
|
|
539
|
-
</Dialog>
|
|
540
|
-
</div>
|
|
541
|
-
);
|
|
542
|
-
}
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Top-bar visibility control for pages, posts and products.
|
|
5
|
+
*
|
|
6
|
+
* This is the only place in the CMS that can change what the public sees. It is
|
|
7
|
+
* deliberately NOT part of the content form: editing settings autosaves into the
|
|
8
|
+
* Live Draft, while publishing writes straight to the row. Two decisions, two
|
|
9
|
+
* controls — see `app/actions/visibilityActions.ts`.
|
|
10
|
+
*
|
|
11
|
+
* The pill is read-only on purpose. A <select> implies every option is equivalent
|
|
12
|
+
* and instantly applied, which is exactly the behaviour this replaces; naming the
|
|
13
|
+
* transitions as verbs in a menu lets each one carry its own confirmation.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as React from "react";
|
|
17
|
+
import { useRouter } from "next/navigation";
|
|
18
|
+
import { toast } from "react-hot-toast";
|
|
19
|
+
import {
|
|
20
|
+
Button,
|
|
21
|
+
Checkbox,
|
|
22
|
+
Dialog,
|
|
23
|
+
DialogContent,
|
|
24
|
+
DialogDescription,
|
|
25
|
+
DialogFooter,
|
|
26
|
+
DialogHeader,
|
|
27
|
+
DialogTitle,
|
|
28
|
+
DropdownMenu,
|
|
29
|
+
DropdownMenuContent,
|
|
30
|
+
DropdownMenuItem,
|
|
31
|
+
DropdownMenuLabel,
|
|
32
|
+
DropdownMenuSeparator,
|
|
33
|
+
DropdownMenuTrigger,
|
|
34
|
+
Input,
|
|
35
|
+
Label,
|
|
36
|
+
} from "@nextblock-cms/ui";
|
|
37
|
+
import {
|
|
38
|
+
LIVE_STATUS,
|
|
39
|
+
resolveVisibilityState,
|
|
40
|
+
type PublishableType,
|
|
41
|
+
type VisibilityState,
|
|
42
|
+
} from "@nextblock-cms/utils";
|
|
43
|
+
import {
|
|
44
|
+
AlertTriangle,
|
|
45
|
+
CalendarClock,
|
|
46
|
+
ChevronDown,
|
|
47
|
+
Link2,
|
|
48
|
+
Loader2,
|
|
49
|
+
Archive,
|
|
50
|
+
EyeOff,
|
|
51
|
+
Globe,
|
|
52
|
+
} from "lucide-react";
|
|
53
|
+
import {
|
|
54
|
+
getSiblingVisibility,
|
|
55
|
+
setContentVisibility,
|
|
56
|
+
type SiblingVisibility,
|
|
57
|
+
type VisibilityIntent,
|
|
58
|
+
} from "../../actions/visibilityActions";
|
|
59
|
+
import { publishVisualEditingDraft, publishVisualEditingProductDraft } from "../../actions/visualEditingActions";
|
|
60
|
+
|
|
61
|
+
interface LanguageOption {
|
|
62
|
+
id: number;
|
|
63
|
+
name: string;
|
|
64
|
+
code: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface VisibilityControlProps {
|
|
68
|
+
type: PublishableType;
|
|
69
|
+
id: number | string;
|
|
70
|
+
/** Live row status — never the draft overlay. */
|
|
71
|
+
status: string;
|
|
72
|
+
/** Live row published_at; a future value means "scheduled". */
|
|
73
|
+
publishedAt: string | null;
|
|
74
|
+
/** Root-relative public URL, e.g. "/about" or "/article/hello". */
|
|
75
|
+
publicPath: string;
|
|
76
|
+
/** Display name of the language this row is written in. */
|
|
77
|
+
languageName?: string;
|
|
78
|
+
translationGroupId?: string | null;
|
|
79
|
+
languages?: LanguageOption[];
|
|
80
|
+
/** True when unpublished content edits exist in the Live Draft. */
|
|
81
|
+
hasDraft?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* A caution to show before this content goes public — e.g. a product whose payment
|
|
84
|
+
* provider isn't configured, which publishes fine but cannot be bought yet. Shown
|
|
85
|
+
* verbatim as an amber callout in the publish and schedule dialogs; it never blocks
|
|
86
|
+
* the action, so write it as a consequence the editor can weigh, not a refusal.
|
|
87
|
+
*/
|
|
88
|
+
publishWarning?: string | null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const STATE_LABEL: Record<VisibilityState, string> = {
|
|
92
|
+
draft: "Draft",
|
|
93
|
+
scheduled: "Scheduled",
|
|
94
|
+
published: "Published",
|
|
95
|
+
archived: "Archived",
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const STATE_STYLE: Record<VisibilityState, string> = {
|
|
99
|
+
draft:
|
|
100
|
+
"border-border bg-muted text-muted-foreground",
|
|
101
|
+
scheduled:
|
|
102
|
+
"border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
|
103
|
+
published:
|
|
104
|
+
"border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
|
|
105
|
+
archived:
|
|
106
|
+
"border-dashed border-border bg-transparent text-muted-foreground",
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const DOT_STYLE: Record<VisibilityState, string> = {
|
|
110
|
+
draft: "bg-muted-foreground/70",
|
|
111
|
+
scheduled: "bg-amber-500",
|
|
112
|
+
published: "bg-emerald-500",
|
|
113
|
+
archived: "bg-muted-foreground/50",
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** `datetime-local` wants "YYYY-MM-DDTHH:mm" in LOCAL time, not an ISO string. */
|
|
117
|
+
function toDateTimeLocal(value: string | null): string {
|
|
118
|
+
if (!value) return "";
|
|
119
|
+
const date = new Date(value);
|
|
120
|
+
if (Number.isNaN(date.getTime())) return "";
|
|
121
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
122
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(
|
|
123
|
+
date.getHours()
|
|
124
|
+
)}:${pad(date.getMinutes())}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function defaultScheduleValue(): string {
|
|
128
|
+
const soon = new Date();
|
|
129
|
+
soon.setDate(soon.getDate() + 1);
|
|
130
|
+
soon.setMinutes(0, 0, 0);
|
|
131
|
+
return toDateTimeLocal(soon.toISOString());
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export default function VisibilityControl({
|
|
135
|
+
type,
|
|
136
|
+
id,
|
|
137
|
+
status,
|
|
138
|
+
publishedAt,
|
|
139
|
+
publicPath,
|
|
140
|
+
languageName,
|
|
141
|
+
translationGroupId,
|
|
142
|
+
languages = [],
|
|
143
|
+
hasDraft = false,
|
|
144
|
+
publishWarning = null,
|
|
145
|
+
}: VisibilityControlProps) {
|
|
146
|
+
const router = useRouter();
|
|
147
|
+
const liveStatus = LIVE_STATUS[type];
|
|
148
|
+
const state = resolveVisibilityState({ status, publishedAt, liveStatus });
|
|
149
|
+
|
|
150
|
+
const [dialog, setDialog] = React.useState<null | "publish" | "schedule" | "unpublish" | "archive">(null);
|
|
151
|
+
const [isBusy, setIsBusy] = React.useState(false);
|
|
152
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
153
|
+
const [alsoPublishDraft, setAlsoPublishDraft] = React.useState(true);
|
|
154
|
+
const [scheduleValue, setScheduleValue] = React.useState(() => toDateTimeLocal(publishedAt) || defaultScheduleValue());
|
|
155
|
+
const [siblings, setSiblings] = React.useState<SiblingVisibility[] | null>(null);
|
|
156
|
+
|
|
157
|
+
// The schedule date is formatted in the viewer's timezone, which the server
|
|
158
|
+
// doesn't know — render it only after mount so the two never disagree.
|
|
159
|
+
const [mounted, setMounted] = React.useState(false);
|
|
160
|
+
React.useEffect(() => setMounted(true), []);
|
|
161
|
+
|
|
162
|
+
const scheduleLabel = React.useMemo(() => {
|
|
163
|
+
if (!mounted || !publishedAt) return null;
|
|
164
|
+
const date = new Date(publishedAt);
|
|
165
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
166
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
167
|
+
month: "short",
|
|
168
|
+
day: "numeric",
|
|
169
|
+
hour: "numeric",
|
|
170
|
+
minute: "2-digit",
|
|
171
|
+
timeZoneName: "short",
|
|
172
|
+
}).format(date);
|
|
173
|
+
}, [mounted, publishedAt]);
|
|
174
|
+
|
|
175
|
+
const label = state === "scheduled" && scheduleLabel ? `Goes live ${scheduleLabel}` : STATE_LABEL[state];
|
|
176
|
+
|
|
177
|
+
const languageNameById = React.useMemo(
|
|
178
|
+
() => new Map(languages.map((language) => [language.id, `${language.name} (${language.code.toUpperCase()})`])),
|
|
179
|
+
[languages]
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const openPublishDialog = React.useCallback(() => {
|
|
183
|
+
// A warning is not an error: seeding it into `error` renders it destructive-red and
|
|
184
|
+
// `run()` clears it on the next attempt. It belongs in the dialog body instead.
|
|
185
|
+
setError(null);
|
|
186
|
+
setAlsoPublishDraft(true);
|
|
187
|
+
setSiblings(null);
|
|
188
|
+
setDialog("publish");
|
|
189
|
+
|
|
190
|
+
if (translationGroupId) {
|
|
191
|
+
void getSiblingVisibility(type, translationGroupId, id).then(setSiblings);
|
|
192
|
+
}
|
|
193
|
+
}, [id, translationGroupId, type]);
|
|
194
|
+
|
|
195
|
+
const run = async (intent: VisibilityIntent, opts?: { publishDraft?: boolean }) => {
|
|
196
|
+
setIsBusy(true);
|
|
197
|
+
setError(null);
|
|
198
|
+
try {
|
|
199
|
+
const result = await setContentVisibility(type, id, intent);
|
|
200
|
+
if (result.error) {
|
|
201
|
+
setError(result.error);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Publishing the row and publishing pending edits are separate operations;
|
|
206
|
+
// the dialog offers to do both so a never-live page doesn't go public
|
|
207
|
+
// showing content the editor has already replaced.
|
|
208
|
+
if (opts?.publishDraft && hasDraft) {
|
|
209
|
+
const draftResult =
|
|
210
|
+
type === "product"
|
|
211
|
+
? await publishVisualEditingProductDraft(String(id))
|
|
212
|
+
: await publishVisualEditingDraft(type, Number(id));
|
|
213
|
+
|
|
214
|
+
if (draftResult && "error" in draftResult && draftResult.error) {
|
|
215
|
+
toast.error(`Visibility updated, but the edits didn't publish: ${draftResult.error}`);
|
|
216
|
+
setDialog(null);
|
|
217
|
+
router.refresh();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Partial success: the edits went live but the revision didn't record.
|
|
222
|
+
if (draftResult && "success" in draftResult && draftResult.warning) {
|
|
223
|
+
toast.error(draftResult.warning, { duration: 8000 });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
toast.success(
|
|
228
|
+
intent.action === "publish"
|
|
229
|
+
? "Published."
|
|
230
|
+
: intent.action === "schedule"
|
|
231
|
+
? "Scheduled."
|
|
232
|
+
: intent.action === "unpublish"
|
|
233
|
+
? "Unpublished."
|
|
234
|
+
: "Archived."
|
|
235
|
+
);
|
|
236
|
+
setDialog(null);
|
|
237
|
+
router.refresh();
|
|
238
|
+
} catch (err) {
|
|
239
|
+
setError(err instanceof Error ? err.message : "Something went wrong.");
|
|
240
|
+
} finally {
|
|
241
|
+
setIsBusy(false);
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const copyPublicUrl = async () => {
|
|
246
|
+
const url = typeof window === "undefined" ? publicPath : new URL(publicPath, window.location.origin).toString();
|
|
247
|
+
try {
|
|
248
|
+
await navigator.clipboard.writeText(url);
|
|
249
|
+
toast.success("Public URL copied.");
|
|
250
|
+
} catch {
|
|
251
|
+
toast.error("Couldn't copy the URL.");
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const typeLabel = type === "product" ? "product" : type;
|
|
256
|
+
const busySpinner = <Loader2 className="mr-2 h-4 w-4 animate-spin" />;
|
|
257
|
+
|
|
258
|
+
// Amber, not destructive: publishing is still allowed. Same treatment as the
|
|
259
|
+
// "publish your unpublished edits too" opt-in below, which is the other caution
|
|
260
|
+
// shown in this dialog.
|
|
261
|
+
const publishWarningCallout = (
|
|
262
|
+
<div className="flex items-start gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
|
|
263
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
|
264
|
+
<span>{publishWarning}</span>
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<div className="flex items-center gap-2">
|
|
270
|
+
<span
|
|
271
|
+
className={`inline-flex items-center gap-2 whitespace-nowrap rounded-full border px-2.5 py-1 text-xs font-semibold ${STATE_STYLE[state]}`}
|
|
272
|
+
title={
|
|
273
|
+
state === "published"
|
|
274
|
+
? `Live at ${publicPath}`
|
|
275
|
+
: state === "scheduled"
|
|
276
|
+
? "Not public yet — goes live automatically"
|
|
277
|
+
: "Not visible to the public"
|
|
278
|
+
}
|
|
279
|
+
>
|
|
280
|
+
<span className={`h-1.5 w-1.5 rounded-full ${DOT_STYLE[state]}`} aria-hidden="true" />
|
|
281
|
+
{label}
|
|
282
|
+
</span>
|
|
283
|
+
|
|
284
|
+
{hasDraft && (
|
|
285
|
+
<span className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-full border border-dashed border-amber-500/50 px-2.5 py-1 text-xs font-medium text-amber-700 dark:text-amber-300">
|
|
286
|
+
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" aria-hidden="true" />
|
|
287
|
+
Unpublished edits
|
|
288
|
+
</span>
|
|
289
|
+
)}
|
|
290
|
+
|
|
291
|
+
<div className="flex items-center">
|
|
292
|
+
{state !== "published" && (
|
|
293
|
+
<Button
|
|
294
|
+
size="sm"
|
|
295
|
+
className="rounded-r-none"
|
|
296
|
+
onClick={openPublishDialog}
|
|
297
|
+
disabled={isBusy}
|
|
298
|
+
>
|
|
299
|
+
<Globe className="mr-2 h-4 w-4" />
|
|
300
|
+
{state === "scheduled" ? "Publish now" : "Publish…"}
|
|
301
|
+
</Button>
|
|
302
|
+
)}
|
|
303
|
+
<DropdownMenu>
|
|
304
|
+
<DropdownMenuTrigger asChild>
|
|
305
|
+
<Button
|
|
306
|
+
size="sm"
|
|
307
|
+
variant={state === "published" ? "outline" : "default"}
|
|
308
|
+
className={state === "published" ? "" : "-ml-px rounded-l-none px-2"}
|
|
309
|
+
aria-label="Visibility options"
|
|
310
|
+
disabled={isBusy}
|
|
311
|
+
>
|
|
312
|
+
{state === "published" && <span className="mr-1">Visibility</span>}
|
|
313
|
+
<ChevronDown className="h-4 w-4" />
|
|
314
|
+
</Button>
|
|
315
|
+
</DropdownMenuTrigger>
|
|
316
|
+
<DropdownMenuContent align="end" className="w-64">
|
|
317
|
+
<DropdownMenuLabel>Visibility</DropdownMenuLabel>
|
|
318
|
+
<DropdownMenuSeparator />
|
|
319
|
+
<DropdownMenuItem
|
|
320
|
+
onSelect={() => {
|
|
321
|
+
setError(null);
|
|
322
|
+
setScheduleValue(toDateTimeLocal(publishedAt) || defaultScheduleValue());
|
|
323
|
+
setDialog("schedule");
|
|
324
|
+
}}
|
|
325
|
+
>
|
|
326
|
+
<CalendarClock className="mr-2 h-4 w-4" />
|
|
327
|
+
{state === "scheduled" ? "Reschedule…" : "Schedule…"}
|
|
328
|
+
</DropdownMenuItem>
|
|
329
|
+
<DropdownMenuItem onSelect={() => void copyPublicUrl()}>
|
|
330
|
+
<Link2 className="mr-2 h-4 w-4" />
|
|
331
|
+
Copy public URL
|
|
332
|
+
</DropdownMenuItem>
|
|
333
|
+
{(state === "published" || state === "scheduled") && (
|
|
334
|
+
<>
|
|
335
|
+
<DropdownMenuSeparator />
|
|
336
|
+
<DropdownMenuItem
|
|
337
|
+
className="text-destructive focus:text-destructive"
|
|
338
|
+
onSelect={() => {
|
|
339
|
+
setError(null);
|
|
340
|
+
setDialog("unpublish");
|
|
341
|
+
}}
|
|
342
|
+
>
|
|
343
|
+
<EyeOff className="mr-2 h-4 w-4" />
|
|
344
|
+
Unpublish
|
|
345
|
+
</DropdownMenuItem>
|
|
346
|
+
</>
|
|
347
|
+
)}
|
|
348
|
+
{state !== "archived" && (
|
|
349
|
+
<DropdownMenuItem
|
|
350
|
+
className="text-destructive focus:text-destructive"
|
|
351
|
+
onSelect={() => {
|
|
352
|
+
setError(null);
|
|
353
|
+
setDialog("archive");
|
|
354
|
+
}}
|
|
355
|
+
>
|
|
356
|
+
<Archive className="mr-2 h-4 w-4" />
|
|
357
|
+
Archive
|
|
358
|
+
</DropdownMenuItem>
|
|
359
|
+
)}
|
|
360
|
+
</DropdownMenuContent>
|
|
361
|
+
</DropdownMenu>
|
|
362
|
+
</div>
|
|
363
|
+
|
|
364
|
+
{/* ---- Publish ---- */}
|
|
365
|
+
<Dialog open={dialog === "publish"} onOpenChange={(open) => !open && setDialog(null)}>
|
|
366
|
+
<DialogContent>
|
|
367
|
+
<DialogHeader>
|
|
368
|
+
<DialogTitle>Make this {typeLabel} public?</DialogTitle>
|
|
369
|
+
<DialogDescription>
|
|
370
|
+
Anyone with the link will be able to see it, and search engines can index it.
|
|
371
|
+
</DialogDescription>
|
|
372
|
+
</DialogHeader>
|
|
373
|
+
|
|
374
|
+
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 rounded-md border bg-muted/40 p-3 text-sm">
|
|
375
|
+
<dt className="text-xs uppercase tracking-wide text-muted-foreground">URL</dt>
|
|
376
|
+
<dd className="font-mono text-xs break-all">{publicPath}</dd>
|
|
377
|
+
{languageName && (
|
|
378
|
+
<>
|
|
379
|
+
<dt className="text-xs uppercase tracking-wide text-muted-foreground">Language</dt>
|
|
380
|
+
<dd>{languageName}</dd>
|
|
381
|
+
</>
|
|
382
|
+
)}
|
|
383
|
+
<dt className="text-xs uppercase tracking-wide text-muted-foreground">Goes live</dt>
|
|
384
|
+
<dd>Immediately</dd>
|
|
385
|
+
</dl>
|
|
386
|
+
|
|
387
|
+
{hasDraft && (
|
|
388
|
+
<label className="flex items-start gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
|
|
389
|
+
<Checkbox
|
|
390
|
+
checked={alsoPublishDraft}
|
|
391
|
+
onCheckedChange={(checked) => setAlsoPublishDraft(checked === true)}
|
|
392
|
+
className="mt-0.5"
|
|
393
|
+
/>
|
|
394
|
+
<span>
|
|
395
|
+
Publish your unpublished edits at the same time — otherwise the older live version
|
|
396
|
+
is what goes public.
|
|
397
|
+
</span>
|
|
398
|
+
</label>
|
|
399
|
+
)}
|
|
400
|
+
|
|
401
|
+
{siblings && siblings.some((sibling) => sibling.state !== "published") && (
|
|
402
|
+
<div className="space-y-1.5 text-sm text-muted-foreground">
|
|
403
|
+
{siblings
|
|
404
|
+
.filter((sibling) => sibling.state !== "published")
|
|
405
|
+
.map((sibling) => (
|
|
406
|
+
<p key={String(sibling.id)}>
|
|
407
|
+
<span className="font-semibold text-amber-600 dark:text-amber-400">!</span>{" "}
|
|
408
|
+
The {languageNameById.get(sibling.languageId) ?? "other language"} version is
|
|
409
|
+
still {STATE_LABEL[sibling.state].toLowerCase()}.
|
|
410
|
+
</p>
|
|
411
|
+
))}
|
|
412
|
+
</div>
|
|
413
|
+
)}
|
|
414
|
+
|
|
415
|
+
{publishWarning && publishWarningCallout}
|
|
416
|
+
|
|
417
|
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
418
|
+
|
|
419
|
+
<DialogFooter className="mt-2 gap-2">
|
|
420
|
+
<Button variant="outline" onClick={() => setDialog(null)} disabled={isBusy}>
|
|
421
|
+
Cancel
|
|
422
|
+
</Button>
|
|
423
|
+
<Button
|
|
424
|
+
onClick={() => void run({ action: "publish" }, { publishDraft: alsoPublishDraft })}
|
|
425
|
+
disabled={isBusy}
|
|
426
|
+
>
|
|
427
|
+
{isBusy && busySpinner}
|
|
428
|
+
{publishWarning ? `Publish anyway` : `Publish ${typeLabel}`}
|
|
429
|
+
</Button>
|
|
430
|
+
</DialogFooter>
|
|
431
|
+
</DialogContent>
|
|
432
|
+
</Dialog>
|
|
433
|
+
|
|
434
|
+
{/* ---- Schedule ---- */}
|
|
435
|
+
<Dialog open={dialog === "schedule"} onOpenChange={(open) => !open && setDialog(null)}>
|
|
436
|
+
<DialogContent>
|
|
437
|
+
<DialogHeader>
|
|
438
|
+
<DialogTitle>Schedule this {typeLabel}</DialogTitle>
|
|
439
|
+
<DialogDescription>
|
|
440
|
+
It stays private until the moment you pick, then goes live on its own. Times are in
|
|
441
|
+
your own timezone.
|
|
442
|
+
</DialogDescription>
|
|
443
|
+
</DialogHeader>
|
|
444
|
+
|
|
445
|
+
<div className="space-y-2">
|
|
446
|
+
<Label htmlFor="visibility-schedule">Go live on</Label>
|
|
447
|
+
<Input
|
|
448
|
+
id="visibility-schedule"
|
|
449
|
+
type="datetime-local"
|
|
450
|
+
value={scheduleValue}
|
|
451
|
+
onChange={(event) => setScheduleValue(event.target.value)}
|
|
452
|
+
/>
|
|
453
|
+
</div>
|
|
454
|
+
|
|
455
|
+
{publishWarning && publishWarningCallout}
|
|
456
|
+
|
|
457
|
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
458
|
+
|
|
459
|
+
<DialogFooter className="mt-2 gap-2">
|
|
460
|
+
<Button variant="outline" onClick={() => setDialog(null)} disabled={isBusy}>
|
|
461
|
+
Cancel
|
|
462
|
+
</Button>
|
|
463
|
+
<Button
|
|
464
|
+
onClick={() => {
|
|
465
|
+
const local = new Date(scheduleValue);
|
|
466
|
+
if (Number.isNaN(local.getTime())) {
|
|
467
|
+
setError("That isn't a valid date and time.");
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
void run({ action: "schedule", publishedAt: local.toISOString() });
|
|
471
|
+
}}
|
|
472
|
+
disabled={isBusy || !scheduleValue}
|
|
473
|
+
>
|
|
474
|
+
{isBusy && busySpinner}
|
|
475
|
+
{publishWarning ? "Schedule anyway" : "Schedule"}
|
|
476
|
+
</Button>
|
|
477
|
+
</DialogFooter>
|
|
478
|
+
</DialogContent>
|
|
479
|
+
</Dialog>
|
|
480
|
+
|
|
481
|
+
{/* ---- Unpublish ---- */}
|
|
482
|
+
<Dialog open={dialog === "unpublish"} onOpenChange={(open) => !open && setDialog(null)}>
|
|
483
|
+
<DialogContent>
|
|
484
|
+
<DialogHeader>
|
|
485
|
+
<DialogTitle>Take this {typeLabel} off the live site?</DialogTitle>
|
|
486
|
+
<DialogDescription>
|
|
487
|
+
Visitors will get a 404 at{" "}
|
|
488
|
+
<span className="font-mono text-xs">{publicPath}</span>. Your content and any
|
|
489
|
+
unpublished edits are kept — you can publish it again at any time. Check your
|
|
490
|
+
navigation menus for links pointing here.
|
|
491
|
+
</DialogDescription>
|
|
492
|
+
</DialogHeader>
|
|
493
|
+
|
|
494
|
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
495
|
+
|
|
496
|
+
<DialogFooter className="mt-2 gap-2">
|
|
497
|
+
<Button variant="outline" onClick={() => setDialog(null)} disabled={isBusy}>
|
|
498
|
+
Cancel
|
|
499
|
+
</Button>
|
|
500
|
+
<Button
|
|
501
|
+
variant="destructive"
|
|
502
|
+
onClick={() => void run({ action: "unpublish" })}
|
|
503
|
+
disabled={isBusy}
|
|
504
|
+
>
|
|
505
|
+
{isBusy && busySpinner}
|
|
506
|
+
Unpublish
|
|
507
|
+
</Button>
|
|
508
|
+
</DialogFooter>
|
|
509
|
+
</DialogContent>
|
|
510
|
+
</Dialog>
|
|
511
|
+
|
|
512
|
+
{/* ---- Archive ---- */}
|
|
513
|
+
<Dialog open={dialog === "archive"} onOpenChange={(open) => !open && setDialog(null)}>
|
|
514
|
+
<DialogContent>
|
|
515
|
+
<DialogHeader>
|
|
516
|
+
<DialogTitle>Archive this {typeLabel}?</DialogTitle>
|
|
517
|
+
<DialogDescription>
|
|
518
|
+
It comes off the live site and drops out of the default {typeLabel} list. Nothing is
|
|
519
|
+
deleted, and you can publish it again later.
|
|
520
|
+
</DialogDescription>
|
|
521
|
+
</DialogHeader>
|
|
522
|
+
|
|
523
|
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
524
|
+
|
|
525
|
+
<DialogFooter className="mt-2 gap-2">
|
|
526
|
+
<Button variant="outline" onClick={() => setDialog(null)} disabled={isBusy}>
|
|
527
|
+
Cancel
|
|
528
|
+
</Button>
|
|
529
|
+
<Button
|
|
530
|
+
variant="destructive"
|
|
531
|
+
onClick={() => void run({ action: "archive" })}
|
|
532
|
+
disabled={isBusy}
|
|
533
|
+
>
|
|
534
|
+
{isBusy && busySpinner}
|
|
535
|
+
Archive
|
|
536
|
+
</Button>
|
|
537
|
+
</DialogFooter>
|
|
538
|
+
</DialogContent>
|
|
539
|
+
</Dialog>
|
|
540
|
+
</div>
|
|
541
|
+
);
|
|
542
|
+
}
|