@agent-native/core 0.81.3 → 0.83.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/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +12 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/CommandMenu.tsx +34 -12
- package/corpus/core/src/file-upload/builder.ts +199 -37
- package/corpus/core/src/file-upload/index.ts +2 -0
- package/corpus/core/src/file-upload/types.ts +38 -0
- package/corpus/templates/analytics/actions/hubspot-deals.ts +64 -4
- package/corpus/templates/clips/actions/create-recording.ts +44 -0
- package/corpus/templates/clips/actions/finalize-recording.ts +198 -88
- package/corpus/templates/clips/actions/lib/create-recording-schema.ts +12 -0
- package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +83 -29
- package/corpus/templates/clips/app/routes/record.tsx +12 -1
- package/corpus/templates/clips/server/lib/resumable-session.ts +39 -0
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +2 -0
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +186 -29
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +4 -0
- package/corpus/templates/clips/shared/recording-core.ts +5 -0
- package/corpus/templates/content/.env.local.example +6 -0
- package/corpus/templates/content/AGENTS.md +10 -1
- package/corpus/templates/content/actions/_builder-cms-read-client.ts +14 -1
- package/corpus/templates/content/actions/_builder-cms-source-adapter.ts +43 -0
- package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +79 -7
- package/corpus/templates/content/actions/_database-source-utils.ts +987 -22
- package/corpus/templates/content/actions/_database-utils.ts +41 -0
- package/corpus/templates/content/actions/_local-file-documents.ts +74 -1
- package/corpus/templates/content/actions/attach-content-database-source.ts +33 -8
- package/corpus/templates/content/actions/change-content-database-source-role.ts +14 -0
- package/corpus/templates/content/actions/disconnect-content-database-source.ts +3 -0
- package/corpus/templates/content/actions/execute-builder-source-execution.ts +71 -3
- package/corpus/templates/content/actions/list-content-databases.ts +41 -33
- package/corpus/templates/content/actions/process-builder-body-hydration.ts +47 -0
- package/corpus/templates/content/actions/update-document.ts +93 -0
- package/corpus/templates/content/app/blocks/SourceComponentBlock.tsx +277 -0
- package/corpus/templates/content/app/blocks/contentBlockRegistry.tsx +2 -0
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +105 -17
- package/corpus/templates/content/app/components/editor/VisualEditor.tsx +5 -1
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +92 -0
- package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +5 -3
- package/corpus/templates/content/app/components/editor/extensions/registryBlocks.ts +41 -0
- package/corpus/templates/content/app/components/editor/registrySlashItems.ts +4 -0
- package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +28 -33
- package/corpus/templates/content/app/components/sidebar/document-sidebar-sections.ts +47 -0
- package/corpus/templates/content/app/global.css +10 -3
- package/corpus/templates/content/app/hooks/use-content-database.ts +24 -0
- package/corpus/templates/content/app/hooks/use-documents.ts +37 -0
- package/corpus/templates/content/app/i18n/zh-TW.ts +24 -0
- package/corpus/templates/content/app/i18n-data.ts +212 -0
- package/corpus/templates/content/app/lib/content-command-search.ts +64 -0
- package/corpus/templates/content/app/root.tsx +236 -8
- package/corpus/templates/content/changelog/2026-06-29-builder-cms-sources-now-sync-article-bodies-through-content.md +6 -0
- package/corpus/templates/content/changelog/2026-06-30-builder-article-media-now-render-as-images-and-embeds-when-r.md +6 -0
- package/corpus/templates/content/changelog/2026-06-30-builder-source-components-now-render-as-preserved-preview-bl.md +6 -0
- package/corpus/templates/content/changelog/2026-06-30-cmd-k-search-now-opens-from-the-editor-and-finds-real-content.md +6 -0
- package/corpus/templates/content/changelog/2026-06-30-sidebar-favorites-now-keep-long-titles-tidy-and-stay-in-sync.md +6 -0
- package/corpus/templates/content/package.json +3 -1
- package/corpus/templates/content/scripts/check-native-deps.mjs +57 -0
- package/corpus/templates/content/scripts/dev-database.mjs +83 -0
- package/corpus/templates/content/scripts/seed-demo-user.mjs +107 -0
- package/corpus/templates/content/server/db/schema.ts +27 -0
- package/corpus/templates/content/server/plugins/db.ts +33 -0
- package/corpus/templates/content/shared/api.ts +45 -0
- package/corpus/templates/content/shared/builder-mdx.ts +905 -5
- package/corpus/templates/content/shared/nfm-registry.ts +2 -0
- package/corpus/templates/content/shared/source-component-block.ts +141 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +5 -1
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +229 -131
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +103 -6
- package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +6 -3
- package/corpus/templates/design/app/pages/DesignEditor.tsx +382 -72
- package/corpus/templates/design/changelog/2026-06-30-canvas-editing-now-keeps-undo-redo-and-cross-screen-layer-mo.md +6 -0
- package/dist/client/CommandMenu.d.ts +6 -2
- package/dist/client/CommandMenu.d.ts.map +1 -1
- package/dist/client/CommandMenu.js +25 -14
- package/dist/client/CommandMenu.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/file-upload/builder.d.ts.map +1 -1
- package/dist/file-upload/builder.js +137 -25
- package/dist/file-upload/builder.js.map +1 -1
- package/dist/file-upload/index.d.ts +1 -1
- package/dist/file-upload/index.d.ts.map +1 -1
- package/dist/file-upload/index.js.map +1 -1
- package/dist/file-upload/types.d.ts +26 -0
- package/dist/file-upload/types.d.ts.map +1 -1
- package/dist/file-upload/types.js.map +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +3 -3
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
parseDocumentHideFromSearch,
|
|
11
11
|
} from "../server/lib/documents.js";
|
|
12
12
|
import type { DocumentUpdateResponse } from "../shared/api.js";
|
|
13
|
+
import { BUILDER_CMS_BODY_CONTENT_KEY } from "./_builder-cms-source-adapter.js";
|
|
13
14
|
import { reconcileInlineDatabasesForDocument } from "./_content-database-lifecycle.js";
|
|
14
15
|
import { serializeDocumentSource } from "./_document-source.js";
|
|
15
16
|
import {
|
|
@@ -33,6 +34,60 @@ function canManageRole(role: string) {
|
|
|
33
34
|
return role === "owner" || role === "admin";
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
function builderBodyWithoutImageSourceComponentMarkers(
|
|
38
|
+
content: string | null | undefined,
|
|
39
|
+
) {
|
|
40
|
+
return (content ?? "")
|
|
41
|
+
.replace(/(?:^|\n)<SourceComponent\b[\s\S]*?\/>[ \t]*(?=\n|$)/g, (marker) =>
|
|
42
|
+
marker.includes('componentName="Image"') ? "\n" : marker,
|
|
43
|
+
)
|
|
44
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
45
|
+
.trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function builderBodyWithoutMarkdownImages(content: string | null | undefined) {
|
|
49
|
+
return (content ?? "")
|
|
50
|
+
.replace(
|
|
51
|
+
/(?:^|\n)!\[(?:\\.|[^\]\\])*\]\(\S+?(?:\s+"[^"]*")?\)[ \t]*(?=\n|$)/g,
|
|
52
|
+
"\n",
|
|
53
|
+
)
|
|
54
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
55
|
+
.trim();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizedBuilderBodyProse(content: string | null | undefined) {
|
|
59
|
+
return (content ?? "")
|
|
60
|
+
.normalize("NFKC")
|
|
61
|
+
.replace(/\u00a0/g, " ")
|
|
62
|
+
.replace(/\s+/g, " ")
|
|
63
|
+
.trim();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isStaleBuilderImageSourceComponentSave(args: {
|
|
67
|
+
incomingContent: string;
|
|
68
|
+
currentContent: string;
|
|
69
|
+
sourceContent: string | null | undefined;
|
|
70
|
+
}) {
|
|
71
|
+
const sourceContent = args.sourceContent ?? "";
|
|
72
|
+
const currentMatchesSource =
|
|
73
|
+
normalizedBuilderBodyProse(args.currentContent) ===
|
|
74
|
+
normalizedBuilderBodyProse(sourceContent);
|
|
75
|
+
if (
|
|
76
|
+
!args.incomingContent.includes('componentName="Image"') ||
|
|
77
|
+
!sourceContent.includes("![") ||
|
|
78
|
+
sourceContent.includes('componentName="Image"') ||
|
|
79
|
+
!currentMatchesSource
|
|
80
|
+
) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
return (
|
|
84
|
+
normalizedBuilderBodyProse(
|
|
85
|
+
builderBodyWithoutImageSourceComponentMarkers(args.incomingContent),
|
|
86
|
+
) ===
|
|
87
|
+
normalizedBuilderBodyProse(builderBodyWithoutMarkdownImages(sourceContent))
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
36
91
|
export default defineAction({
|
|
37
92
|
description:
|
|
38
93
|
"Update an existing document's title, content, icon, or favorite status.",
|
|
@@ -79,6 +134,44 @@ export default defineAction({
|
|
|
79
134
|
content = content.slice(h1Match[0].length).trimStart();
|
|
80
135
|
}
|
|
81
136
|
}
|
|
137
|
+
if (
|
|
138
|
+
content.includes('componentName="Image"') &&
|
|
139
|
+
existing.content.includes("![")
|
|
140
|
+
) {
|
|
141
|
+
const [builderBody] = await db
|
|
142
|
+
.select({
|
|
143
|
+
sourceValuesJson: schema.contentDatabaseSourceRows.sourceValuesJson,
|
|
144
|
+
})
|
|
145
|
+
.from(schema.contentDatabaseSourceRows)
|
|
146
|
+
.innerJoin(
|
|
147
|
+
schema.contentDatabaseSources,
|
|
148
|
+
eq(
|
|
149
|
+
schema.contentDatabaseSources.id,
|
|
150
|
+
schema.contentDatabaseSourceRows.sourceId,
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
.where(
|
|
154
|
+
and(
|
|
155
|
+
eq(schema.contentDatabaseSourceRows.documentId, id),
|
|
156
|
+
eq(schema.contentDatabaseSources.sourceType, "builder-cms"),
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
.limit(1);
|
|
160
|
+
const sourceValues = JSON.parse(
|
|
161
|
+
builderBody?.sourceValuesJson ?? "{}",
|
|
162
|
+
) as Record<string, unknown>;
|
|
163
|
+
const sourceContent = sourceValues[BUILDER_CMS_BODY_CONTENT_KEY];
|
|
164
|
+
if (
|
|
165
|
+
typeof sourceContent === "string" &&
|
|
166
|
+
isStaleBuilderImageSourceComponentSave({
|
|
167
|
+
incomingContent: content,
|
|
168
|
+
currentContent: existing.content,
|
|
169
|
+
sourceContent,
|
|
170
|
+
})
|
|
171
|
+
) {
|
|
172
|
+
content = existing.content;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
82
175
|
}
|
|
83
176
|
|
|
84
177
|
// Detect actual changes — a no-op call (e.g. the editor echoing back the
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import type { BlockReadProps, BlockSpec } from "@agent-native/core/blocks";
|
|
2
|
+
import { useT } from "@agent-native/core/client";
|
|
3
|
+
import {
|
|
4
|
+
sourceComponentBlockConfig,
|
|
5
|
+
type SourceComponentData,
|
|
6
|
+
} from "@shared/source-component-block";
|
|
7
|
+
import {
|
|
8
|
+
IconBox,
|
|
9
|
+
IconDatabase,
|
|
10
|
+
IconExternalLink,
|
|
11
|
+
IconPhoto,
|
|
12
|
+
IconLink,
|
|
13
|
+
IconTable,
|
|
14
|
+
} from "@tabler/icons-react";
|
|
15
|
+
|
|
16
|
+
function providerLabel(provider: string) {
|
|
17
|
+
if (provider === "builder") return "Builder";
|
|
18
|
+
return provider.trim() || "Source";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function safeHttpUrl(value: string | undefined) {
|
|
22
|
+
if (!value) return null;
|
|
23
|
+
try {
|
|
24
|
+
const url = new URL(value);
|
|
25
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
|
26
|
+
return url;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function youtubeEmbedUrl(value: string | undefined) {
|
|
33
|
+
const url = safeHttpUrl(value);
|
|
34
|
+
if (!url) return null;
|
|
35
|
+
if (url.hostname === "youtu.be") {
|
|
36
|
+
const id = url.pathname.replace(/^\/+/, "").split("/")[0];
|
|
37
|
+
return id ? `https://www.youtube.com/embed/${id}` : null;
|
|
38
|
+
}
|
|
39
|
+
if (
|
|
40
|
+
url.hostname === "youtube.com" ||
|
|
41
|
+
url.hostname === "www.youtube.com" ||
|
|
42
|
+
url.hostname === "m.youtube.com"
|
|
43
|
+
) {
|
|
44
|
+
const id = url.searchParams.get("v");
|
|
45
|
+
if (id) return `https://www.youtube.com/embed/${id}`;
|
|
46
|
+
if (url.pathname.startsWith("/embed/")) return url.toString();
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function SourceImagePreview({ url, alt }: { url: string; alt?: string }) {
|
|
52
|
+
return (
|
|
53
|
+
<figure className="mt-2 overflow-hidden rounded-md border bg-background">
|
|
54
|
+
<img
|
|
55
|
+
src={url}
|
|
56
|
+
alt={alt || ""}
|
|
57
|
+
loading="lazy"
|
|
58
|
+
className="max-h-[520px] w-full object-contain"
|
|
59
|
+
/>
|
|
60
|
+
{alt ? (
|
|
61
|
+
<figcaption className="border-t px-2.5 py-2 text-xs leading-5 text-muted-foreground">
|
|
62
|
+
{alt}
|
|
63
|
+
</figcaption>
|
|
64
|
+
) : null}
|
|
65
|
+
</figure>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function SourceEmbedPreview({ url }: { url: string }) {
|
|
70
|
+
const embedUrl = youtubeEmbedUrl(url);
|
|
71
|
+
if (embedUrl) {
|
|
72
|
+
return (
|
|
73
|
+
<div className="mt-2 overflow-hidden rounded-md border bg-background">
|
|
74
|
+
<iframe
|
|
75
|
+
data-plan-interactive
|
|
76
|
+
className="aspect-video w-full"
|
|
77
|
+
src={embedUrl}
|
|
78
|
+
title="Builder embedded video"
|
|
79
|
+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
|
80
|
+
allowFullScreen
|
|
81
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return (
|
|
86
|
+
<a
|
|
87
|
+
data-plan-interactive
|
|
88
|
+
className="mt-2 flex min-w-0 items-center gap-2 rounded-md border bg-background px-2.5 py-2 text-xs text-muted-foreground hover:text-foreground"
|
|
89
|
+
href={url}
|
|
90
|
+
target="_blank"
|
|
91
|
+
rel="noreferrer"
|
|
92
|
+
>
|
|
93
|
+
<IconLink className="size-4 shrink-0" />
|
|
94
|
+
<span className="truncate">{url}</span>
|
|
95
|
+
</a>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function SourceComponentPreview({ data }: { data: SourceComponentData }) {
|
|
100
|
+
const t = useT();
|
|
101
|
+
const preview = data.preview;
|
|
102
|
+
const previewUrl = preview?.url ?? data.previewUrl;
|
|
103
|
+
const safePreviewUrl = safeHttpUrl(previewUrl)?.toString();
|
|
104
|
+
const imageAlt =
|
|
105
|
+
preview?.fields?.find((field) => field.label.toLowerCase() === "alt")
|
|
106
|
+
?.value ?? preview?.summary;
|
|
107
|
+
if (data.componentName === "Image" && safePreviewUrl) {
|
|
108
|
+
return <SourceImagePreview url={safePreviewUrl} alt={imageAlt} />;
|
|
109
|
+
}
|
|
110
|
+
if (preview?.kind === "table" && preview.table) {
|
|
111
|
+
return (
|
|
112
|
+
<div className="mt-2 overflow-hidden rounded-md border bg-background text-xs">
|
|
113
|
+
<table className="w-full table-fixed border-collapse">
|
|
114
|
+
<thead className="bg-muted/60 text-muted-foreground">
|
|
115
|
+
<tr>
|
|
116
|
+
{preview.table.columns.map((column) => (
|
|
117
|
+
<th
|
|
118
|
+
key={column.id}
|
|
119
|
+
className="border-b px-2 py-1.5 text-left font-medium"
|
|
120
|
+
>
|
|
121
|
+
<span className="block truncate">{column.label}</span>
|
|
122
|
+
</th>
|
|
123
|
+
))}
|
|
124
|
+
</tr>
|
|
125
|
+
</thead>
|
|
126
|
+
<tbody>
|
|
127
|
+
{preview.table.rows.length ? (
|
|
128
|
+
preview.table.rows.map((row, index) => (
|
|
129
|
+
<tr key={index} className="border-b last:border-b-0">
|
|
130
|
+
{preview.table?.columns.map((column) => (
|
|
131
|
+
<td key={column.id} className="px-2 py-1.5">
|
|
132
|
+
<span className="block truncate">
|
|
133
|
+
{row[column.id] ?? ""}
|
|
134
|
+
</span>
|
|
135
|
+
</td>
|
|
136
|
+
))}
|
|
137
|
+
</tr>
|
|
138
|
+
))
|
|
139
|
+
) : (
|
|
140
|
+
<tr>
|
|
141
|
+
<td
|
|
142
|
+
className="px-2 py-2 text-muted-foreground"
|
|
143
|
+
colSpan={Math.max(preview.table.columns.length, 1)}
|
|
144
|
+
>
|
|
145
|
+
{t("editor.sourceComponent.noPreviewRows")}
|
|
146
|
+
</td>
|
|
147
|
+
</tr>
|
|
148
|
+
)}
|
|
149
|
+
</tbody>
|
|
150
|
+
</table>
|
|
151
|
+
{preview.table.truncated ? (
|
|
152
|
+
<div className="border-t px-2 py-1.5 text-muted-foreground">
|
|
153
|
+
{t("editor.sourceComponent.previewTruncated")}
|
|
154
|
+
</div>
|
|
155
|
+
) : null}
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (preview?.kind === "embed" && preview.url) {
|
|
160
|
+
return <SourceEmbedPreview url={preview.url} />;
|
|
161
|
+
}
|
|
162
|
+
if (preview?.fields?.length) {
|
|
163
|
+
return (
|
|
164
|
+
<div className="mt-2 flex flex-wrap gap-1.5 text-xs text-muted-foreground">
|
|
165
|
+
{preview.fields.map((field) => (
|
|
166
|
+
<span
|
|
167
|
+
key={`${field.label}:${field.value}`}
|
|
168
|
+
className="rounded border bg-background px-1.5 py-0.5"
|
|
169
|
+
>
|
|
170
|
+
{field.label}: {field.value}
|
|
171
|
+
</span>
|
|
172
|
+
))}
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (data.previewKind === "table") {
|
|
177
|
+
return (
|
|
178
|
+
<div className="mt-2 flex flex-wrap items-center gap-2 rounded-md border bg-background px-2.5 py-2 text-xs text-muted-foreground">
|
|
179
|
+
<IconTable className="size-4 shrink-0" />
|
|
180
|
+
{(data.previewItems?.length ? data.previewItems : ["Table"]).map(
|
|
181
|
+
(item) => (
|
|
182
|
+
<span
|
|
183
|
+
key={item}
|
|
184
|
+
className="rounded border bg-muted/40 px-1.5 py-0.5"
|
|
185
|
+
>
|
|
186
|
+
{item}
|
|
187
|
+
</span>
|
|
188
|
+
),
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (data.previewKind === "embed" && data.previewUrl) {
|
|
194
|
+
return <SourceEmbedPreview url={data.previewUrl} />;
|
|
195
|
+
}
|
|
196
|
+
if (data.previewItems?.length) {
|
|
197
|
+
return (
|
|
198
|
+
<div className="mt-2 flex flex-wrap gap-1.5 text-xs text-muted-foreground">
|
|
199
|
+
{data.previewItems.map((item) => (
|
|
200
|
+
<span
|
|
201
|
+
key={item}
|
|
202
|
+
className="rounded border bg-background px-1.5 py-0.5"
|
|
203
|
+
>
|
|
204
|
+
{item}
|
|
205
|
+
</span>
|
|
206
|
+
))}
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function SourceComponentRead({
|
|
214
|
+
data,
|
|
215
|
+
blockId,
|
|
216
|
+
}: BlockReadProps<SourceComponentData>) {
|
|
217
|
+
const t = useT();
|
|
218
|
+
const label =
|
|
219
|
+
data.title ||
|
|
220
|
+
data.componentName ||
|
|
221
|
+
t("editor.sourceComponent.defaultTitle");
|
|
222
|
+
const status = data.preview?.status ?? data.previewStatus ?? "unavailable";
|
|
223
|
+
const statusLabel =
|
|
224
|
+
status === "available"
|
|
225
|
+
? t("editor.sourceComponent.previewAvailable")
|
|
226
|
+
: status === "warning"
|
|
227
|
+
? t("editor.sourceComponent.needsAttention")
|
|
228
|
+
: t("editor.sourceComponent.previewUnavailable");
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<section
|
|
232
|
+
data-block-id={blockId}
|
|
233
|
+
data-plan-interactive
|
|
234
|
+
className="an-block rounded-md border border-dashed bg-muted/20 px-3 py-3 text-sm"
|
|
235
|
+
>
|
|
236
|
+
<div className="flex min-w-0 items-start justify-between gap-3">
|
|
237
|
+
<div className="flex min-w-0 items-start gap-2">
|
|
238
|
+
{data.componentName === "Image" ? (
|
|
239
|
+
<IconPhoto className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
|
240
|
+
) : (
|
|
241
|
+
<IconBox className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
|
242
|
+
)}
|
|
243
|
+
<div className="min-w-0">
|
|
244
|
+
<div className="truncate font-medium text-foreground">{label}</div>
|
|
245
|
+
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
|
246
|
+
<span className="inline-flex items-center gap-1">
|
|
247
|
+
<IconDatabase className="size-3.5" />
|
|
248
|
+
{providerLabel(data.provider)}
|
|
249
|
+
</span>
|
|
250
|
+
<span>{data.componentName}</span>
|
|
251
|
+
{data.sourceLabel ? <span>{data.sourceLabel}</span> : null}
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
<span className="shrink-0 rounded-full border bg-background px-2 py-0.5 text-[11px] text-muted-foreground">
|
|
256
|
+
{statusLabel}
|
|
257
|
+
</span>
|
|
258
|
+
</div>
|
|
259
|
+
{data.summary ? (
|
|
260
|
+
<div className="mt-2 text-sm leading-6 text-muted-foreground">
|
|
261
|
+
{data.summary}
|
|
262
|
+
</div>
|
|
263
|
+
) : null}
|
|
264
|
+
<SourceComponentPreview data={data} />
|
|
265
|
+
<div className="mt-2 flex min-w-0 items-center gap-1 truncate text-[11px] text-muted-foreground">
|
|
266
|
+
<IconExternalLink className="size-3.5 shrink-0" />
|
|
267
|
+
<span className="truncate">{data.rawRef}</span>
|
|
268
|
+
</div>
|
|
269
|
+
</section>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export const sourceComponentBlock: BlockSpec<SourceComponentData> = {
|
|
274
|
+
...sourceComponentBlockConfig,
|
|
275
|
+
Read: SourceComponentRead,
|
|
276
|
+
icon: IconBox,
|
|
277
|
+
};
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
ContentBlockMarkdownEditor,
|
|
27
27
|
} from "./ContentBlockMarkdown";
|
|
28
28
|
import { inlineDatabaseBlock } from "./InlineDatabaseBlock";
|
|
29
|
+
import { sourceComponentBlock } from "./SourceComponentBlock";
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
32
|
* Content's BROWSER block registry. Registers the same structured-block library
|
|
@@ -58,6 +59,7 @@ registerLibraryBlocks(contentBlockRegistry, {
|
|
|
58
59
|
for (const block of builderDocsBlocks) {
|
|
59
60
|
contentBlockRegistry.register(block);
|
|
60
61
|
}
|
|
62
|
+
contentBlockRegistry.register(sourceComponentBlock);
|
|
61
63
|
contentBlockRegistry.register(inlineDatabaseBlock);
|
|
62
64
|
|
|
63
65
|
type ContentBlockRenderContext = BlockRenderContext & {
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
useRef,
|
|
23
23
|
useState,
|
|
24
24
|
} from "react";
|
|
25
|
-
import type { ClipboardEvent } from "react";
|
|
25
|
+
import type { ClipboardEvent, MutableRefObject } from "react";
|
|
26
26
|
import { useNavigate } from "react-router";
|
|
27
27
|
import { toast } from "sonner";
|
|
28
28
|
|
|
@@ -34,6 +34,7 @@ import { Button } from "@/components/ui/button";
|
|
|
34
34
|
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
|
35
35
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
36
36
|
import { useComments } from "@/hooks/use-comments";
|
|
37
|
+
import { useProcessBuilderBodyHydration } from "@/hooks/use-content-database";
|
|
37
38
|
import {
|
|
38
39
|
useDocument,
|
|
39
40
|
useDocuments,
|
|
@@ -69,6 +70,54 @@ interface DocumentEditorProps {
|
|
|
69
70
|
documentId: string;
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
type FieldSaveWatermark = { title: string; updatedAt: string | null };
|
|
74
|
+
type ContentSaveWatermark = { content: string; updatedAt: string | null };
|
|
75
|
+
|
|
76
|
+
function adoptConfirmedSaveWatermarks({
|
|
77
|
+
saved,
|
|
78
|
+
savedAt,
|
|
79
|
+
title,
|
|
80
|
+
content,
|
|
81
|
+
updates,
|
|
82
|
+
lastSavedTitleRef,
|
|
83
|
+
lastSavedContentRef,
|
|
84
|
+
}: {
|
|
85
|
+
saved: Document | undefined;
|
|
86
|
+
savedAt: string;
|
|
87
|
+
title: string;
|
|
88
|
+
content: string;
|
|
89
|
+
updates: {
|
|
90
|
+
title?: string;
|
|
91
|
+
content?: string;
|
|
92
|
+
icon?: string | null;
|
|
93
|
+
};
|
|
94
|
+
lastSavedTitleRef: MutableRefObject<FieldSaveWatermark>;
|
|
95
|
+
lastSavedContentRef: MutableRefObject<ContentSaveWatermark>;
|
|
96
|
+
}) {
|
|
97
|
+
if (updates.title !== undefined) {
|
|
98
|
+
lastSavedTitleRef.current = { title, updatedAt: savedAt };
|
|
99
|
+
} else if (
|
|
100
|
+
(updates.content !== undefined || updates.icon !== undefined) &&
|
|
101
|
+
saved?.title === lastSavedTitleRef.current.title
|
|
102
|
+
) {
|
|
103
|
+
lastSavedTitleRef.current = {
|
|
104
|
+
...lastSavedTitleRef.current,
|
|
105
|
+
updatedAt: savedAt,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (updates.content !== undefined) {
|
|
109
|
+
lastSavedContentRef.current = { content, updatedAt: savedAt };
|
|
110
|
+
} else if (
|
|
111
|
+
(updates.title !== undefined || updates.icon !== undefined) &&
|
|
112
|
+
saved?.content === lastSavedContentRef.current.content
|
|
113
|
+
) {
|
|
114
|
+
lastSavedContentRef.current = {
|
|
115
|
+
...lastSavedContentRef.current,
|
|
116
|
+
updatedAt: savedAt,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
72
121
|
function DocumentEditorSkeleton() {
|
|
73
122
|
return (
|
|
74
123
|
<div className="flex min-h-0 flex-1 flex-col bg-background">
|
|
@@ -282,6 +331,9 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
|
|
|
282
331
|
const t = useT();
|
|
283
332
|
const updateDocument = useUpdateDocument();
|
|
284
333
|
const queryClient = useQueryClient();
|
|
334
|
+
const processBuilderBodies = useProcessBuilderBodyHydration(
|
|
335
|
+
document.databaseMembership?.databaseDocumentId ?? documentId,
|
|
336
|
+
);
|
|
285
337
|
const canEdit = document.canEdit ?? true;
|
|
286
338
|
const canEditRef = useRef(canEdit);
|
|
287
339
|
canEditRef.current = canEdit;
|
|
@@ -314,6 +366,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
|
|
|
314
366
|
string | null
|
|
315
367
|
>(document.updatedAt ?? null);
|
|
316
368
|
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
369
|
+
const promotedBuilderBodyRef = useRef<string | null>(null);
|
|
317
370
|
const pendingDocumentSaveRef = useRef<PendingDocumentSave | null>(null);
|
|
318
371
|
// Separate freshness watermarks for title and content so that a content save
|
|
319
372
|
// never suppresses adopting a newer external title and vice versa.
|
|
@@ -344,6 +397,26 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
|
|
|
344
397
|
},
|
|
345
398
|
[t],
|
|
346
399
|
);
|
|
400
|
+
|
|
401
|
+
useEffect(() => {
|
|
402
|
+
const membership = document.databaseMembership;
|
|
403
|
+
const hydration = membership?.bodyHydration;
|
|
404
|
+
if (
|
|
405
|
+
!membership?.sourceId ||
|
|
406
|
+
!hydration ||
|
|
407
|
+
(hydration.status !== "pending" && hydration.status !== "error")
|
|
408
|
+
) {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const promotionKey = `${membership.sourceId}:${documentId}:${hydration.status}:${hydration.version ?? ""}`;
|
|
412
|
+
if (promotedBuilderBodyRef.current === promotionKey) return;
|
|
413
|
+
promotedBuilderBodyRef.current = promotionKey;
|
|
414
|
+
processBuilderBodies.mutate({
|
|
415
|
+
sourceId: membership.sourceId,
|
|
416
|
+
documentId,
|
|
417
|
+
limit: 1,
|
|
418
|
+
});
|
|
419
|
+
}, [document.databaseMembership, documentId, processBuilderBodies.mutate]);
|
|
347
420
|
const titleFocusedRef = useRef(false);
|
|
348
421
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
349
422
|
const titleInputRef = useRef<HTMLTextAreaElement>(null);
|
|
@@ -686,12 +759,15 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
|
|
|
686
759
|
const saved = await persistDocumentUpdates(updates, options);
|
|
687
760
|
// Adopt the server updatedAt per saved field.
|
|
688
761
|
const savedAt = saved?.updatedAt ?? new Date().toISOString();
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
762
|
+
adoptConfirmedSaveWatermarks({
|
|
763
|
+
saved,
|
|
764
|
+
savedAt,
|
|
765
|
+
title,
|
|
766
|
+
content,
|
|
767
|
+
updates,
|
|
768
|
+
lastSavedTitleRef,
|
|
769
|
+
lastSavedContentRef,
|
|
770
|
+
});
|
|
695
771
|
|
|
696
772
|
// Push-on-save: when auto-sync is on, trigger a Notion push
|
|
697
773
|
// immediately after the save lands in SQL. This eliminates the
|
|
@@ -818,15 +894,15 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
|
|
|
818
894
|
if (Object.keys(updates).length > 0) {
|
|
819
895
|
const saved = await persistDocumentUpdates(updates);
|
|
820
896
|
const savedAt = saved?.updatedAt ?? new Date().toISOString();
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
}
|
|
897
|
+
adoptConfirmedSaveWatermarks({
|
|
898
|
+
saved,
|
|
899
|
+
savedAt,
|
|
900
|
+
title,
|
|
901
|
+
content,
|
|
902
|
+
updates,
|
|
903
|
+
lastSavedTitleRef,
|
|
904
|
+
lastSavedContentRef,
|
|
905
|
+
});
|
|
830
906
|
}
|
|
831
907
|
} finally {
|
|
832
908
|
// Acknowledge the flush even if nothing changed — the SQL row is
|
|
@@ -1113,7 +1189,19 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
|
|
|
1113
1189
|
}
|
|
1114
1190
|
onSelect={(emoji) => {
|
|
1115
1191
|
void (async () => {
|
|
1116
|
-
await persistDocumentUpdates({
|
|
1192
|
+
const saved = await persistDocumentUpdates({
|
|
1193
|
+
icon: emoji,
|
|
1194
|
+
});
|
|
1195
|
+
adoptConfirmedSaveWatermarks({
|
|
1196
|
+
saved,
|
|
1197
|
+
savedAt:
|
|
1198
|
+
saved?.updatedAt ?? new Date().toISOString(),
|
|
1199
|
+
title: localTitleRef.current,
|
|
1200
|
+
content: localContentRef.current,
|
|
1201
|
+
updates: { icon: emoji },
|
|
1202
|
+
lastSavedTitleRef,
|
|
1203
|
+
lastSavedContentRef,
|
|
1204
|
+
});
|
|
1117
1205
|
})().catch(handleBackgroundSaveError);
|
|
1118
1206
|
}}
|
|
1119
1207
|
/>
|
|
@@ -66,7 +66,10 @@ import {
|
|
|
66
66
|
type NotionPageLink,
|
|
67
67
|
} from "./extensions/NotionExtensions";
|
|
68
68
|
import { notionFidelityExtensions } from "./extensions/NotionFidelity";
|
|
69
|
-
import {
|
|
69
|
+
import {
|
|
70
|
+
LockedSourceComponentBlocks,
|
|
71
|
+
RegistryBlockNode,
|
|
72
|
+
} from "./extensions/registryBlocks";
|
|
70
73
|
import { VideoNode } from "./extensions/VideoNode";
|
|
71
74
|
import {
|
|
72
75
|
getImageFiles,
|
|
@@ -1307,6 +1310,7 @@ export function createVisualEditorExtensions({
|
|
|
1307
1310
|
// `VisualEditor` below. Mounted after the Notion nodes and before the
|
|
1308
1311
|
// Markdown extension so the NFM <-> doc round-trip recognizes the node.
|
|
1309
1312
|
RegistryBlockNode,
|
|
1313
|
+
LockedSourceComponentBlocks,
|
|
1310
1314
|
ContentReferenceNode.configure({
|
|
1311
1315
|
currentPath: localFilePath ?? null,
|
|
1312
1316
|
referenceDepth,
|