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
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The findings half of the SEO panel: every issue the audit raised, grouped
|
|
5
|
+
* under the part of the page it belongs to, each with the one-click Cortex AI
|
|
6
|
+
* repair where the engine says a repair is possible.
|
|
7
|
+
*
|
|
8
|
+
* Grouping by category rather than listing findings in severity order is a
|
|
9
|
+
* deliberate trade. Severity order answers "what is worst?", which the score
|
|
10
|
+
* dial and the summary counts already answer; category order answers "what do I
|
|
11
|
+
* have to go and change?", which is the question someone with the document open
|
|
12
|
+
* in front of them is actually asking. Within a category the audit's own
|
|
13
|
+
* severity ordering is preserved, so the worst thing in each section still
|
|
14
|
+
* comes first.
|
|
15
|
+
*
|
|
16
|
+
* Severity is never carried by colour alone. Each finding gets an icon and a
|
|
17
|
+
* spelled-out word ("Error", "Warning", "Suggestion") next to the tint, so the
|
|
18
|
+
* list reads identically to someone who cannot distinguish red from amber, and
|
|
19
|
+
* survives being printed or screenshotted in greyscale.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as React from 'react';
|
|
23
|
+
|
|
24
|
+
import { Button } from '@nextblock-cms/ui';
|
|
25
|
+
import { cn } from '@nextblock-cms/utils';
|
|
26
|
+
import type { SeoIssue, SeoIssueCategory, SeoIssueSeverity } from '@nextblock-cms/utils/seo';
|
|
27
|
+
import { AlertTriangle, Info, Loader2, Sparkles, XCircle } from 'lucide-react';
|
|
28
|
+
|
|
29
|
+
import { canFixIssueWithCortexAi } from '../../lib/seo/fix-prompts';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Category order, chosen to walk the document the way an author does: fix the
|
|
33
|
+
* skeleton first (headings), then how it reads, then what it is about, then how
|
|
34
|
+
* much of it there is, then its assets, and finally the metadata that wraps it.
|
|
35
|
+
*/
|
|
36
|
+
const CATEGORY_ORDER: SeoIssueCategory[] = [
|
|
37
|
+
'headings',
|
|
38
|
+
'readability',
|
|
39
|
+
'keyword',
|
|
40
|
+
'content',
|
|
41
|
+
'images',
|
|
42
|
+
'meta',
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const CATEGORY_LABEL: Record<SeoIssueCategory, string> = {
|
|
46
|
+
content: 'Content',
|
|
47
|
+
headings: 'Headings',
|
|
48
|
+
images: 'Images',
|
|
49
|
+
keyword: 'Keyphrase',
|
|
50
|
+
meta: 'Meta',
|
|
51
|
+
readability: 'Readability',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Severity styling, following the same raw-palette-with-dark-variant pattern as
|
|
56
|
+
* `VisibilityControl`: red for something materially broken, amber for something
|
|
57
|
+
* measurably worse than it should be, and the neutral muted tokens for advice
|
|
58
|
+
* the author is free to ignore.
|
|
59
|
+
*/
|
|
60
|
+
const SEVERITY_STYLE: Record<SeoIssueSeverity, string> = {
|
|
61
|
+
error: 'border-red-500/40 bg-red-500/10',
|
|
62
|
+
info: 'border-border bg-muted/40',
|
|
63
|
+
warning: 'border-amber-500/40 bg-amber-500/10',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const SEVERITY_ICON_STYLE: Record<SeoIssueSeverity, string> = {
|
|
67
|
+
error: 'text-red-600 dark:text-red-400',
|
|
68
|
+
info: 'text-muted-foreground',
|
|
69
|
+
warning: 'text-amber-600 dark:text-amber-400',
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const SEVERITY_ICON: Record<SeoIssueSeverity, React.ComponentType<{ className?: string }>> = {
|
|
73
|
+
error: XCircle,
|
|
74
|
+
info: Info,
|
|
75
|
+
warning: AlertTriangle,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** "Suggestion" rather than "Info": it tells the reader what to do with it. */
|
|
79
|
+
const SEVERITY_LABEL: Record<SeoIssueSeverity, string> = {
|
|
80
|
+
error: 'Error',
|
|
81
|
+
info: 'Suggestion',
|
|
82
|
+
warning: 'Warning',
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export interface SeoIssueListProps {
|
|
86
|
+
/** The issue whose fix is in flight, if any; disables every other button. */
|
|
87
|
+
busyIssueId?: string | null;
|
|
88
|
+
className?: string;
|
|
89
|
+
/** Failure text from the last fix attempt, keyed by issue id. */
|
|
90
|
+
errors?: Record<string, string>;
|
|
91
|
+
issues: SeoIssue[];
|
|
92
|
+
/**
|
|
93
|
+
* Runs the Cortex AI repair. Leaving this undefined removes every Fix button
|
|
94
|
+
* from the list — which is how the panel expresses both "Cortex AI is not
|
|
95
|
+
* activated on this install" and "no editor instance was handed to us", two
|
|
96
|
+
* situations where offering the button would only produce a dead click.
|
|
97
|
+
*/
|
|
98
|
+
onFix?: (issue: SeoIssue) => void;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function SeoIssueList({ busyIssueId, className, errors, issues, onFix }: SeoIssueListProps) {
|
|
102
|
+
const grouped = React.useMemo(() => {
|
|
103
|
+
return CATEGORY_ORDER.map((category) => ({
|
|
104
|
+
category,
|
|
105
|
+
issues: issues.filter((issue) => issue.category === category),
|
|
106
|
+
})).filter((group) => group.issues.length > 0);
|
|
107
|
+
}, [issues]);
|
|
108
|
+
|
|
109
|
+
if (grouped.length === 0) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div className={cn('space-y-4', className)}>
|
|
115
|
+
{grouped.map((group) => (
|
|
116
|
+
<section key={group.category} className="space-y-2">
|
|
117
|
+
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
118
|
+
{CATEGORY_LABEL[group.category]}
|
|
119
|
+
</h4>
|
|
120
|
+
|
|
121
|
+
<ul className="space-y-2">
|
|
122
|
+
{group.issues.map((issue) => {
|
|
123
|
+
const Icon = SEVERITY_ICON[issue.severity];
|
|
124
|
+
const isBusy = busyIssueId === issue.id;
|
|
125
|
+
const error = errors?.[issue.id];
|
|
126
|
+
const showFix = onFix !== undefined && canFixIssueWithCortexAi(issue);
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<li
|
|
130
|
+
key={issue.id}
|
|
131
|
+
className={cn('rounded-md border p-2.5 text-sm', SEVERITY_STYLE[issue.severity])}
|
|
132
|
+
>
|
|
133
|
+
<div className="flex items-start gap-2">
|
|
134
|
+
<Icon
|
|
135
|
+
aria-hidden="true"
|
|
136
|
+
className={cn('mt-0.5 h-4 w-4 shrink-0', SEVERITY_ICON_STYLE[issue.severity])}
|
|
137
|
+
/>
|
|
138
|
+
<div className="min-w-0 flex-1 space-y-1">
|
|
139
|
+
<p className="font-medium leading-snug">
|
|
140
|
+
<span className="sr-only">{SEVERITY_LABEL[issue.severity]}: </span>
|
|
141
|
+
{issue.message}
|
|
142
|
+
</p>
|
|
143
|
+
{issue.detail && (
|
|
144
|
+
<p className="text-xs leading-relaxed text-muted-foreground">
|
|
145
|
+
{issue.detail}
|
|
146
|
+
</p>
|
|
147
|
+
)}
|
|
148
|
+
<p
|
|
149
|
+
aria-hidden="true"
|
|
150
|
+
className={cn(
|
|
151
|
+
'text-[11px] font-semibold uppercase tracking-wide',
|
|
152
|
+
SEVERITY_ICON_STYLE[issue.severity]
|
|
153
|
+
)}
|
|
154
|
+
>
|
|
155
|
+
{SEVERITY_LABEL[issue.severity]}
|
|
156
|
+
</p>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{showFix && (
|
|
161
|
+
<div className="mt-2 flex justify-end">
|
|
162
|
+
<Button
|
|
163
|
+
className="h-7 text-xs"
|
|
164
|
+
disabled={busyIssueId !== null && busyIssueId !== undefined}
|
|
165
|
+
onClick={() => onFix?.(issue)}
|
|
166
|
+
size="sm"
|
|
167
|
+
type="button"
|
|
168
|
+
variant="outline"
|
|
169
|
+
>
|
|
170
|
+
{isBusy ? (
|
|
171
|
+
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
172
|
+
) : (
|
|
173
|
+
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
|
|
174
|
+
)}
|
|
175
|
+
{isBusy ? 'Fixing…' : 'Fix with Cortex AI'}
|
|
176
|
+
</Button>
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
{error && (
|
|
181
|
+
<p className="mt-2 text-xs text-destructive" role="alert">
|
|
182
|
+
{error}
|
|
183
|
+
</p>
|
|
184
|
+
)}
|
|
185
|
+
</li>
|
|
186
|
+
);
|
|
187
|
+
})}
|
|
188
|
+
</ul>
|
|
189
|
+
</section>
|
|
190
|
+
))}
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export default SeoIssueList;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The 0-100 SEO score, drawn as a ring.
|
|
5
|
+
*
|
|
6
|
+
* Built from inline SVG rather than the shared `Progress` primitive because the
|
|
7
|
+
* primitive hard-codes `bg-primary` on its indicator, and the whole point of
|
|
8
|
+
* this control is that its colour carries the verdict: emerald when the page is
|
|
9
|
+
* in good shape, amber when it needs attention, red when it is failing. A bar
|
|
10
|
+
* that is always brand-purple would force the reader to parse the number before
|
|
11
|
+
* they learn anything, which defeats having a meter at all.
|
|
12
|
+
*
|
|
13
|
+
* Accessibility is the other reason for hand-drawing it. The ring is exposed as
|
|
14
|
+
* an ARIA `meter` with a real name, a real value and a spoken `aria-valuetext`
|
|
15
|
+
* that includes the band, so a screen reader announces "SEO score: 82 out of
|
|
16
|
+
* 100, good" instead of reading an unlabelled graphic. The band is also printed
|
|
17
|
+
* as text under the ring, so nothing here is communicated by colour alone.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import * as React from 'react';
|
|
21
|
+
|
|
22
|
+
import { cn } from '@nextblock-cms/utils';
|
|
23
|
+
import type { SeoAuditScope, SeoScoreBand } from '@nextblock-cms/utils/seo';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Band to colour, following the CMS convention set by `VisibilityControl`:
|
|
27
|
+
* emerald reads as fine, amber as a caution, red as a failure.
|
|
28
|
+
*
|
|
29
|
+
* `excellent` and `good` deliberately share emerald. Splitting them across two
|
|
30
|
+
* hues would introduce a fourth colour with no established meaning in this UI;
|
|
31
|
+
* the difference between the two bands is carried by the label instead, which
|
|
32
|
+
* is the part a reader can actually act on.
|
|
33
|
+
*/
|
|
34
|
+
const BAND_STROKE: Record<SeoScoreBand, string> = {
|
|
35
|
+
excellent: 'text-emerald-500',
|
|
36
|
+
fair: 'text-amber-500',
|
|
37
|
+
good: 'text-emerald-500',
|
|
38
|
+
poor: 'text-red-500',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const BAND_TEXT: Record<SeoScoreBand, string> = {
|
|
42
|
+
excellent: 'text-emerald-700 dark:text-emerald-400',
|
|
43
|
+
fair: 'text-amber-700 dark:text-amber-400',
|
|
44
|
+
good: 'text-emerald-700 dark:text-emerald-400',
|
|
45
|
+
poor: 'text-red-700 dark:text-red-400',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Reader-facing wording for each band; `poor` is phrased as work, not failure. */
|
|
49
|
+
const BAND_LABEL: Record<SeoScoreBand, string> = {
|
|
50
|
+
excellent: 'Excellent',
|
|
51
|
+
fair: 'Fair',
|
|
52
|
+
good: 'Good',
|
|
53
|
+
poor: 'Needs work',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What the number under the ring is a score *of*.
|
|
58
|
+
*
|
|
59
|
+
* This caption is the one line that tells a reader whether they are looking at a
|
|
60
|
+
* verdict on the whole page or on the paragraph in front of them, and confusing
|
|
61
|
+
* the two is the exact bug the page-level audit was introduced to end. It is
|
|
62
|
+
* therefore keyed off the scope rather than written once and left to drift.
|
|
63
|
+
*/
|
|
64
|
+
const SCOPE_CAPTION: Record<SeoAuditScope, string> = {
|
|
65
|
+
block: 'Weighted across the checks a single block can pass or fail.',
|
|
66
|
+
page: 'Weighted across every check that applies to this page.',
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export interface SeoScoreDialProps {
|
|
70
|
+
band: SeoScoreBand;
|
|
71
|
+
className?: string;
|
|
72
|
+
/** 0-100. Values outside that range are clamped rather than drawn wrong. */
|
|
73
|
+
score: number;
|
|
74
|
+
/** Defaults to `'page'`, matching the audit engine's own default scope. */
|
|
75
|
+
scope?: SeoAuditScope;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Geometry constants. The ring is drawn in a fixed 120x120 user-space viewBox
|
|
80
|
+
* and scaled by CSS, so the stroke stays circular at any rendered size and the
|
|
81
|
+
* dash maths below never has to know how big the element ended up on screen.
|
|
82
|
+
*/
|
|
83
|
+
const RADIUS = 52;
|
|
84
|
+
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
|
85
|
+
|
|
86
|
+
export function SeoScoreDial({ band, className, score, scope = 'page' }: SeoScoreDialProps) {
|
|
87
|
+
const clamped = Math.max(0, Math.min(100, Math.round(Number.isFinite(score) ? score : 0)));
|
|
88
|
+
// A stroke-dashoffset of the full circumference is an empty ring and an
|
|
89
|
+
// offset of zero is a full one, so the filled fraction is the complement.
|
|
90
|
+
const dashOffset = CIRCUMFERENCE * (1 - clamped / 100);
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div className={cn('flex items-center gap-4', className)}>
|
|
94
|
+
<div
|
|
95
|
+
aria-label="SEO score"
|
|
96
|
+
aria-valuemax={100}
|
|
97
|
+
aria-valuemin={0}
|
|
98
|
+
aria-valuenow={clamped}
|
|
99
|
+
aria-valuetext={`${clamped} out of 100, ${BAND_LABEL[band].toLowerCase()}`}
|
|
100
|
+
className="relative h-20 w-20 shrink-0"
|
|
101
|
+
role="meter"
|
|
102
|
+
>
|
|
103
|
+
<svg className="h-full w-full -rotate-90" viewBox="0 0 120 120">
|
|
104
|
+
<circle
|
|
105
|
+
className="text-muted"
|
|
106
|
+
cx="60"
|
|
107
|
+
cy="60"
|
|
108
|
+
fill="none"
|
|
109
|
+
r={RADIUS}
|
|
110
|
+
stroke="currentColor"
|
|
111
|
+
strokeWidth="10"
|
|
112
|
+
/>
|
|
113
|
+
<circle
|
|
114
|
+
className={cn('transition-[stroke-dashoffset] duration-500', BAND_STROKE[band])}
|
|
115
|
+
cx="60"
|
|
116
|
+
cy="60"
|
|
117
|
+
fill="none"
|
|
118
|
+
r={RADIUS}
|
|
119
|
+
stroke="currentColor"
|
|
120
|
+
strokeDasharray={CIRCUMFERENCE}
|
|
121
|
+
strokeDashoffset={dashOffset}
|
|
122
|
+
strokeLinecap="round"
|
|
123
|
+
strokeWidth="10"
|
|
124
|
+
/>
|
|
125
|
+
</svg>
|
|
126
|
+
{/* The number inside the ring duplicates `aria-valuetext`, so it is
|
|
127
|
+
hidden from assistive technology to avoid announcing it twice. */}
|
|
128
|
+
<span
|
|
129
|
+
aria-hidden="true"
|
|
130
|
+
className="absolute inset-0 flex items-center justify-center text-xl font-semibold tabular-nums"
|
|
131
|
+
>
|
|
132
|
+
{clamped}
|
|
133
|
+
</span>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div className="min-w-0">
|
|
137
|
+
<p className={cn('text-sm font-semibold', BAND_TEXT[band])}>{BAND_LABEL[band]}</p>
|
|
138
|
+
<p className="text-xs text-muted-foreground">{SCOPE_CAPTION[scope]}</p>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export default SeoScoreDial;
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from 'react';
|
|
4
|
+
import { ImageIcon, MessageCircle, Repeat2, Heart, ThumbsUp, Share2 } from 'lucide-react';
|
|
5
|
+
import { cn } from '@nextblock-cms/utils';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A read-only rehearsal of the share card a page will produce.
|
|
9
|
+
*
|
|
10
|
+
* The value here is not decoration. Meta title and description are the two fields an
|
|
11
|
+
* operator is most likely to get wrong in a way nothing else in the CMS will catch:
|
|
12
|
+
* they are invisible on the page itself, and the consequence — a headline chopped
|
|
13
|
+
* mid-word in someone's feed — only shows up after the link has already been shared.
|
|
14
|
+
* Rendering the truncation locally turns that into a visible, immediate edit.
|
|
15
|
+
*
|
|
16
|
+
* The three tabs are genuinely different, not one box repainted, because the platforms
|
|
17
|
+
* genuinely differ. Facebook shows a wide image above a grey caption bar with the domain
|
|
18
|
+
* in small caps and a hard title clamp. X's summary_large_image card has no caption bar
|
|
19
|
+
* and stamps the domain UNDER the text. LinkedIn keeps the image and gives the headline
|
|
20
|
+
* more room but drops the description entirely — which is exactly the surprise worth
|
|
21
|
+
* showing someone who has just spent five minutes wording a description. If all three
|
|
22
|
+
* looked the same, the component would be teaching a falsehood.
|
|
23
|
+
*
|
|
24
|
+
* The numbers below approximate observed rendering rather than a published spec — every
|
|
25
|
+
* platform reserves the right to reflow — so they are tuned to be slightly pessimistic.
|
|
26
|
+
* A preview that clips a little early nudges toward safer copy; one that clips late gives
|
|
27
|
+
* false confidence, which is the failure that actually costs something.
|
|
28
|
+
*/
|
|
29
|
+
interface SocialPreviewProps {
|
|
30
|
+
description: string;
|
|
31
|
+
imageUrl?: string | null;
|
|
32
|
+
siteName?: string | null;
|
|
33
|
+
title: string;
|
|
34
|
+
url: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type SocialPlatform = 'facebook' | 'linkedin' | 'x';
|
|
38
|
+
|
|
39
|
+
const PLATFORM_TABS: ReadonlyArray<{ id: SocialPlatform; label: string }> = [
|
|
40
|
+
{ id: 'facebook', label: 'Facebook' },
|
|
41
|
+
{ id: 'x', label: 'X' },
|
|
42
|
+
{ id: 'linkedin', label: 'LinkedIn' },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Per-platform clamps, in characters. `description: 0` means the platform does not render
|
|
47
|
+
* a description at all — a meaningfully different statement from "renders a short one",
|
|
48
|
+
* and drawn differently below.
|
|
49
|
+
*/
|
|
50
|
+
const PLATFORM_LIMITS: Record<SocialPlatform, { description: number; title: number }> = {
|
|
51
|
+
facebook: { description: 110, title: 65 },
|
|
52
|
+
linkedin: { description: 0, title: 90 },
|
|
53
|
+
x: { description: 125, title: 70 },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Clip to a character budget the way a feed does — at a word boundary, with an ellipsis —
|
|
58
|
+
* rather than mid-word, so the preview shows the shape of the real damage.
|
|
59
|
+
*/
|
|
60
|
+
function clampToLength(value: string, maxLength: number): string {
|
|
61
|
+
const normalized = value.replace(/\s+/g, ' ').trim();
|
|
62
|
+
if (maxLength <= 0 || normalized.length <= maxLength) {
|
|
63
|
+
return normalized;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const sliced = normalized.slice(0, maxLength);
|
|
67
|
+
const lastSpace = sliced.lastIndexOf(' ');
|
|
68
|
+
const kept = lastSpace > maxLength * 0.6 ? sliced.slice(0, lastSpace) : sliced;
|
|
69
|
+
return `${kept.trimEnd()}…`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The domain each platform stamps on the card. Falls back to the raw string when the URL
|
|
74
|
+
* is not parseable — while a slug is being typed, `url` is briefly not a valid URL.
|
|
75
|
+
*/
|
|
76
|
+
function toDisplayDomain(url: string): string {
|
|
77
|
+
try {
|
|
78
|
+
return new URL(url).hostname.replace(/^www\./, '');
|
|
79
|
+
} catch {
|
|
80
|
+
return url.replace(/^https?:\/\//i, '').split('/')[0] || url;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Remote share images render through a plain `<img>`, deliberately.
|
|
86
|
+
*
|
|
87
|
+
* `next/image` would require every host an operator might point a feature image at to be
|
|
88
|
+
* pre-declared in `next.config` `images.remotePatterns`, and an undeclared host does not
|
|
89
|
+
* degrade — it throws. The CMS already makes this call the same way: the external-URL
|
|
90
|
+
* branch of `ImageBlockEditor` drops to a plain `<img>` for precisely this reason. A
|
|
91
|
+
* preview thumbnail gains nothing from the optimizer in exchange for that fragility.
|
|
92
|
+
*/
|
|
93
|
+
function PreviewImage({
|
|
94
|
+
aspectClassName,
|
|
95
|
+
imageUrl,
|
|
96
|
+
title,
|
|
97
|
+
}: {
|
|
98
|
+
aspectClassName: string;
|
|
99
|
+
imageUrl?: string | null;
|
|
100
|
+
title: string;
|
|
101
|
+
}) {
|
|
102
|
+
if (!imageUrl) {
|
|
103
|
+
return (
|
|
104
|
+
<div
|
|
105
|
+
className={cn(
|
|
106
|
+
'flex w-full flex-col items-center justify-center gap-1 bg-muted text-muted-foreground',
|
|
107
|
+
aspectClassName
|
|
108
|
+
)}
|
|
109
|
+
>
|
|
110
|
+
<ImageIcon aria-hidden="true" className="h-6 w-6" />
|
|
111
|
+
<span className="text-[10px] font-medium uppercase tracking-wide">No share image</span>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
118
|
+
<img
|
|
119
|
+
alt={`Share preview for ${title}`}
|
|
120
|
+
className={cn('w-full bg-muted object-cover', aspectClassName)}
|
|
121
|
+
loading="lazy"
|
|
122
|
+
src={imageUrl}
|
|
123
|
+
/>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export default function SocialPreview({
|
|
128
|
+
description,
|
|
129
|
+
imageUrl,
|
|
130
|
+
siteName,
|
|
131
|
+
title,
|
|
132
|
+
url,
|
|
133
|
+
}: SocialPreviewProps) {
|
|
134
|
+
const [platform, setPlatform] = useState<SocialPlatform>('facebook');
|
|
135
|
+
|
|
136
|
+
const domain = useMemo(() => toDisplayDomain(url), [url]);
|
|
137
|
+
const limits = PLATFORM_LIMITS[platform];
|
|
138
|
+
const clampedTitle = clampToLength(title || 'Untitled', limits.title);
|
|
139
|
+
const clampedDescription = clampToLength(description, limits.description);
|
|
140
|
+
// LinkedIn labels the card with the publisher name when one is known and quietly falls
|
|
141
|
+
// back to the bare domain when it is not; Facebook and X only ever show the domain.
|
|
142
|
+
const publisher = (siteName ?? '').trim() || domain;
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<div className="space-y-2">
|
|
146
|
+
{/* Hand-rolled tabs: this design system ships no Tabs primitive, and the house
|
|
147
|
+
pattern for a small switcher is buttons plus useState. The ARIA roles restore
|
|
148
|
+
the semantics a real tab component would have provided for free. */}
|
|
149
|
+
<div
|
|
150
|
+
aria-label="Social platform preview"
|
|
151
|
+
className="inline-flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5"
|
|
152
|
+
role="tablist"
|
|
153
|
+
>
|
|
154
|
+
{PLATFORM_TABS.map((tab) => (
|
|
155
|
+
<button
|
|
156
|
+
aria-selected={platform === tab.id}
|
|
157
|
+
className={cn(
|
|
158
|
+
'rounded px-2.5 py-1 text-[11px] font-medium transition-colors',
|
|
159
|
+
platform === tab.id
|
|
160
|
+
? 'bg-background text-foreground shadow-sm'
|
|
161
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
162
|
+
)}
|
|
163
|
+
key={tab.id}
|
|
164
|
+
onClick={() => setPlatform(tab.id)}
|
|
165
|
+
role="tab"
|
|
166
|
+
type="button"
|
|
167
|
+
>
|
|
168
|
+
{tab.label}
|
|
169
|
+
</button>
|
|
170
|
+
))}
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
{platform === 'facebook' && (
|
|
174
|
+
<div className="max-w-[500px] overflow-hidden rounded-lg border border-border bg-card">
|
|
175
|
+
<PreviewImage aspectClassName="aspect-[1.91/1]" imageUrl={imageUrl} title={title} />
|
|
176
|
+
{/* Facebook's grey caption bar: domain in small caps above a bold headline. */}
|
|
177
|
+
<div className="space-y-0.5 bg-muted/60 px-3 py-2">
|
|
178
|
+
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">{domain}</p>
|
|
179
|
+
<p className="text-sm font-semibold leading-snug text-foreground">{clampedTitle}</p>
|
|
180
|
+
{clampedDescription && (
|
|
181
|
+
<p className="text-xs leading-snug text-muted-foreground">{clampedDescription}</p>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
<div className="flex items-center gap-4 border-t border-border px-3 py-1.5 text-[11px] text-muted-foreground">
|
|
185
|
+
<span className="flex items-center gap-1">
|
|
186
|
+
<ThumbsUp aria-hidden="true" className="h-3 w-3" /> Like
|
|
187
|
+
</span>
|
|
188
|
+
<span className="flex items-center gap-1">
|
|
189
|
+
<MessageCircle aria-hidden="true" className="h-3 w-3" /> Comment
|
|
190
|
+
</span>
|
|
191
|
+
<span className="flex items-center gap-1">
|
|
192
|
+
<Share2 aria-hidden="true" className="h-3 w-3" /> Share
|
|
193
|
+
</span>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
|
|
198
|
+
{platform === 'x' && (
|
|
199
|
+
// X rounds the card hard, drops the caption bar, and puts the domain BELOW the
|
|
200
|
+
// text instead of above the headline.
|
|
201
|
+
<div className="max-w-[500px] overflow-hidden rounded-2xl border border-border bg-card">
|
|
202
|
+
<PreviewImage aspectClassName="aspect-[1.91/1]" imageUrl={imageUrl} title={title} />
|
|
203
|
+
<div className="space-y-0.5 px-3 py-2">
|
|
204
|
+
<p className="text-sm font-medium leading-snug text-foreground">{clampedTitle}</p>
|
|
205
|
+
{clampedDescription && (
|
|
206
|
+
<p className="text-xs leading-snug text-muted-foreground">{clampedDescription}</p>
|
|
207
|
+
)}
|
|
208
|
+
<p className="text-xs text-muted-foreground">{domain}</p>
|
|
209
|
+
</div>
|
|
210
|
+
<div className="flex items-center gap-5 border-t border-border px-3 py-1.5 text-[11px] text-muted-foreground">
|
|
211
|
+
<span className="flex items-center gap-1">
|
|
212
|
+
<MessageCircle aria-hidden="true" className="h-3 w-3" /> Reply
|
|
213
|
+
</span>
|
|
214
|
+
<span className="flex items-center gap-1">
|
|
215
|
+
<Repeat2 aria-hidden="true" className="h-3 w-3" /> Repost
|
|
216
|
+
</span>
|
|
217
|
+
<span className="flex items-center gap-1">
|
|
218
|
+
<Heart aria-hidden="true" className="h-3 w-3" /> Like
|
|
219
|
+
</span>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
)}
|
|
223
|
+
|
|
224
|
+
{platform === 'linkedin' && (
|
|
225
|
+
// LinkedIn is the outlier worth showing: square-ish chrome, a roomier headline,
|
|
226
|
+
// and NO description at all — the meta description simply never appears here.
|
|
227
|
+
<div className="max-w-[520px] overflow-hidden rounded-sm border border-border bg-card">
|
|
228
|
+
<PreviewImage aspectClassName="aspect-[1.91/1]" imageUrl={imageUrl} title={title} />
|
|
229
|
+
<div className="space-y-1 bg-muted/40 px-3 py-2.5">
|
|
230
|
+
<p className="text-sm font-semibold leading-snug text-foreground">{clampedTitle}</p>
|
|
231
|
+
<p className="text-[11px] text-muted-foreground">
|
|
232
|
+
{publisher}
|
|
233
|
+
{publisher === domain ? '' : ` • ${domain}`}
|
|
234
|
+
</p>
|
|
235
|
+
<p className="text-[10px] italic text-muted-foreground/70">
|
|
236
|
+
LinkedIn does not display the meta description on link previews.
|
|
237
|
+
</p>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
)}
|
|
241
|
+
</div>
|
|
242
|
+
);
|
|
243
|
+
}
|