@autoview/cli 0.1.0
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/LICENSE +661 -0
- package/README.md +407 -0
- package/lib/AutoViewAgent.d.ts +109 -0
- package/lib/AutoViewAgent.js +123 -0
- package/lib/AutoViewAgent.js.map +1 -0
- package/lib/agent/emitMcpServer.d.ts +15 -0
- package/lib/agent/emitMcpServer.js +157 -0
- package/lib/agent/emitMcpServer.js.map +1 -0
- package/lib/agent/emitReport.d.ts +14 -0
- package/lib/agent/emitReport.js +85 -0
- package/lib/agent/emitReport.js.map +1 -0
- package/lib/agent/toolSurface.d.ts +130 -0
- package/lib/agent/toolSurface.js +342 -0
- package/lib/agent/toolSurface.js.map +1 -0
- package/lib/agent/verifyAgentTasks.d.ts +87 -0
- package/lib/agent/verifyAgentTasks.js +126 -0
- package/lib/agent/verifyAgentTasks.js.map +1 -0
- package/lib/cli/main.d.ts +2 -0
- package/lib/cli/main.js +295 -0
- package/lib/cli/main.js.map +1 -0
- package/lib/compiler/AutoViewInterfaceCompiler.d.ts +27 -0
- package/lib/compiler/AutoViewInterfaceCompiler.js +68 -0
- package/lib/compiler/AutoViewInterfaceCompiler.js.map +1 -0
- package/lib/constants/AutoViewFrontendTemplate.d.ts +1 -0
- package/lib/constants/AutoViewFrontendTemplate.js +46 -0
- package/lib/constants/AutoViewFrontendTemplate.js.map +1 -0
- package/lib/constants/AutoViewSystemPromptConstant.d.ts +5 -0
- package/lib/constants/AutoViewSystemPromptConstant.js +4 -0
- package/lib/constants/AutoViewSystemPromptConstant.js.map +1 -0
- package/lib/context/IAutoViewAgentContext.d.ts +60 -0
- package/lib/context/IAutoViewAgentContext.js +3 -0
- package/lib/context/IAutoViewAgentContext.js.map +1 -0
- package/lib/fromSwagger.d.ts +53 -0
- package/lib/fromSwagger.js +513 -0
- package/lib/fromSwagger.js.map +1 -0
- package/lib/generateDeterministic.d.ts +26 -0
- package/lib/generateDeterministic.js +75 -0
- package/lib/generateDeterministic.js.map +1 -0
- package/lib/index.d.ts +15 -0
- package/lib/index.js +41 -0
- package/lib/index.js.map +1 -0
- package/lib/orchestrate/orchestrateAutoView.d.ts +17 -0
- package/lib/orchestrate/orchestrateAutoView.js +491 -0
- package/lib/orchestrate/orchestrateAutoView.js.map +1 -0
- package/lib/orchestrate/orchestrateAutoViewProductPlan.d.ts +37 -0
- package/lib/orchestrate/orchestrateAutoViewProductPlan.js +109 -0
- package/lib/orchestrate/orchestrateAutoViewProductPlan.js.map +1 -0
- package/lib/orchestrate/orchestrateAutoViewRender.d.ts +133 -0
- package/lib/orchestrate/orchestrateAutoViewRender.js +943 -0
- package/lib/orchestrate/orchestrateAutoViewRender.js.map +1 -0
- package/lib/orchestrate/orchestrateAutoViewRenderDeterministic.d.ts +24 -0
- package/lib/orchestrate/orchestrateAutoViewRenderDeterministic.js +92 -0
- package/lib/orchestrate/orchestrateAutoViewRenderDeterministic.js.map +1 -0
- package/lib/orchestrate/orchestrateAutoViewReview.d.ts +48 -0
- package/lib/orchestrate/orchestrateAutoViewReview.js +328 -0
- package/lib/orchestrate/orchestrateAutoViewReview.js.map +1 -0
- package/lib/orchestrate/orchestrateAutoViewScaffold.d.ts +45 -0
- package/lib/orchestrate/orchestrateAutoViewScaffold.js +586 -0
- package/lib/orchestrate/orchestrateAutoViewScaffold.js.map +1 -0
- package/lib/orchestrate/orchestrateAutoViewSdkStudy.d.ts +26 -0
- package/lib/orchestrate/orchestrateAutoViewSdkStudy.js +85 -0
- package/lib/orchestrate/orchestrateAutoViewSdkStudy.js.map +1 -0
- package/lib/orchestrate/structures/IAutoViewProductPlan.d.ts +96 -0
- package/lib/orchestrate/structures/IAutoViewProductPlan.js +3 -0
- package/lib/orchestrate/structures/IAutoViewProductPlan.js.map +1 -0
- package/lib/orchestrate/structures/IAutoViewProductPlanApplication.d.ts +38 -0
- package/lib/orchestrate/structures/IAutoViewProductPlanApplication.js +3 -0
- package/lib/orchestrate/structures/IAutoViewProductPlanApplication.js.map +1 -0
- package/lib/orchestrate/structures/IAutoViewRenderApplication.d.ts +38 -0
- package/lib/orchestrate/structures/IAutoViewRenderApplication.js +3 -0
- package/lib/orchestrate/structures/IAutoViewRenderApplication.js.map +1 -0
- package/lib/orchestrate/structures/IAutoViewReviewApplication.d.ts +40 -0
- package/lib/orchestrate/structures/IAutoViewReviewApplication.js +3 -0
- package/lib/orchestrate/structures/IAutoViewReviewApplication.js.map +1 -0
- package/lib/orchestrate/structures/IAutoViewSdkMap.d.ts +63 -0
- package/lib/orchestrate/structures/IAutoViewSdkMap.js +3 -0
- package/lib/orchestrate/structures/IAutoViewSdkMap.js.map +1 -0
- package/lib/orchestrate/structures/IAutoViewSdkStudyApplication.d.ts +37 -0
- package/lib/orchestrate/structures/IAutoViewSdkStudyApplication.js +3 -0
- package/lib/orchestrate/structures/IAutoViewSdkStudyApplication.js.map +1 -0
- package/lib/orchestrate/utils/HistoryMessage.d.ts +10 -0
- package/lib/orchestrate/utils/HistoryMessage.js +25 -0
- package/lib/orchestrate/utils/HistoryMessage.js.map +1 -0
- package/lib/orchestrate/utils/auditFrontendRuntime.d.ts +53 -0
- package/lib/orchestrate/utils/auditFrontendRuntime.js +362 -0
- package/lib/orchestrate/utils/auditFrontendRuntime.js.map +1 -0
- package/lib/orchestrate/utils/buildDeterministicPlan.d.ts +4 -0
- package/lib/orchestrate/utils/buildDeterministicPlan.js +233 -0
- package/lib/orchestrate/utils/buildDeterministicPlan.js.map +1 -0
- package/lib/orchestrate/utils/buildDeterministicSdkMap.d.ts +22 -0
- package/lib/orchestrate/utils/buildDeterministicSdkMap.js +154 -0
- package/lib/orchestrate/utils/buildDeterministicSdkMap.js.map +1 -0
- package/lib/orchestrate/utils/cacheNodeModules.d.ts +31 -0
- package/lib/orchestrate/utils/cacheNodeModules.js +134 -0
- package/lib/orchestrate/utils/cacheNodeModules.js.map +1 -0
- package/lib/orchestrate/utils/describeEndpointPropsShape.d.ts +37 -0
- package/lib/orchestrate/utils/describeEndpointPropsShape.js +192 -0
- package/lib/orchestrate/utils/describeEndpointPropsShape.js.map +1 -0
- package/lib/orchestrate/utils/describeEndpointRequestBodyShape.d.ts +22 -0
- package/lib/orchestrate/utils/describeEndpointRequestBodyShape.js +29 -0
- package/lib/orchestrate/utils/describeEndpointRequestBodyShape.js.map +1 -0
- package/lib/orchestrate/utils/describeEndpointResponseShape.d.ts +19 -0
- package/lib/orchestrate/utils/describeEndpointResponseShape.js +30 -0
- package/lib/orchestrate/utils/describeEndpointResponseShape.js.map +1 -0
- package/lib/orchestrate/utils/executeCachedBatch.d.ts +22 -0
- package/lib/orchestrate/utils/executeCachedBatch.js +64 -0
- package/lib/orchestrate/utils/executeCachedBatch.js.map +1 -0
- package/lib/orchestrate/utils/loadShoppingFixture.d.ts +33 -0
- package/lib/orchestrate/utils/loadShoppingFixture.js +17 -0
- package/lib/orchestrate/utils/loadShoppingFixture.js.map +1 -0
- package/lib/orchestrate/utils/normalizeProductPlanPaths.d.ts +24 -0
- package/lib/orchestrate/utils/normalizeProductPlanPaths.js +77 -0
- package/lib/orchestrate/utils/normalizeProductPlanPaths.js.map +1 -0
- package/lib/orchestrate/utils/renderJsonSchema.d.ts +23 -0
- package/lib/orchestrate/utils/renderJsonSchema.js +122 -0
- package/lib/orchestrate/utils/renderJsonSchema.js.map +1 -0
- package/lib/orchestrate/utils/renderResourcePage.d.ts +36 -0
- package/lib/orchestrate/utils/renderResourcePage.js +1415 -0
- package/lib/orchestrate/utils/renderResourcePage.js.map +1 -0
- package/lib/orchestrate/utils/validateFrontendTypecheck.d.ts +109 -0
- package/lib/orchestrate/utils/validateFrontendTypecheck.js +274 -0
- package/lib/orchestrate/utils/validateFrontendTypecheck.js.map +1 -0
- package/lib/preview/renderPreview.d.ts +22 -0
- package/lib/preview/renderPreview.js +198 -0
- package/lib/preview/renderPreview.js.map +1 -0
- package/lib/typings/compiler.d.ts +39 -0
- package/lib/typings/compiler.js +3 -0
- package/lib/typings/compiler.js.map +1 -0
- package/lib/typings/events.d.ts +106 -0
- package/lib/typings/events.js +3 -0
- package/lib/typings/events.js.map +1 -0
- package/lib/typings/index.d.ts +10 -0
- package/lib/typings/index.js +27 -0
- package/lib/typings/index.js.map +1 -0
- package/lib/typings/misc.d.ts +78 -0
- package/lib/typings/misc.js +3 -0
- package/lib/typings/misc.js.map +1 -0
- package/lib/utils/ArrayUtil.d.ts +8 -0
- package/lib/utils/ArrayUtil.js +30 -0
- package/lib/utils/ArrayUtil.js.map +1 -0
- package/lib/utils/StringUtil.d.ts +11 -0
- package/lib/utils/StringUtil.js +28 -0
- package/lib/utils/StringUtil.js.map +1 -0
- package/lib/utils/classifyEndpoints.d.ts +62 -0
- package/lib/utils/classifyEndpoints.js +216 -0
- package/lib/utils/classifyEndpoints.js.map +1 -0
- package/lib/utils/endpointFilter.d.ts +26 -0
- package/lib/utils/endpointFilter.js +0 -0
- package/lib/utils/endpointFilter.js.map +1 -0
- package/lib/utils/extractFields.d.ts +85 -0
- package/lib/utils/extractFields.js +231 -0
- package/lib/utils/extractFields.js.map +1 -0
- package/lib/utils/index.d.ts +13 -0
- package/lib/utils/index.js +30 -0
- package/lib/utils/index.js.map +1 -0
- package/lib/utils/normalizeForNestia.d.ts +34 -0
- package/lib/utils/normalizeForNestia.js +133 -0
- package/lib/utils/normalizeForNestia.js.map +1 -0
- package/lib/utils/resourcePlan.d.ts +39 -0
- package/lib/utils/resourcePlan.js +95 -0
- package/lib/utils/resourcePlan.js.map +1 -0
- package/lib/utils/sliceDocument.d.ts +17 -0
- package/lib/utils/sliceDocument.js +114 -0
- package/lib/utils/sliceDocument.js.map +1 -0
- package/lib/utils/toEndpoints.d.ts +90 -0
- package/lib/utils/toEndpoints.js +227 -0
- package/lib/utils/toEndpoints.js.map +1 -0
- package/lib/verify/runWorkflows.d.ts +25 -0
- package/lib/verify/runWorkflows.js +366 -0
- package/lib/verify/runWorkflows.js.map +1 -0
- package/lib/verify/workflows.d.ts +53 -0
- package/lib/verify/workflows.js +107 -0
- package/lib/verify/workflows.js.map +1 -0
- package/package.json +82 -0
- package/prompts/AUTOVIEW_RENDER.md +398 -0
- package/prompts/AUTOVIEW_REVIEW.md +60 -0
- package/prompts/AUTOVIEW_SDK_STUDY.md +89 -0
- package/src/AutoViewAgent.ts +222 -0
- package/src/agent/emitMcpServer.integration.test.ts +168 -0
- package/src/agent/emitMcpServer.test.ts +51 -0
- package/src/agent/emitMcpServer.ts +178 -0
- package/src/agent/emitReport.ts +117 -0
- package/src/agent/toolSurface.test.ts +243 -0
- package/src/agent/toolSurface.ts +501 -0
- package/src/agent/verifyAgentTasks.test.ts +106 -0
- package/src/agent/verifyAgentTasks.ts +171 -0
- package/src/cli/main.ts +363 -0
- package/src/compiler/AutoViewInterfaceCompiler.ts +69 -0
- package/src/constants/AutoViewFrontendTemplate.ts +42 -0
- package/src/constants/AutoViewSystemPromptConstant.ts +6 -0
- package/src/context/IAutoViewAgentContext.ts +84 -0
- package/src/fromSwagger.test.ts +269 -0
- package/src/fromSwagger.ts +500 -0
- package/src/generateDeterministic.test.ts +39 -0
- package/src/generateDeterministic.ts +77 -0
- package/src/index.ts +30 -0
- package/src/orchestrate/orchestrateAutoView.ts +590 -0
- package/src/orchestrate/orchestrateAutoViewProductPlan.ts +121 -0
- package/src/orchestrate/orchestrateAutoViewRender.ts +1117 -0
- package/src/orchestrate/orchestrateAutoViewRenderDeterministic.ts +101 -0
- package/src/orchestrate/orchestrateAutoViewReview.ts +272 -0
- package/src/orchestrate/orchestrateAutoViewScaffold.ts +627 -0
- package/src/orchestrate/orchestrateAutoViewSdkStudy.ts +90 -0
- package/src/orchestrate/renderNavTs.test.ts +74 -0
- package/src/orchestrate/structures/IAutoViewProductPlan.ts +119 -0
- package/src/orchestrate/structures/IAutoViewProductPlanApplication.ts +41 -0
- package/src/orchestrate/structures/IAutoViewRenderApplication.ts +40 -0
- package/src/orchestrate/structures/IAutoViewReviewApplication.ts +42 -0
- package/src/orchestrate/structures/IAutoViewSdkMap.ts +72 -0
- package/src/orchestrate/structures/IAutoViewSdkStudyApplication.ts +40 -0
- package/src/orchestrate/utils/HistoryMessage.ts +41 -0
- package/src/orchestrate/utils/auditFrontendRuntime.test.ts +18 -0
- package/src/orchestrate/utils/auditFrontendRuntime.ts +454 -0
- package/src/orchestrate/utils/buildDeterministicPlan.test.ts +170 -0
- package/src/orchestrate/utils/buildDeterministicPlan.ts +289 -0
- package/src/orchestrate/utils/buildDeterministicSdkMap.test.ts +90 -0
- package/src/orchestrate/utils/buildDeterministicSdkMap.ts +169 -0
- package/src/orchestrate/utils/cacheNodeModules.ts +136 -0
- package/src/orchestrate/utils/describeEndpointPropsShape.test.ts +86 -0
- package/src/orchestrate/utils/describeEndpointPropsShape.ts +202 -0
- package/src/orchestrate/utils/describeEndpointRequestBodyShape.test.ts +87 -0
- package/src/orchestrate/utils/describeEndpointRequestBodyShape.ts +31 -0
- package/src/orchestrate/utils/describeEndpointResponseShape.test.ts +70 -0
- package/src/orchestrate/utils/describeEndpointResponseShape.ts +32 -0
- package/src/orchestrate/utils/executeCachedBatch.ts +59 -0
- package/src/orchestrate/utils/loadShoppingFixture.ts +52 -0
- package/src/orchestrate/utils/normalizeProductPlanPaths.ts +92 -0
- package/src/orchestrate/utils/renderJsonSchema.test.ts +162 -0
- package/src/orchestrate/utils/renderJsonSchema.ts +133 -0
- package/src/orchestrate/utils/renderResourcePage.test.ts +468 -0
- package/src/orchestrate/utils/renderResourcePage.ts +1624 -0
- package/src/orchestrate/utils/validateFrontendTypecheck.test.ts +32 -0
- package/src/orchestrate/utils/validateFrontendTypecheck.ts +335 -0
- package/src/preview/renderPreview.ts +273 -0
- package/src/typings/compiler.ts +47 -0
- package/src/typings/events.ts +155 -0
- package/src/typings/index.ts +10 -0
- package/src/typings/misc.ts +93 -0
- package/src/utils/ArrayUtil.ts +16 -0
- package/src/utils/StringUtil.ts +29 -0
- package/src/utils/classifyEndpoints.test.ts +86 -0
- package/src/utils/classifyEndpoints.ts +291 -0
- package/src/utils/endpointFilter.test.ts +50 -0
- package/src/utils/endpointFilter.ts +0 -0
- package/src/utils/extractFields.test.ts +82 -0
- package/src/utils/extractFields.ts +306 -0
- package/src/utils/index.ts +13 -0
- package/src/utils/normalizeForNestia.test.ts +93 -0
- package/src/utils/normalizeForNestia.ts +139 -0
- package/src/utils/resourcePlan.test.ts +104 -0
- package/src/utils/resourcePlan.ts +180 -0
- package/src/utils/sliceDocument.test.ts +85 -0
- package/src/utils/sliceDocument.ts +119 -0
- package/src/utils/toEndpoints.test.ts +251 -0
- package/src/utils/toEndpoints.ts +343 -0
- package/src/verify/runWorkflows.ts +403 -0
- package/src/verify/workflows.test.ts +117 -0
- package/src/verify/workflows.ts +154 -0
- package/template/CLAUDE.md +140 -0
- package/template/Dockerfile +31 -0
- package/template/PROMPT.md +80 -0
- package/template/SANDBOX.md +70 -0
- package/template/app/api/health/route.ts +10 -0
- package/template/app/globals.css +97 -0
- package/template/app/layout.tsx +30 -0
- package/template/app/page.tsx +19 -0
- package/template/components/AppShell.tsx +114 -0
- package/template/components/auto/CatalogGrid.tsx +159 -0
- package/template/components/auto/ConfirmButton.tsx +67 -0
- package/template/components/auto/EmbeddedCollection.tsx +144 -0
- package/template/components/auto/ResourceDashboard.tsx +104 -0
- package/template/components/auto/ResourceDetail.tsx +93 -0
- package/template/components/auto/ResourceForm.tsx +235 -0
- package/template/components/auto/ResourceIcon.tsx +88 -0
- package/template/components/auto/ResourceLanding.tsx +155 -0
- package/template/components/auto/ResourceTable.tsx +223 -0
- package/template/components/auto/formatValue.tsx +186 -0
- package/template/components/auto/types.ts +42 -0
- package/template/components/ui/badge.tsx +40 -0
- package/template/components/ui/button.tsx +57 -0
- package/template/components/ui/card.tsx +86 -0
- package/template/components/ui/dialog.tsx +119 -0
- package/template/components/ui/input.tsx +23 -0
- package/template/components/ui/label.tsx +24 -0
- package/template/components/ui/pagination.tsx +117 -0
- package/template/components/ui/select.tsx +92 -0
- package/template/components/ui/sheet.tsx +135 -0
- package/template/components/ui/skeleton.tsx +15 -0
- package/template/components/ui/table.tsx +120 -0
- package/template/components/ui/tabs.tsx +55 -0
- package/template/lib/utils.ts +35 -0
- package/template/next.config.mjs +52 -0
- package/template/package.json +46 -0
- package/template/postcss.config.js +6 -0
- package/template/scripts/start-shopping-backend.sh +56 -0
- package/template/tailwind.config.ts +96 -0
- package/template/tsconfig.json +29 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { Card, CardContent } from "@/components/ui/card";
|
|
8
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
9
|
+
|
|
10
|
+
import { formatCell } from "./formatValue";
|
|
11
|
+
import type { ColumnSpec } from "./types";
|
|
12
|
+
|
|
13
|
+
/** Which fields drive a catalog card, baked by the generator from the schema. */
|
|
14
|
+
export interface CatalogSpec {
|
|
15
|
+
/** Field holding the image URL. */
|
|
16
|
+
imageField: string;
|
|
17
|
+
/** True when {@link imageField} is a `string[]` — use its first element. */
|
|
18
|
+
imageIsArray: boolean;
|
|
19
|
+
/** Field holding the card's title. */
|
|
20
|
+
titleField: string;
|
|
21
|
+
/** A few secondary columns shown under the title. */
|
|
22
|
+
meta: ColumnSpec[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CatalogGridProps {
|
|
26
|
+
title: string;
|
|
27
|
+
description?: string;
|
|
28
|
+
spec: CatalogSpec;
|
|
29
|
+
rows: readonly unknown[];
|
|
30
|
+
loading: boolean;
|
|
31
|
+
error?: string | null;
|
|
32
|
+
onRetry?: () => void;
|
|
33
|
+
rowHref?: (row: Record<string, unknown>) => string | null;
|
|
34
|
+
emptyHint?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function imageUrl(row: Record<string, unknown>, spec: CatalogSpec): string | null {
|
|
38
|
+
const v = row[spec.imageField];
|
|
39
|
+
const raw = spec.imageIsArray ? (Array.isArray(v) ? v[0] : undefined) : v;
|
|
40
|
+
return typeof raw === "string" && raw.length > 0 ? raw : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Universal catalog screen — a grid of image cards, one per record. Chosen over
|
|
45
|
+
* the table when the row schema carries an image + a title (a product, a photo,
|
|
46
|
+
* a profile). Schema-driven: the image / title / meta fields are decided by the
|
|
47
|
+
* generator from the element type, so any image-bearing collection reads as a
|
|
48
|
+
* gallery instead of a dense table. Same loading / error / empty / data states
|
|
49
|
+
* as the table.
|
|
50
|
+
*/
|
|
51
|
+
export function CatalogGrid(props: CatalogGridProps) {
|
|
52
|
+
const { title, description, spec, loading, error, onRetry, rowHref, emptyHint } =
|
|
53
|
+
props;
|
|
54
|
+
const rows = Array.isArray(props.rows) ? props.rows : [];
|
|
55
|
+
|
|
56
|
+
const body = (() => {
|
|
57
|
+
if (error !== null && error !== undefined) {
|
|
58
|
+
return (
|
|
59
|
+
<Card className="border-destructive/40 bg-destructive/5 p-5">
|
|
60
|
+
<p className="text-sm text-destructive">{error}</p>
|
|
61
|
+
{onRetry ? (
|
|
62
|
+
<Button variant="outline" size="sm" className="mt-3" onClick={onRetry}>
|
|
63
|
+
Retry
|
|
64
|
+
</Button>
|
|
65
|
+
) : null}
|
|
66
|
+
</Card>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (loading) {
|
|
70
|
+
return (
|
|
71
|
+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
72
|
+
{Array.from({ length: 8 }).map((_, i) => (
|
|
73
|
+
<div key={i} className="space-y-2">
|
|
74
|
+
<Skeleton className="aspect-[4/3] w-full" />
|
|
75
|
+
<Skeleton className="h-4 w-2/3" />
|
|
76
|
+
</div>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
if (rows.length === 0) {
|
|
82
|
+
return (
|
|
83
|
+
<Card className="p-6">
|
|
84
|
+
<p className="text-sm text-muted-foreground">
|
|
85
|
+
{emptyHint ?? "The list came back empty."}
|
|
86
|
+
</p>
|
|
87
|
+
</Card>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return (
|
|
91
|
+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
92
|
+
{rows.map((raw, i) => {
|
|
93
|
+
const row = (raw ?? {}) as Record<string, unknown>;
|
|
94
|
+
const img = imageUrl(row, spec);
|
|
95
|
+
const href = rowHref ? rowHref(row) : null;
|
|
96
|
+
const card = (
|
|
97
|
+
<Card className="h-full overflow-hidden transition-colors hover:border-primary/50">
|
|
98
|
+
<div className="aspect-[4/3] bg-muted">
|
|
99
|
+
{img ? (
|
|
100
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
101
|
+
<img
|
|
102
|
+
src={img}
|
|
103
|
+
alt=""
|
|
104
|
+
loading="lazy"
|
|
105
|
+
className="h-full w-full object-cover"
|
|
106
|
+
/>
|
|
107
|
+
) : (
|
|
108
|
+
<div className="flex h-full items-center justify-center text-muted-foreground">
|
|
109
|
+
—
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
<CardContent className="p-3">
|
|
114
|
+
<p className="truncate text-sm font-medium">
|
|
115
|
+
{String(row[spec.titleField] ?? "—")}
|
|
116
|
+
</p>
|
|
117
|
+
{spec.meta.length > 0 ? (
|
|
118
|
+
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
|
119
|
+
{spec.meta.map((c) => (
|
|
120
|
+
<span key={c.name} className="inline-flex items-center">
|
|
121
|
+
{formatCell(row[c.name], c)}
|
|
122
|
+
</span>
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
) : null}
|
|
126
|
+
</CardContent>
|
|
127
|
+
</Card>
|
|
128
|
+
);
|
|
129
|
+
return href ? (
|
|
130
|
+
<Link key={i} href={href} className="block">
|
|
131
|
+
{card}
|
|
132
|
+
</Link>
|
|
133
|
+
) : (
|
|
134
|
+
<div key={i}>{card}</div>
|
|
135
|
+
);
|
|
136
|
+
})}
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
})();
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div className="px-5 py-6 md:px-8">
|
|
143
|
+
<div className="mb-5 flex items-end justify-between gap-4">
|
|
144
|
+
<div>
|
|
145
|
+
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
|
|
146
|
+
{description ? (
|
|
147
|
+
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
|
148
|
+
) : null}
|
|
149
|
+
</div>
|
|
150
|
+
{!loading && !error ? (
|
|
151
|
+
<span className="hidden text-xs text-muted-foreground sm:inline">
|
|
152
|
+
{rows.length} item{rows.length === 1 ? "" : "s"}
|
|
153
|
+
</span>
|
|
154
|
+
) : null}
|
|
155
|
+
</div>
|
|
156
|
+
{body}
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import {
|
|
7
|
+
Dialog,
|
|
8
|
+
DialogClose,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogDescription,
|
|
11
|
+
DialogFooter,
|
|
12
|
+
DialogHeader,
|
|
13
|
+
DialogTitle,
|
|
14
|
+
DialogTrigger,
|
|
15
|
+
} from "@/components/ui/dialog";
|
|
16
|
+
|
|
17
|
+
export interface ConfirmButtonProps {
|
|
18
|
+
label: string;
|
|
19
|
+
onConfirm: () => void;
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
title?: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A destructive button that asks for confirmation before firing. Used for
|
|
27
|
+
* DELETE actions so a single click cannot irreversibly destroy a record — the
|
|
28
|
+
* generated detail page wires `onConfirm` to the SDK delete call.
|
|
29
|
+
*/
|
|
30
|
+
export function ConfirmButton(props: ConfirmButtonProps) {
|
|
31
|
+
const { label, onConfirm, disabled, title, description } = props;
|
|
32
|
+
const [open, setOpen] = React.useState(false);
|
|
33
|
+
return (
|
|
34
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
35
|
+
<DialogTrigger asChild>
|
|
36
|
+
<Button variant="destructive" size="sm" disabled={disabled}>
|
|
37
|
+
{label}
|
|
38
|
+
</Button>
|
|
39
|
+
</DialogTrigger>
|
|
40
|
+
<DialogContent>
|
|
41
|
+
<DialogHeader>
|
|
42
|
+
<DialogTitle>{title ?? "Are you sure?"}</DialogTitle>
|
|
43
|
+
<DialogDescription>
|
|
44
|
+
{description ?? "This action cannot be undone."}
|
|
45
|
+
</DialogDescription>
|
|
46
|
+
</DialogHeader>
|
|
47
|
+
<DialogFooter>
|
|
48
|
+
<DialogClose asChild>
|
|
49
|
+
<Button variant="outline" size="sm">
|
|
50
|
+
Cancel
|
|
51
|
+
</Button>
|
|
52
|
+
</DialogClose>
|
|
53
|
+
<Button
|
|
54
|
+
variant="destructive"
|
|
55
|
+
size="sm"
|
|
56
|
+
onClick={() => {
|
|
57
|
+
setOpen(false);
|
|
58
|
+
onConfirm();
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
{label}
|
|
62
|
+
</Button>
|
|
63
|
+
</DialogFooter>
|
|
64
|
+
</DialogContent>
|
|
65
|
+
</Dialog>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
Card,
|
|
8
|
+
CardContent,
|
|
9
|
+
CardHeader,
|
|
10
|
+
CardTitle,
|
|
11
|
+
} from "@/components/ui/card";
|
|
12
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
13
|
+
import {
|
|
14
|
+
Table,
|
|
15
|
+
TableBody,
|
|
16
|
+
TableCell,
|
|
17
|
+
TableHead,
|
|
18
|
+
TableHeader,
|
|
19
|
+
TableRow,
|
|
20
|
+
} from "@/components/ui/table";
|
|
21
|
+
import { humanizeLabel } from "@/lib/utils";
|
|
22
|
+
|
|
23
|
+
import { formatCell } from "./formatValue";
|
|
24
|
+
import type { ColumnSpec } from "./types";
|
|
25
|
+
|
|
26
|
+
/** Rows shown inline; the full set lives on the child's own list screen. */
|
|
27
|
+
const PREVIEW_LIMIT = 5;
|
|
28
|
+
/** Columns shown inline — a detail embed stays compact, not the full table. */
|
|
29
|
+
const PREVIEW_COLUMNS = 5;
|
|
30
|
+
|
|
31
|
+
export interface EmbeddedCollectionProps {
|
|
32
|
+
title: string;
|
|
33
|
+
columns: ColumnSpec[];
|
|
34
|
+
/** Fetch + extract the child rows. Supplied by the generated detail page. */
|
|
35
|
+
load: () => Promise<readonly unknown[]>;
|
|
36
|
+
/** Link to the child's full list screen ("View all"). */
|
|
37
|
+
href?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A child collection rendered inline inside a parent's detail page — the master
|
|
42
|
+
* half of a master-detail view. Fetches its own rows, shows a compact preview
|
|
43
|
+
* table, and links to the full list screen. This is how a nested resource reads
|
|
44
|
+
* as part of its parent (an order's items, a folder's files) instead of a
|
|
45
|
+
* separate page you have to navigate to.
|
|
46
|
+
*/
|
|
47
|
+
export function EmbeddedCollection(props: EmbeddedCollectionProps) {
|
|
48
|
+
const { title, columns, load, href } = props;
|
|
49
|
+
const [rows, setRows] = React.useState<readonly unknown[]>([]);
|
|
50
|
+
const [loading, setLoading] = React.useState(true);
|
|
51
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
52
|
+
|
|
53
|
+
React.useEffect(() => {
|
|
54
|
+
let alive = true;
|
|
55
|
+
setLoading(true);
|
|
56
|
+
setError(null);
|
|
57
|
+
load()
|
|
58
|
+
.then((r) => {
|
|
59
|
+
if (alive) setRows(Array.isArray(r) ? r : []);
|
|
60
|
+
})
|
|
61
|
+
.catch((e) => {
|
|
62
|
+
if (alive) setError(e instanceof Error ? e.message : String(e));
|
|
63
|
+
})
|
|
64
|
+
.finally(() => {
|
|
65
|
+
if (alive) setLoading(false);
|
|
66
|
+
});
|
|
67
|
+
return () => {
|
|
68
|
+
alive = false;
|
|
69
|
+
};
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
const cols = columns.slice(0, PREVIEW_COLUMNS);
|
|
73
|
+
const preview = rows.slice(0, PREVIEW_LIMIT);
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<Card>
|
|
77
|
+
<CardHeader className="flex flex-row items-center justify-between gap-3 space-y-0 py-3">
|
|
78
|
+
<CardTitle className="text-sm font-semibold">
|
|
79
|
+
{title}
|
|
80
|
+
{!loading && !error ? (
|
|
81
|
+
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
|
82
|
+
{rows.length}
|
|
83
|
+
{rows.length >= PREVIEW_LIMIT ? "+" : ""}
|
|
84
|
+
</span>
|
|
85
|
+
) : null}
|
|
86
|
+
</CardTitle>
|
|
87
|
+
{href ? (
|
|
88
|
+
<Link
|
|
89
|
+
href={href}
|
|
90
|
+
className="shrink-0 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
|
91
|
+
>
|
|
92
|
+
View all →
|
|
93
|
+
</Link>
|
|
94
|
+
) : null}
|
|
95
|
+
</CardHeader>
|
|
96
|
+
<CardContent className="p-0">
|
|
97
|
+
{error ? (
|
|
98
|
+
<p className="px-5 py-4 text-sm text-destructive">{error}</p>
|
|
99
|
+
) : loading ? (
|
|
100
|
+
<div className="space-y-2 px-5 py-4">
|
|
101
|
+
{Array.from({ length: 3 }).map((_, i) => (
|
|
102
|
+
<Skeleton key={i} className="h-6 w-full" />
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
) : preview.length === 0 ? (
|
|
106
|
+
<p className="px-5 py-4 text-sm text-muted-foreground">
|
|
107
|
+
No {title.toLowerCase()} yet.
|
|
108
|
+
</p>
|
|
109
|
+
) : (
|
|
110
|
+
<div className="overflow-x-auto border-t">
|
|
111
|
+
<Table>
|
|
112
|
+
<TableHeader>
|
|
113
|
+
<TableRow className="hover:bg-transparent">
|
|
114
|
+
{cols.map((c) => (
|
|
115
|
+
<TableHead
|
|
116
|
+
key={c.name}
|
|
117
|
+
className="h-9 whitespace-nowrap text-xs uppercase tracking-wide text-muted-foreground"
|
|
118
|
+
>
|
|
119
|
+
{humanizeLabel(c.name)}
|
|
120
|
+
</TableHead>
|
|
121
|
+
))}
|
|
122
|
+
</TableRow>
|
|
123
|
+
</TableHeader>
|
|
124
|
+
<TableBody>
|
|
125
|
+
{preview.map((raw, i) => {
|
|
126
|
+
const row = (raw ?? {}) as Record<string, unknown>;
|
|
127
|
+
return (
|
|
128
|
+
<TableRow key={i}>
|
|
129
|
+
{cols.map((c) => (
|
|
130
|
+
<TableCell key={c.name} className="max-w-xs align-top text-sm">
|
|
131
|
+
{formatCell(row[c.name], c)}
|
|
132
|
+
</TableCell>
|
|
133
|
+
))}
|
|
134
|
+
</TableRow>
|
|
135
|
+
);
|
|
136
|
+
})}
|
|
137
|
+
</TableBody>
|
|
138
|
+
</Table>
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
141
|
+
</CardContent>
|
|
142
|
+
</Card>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
|
|
6
|
+
import { Card } from "@/components/ui/card";
|
|
7
|
+
import { ResourceIcon } from "@/components/auto/ResourceIcon";
|
|
8
|
+
import { ResourceLinks, type LandingLink } from "@/components/auto/ResourceLanding";
|
|
9
|
+
|
|
10
|
+
export interface DashboardStat {
|
|
11
|
+
href: string;
|
|
12
|
+
title: string;
|
|
13
|
+
/** Live record count for the resource; `null` while loading or on error. */
|
|
14
|
+
count: number | null;
|
|
15
|
+
/** Titles of the first few records, for an at-a-glance "recent" preview. */
|
|
16
|
+
recent?: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ResourceDashboardProps {
|
|
20
|
+
title: string;
|
|
21
|
+
subtitle?: string;
|
|
22
|
+
/** Top hub resources surfaced as count cards. Empty → just the link grid. */
|
|
23
|
+
stats: DashboardStat[];
|
|
24
|
+
links: LandingLink[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Home dashboard. A strip of live count cards for the key hub resources sits
|
|
29
|
+
* above the full grouped resource-link grid. Turns the bare link hub into an
|
|
30
|
+
* actual operator landing — "how much is in each resource" at a glance — while
|
|
31
|
+
* keeping navigation to every resource. Counts are fetched by the generated
|
|
32
|
+
* page (one list call per stat); this component only renders.
|
|
33
|
+
*/
|
|
34
|
+
export function ResourceDashboard(props: ResourceDashboardProps) {
|
|
35
|
+
const { title, subtitle, stats, links } = props;
|
|
36
|
+
// Largest count, so each card's bar reads as magnitude relative to the others.
|
|
37
|
+
const max = Math.max(1, ...stats.map((s) => s.count ?? 0));
|
|
38
|
+
return (
|
|
39
|
+
<div className="px-5 py-8 md:px-8">
|
|
40
|
+
<div className="mb-7">
|
|
41
|
+
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
|
42
|
+
{subtitle ? (
|
|
43
|
+
<p className="mt-1.5 text-sm text-muted-foreground">{subtitle}</p>
|
|
44
|
+
) : null}
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
{stats.length > 0 ? (
|
|
48
|
+
<div className="mb-9 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
|
49
|
+
{stats.map((stat) => (
|
|
50
|
+
<Link key={stat.href} href={stat.href} className="group">
|
|
51
|
+
<Card className="flex h-full flex-col p-4 transition-colors group-hover:border-primary/50 group-hover:bg-muted/30">
|
|
52
|
+
<div className="flex items-center justify-between gap-2">
|
|
53
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
54
|
+
<ResourceIcon
|
|
55
|
+
name={stat.href}
|
|
56
|
+
className="h-4 w-4 shrink-0 text-muted-foreground"
|
|
57
|
+
/>
|
|
58
|
+
<p className="truncate text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
59
|
+
{stat.title}
|
|
60
|
+
</p>
|
|
61
|
+
</div>
|
|
62
|
+
<span className="shrink-0 text-muted-foreground opacity-0 transition group-hover:opacity-100">
|
|
63
|
+
→
|
|
64
|
+
</span>
|
|
65
|
+
</div>
|
|
66
|
+
<p className="mt-2 text-3xl font-semibold tabular-nums">
|
|
67
|
+
{stat.count === null ? (
|
|
68
|
+
<span className="text-muted-foreground">—</span>
|
|
69
|
+
) : (
|
|
70
|
+
stat.count.toLocaleString()
|
|
71
|
+
)}
|
|
72
|
+
</p>
|
|
73
|
+
{stat.count !== null ? (
|
|
74
|
+
<div className="mt-2 h-1 w-full overflow-hidden rounded-full bg-muted">
|
|
75
|
+
<div
|
|
76
|
+
className="h-full rounded-full bg-primary/60"
|
|
77
|
+
style={{
|
|
78
|
+
width: `${Math.max(4, Math.round((stat.count / max) * 100))}%`,
|
|
79
|
+
}}
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
) : null}
|
|
83
|
+
{stat.recent && stat.recent.length > 0 ? (
|
|
84
|
+
<ul className="mt-3 space-y-1 border-t pt-2">
|
|
85
|
+
{stat.recent.slice(0, 2).map((r, i) => (
|
|
86
|
+
<li
|
|
87
|
+
key={i}
|
|
88
|
+
className="truncate text-xs text-muted-foreground"
|
|
89
|
+
>
|
|
90
|
+
{r}
|
|
91
|
+
</li>
|
|
92
|
+
))}
|
|
93
|
+
</ul>
|
|
94
|
+
) : null}
|
|
95
|
+
</Card>
|
|
96
|
+
</Link>
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
) : null}
|
|
100
|
+
|
|
101
|
+
<ResourceLinks links={links} />
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import {
|
|
7
|
+
Card,
|
|
8
|
+
CardContent,
|
|
9
|
+
CardDescription,
|
|
10
|
+
CardHeader,
|
|
11
|
+
CardTitle,
|
|
12
|
+
} from "@/components/ui/card";
|
|
13
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
14
|
+
import { humanizeLabel } from "@/lib/utils";
|
|
15
|
+
|
|
16
|
+
import { formatCell } from "./formatValue";
|
|
17
|
+
import type { ColumnSpec } from "./types";
|
|
18
|
+
|
|
19
|
+
export interface ResourceDetailProps {
|
|
20
|
+
title: string;
|
|
21
|
+
/** One row per field of the response type — the whole record. */
|
|
22
|
+
fields: ColumnSpec[];
|
|
23
|
+
/** The single record the detail endpoint returned. `unknown` so the page never casts. */
|
|
24
|
+
data: unknown;
|
|
25
|
+
loading: boolean;
|
|
26
|
+
error?: string | null;
|
|
27
|
+
onRetry?: () => void;
|
|
28
|
+
/** Buttons rendered next to the title (edit link, delete, …). */
|
|
29
|
+
actions?: React.ReactNode;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Universal detail screen. Renders a definition list — one row per field of the
|
|
34
|
+
* response type, formatted by kind. Schema-driven, so every property of the
|
|
35
|
+
* record is shown, never an LLM-chosen subset.
|
|
36
|
+
*/
|
|
37
|
+
export function ResourceDetail(props: ResourceDetailProps) {
|
|
38
|
+
const { title, fields, data, loading, error, onRetry, actions } = props;
|
|
39
|
+
|
|
40
|
+
const body = (() => {
|
|
41
|
+
if (error !== null && error !== undefined) {
|
|
42
|
+
return (
|
|
43
|
+
<Card className="border-destructive/40 bg-destructive/5">
|
|
44
|
+
<CardHeader>
|
|
45
|
+
<CardTitle className="text-base text-destructive">Couldn’t load</CardTitle>
|
|
46
|
+
<CardDescription className="break-words">{error}</CardDescription>
|
|
47
|
+
</CardHeader>
|
|
48
|
+
{onRetry ? (
|
|
49
|
+
<CardContent>
|
|
50
|
+
<Button variant="outline" size="sm" onClick={onRetry}>
|
|
51
|
+
Retry
|
|
52
|
+
</Button>
|
|
53
|
+
</CardContent>
|
|
54
|
+
) : null}
|
|
55
|
+
</Card>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (loading || data === null || data === undefined) {
|
|
59
|
+
return (
|
|
60
|
+
<div className="space-y-3">
|
|
61
|
+
{Array.from({ length: 6 }).map((_, i) => (
|
|
62
|
+
<Skeleton key={i} className="h-8 w-full" />
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
const record = data as Record<string, unknown>;
|
|
68
|
+
return (
|
|
69
|
+
<Card>
|
|
70
|
+
<CardContent className="divide-y p-0">
|
|
71
|
+
{fields.map((f) => (
|
|
72
|
+
<div key={f.name} className="grid grid-cols-3 gap-4 px-5 py-3">
|
|
73
|
+
<dt className="text-sm font-medium text-muted-foreground">{humanizeLabel(f.name)}</dt>
|
|
74
|
+
<dd className="col-span-2 break-words text-sm">
|
|
75
|
+
{formatCell(record[f.name], f)}
|
|
76
|
+
</dd>
|
|
77
|
+
</div>
|
|
78
|
+
))}
|
|
79
|
+
</CardContent>
|
|
80
|
+
</Card>
|
|
81
|
+
);
|
|
82
|
+
})();
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="px-5 py-6 md:px-8">
|
|
86
|
+
<div className="mb-5 flex max-w-3xl flex-wrap items-center justify-between gap-3">
|
|
87
|
+
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
|
|
88
|
+
<div className="flex flex-wrap items-center gap-2">{actions}</div>
|
|
89
|
+
</div>
|
|
90
|
+
<div className="max-w-3xl">{body}</div>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|