create-nextblock 0.14.4 → 0.14.5
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/package.json +1 -1
- package/templates/nextblock-template/app/[slug]/page.tsx +7 -2
- package/templates/nextblock-template/app/[slug]/page.utils.ts +8 -3
- package/templates/nextblock-template/app/actions/postActions.ts +3 -0
- package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -0
- package/templates/nextblock-template/app/actions/visualEditingActions.test.ts +83 -3
- package/templates/nextblock-template/app/actions/visualEditingActions.ts +34 -14
- package/templates/nextblock-template/app/api/ai/global-agent/route.ts +45 -0
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +362 -1
- package/templates/nextblock-template/app/api/view/route.ts +114 -0
- package/templates/nextblock-template/app/article/[slug]/page.utils.ts +1 -2
- package/templates/nextblock-template/app/cms/components/DraftStatusActions.tsx +10 -0
- package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -0
- package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +528 -0
- package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +33 -17
- package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +19 -7
- package/templates/nextblock-template/app/cms/pages/actions.ts +17 -10
- package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +7 -29
- package/templates/nextblock-template/app/cms/pages/page.tsx +6 -19
- package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +42 -18
- package/templates/nextblock-template/app/cms/posts/actions.ts +16 -27
- package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +3 -60
- package/templates/nextblock-template/app/cms/posts/page.tsx +6 -13
- package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +63 -9
- package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +66 -32
- package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -285
- package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -0
- package/templates/nextblock-template/app/cms/revisions/service.ts +549 -471
- package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -132
- package/templates/nextblock-template/app/lib/sitemap-utils.ts +6 -4
- package/templates/nextblock-template/app/lib/ucp/server.ts +4 -1
- package/templates/nextblock-template/app/page.tsx +6 -3
- package/templates/nextblock-template/app/product/[slug]/page.tsx +27 -3
- package/templates/nextblock-template/components/visual-editing/NextblockVisualEditing.tsx +4 -1
- package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +7 -2
- package/templates/nextblock-template/lib/cms-transfer/server.ts +13 -0
- package/templates/nextblock-template/lib/full-backup/server.ts +1 -0
- package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -0
- package/templates/nextblock-template/lib/search/server.ts +3 -0
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +10 -0
- package/templates/nextblock-template/lib/visual-editing/mutations.ts +4 -1
- package/templates/nextblock-template/lib/visual-editing/product-drafts.ts +46 -1
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
|
@@ -1,132 +1,304 @@
|
|
|
1
|
-
// apps/nextblock/app/cms/revisions/utils.ts
|
|
2
|
-
import { createClient } from "@nextblock-cms/db/server";
|
|
3
|
-
import type { Database, Json } from "@nextblock-cms/db";
|
|
4
|
-
|
|
5
|
-
type BlockRow = Database['public']['Tables']['blocks']['Row'];
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
export
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
1
|
+
// apps/nextblock/app/cms/revisions/utils.ts
|
|
2
|
+
import { createClient } from "@nextblock-cms/db/server";
|
|
3
|
+
import type { Database, Json } from "@nextblock-cms/db";
|
|
4
|
+
|
|
5
|
+
type BlockRow = Database['public']['Tables']['blocks']['Row'];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The columns a revision snapshot captures, in one place.
|
|
9
|
+
*
|
|
10
|
+
* These lists are the contract between four things that must agree exactly:
|
|
11
|
+
* - the SELECTs in this file (what a snapshot records),
|
|
12
|
+
* - the UPDATEs in service.ts (what a restore replays),
|
|
13
|
+
* - the jsonb_build_object() calls in migration 00000000000016 (the stored baseline),
|
|
14
|
+
* - RESTORE_EXCLUDED_META_COLUMNS below (what a restore must never replay).
|
|
15
|
+
*
|
|
16
|
+
* Anything editable that is missing here is silently un-restorable: a publish that only
|
|
17
|
+
* changed that field produces an empty JSON Patch, which createPageRevision treats as
|
|
18
|
+
* "nothing happened" and drops on the floor.
|
|
19
|
+
*/
|
|
20
|
+
export const PAGE_META_COLUMNS = [
|
|
21
|
+
'title',
|
|
22
|
+
'slug',
|
|
23
|
+
'language_id',
|
|
24
|
+
'status',
|
|
25
|
+
'meta_title',
|
|
26
|
+
'meta_description',
|
|
27
|
+
'custom_canonical',
|
|
28
|
+
'published_at',
|
|
29
|
+
'feature_image_id',
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
32
|
+
export const POST_META_COLUMNS = [
|
|
33
|
+
...PAGE_META_COLUMNS,
|
|
34
|
+
'label',
|
|
35
|
+
'excerpt',
|
|
36
|
+
'subtitle',
|
|
37
|
+
] as const;
|
|
38
|
+
|
|
39
|
+
export const PRODUCT_META_COLUMNS = [
|
|
40
|
+
'title',
|
|
41
|
+
'slug',
|
|
42
|
+
'language_id',
|
|
43
|
+
'status',
|
|
44
|
+
'meta_title',
|
|
45
|
+
'meta_description',
|
|
46
|
+
'custom_canonical',
|
|
47
|
+
'published_at',
|
|
48
|
+
'short_description',
|
|
49
|
+
'description_json',
|
|
50
|
+
] as const;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Visibility is owned by setContentVisibility (app/actions/visibilityActions.ts), which
|
|
54
|
+
* deliberately bypasses the draft/revision pipeline. Both fields are still *captured* —
|
|
55
|
+
* they are useful history — but replaying them on restore would let "restore the wording
|
|
56
|
+
* from last Tuesday" silently unpublish a live page or resurrect a cancelled schedule.
|
|
57
|
+
*
|
|
58
|
+
* They are excluded from change detection for the same reason: a status flip made from
|
|
59
|
+
* the top bar would otherwise be misattributed to whichever author saved next.
|
|
60
|
+
*/
|
|
61
|
+
export const RESTORE_EXCLUDED_META_COLUMNS = ['status', 'published_at'] as const;
|
|
62
|
+
|
|
63
|
+
export interface PageMetaContent {
|
|
64
|
+
title: string;
|
|
65
|
+
slug: string;
|
|
66
|
+
language_id: number;
|
|
67
|
+
status: Database['public']['Enums']['page_status'];
|
|
68
|
+
meta_title: string | null;
|
|
69
|
+
meta_description: string | null;
|
|
70
|
+
custom_canonical: string | null;
|
|
71
|
+
published_at: string | null;
|
|
72
|
+
feature_image_id: string | null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface PostMetaContent extends PageMetaContent {
|
|
76
|
+
label: string | null;
|
|
77
|
+
excerpt: string | null;
|
|
78
|
+
subtitle: string | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ProductMetaContent {
|
|
82
|
+
title: string;
|
|
83
|
+
slug: string;
|
|
84
|
+
language_id: number;
|
|
85
|
+
status: string;
|
|
86
|
+
meta_title: string | null;
|
|
87
|
+
meta_description: string | null;
|
|
88
|
+
custom_canonical: string | null;
|
|
89
|
+
published_at: string | null;
|
|
90
|
+
short_description: string | null;
|
|
91
|
+
description_json: Json | null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface SimpleBlockContent {
|
|
95
|
+
language_id: number;
|
|
96
|
+
block_type: BlockRow['block_type'];
|
|
97
|
+
content: Json;
|
|
98
|
+
order: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface FullPageContent {
|
|
102
|
+
meta: PageMetaContent;
|
|
103
|
+
blocks: SimpleBlockContent[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface FullPostContent {
|
|
107
|
+
meta: PostMetaContent;
|
|
108
|
+
blocks: SimpleBlockContent[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface FullProductContent {
|
|
112
|
+
meta: ProductMetaContent;
|
|
113
|
+
blocks: SimpleBlockContent[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export type AnyFullContent = FullPageContent | FullPostContent | FullProductContent;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Any Supabase client shape. Callers pass the cookie-scoped client, the service-role client,
|
|
120
|
+
* or nothing at all — the generics on those two differ enough that a union of them makes
|
|
121
|
+
* `.from()` uncallable, so this stays deliberately loose.
|
|
122
|
+
*/
|
|
123
|
+
type AnySupabaseClient = any;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Normalise a timestamp to the exact format JSON snapshots use everywhere else —
|
|
127
|
+
* `Date#toISOString()`, millisecond precision, `Z` suffix. Postgres renders timestamptz
|
|
128
|
+
* with microseconds and a `+00:00` offset, so without this a value that round-trips
|
|
129
|
+
* through the database comes back textually different and shows up as a phantom diff.
|
|
130
|
+
*/
|
|
131
|
+
function normalizeTimestamp(value: string | null | undefined): string | null {
|
|
132
|
+
if (!value) return null;
|
|
133
|
+
const parsed = new Date(value);
|
|
134
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface BlockCaptureOptions {
|
|
138
|
+
overrideBlockId?: number;
|
|
139
|
+
overrideBlockContent?: unknown;
|
|
140
|
+
excludeDeletedBlockId?: number;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Reduce raw block rows to the id-free shape stored in revisions. */
|
|
144
|
+
function toSimpleBlocks(
|
|
145
|
+
blocks: Pick<BlockRow, 'id' | 'language_id' | 'block_type' | 'content' | 'order'>[] | null,
|
|
146
|
+
opts?: BlockCaptureOptions
|
|
147
|
+
): SimpleBlockContent[] {
|
|
148
|
+
return (blocks || [])
|
|
149
|
+
.filter(b => (opts?.excludeDeletedBlockId ? b.id !== opts.excludeDeletedBlockId : true))
|
|
150
|
+
.map(b => ({
|
|
151
|
+
language_id: b.language_id,
|
|
152
|
+
block_type: b.block_type,
|
|
153
|
+
content: (opts?.overrideBlockId && b.id === opts.overrideBlockId)
|
|
154
|
+
? (opts.overrideBlockContent as Json)
|
|
155
|
+
: (b.content as Json),
|
|
156
|
+
order: b.order,
|
|
157
|
+
} satisfies SimpleBlockContent));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Blocks are ordered by `order` then `id`. Revision blocks carry no id, so their array
|
|
162
|
+
* position *is* their identity — two blocks sharing an `order` value would otherwise swap
|
|
163
|
+
* places between two reads and register as a content change that never happened.
|
|
164
|
+
*/
|
|
165
|
+
const BLOCK_SELECT = 'id, language_id, block_type, content, order';
|
|
166
|
+
|
|
167
|
+
export async function getFullPageContent(
|
|
168
|
+
pageId: number,
|
|
169
|
+
opts?: BlockCaptureOptions,
|
|
170
|
+
client?: AnySupabaseClient
|
|
171
|
+
): Promise<FullPageContent | null> {
|
|
172
|
+
const supabase = (client ?? createClient()) as AnySupabaseClient;
|
|
173
|
+
const { data: page, error: pageError } = await supabase
|
|
174
|
+
.from('pages')
|
|
175
|
+
.select(`id, ${PAGE_META_COLUMNS.join(', ')}`)
|
|
176
|
+
.eq('id', pageId)
|
|
177
|
+
.single();
|
|
178
|
+
if (pageError || !page) return null;
|
|
179
|
+
|
|
180
|
+
const { data: blocks, error: blocksError } = await supabase
|
|
181
|
+
.from('blocks')
|
|
182
|
+
.select(BLOCK_SELECT)
|
|
183
|
+
.eq('page_id', pageId)
|
|
184
|
+
.order('order', { ascending: true })
|
|
185
|
+
.order('id', { ascending: true });
|
|
186
|
+
if (blocksError) return null;
|
|
187
|
+
|
|
188
|
+
const row = page as Record<string, any>;
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
meta: {
|
|
192
|
+
title: row['title'],
|
|
193
|
+
slug: row['slug'],
|
|
194
|
+
language_id: row['language_id'],
|
|
195
|
+
status: row['status'],
|
|
196
|
+
meta_title: row['meta_title'] ?? null,
|
|
197
|
+
meta_description: row['meta_description'] ?? null,
|
|
198
|
+
custom_canonical: row['custom_canonical'] ?? null,
|
|
199
|
+
published_at: normalizeTimestamp(row['published_at']),
|
|
200
|
+
feature_image_id: row['feature_image_id'] ?? null,
|
|
201
|
+
},
|
|
202
|
+
blocks: toSimpleBlocks(blocks as any, opts),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function getFullPostContent(
|
|
207
|
+
postId: number,
|
|
208
|
+
opts?: BlockCaptureOptions,
|
|
209
|
+
client?: AnySupabaseClient
|
|
210
|
+
): Promise<FullPostContent | null> {
|
|
211
|
+
const supabase = (client ?? createClient()) as AnySupabaseClient;
|
|
212
|
+
const { data: post, error: postError } = await supabase
|
|
213
|
+
.from('posts')
|
|
214
|
+
.select(`id, ${POST_META_COLUMNS.join(', ')}`)
|
|
215
|
+
.eq('id', postId)
|
|
216
|
+
.single();
|
|
217
|
+
if (postError || !post) return null;
|
|
218
|
+
|
|
219
|
+
const { data: blocks, error: blocksError } = await supabase
|
|
220
|
+
.from('blocks')
|
|
221
|
+
.select(BLOCK_SELECT)
|
|
222
|
+
.eq('post_id', postId)
|
|
223
|
+
.order('order', { ascending: true })
|
|
224
|
+
.order('id', { ascending: true });
|
|
225
|
+
if (blocksError) return null;
|
|
226
|
+
|
|
227
|
+
const row = post as Record<string, any>;
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
meta: {
|
|
231
|
+
title: row['title'],
|
|
232
|
+
slug: row['slug'],
|
|
233
|
+
language_id: row['language_id'],
|
|
234
|
+
status: row['status'],
|
|
235
|
+
meta_title: row['meta_title'] ?? null,
|
|
236
|
+
meta_description: row['meta_description'] ?? null,
|
|
237
|
+
custom_canonical: row['custom_canonical'] ?? null,
|
|
238
|
+
published_at: normalizeTimestamp(row['published_at']),
|
|
239
|
+
feature_image_id: row['feature_image_id'] ?? null,
|
|
240
|
+
label: row['label'] ?? null,
|
|
241
|
+
excerpt: row['excerpt'] ?? null,
|
|
242
|
+
subtitle: row['subtitle'] ?? null,
|
|
243
|
+
},
|
|
244
|
+
blocks: toSimpleBlocks(blocks as any, opts),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function getFullProductContent(
|
|
249
|
+
productId: string,
|
|
250
|
+
opts?: BlockCaptureOptions,
|
|
251
|
+
client?: AnySupabaseClient
|
|
252
|
+
): Promise<FullProductContent | null> {
|
|
253
|
+
const supabase = (client ?? createClient()) as AnySupabaseClient;
|
|
254
|
+
const { data: product, error: productError } = await supabase
|
|
255
|
+
.from('products')
|
|
256
|
+
.select(`id, ${PRODUCT_META_COLUMNS.join(', ')}`)
|
|
257
|
+
.eq('id', productId)
|
|
258
|
+
.single();
|
|
259
|
+
if (productError || !product) return null;
|
|
260
|
+
|
|
261
|
+
const { data: blocks, error: blocksError } = await supabase
|
|
262
|
+
.from('blocks')
|
|
263
|
+
.select(BLOCK_SELECT)
|
|
264
|
+
.eq('product_id', productId)
|
|
265
|
+
.order('order', { ascending: true })
|
|
266
|
+
.order('id', { ascending: true });
|
|
267
|
+
if (blocksError) return null;
|
|
268
|
+
|
|
269
|
+
const row = product as Record<string, any>;
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
meta: {
|
|
273
|
+
title: row['title'],
|
|
274
|
+
slug: row['slug'],
|
|
275
|
+
language_id: row['language_id'],
|
|
276
|
+
status: row['status'],
|
|
277
|
+
meta_title: row['meta_title'] ?? null,
|
|
278
|
+
meta_description: row['meta_description'] ?? null,
|
|
279
|
+
custom_canonical: row['custom_canonical'] ?? null,
|
|
280
|
+
published_at: normalizeTimestamp(row['published_at']),
|
|
281
|
+
short_description: row['short_description'] ?? null,
|
|
282
|
+
description_json: (row['description_json'] ?? null) as Json | null,
|
|
283
|
+
},
|
|
284
|
+
blocks: toSimpleBlocks(blocks as any, opts),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Build the meta payload a restore writes back, minus the fields a restore must never
|
|
290
|
+
* replay (see RESTORE_EXCLUDED_META_COLUMNS).
|
|
291
|
+
*/
|
|
292
|
+
export function buildRestoreMetaUpdate(
|
|
293
|
+
meta: Record<string, unknown>,
|
|
294
|
+
columns: readonly string[]
|
|
295
|
+
): Record<string, unknown> {
|
|
296
|
+
const excluded = new Set<string>(RESTORE_EXCLUDED_META_COLUMNS);
|
|
297
|
+
const update: Record<string, unknown> = {};
|
|
298
|
+
for (const column of columns) {
|
|
299
|
+
if (excluded.has(column)) continue;
|
|
300
|
+
if (!Object.prototype.hasOwnProperty.call(meta, column)) continue;
|
|
301
|
+
update[column] = meta[column];
|
|
302
|
+
}
|
|
303
|
+
return update;
|
|
304
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getSsgSupabaseClient } from '@nextblock-cms/db/server';
|
|
2
|
+
import { buildPublishedAtOrFilter } from '@nextblock-cms/utils';
|
|
2
3
|
import { getHomepageTranslationGroupId } from './homepage';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -164,7 +165,8 @@ export async function fetchAllPublishedPages(): Promise<SitemapEntry[]> {
|
|
|
164
165
|
supabase
|
|
165
166
|
.from('pages')
|
|
166
167
|
.select('slug, updated_at, language_id, translation_group_id')
|
|
167
|
-
.eq('status', 'published')
|
|
168
|
+
.eq('status', 'published')
|
|
169
|
+
.or(buildPublishedAtOrFilter()),
|
|
168
170
|
fetchLanguageMap(supabase),
|
|
169
171
|
getHomepageTranslationGroupId(supabase),
|
|
170
172
|
]);
|
|
@@ -200,13 +202,12 @@ export async function fetchAllPublishedPages(): Promise<SitemapEntry[]> {
|
|
|
200
202
|
export async function fetchAllPublishedPosts(): Promise<SitemapEntry[]> {
|
|
201
203
|
const supabase = getSsgSupabaseClient();
|
|
202
204
|
try {
|
|
203
|
-
const nowIso = new Date().toISOString();
|
|
204
205
|
const [{ data: posts, error }, languageMap] = await Promise.all([
|
|
205
206
|
supabase
|
|
206
207
|
.from('posts')
|
|
207
208
|
.select('slug, updated_at, language_id, translation_group_id')
|
|
208
209
|
.eq('status', 'published')
|
|
209
|
-
.or(
|
|
210
|
+
.or(buildPublishedAtOrFilter()),
|
|
210
211
|
fetchLanguageMap(supabase),
|
|
211
212
|
]);
|
|
212
213
|
|
|
@@ -238,7 +239,8 @@ export async function fetchAllActiveProducts(): Promise<SitemapEntry[]> {
|
|
|
238
239
|
supabase
|
|
239
240
|
.from('products')
|
|
240
241
|
.select('slug, updated_at, created_at, language_id, translation_group_id')
|
|
241
|
-
.eq('status', 'active')
|
|
242
|
+
.eq('status', 'active')
|
|
243
|
+
.or(buildPublishedAtOrFilter()),
|
|
242
244
|
fetchLanguageMap(supabase),
|
|
243
245
|
]);
|
|
244
246
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import 'server-only';
|
|
2
2
|
|
|
3
3
|
import { getServiceRoleSupabaseClient, verifyPackageOnline } from '@nextblock-cms/db/server';
|
|
4
|
+
import { buildPublishedAtOrFilter } from '@nextblock-cms/utils';
|
|
4
5
|
import {
|
|
5
6
|
getDefaultCurrency,
|
|
6
7
|
inferCurrencyCodeFromLocale,
|
|
@@ -1099,6 +1100,7 @@ export async function searchCatalogProducts(body: unknown, request: Request) {
|
|
|
1099
1100
|
.from('products')
|
|
1100
1101
|
.select(PRODUCT_SELECT, { count: 'exact' })
|
|
1101
1102
|
.eq('status', 'active')
|
|
1103
|
+
.or(buildPublishedAtOrFilter())
|
|
1102
1104
|
.order('created_at', { ascending: false })
|
|
1103
1105
|
.range(pagination.offset, pagination.offset + pagination.limit - 1);
|
|
1104
1106
|
|
|
@@ -1192,7 +1194,7 @@ async function selectRowsByField(
|
|
|
1192
1194
|
|
|
1193
1195
|
let query = client.from(table).select(select).in(field, values);
|
|
1194
1196
|
if (table === 'products') {
|
|
1195
|
-
query = query.eq('status', 'active');
|
|
1197
|
+
query = query.eq('status', 'active').or(buildPublishedAtOrFilter());
|
|
1196
1198
|
}
|
|
1197
1199
|
|
|
1198
1200
|
const { data } = await query;
|
|
@@ -1251,6 +1253,7 @@ async function resolveProductRowsByIdentifiers(ids: string[]): Promise<{
|
|
|
1251
1253
|
.from('products')
|
|
1252
1254
|
.select(PRODUCT_SELECT)
|
|
1253
1255
|
.eq('status', 'active')
|
|
1256
|
+
.or(buildPublishedAtOrFilter())
|
|
1254
1257
|
.in('id', productIds);
|
|
1255
1258
|
|
|
1256
1259
|
if (error) {
|
|
@@ -3,6 +3,7 @@ import { cookies, draftMode, headers } from 'next/headers';
|
|
|
3
3
|
import { notFound } from 'next/navigation';
|
|
4
4
|
import type { Metadata } from 'next';
|
|
5
5
|
import { createClient, getSsgSupabaseClient } from '@nextblock-cms/db/server';
|
|
6
|
+
import { buildPublishedAtOrFilter } from '@nextblock-cms/utils';
|
|
6
7
|
import PageClientContent from './[slug]/PageClientContent';
|
|
7
8
|
import { getPageDataBySlug } from './[slug]/page.utils';
|
|
8
9
|
import BlockRenderer from '../components/BlockRenderer';
|
|
@@ -56,7 +57,7 @@ async function resolveHomepageData(preferredLocale: string) {
|
|
|
56
57
|
.limit(1);
|
|
57
58
|
|
|
58
59
|
if (!draft.isEnabled) {
|
|
59
|
-
siblingQuery = siblingQuery.eq('status', 'published');
|
|
60
|
+
siblingQuery = siblingQuery.eq('status', 'published').or(buildPublishedAtOrFilter());
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
const { data: sibling } = await siblingQuery.maybeSingle();
|
|
@@ -131,7 +132,8 @@ export async function generateMetadata(): Promise<Metadata> {
|
|
|
131
132
|
.from('pages')
|
|
132
133
|
.select('language_id, slug')
|
|
133
134
|
.eq('translation_group_id', pageData.translation_group_id)
|
|
134
|
-
.eq('status', 'published')
|
|
135
|
+
.eq('status', 'published')
|
|
136
|
+
.or(buildPublishedAtOrFilter()),
|
|
135
137
|
]);
|
|
136
138
|
|
|
137
139
|
const { data: languages } = languagesResult;
|
|
@@ -188,7 +190,8 @@ export default async function RootPage() {
|
|
|
188
190
|
.from('pages')
|
|
189
191
|
.select('slug, languages!inner(code)')
|
|
190
192
|
.eq('translation_group_id', pageData.translation_group_id)
|
|
191
|
-
.eq('status', 'published')
|
|
193
|
+
.eq('status', 'published')
|
|
194
|
+
.or(buildPublishedAtOrFilter());
|
|
192
195
|
|
|
193
196
|
if (translations) {
|
|
194
197
|
translations.forEach((translation: PageTranslation) => {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
resolveTranslatedText,
|
|
9
9
|
} from '@nextblock-cms/ecommerce';
|
|
10
10
|
import { getSsgSupabaseClient, verifyPackageOnline } from '@nextblock-cms/db/server';
|
|
11
|
+
import { LIVE_STATUS, buildPublishedAtOrFilter, isPubliclyVisible } from '@nextblock-cms/utils';
|
|
11
12
|
import { notFound } from 'next/navigation';
|
|
12
13
|
import { Metadata } from 'next';
|
|
13
14
|
import { draftMode, cookies, headers } from 'next/headers';
|
|
@@ -124,7 +125,16 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|
|
124
125
|
const { data: product } = await getProductBySlug(supabase, slug, preferredLocale);
|
|
125
126
|
const productRecord = product as any;
|
|
126
127
|
|
|
127
|
-
if (
|
|
128
|
+
if (
|
|
129
|
+
!productRecord ||
|
|
130
|
+
!isPubliclyVisible({
|
|
131
|
+
status: productRecord.status,
|
|
132
|
+
publishedAt: productRecord.published_at,
|
|
133
|
+
liveStatus: LIVE_STATUS.product,
|
|
134
|
+
})
|
|
135
|
+
) {
|
|
136
|
+
return { title: 'Product Not Found' };
|
|
137
|
+
}
|
|
128
138
|
|
|
129
139
|
// Resolve image URL for OG Image
|
|
130
140
|
let imageUrl = undefined;
|
|
@@ -147,6 +157,8 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|
|
147
157
|
.select('language_id, slug')
|
|
148
158
|
.eq('translation_group_id', productRecord.translation_group_id)
|
|
149
159
|
.eq('status', 'active')
|
|
160
|
+
// Never advertise a scheduled translation via hreflang.
|
|
161
|
+
.or(buildPublishedAtOrFilter())
|
|
150
162
|
]);
|
|
151
163
|
|
|
152
164
|
const { data: languages } = languagesResult;
|
|
@@ -225,11 +237,23 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|
|
225
237
|
const { data: product } = await getProductBySlug(supabase, slug, preferredLocale);
|
|
226
238
|
let productRecord = product as any;
|
|
227
239
|
|
|
228
|
-
|
|
240
|
+
const draft = await draftMode();
|
|
241
|
+
|
|
242
|
+
// Draft mode is how the CMS previews a product before it is public, so it must
|
|
243
|
+
// reach draft and scheduled rows — everyone else only sees active products whose
|
|
244
|
+
// go-live moment has passed.
|
|
245
|
+
if (
|
|
246
|
+
!productRecord ||
|
|
247
|
+
(!draft.isEnabled &&
|
|
248
|
+
!isPubliclyVisible({
|
|
249
|
+
status: productRecord.status,
|
|
250
|
+
publishedAt: productRecord.published_at,
|
|
251
|
+
liveStatus: LIVE_STATUS.product,
|
|
252
|
+
}))
|
|
253
|
+
) {
|
|
229
254
|
notFound();
|
|
230
255
|
}
|
|
231
256
|
|
|
232
|
-
const draft = await draftMode();
|
|
233
257
|
const visualEditingEnabled =
|
|
234
258
|
draft.isEnabled || process.env.NEXTBLOCK_VISUAL_EDITING_ENABLED === 'true';
|
|
235
259
|
|
|
@@ -1018,7 +1018,10 @@ function VisualEditingToolbar() {
|
|
|
1018
1018
|
return;
|
|
1019
1019
|
}
|
|
1020
1020
|
|
|
1021
|
-
|
|
1021
|
+
// A warning means the content is live but the revision didn't record — still a
|
|
1022
|
+
// publish, so the editor closes, but don't report it as a clean one.
|
|
1023
|
+
const warning = result && "success" in result ? result.warning : undefined;
|
|
1024
|
+
setMessage(warning ?? "Draft published.");
|
|
1022
1025
|
hasSavedSinceOpenRef.current = false;
|
|
1023
1026
|
closeVisualEditor();
|
|
1024
1027
|
router.refresh();
|
|
@@ -100,6 +100,7 @@ Defined primarily in `00000000000002_setup_content_tables.sql`:
|
|
|
100
100
|
- `navigation_items`
|
|
101
101
|
- `page_revisions`
|
|
102
102
|
- `post_revisions`
|
|
103
|
+
- `product_revisions` (added in `00000000000016`, alongside `products.version`)
|
|
103
104
|
|
|
104
105
|
### Commerce tables
|
|
105
106
|
|
|
@@ -179,8 +180,12 @@ tree. The current sequence is:
|
|
|
179
180
|
|
|
180
181
|
Every file is fully idempotent. Existing databases already have versions
|
|
181
182
|
`000`–`003` recorded, so both appliers skip the baseline — it only runs on a
|
|
182
|
-
fresh/empty database.
|
|
183
|
-
|
|
183
|
+
fresh/empty database.
|
|
184
|
+
|
|
185
|
+
`00000000000004` was the first migration appended after that re-baseline, not the
|
|
186
|
+
one still to be written — the folder has grown well past it. **To find the next
|
|
187
|
+
number, list `libs/db/src/supabase/migrations` and take the one after the highest
|
|
188
|
+
file on disk.** Never copy a hardcoded "next is N" out of a doc.
|
|
184
189
|
|
|
185
190
|
### Production migration policy
|
|
186
191
|
|