create-nextblock 0.0.4 → 0.2.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/bin/create-nextblock.js +1193 -920
- package/package.json +6 -2
- package/scripts/sync-template.js +279 -276
- package/templates/nextblock-template/.env.example +1 -14
- package/templates/nextblock-template/README.md +1 -1
- package/templates/nextblock-template/app/[slug]/PageClientContent.tsx +67 -40
- package/templates/nextblock-template/app/[slug]/page.tsx +45 -10
- package/templates/nextblock-template/app/[slug]/page.utils.ts +92 -45
- package/templates/nextblock-template/app/api/revalidate/route.ts +15 -15
- package/templates/nextblock-template/app/{blog → article}/[slug]/PostClientContent.tsx +45 -43
- package/templates/nextblock-template/app/{blog → article}/[slug]/page.tsx +108 -98
- package/templates/nextblock-template/app/{blog → article}/[slug]/page.utils.ts +10 -3
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +25 -19
- package/templates/nextblock-template/app/cms/blocks/actions.ts +1 -1
- package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +1 -1
- package/templates/nextblock-template/app/cms/posts/actions.ts +47 -44
- package/templates/nextblock-template/app/cms/posts/page.tsx +2 -2
- package/templates/nextblock-template/app/cms/settings/languages/actions.ts +16 -15
- package/templates/nextblock-template/app/layout.tsx +9 -9
- package/templates/nextblock-template/app/lib/sitemap-utils.ts +52 -52
- package/templates/nextblock-template/app/sitemap.xml/route.ts +2 -2
- package/templates/nextblock-template/components/ResponsiveNav.tsx +22 -16
- package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -7
- package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +25 -26
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/proxy.ts +4 -4
- package/templates/nextblock-template/public/images/NBcover.webp +0 -0
- package/templates/nextblock-template/public/images/developer.webp +0 -0
- package/templates/nextblock-template/public/images/nextblock-logo-small.webp +0 -0
- package/templates/nextblock-template/public/images/nx-graph.webp +0 -0
- package/templates/nextblock-template/public/images/programmer-upscaled.webp +0 -0
- package/templates/nextblock-template/scripts/backup.js +142 -47
- package/templates/nextblock-template/scripts/restore-working.js +102 -0
- package/templates/nextblock-template/scripts/restore.js +434 -0
- package/templates/nextblock-template/app/blog/page.tsx +0 -77
- package/templates/nextblock-template/backup/backup_2025-06-19.sql +0 -8057
- package/templates/nextblock-template/backup/backup_2025-06-20.sql +0 -8159
- package/templates/nextblock-template/backup/backup_2025-07-08.sql +0 -8411
- package/templates/nextblock-template/backup/backup_2025-07-09.sql +0 -8442
- package/templates/nextblock-template/backup/backup_2025-07-10.sql +0 -8442
- package/templates/nextblock-template/backup/backup_2025-10-01.sql +0 -8803
- package/templates/nextblock-template/backup/backup_2025-10-02.sql +0 -9749
|
@@ -9,60 +9,60 @@ interface SitemapEntry {
|
|
|
9
9
|
* Fetches all published pages from Supabase and formats them for the sitemap.
|
|
10
10
|
* @returns {Promise<Array<SitemapEntry>>} A promise that resolves to an array of sitemap entries for pages.
|
|
11
11
|
*/
|
|
12
|
-
export async function fetchAllPublishedPages(): Promise<SitemapEntry[]> {
|
|
13
|
-
const supabase = createClient();
|
|
14
|
-
try {
|
|
15
|
-
const { data: pages, error } = await supabase
|
|
16
|
-
.from('pages')
|
|
17
|
-
.select('slug, updated_at')
|
|
18
|
-
.eq('status', 'published');
|
|
19
|
-
|
|
20
|
-
if (error) {
|
|
21
|
-
console.error('Error fetching published pages:', error);
|
|
22
|
-
return [];
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
if (!pages) {
|
|
26
|
-
return [];
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
return pages.map((page) => ({
|
|
30
|
-
path: `/${page.slug}`,
|
|
31
|
-
lastModified: new Date(page.updated_at).toISOString(),
|
|
32
|
-
}));
|
|
33
|
-
} catch (err) {
|
|
34
|
-
console.error('An unexpected error occurred while fetching pages:', err);
|
|
35
|
-
return [];
|
|
36
|
-
}
|
|
37
|
-
}
|
|
12
|
+
export async function fetchAllPublishedPages(): Promise<SitemapEntry[]> {
|
|
13
|
+
const supabase = createClient();
|
|
14
|
+
try {
|
|
15
|
+
const { data: pages, error } = await supabase
|
|
16
|
+
.from('pages')
|
|
17
|
+
.select('slug, updated_at')
|
|
18
|
+
.eq('status', 'published');
|
|
19
|
+
|
|
20
|
+
if (error) {
|
|
21
|
+
console.error('Error fetching published pages:', error);
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!pages) {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return pages.map((page) => ({
|
|
30
|
+
path: `/${page.slug}`,
|
|
31
|
+
lastModified: new Date(page.updated_at).toISOString(),
|
|
32
|
+
}));
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.error('An unexpected error occurred while fetching pages:', err);
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Fetches all published posts from Supabase and formats them for the sitemap.
|
|
41
41
|
* @returns {Promise<Array<SitemapEntry>>} A promise that resolves to an array of sitemap entries for posts.
|
|
42
42
|
*/
|
|
43
|
-
export async function fetchAllPublishedPosts(): Promise<SitemapEntry[]> {
|
|
44
|
-
const supabase = createClient();
|
|
45
|
-
try {
|
|
46
|
-
const { data: posts, error } = await supabase
|
|
47
|
-
.from('posts')
|
|
48
|
-
.select('slug, updated_at')
|
|
49
|
-
.eq('status', 'published');
|
|
50
|
-
|
|
51
|
-
if (error) {
|
|
52
|
-
console.error('Error fetching published posts:', error);
|
|
53
|
-
return [];
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
if (!posts) {
|
|
57
|
-
return [];
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return posts.map((post) => ({
|
|
61
|
-
path: `/
|
|
62
|
-
lastModified: new Date(post.updated_at).toISOString(),
|
|
63
|
-
}));
|
|
64
|
-
} catch (err) {
|
|
65
|
-
console.error('An unexpected error occurred while fetching posts:', err);
|
|
66
|
-
return [];
|
|
67
|
-
}
|
|
68
|
-
}
|
|
43
|
+
export async function fetchAllPublishedPosts(): Promise<SitemapEntry[]> {
|
|
44
|
+
const supabase = createClient();
|
|
45
|
+
try {
|
|
46
|
+
const { data: posts, error } = await supabase
|
|
47
|
+
.from('posts')
|
|
48
|
+
.select('slug, updated_at')
|
|
49
|
+
.eq('status', 'published');
|
|
50
|
+
|
|
51
|
+
if (error) {
|
|
52
|
+
console.error('Error fetching published posts:', error);
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!posts) {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return posts.map((post) => ({
|
|
61
|
+
path: `/article/${post.slug}`,
|
|
62
|
+
lastModified: new Date(post.updated_at).toISOString(),
|
|
63
|
+
}));
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.error('An unexpected error occurred while fetching posts:', err);
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -31,7 +31,7 @@ export async function GET() {
|
|
|
31
31
|
|
|
32
32
|
const staticRoutes: SitemapEntry[] = [
|
|
33
33
|
{ path: '/', lastModified: new Date().toISOString() },
|
|
34
|
-
{ path: '/
|
|
34
|
+
{ path: '/articles', lastModified: new Date().toISOString() },
|
|
35
35
|
];
|
|
36
36
|
|
|
37
37
|
const allEntries = [...staticRoutes, ...pages, ...posts];
|
|
@@ -60,4 +60,4 @@ export async function GET() {
|
|
|
60
60
|
console.error("Error generating sitemap:", error);
|
|
61
61
|
return new NextResponse('Internal Server Error', { status: 500 });
|
|
62
62
|
}
|
|
63
|
-
}
|
|
63
|
+
}
|
|
@@ -10,7 +10,8 @@ type Logo = Database['public']['Tables']['logos']['Row'] & { media: (Database['p
|
|
|
10
10
|
type NavigationItem = Database['public']['Tables']['navigation_items']['Row'];
|
|
11
11
|
import Image from 'next/image'
|
|
12
12
|
|
|
13
|
-
const R2_BASE_URL = process.env.NEXT_PUBLIC_R2_BASE_URL || ''
|
|
13
|
+
const R2_BASE_URL = process.env.NEXT_PUBLIC_R2_BASE_URL || '';
|
|
14
|
+
const FALLBACK_LOGO_PATH = '/images/nextblock-logo-small.webp';
|
|
14
15
|
|
|
15
16
|
// Define a type for hierarchical navigation items
|
|
16
17
|
interface HierarchicalNavigationItem extends NavigationItem {
|
|
@@ -258,20 +259,25 @@ export default function ResponsiveNav({
|
|
|
258
259
|
href={homeLinkHref}
|
|
259
260
|
className="flex items-center space-x-2 rtl:space-x-reverse"
|
|
260
261
|
>
|
|
261
|
-
{logo && logo.media ? (
|
|
262
|
-
<Image
|
|
263
|
-
src={`${R2_BASE_URL}/${logo.media.object_key}`}
|
|
264
|
-
alt={logo.media.alt_text || '
|
|
265
|
-
width={logo.media.width || 100}
|
|
266
|
-
height={logo.media.height || 32}
|
|
267
|
-
className="h-14 w-auto object-contain"
|
|
268
|
-
priority
|
|
269
|
-
/>
|
|
270
|
-
) : (
|
|
271
|
-
<
|
|
272
|
-
{
|
|
273
|
-
|
|
274
|
-
|
|
262
|
+
{logo && logo.media ? (
|
|
263
|
+
<Image
|
|
264
|
+
src={`${R2_BASE_URL}/${logo.media.object_key}`}
|
|
265
|
+
alt={logo.media.alt_text || siteTitle || 'Nextblock'}
|
|
266
|
+
width={logo.media.width || 100}
|
|
267
|
+
height={logo.media.height || 32}
|
|
268
|
+
className="h-14 w-auto object-contain"
|
|
269
|
+
priority
|
|
270
|
+
/>
|
|
271
|
+
) : (
|
|
272
|
+
<Image
|
|
273
|
+
src={FALLBACK_LOGO_PATH}
|
|
274
|
+
alt={siteTitle || 'Nextblock'}
|
|
275
|
+
width={120}
|
|
276
|
+
height={40}
|
|
277
|
+
className="h-14 w-auto object-contain"
|
|
278
|
+
priority
|
|
279
|
+
/>
|
|
280
|
+
)}
|
|
275
281
|
</Link>
|
|
276
282
|
{/* Desktop: Additional Nav items */}
|
|
277
283
|
<div className="hidden md:flex items-baseline font-semibold ml-6 space-x-1"> {/* Adjusted space-x for items with internal padding */}
|
|
@@ -369,4 +375,4 @@ export default function ResponsiveNav({
|
|
|
369
375
|
</div>
|
|
370
376
|
</>
|
|
371
377
|
);
|
|
372
|
-
}
|
|
378
|
+
}
|
|
@@ -24,7 +24,7 @@ const PostsGridBlock: React.FC<PostsGridBlockProps> = async ({ block, languageId
|
|
|
24
24
|
|
|
25
25
|
const supabase = createClient();
|
|
26
26
|
|
|
27
|
-
const { data: postsData, error: queryError, count } = await supabase
|
|
27
|
+
const { data: postsData, error: queryError, count } = await supabase
|
|
28
28
|
.from('posts')
|
|
29
29
|
.select('id, title, slug, excerpt, published_at, language_id, status, created_at, updated_at, translation_group_id, feature_image_id, feature_media_object:media!feature_image_id(object_key, width, height)', { count: 'exact' })
|
|
30
30
|
.eq('status', 'published')
|
|
@@ -40,13 +40,18 @@ const PostsGridBlock: React.FC<PostsGridBlockProps> = async ({ block, languageId
|
|
|
40
40
|
console.error("Error fetching initial posts directly in PostsGridBlock:", queryError);
|
|
41
41
|
postsError = queryError.message;
|
|
42
42
|
} else {
|
|
43
|
-
|
|
43
|
+
const buildMediaUrl = (objectKey?: string | null) => {
|
|
44
|
+
if (!objectKey) return null;
|
|
45
|
+
if (objectKey.startsWith('/')) return objectKey;
|
|
46
|
+
const base = process.env.NEXT_PUBLIC_R2_BASE_URL || '';
|
|
47
|
+
return base ? `${base}/${objectKey}` : objectKey;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
initialPosts = (postsData as any)?.map((p: any) => {
|
|
44
51
|
// feature_media_object is an object here, not an array, due to the query structure media!feature_image_id(object_key, width, height)
|
|
45
52
|
// Cast to 'unknown' then to the expected single object type to satisfy TypeScript, reflecting runtime reality.
|
|
46
|
-
const mediaObject = p.feature_media_object as unknown as { object_key: string; width?: number | null; height?: number | null; blur_data_url?: string | null } | null;
|
|
47
|
-
const imageUrl = mediaObject?.object_key
|
|
48
|
-
? `${process.env.NEXT_PUBLIC_R2_BASE_URL}/${mediaObject.object_key}`
|
|
49
|
-
: null;
|
|
53
|
+
const mediaObject = p.feature_media_object as unknown as { object_key: string; width?: number | null; height?: number | null; blur_data_url?: string | null } | null;
|
|
54
|
+
const imageUrl = buildMediaUrl(mediaObject?.object_key);
|
|
50
55
|
return {
|
|
51
56
|
...p,
|
|
52
57
|
// Convert feature_media_object to array format to match the type
|
|
@@ -90,4 +95,4 @@ const PostsGridBlock: React.FC<PostsGridBlockProps> = async ({ block, languageId
|
|
|
90
95
|
);
|
|
91
96
|
};
|
|
92
97
|
|
|
93
|
-
export default PostsGridBlock;
|
|
98
|
+
export default PostsGridBlock;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// components/blocks/PostsGridClient.tsx
|
|
2
2
|
'use client';
|
|
3
3
|
|
|
4
|
-
import React, { useState, useEffect } from 'react';
|
|
5
|
-
import type { Database } from '@nextblock-cms/db';
|
|
6
|
-
import Link from 'next/link';
|
|
4
|
+
import React, { useState, useEffect } from 'react';
|
|
5
|
+
import type { Database } from '@nextblock-cms/db';
|
|
6
|
+
import Link from 'next/link';
|
|
7
7
|
|
|
8
8
|
type PostWithMediaDimensions = Database['public']['Tables']['posts']['Row'] & {
|
|
9
9
|
feature_image_url: string | null;
|
|
@@ -26,7 +26,10 @@ interface PostsGridClientProps {
|
|
|
26
26
|
fetchAction: (languageId: number, page: number, limit: number) => Promise<{ posts: PostWithMediaDimensions[], totalCount: number, error?: string }>;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
const
|
|
29
|
+
const DEFAULT_FEATURE_IMAGE_WIDTH = 1600;
|
|
30
|
+
const DEFAULT_FEATURE_IMAGE_HEIGHT = 900;
|
|
31
|
+
|
|
32
|
+
const PostsGridClient: React.FC<PostsGridClientProps> = ({
|
|
30
33
|
initialPosts,
|
|
31
34
|
initialPage,
|
|
32
35
|
postsPerPage,
|
|
@@ -115,29 +118,25 @@ const PostsGridClient: React.FC<PostsGridClientProps> = ({
|
|
|
115
118
|
))
|
|
116
119
|
) : posts.length > 0 ? (
|
|
117
120
|
posts.map((post, index) => (
|
|
118
|
-
<Link href={`/
|
|
121
|
+
<Link href={`/article/${post.slug}`} key={post.id} className="block group">
|
|
119
122
|
<div className="border rounded-lg overflow-hidden shadow-sm hover:shadow-md transition-shadow bg-card text-card-foreground">
|
|
120
123
|
{/* Basic Post Card Structure - Enhanced with Feature Image */}
|
|
121
|
-
{post.feature_image_url
|
|
122
|
-
<div className="aspect-video overflow-hidden">
|
|
123
|
-
<Image
|
|
124
|
-
src={post.feature_image_url}
|
|
125
|
-
alt={`Feature image for ${post.title}`}
|
|
126
|
-
width={post.feature_image_width}
|
|
127
|
-
height={post.feature_image_height}
|
|
128
|
-
sizes={imageSizes}
|
|
129
|
-
priority={index === 0}
|
|
130
|
-
placeholder={post.blur_data_url ? 'blur' : 'empty'}
|
|
131
|
-
blurDataURL={post.blur_data_url ?? undefined}
|
|
132
|
-
quality={60}
|
|
133
|
-
className="h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
134
|
-
/>
|
|
135
|
-
</div>
|
|
136
|
-
) :
|
|
137
|
-
<div className="aspect-video overflow-hidden bg-gray-200 flex items-center justify-center">
|
|
138
|
-
<span className="text-gray-500">Image not available</span>
|
|
139
|
-
</div>
|
|
140
|
-
) : null}
|
|
124
|
+
{post.feature_image_url ? (
|
|
125
|
+
<div className="aspect-video overflow-hidden">
|
|
126
|
+
<Image
|
|
127
|
+
src={post.feature_image_url}
|
|
128
|
+
alt={`Feature image for ${post.title}`}
|
|
129
|
+
width={post.feature_image_width && post.feature_image_width > 0 ? post.feature_image_width : DEFAULT_FEATURE_IMAGE_WIDTH}
|
|
130
|
+
height={post.feature_image_height && post.feature_image_height > 0 ? post.feature_image_height : DEFAULT_FEATURE_IMAGE_HEIGHT}
|
|
131
|
+
sizes={imageSizes}
|
|
132
|
+
priority={index === 0}
|
|
133
|
+
placeholder={post.blur_data_url ? 'blur' : 'empty'}
|
|
134
|
+
blurDataURL={post.blur_data_url ?? undefined}
|
|
135
|
+
quality={60}
|
|
136
|
+
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
137
|
+
/>
|
|
138
|
+
</div>
|
|
139
|
+
) : null}
|
|
141
140
|
<div className="p-4">
|
|
142
141
|
<h3 className="text-lg font-semibold mb-2 group-hover:text-primary">{post.title}</h3>
|
|
143
142
|
{post.excerpt && <p className="text-sm text-muted-foreground mb-3 line-clamp-3">{post.excerpt}</p>}
|
|
@@ -177,4 +176,4 @@ const PostsGridClient: React.FC<PostsGridClientProps> = ({
|
|
|
177
176
|
);
|
|
178
177
|
};
|
|
179
178
|
|
|
180
|
-
export default PostsGridClient;
|
|
179
|
+
export default PostsGridClient;
|
|
@@ -159,11 +159,11 @@ export default async function proxy(request: NextRequest) {
|
|
|
159
159
|
} else if (pathname === '/') {
|
|
160
160
|
finalResponse.headers.set('X-Page-Type', 'home');
|
|
161
161
|
finalResponse.headers.set('X-Prefetch-Priority', 'high');
|
|
162
|
-
} else if (pathname === '/
|
|
163
|
-
finalResponse.headers.set('X-Page-Type', '
|
|
162
|
+
} else if (pathname === '/articles') {
|
|
163
|
+
finalResponse.headers.set('X-Page-Type', 'articles-index');
|
|
164
164
|
finalResponse.headers.set('X-Prefetch-Priority', 'high');
|
|
165
|
-
} else if (pathname.startsWith('/
|
|
166
|
-
finalResponse.headers.set('X-Page-Type', '
|
|
165
|
+
} else if (pathname.startsWith('/article/')) {
|
|
166
|
+
finalResponse.headers.set('X-Page-Type', 'article');
|
|
167
167
|
finalResponse.headers.set('X-Prefetch-Priority', 'medium');
|
|
168
168
|
} else {
|
|
169
169
|
const segments = pathname.split('/').filter(Boolean);
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,53 +1,148 @@
|
|
|
1
|
-
|
|
2
|
-
const
|
|
3
|
-
const
|
|
4
|
-
const
|
|
5
|
-
require('
|
|
1
|
+
// apps/nextblock/scripts/backup.js
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { spawn } = require('child_process');
|
|
5
|
+
const readline = require('readline');
|
|
6
6
|
|
|
7
|
-
//
|
|
8
|
-
|
|
7
|
+
// Load environment (database connection string should be in .env.local or .env)
|
|
8
|
+
require('dotenv').config({ path: '.env.local' }); // adjust path if needed
|
|
9
9
|
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
// Get the database URL from env
|
|
11
|
+
const dbUrl = process.env.POSTGRES_URL || process.env.DATABASE_URL;
|
|
12
|
+
if (!dbUrl) {
|
|
13
|
+
console.error("❌ No database connection URL found in environment.");
|
|
14
|
+
process.exit(1);
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
// Parse the connection URL to extract components
|
|
18
|
+
let connectionUrl;
|
|
19
|
+
try {
|
|
20
|
+
// Ensure the URL scheme is correct for Node's URL parser
|
|
21
|
+
connectionUrl = new URL(dbUrl);
|
|
22
|
+
} catch (err) {
|
|
23
|
+
console.error('Error parsing database URL:', err.message);
|
|
24
|
+
// In case of a parsing error, try prefixing with 'postgresql://' (if not already)
|
|
25
|
+
if (!dbUrl.startsWith('postgresql://') && dbUrl.startsWith('postgres://')) {
|
|
26
|
+
connectionUrl = new URL(dbUrl.replace(/^postgres:\/\//, 'postgresql://'));
|
|
27
|
+
} else {
|
|
28
|
+
console.error("❌ Invalid database URL format.");
|
|
29
|
+
process.exit(1);
|
|
25
30
|
}
|
|
31
|
+
}
|
|
26
32
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
33
|
+
const host = connectionUrl.hostname;
|
|
34
|
+
const port = connectionUrl.port || '5432';
|
|
35
|
+
const dbName = connectionUrl.pathname.replace(/^\//, ''); // strip leading '/'
|
|
36
|
+
const user = connectionUrl.username;
|
|
37
|
+
const password = connectionUrl.password;
|
|
38
|
+
const sslMode = connectionUrl.searchParams.get('sslmode') || 'require'; // default to require SSL
|
|
39
|
+
|
|
40
|
+
// Prepare backup directory with timestamp name
|
|
41
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); // replace colon and dot with hyphen for filename safety
|
|
42
|
+
|
|
43
|
+
function sanitizeName(rawName) {
|
|
44
|
+
if (!rawName) {
|
|
45
|
+
return '';
|
|
46
|
+
}
|
|
47
|
+
return rawName
|
|
48
|
+
.trim()
|
|
49
|
+
.replace(/[^\w.-]+/g, '-') // keep letters/numbers/_/./-
|
|
50
|
+
.replace(/-+/g, '-')
|
|
51
|
+
.replace(/^-|-$/g, '');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseNameFromArgs() {
|
|
55
|
+
const args = process.argv.slice(2);
|
|
56
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
57
|
+
const arg = args[i];
|
|
58
|
+
if (arg === '--name' || arg === '-n') {
|
|
59
|
+
return args[i + 1] ?? '';
|
|
60
|
+
}
|
|
61
|
+
const match = arg.match(/^--name=(.*)$/);
|
|
62
|
+
if (match) {
|
|
63
|
+
return match[1];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (typeof process.env.BACKUP_NAME === 'string') {
|
|
67
|
+
return process.env.BACKUP_NAME;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function promptForName(defaultLabel) {
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
const rl = readline.createInterface({
|
|
75
|
+
input: process.stdin,
|
|
76
|
+
output: process.stdout,
|
|
77
|
+
});
|
|
78
|
+
rl.question(
|
|
79
|
+
`Enter a friendly name for this backup (press Enter to use ${defaultLabel}): `,
|
|
80
|
+
(answer) => {
|
|
81
|
+
rl.close();
|
|
82
|
+
resolve(answer);
|
|
83
|
+
}
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildFolderName(baseName, rawName) {
|
|
89
|
+
const sanitized = sanitizeName(rawName);
|
|
90
|
+
if (!sanitized) {
|
|
91
|
+
return baseName;
|
|
92
|
+
}
|
|
93
|
+
return `${baseName}__${sanitized}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function resolveBackupFolderName(baseName) {
|
|
97
|
+
const providedName = parseNameFromArgs();
|
|
98
|
+
if (providedName !== null) {
|
|
99
|
+
return buildFolderName(baseName, providedName);
|
|
100
|
+
}
|
|
101
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
102
|
+
const answer = await promptForName(baseName);
|
|
103
|
+
return buildFolderName(baseName, answer);
|
|
104
|
+
}
|
|
105
|
+
return baseName;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
(async () => {
|
|
109
|
+
let backupFolderName;
|
|
110
|
+
try {
|
|
111
|
+
backupFolderName = await resolveBackupFolderName(timestamp);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
console.error(`❌ Failed to determine backup name: ${err.message}`);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const backupDir = path.join(__dirname, '../backups', backupFolderName);
|
|
118
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
119
|
+
const dumpFile = path.join(backupDir, 'dump.sql');
|
|
120
|
+
|
|
121
|
+
console.log(`🔄 Backing up database to ${dumpFile} ...`);
|
|
122
|
+
|
|
123
|
+
// Spawn the pg_dump process
|
|
124
|
+
const dumpArgs = [
|
|
125
|
+
'--clean', '--if-exists', '--quote-all-identifiers', // include DROP statements:contentReference[oaicite:8]{index=8}
|
|
126
|
+
'-h', host,
|
|
127
|
+
'-U', user,
|
|
128
|
+
'-p', port,
|
|
129
|
+
'-d', dbName,
|
|
130
|
+
'-f', dumpFile
|
|
131
|
+
];
|
|
132
|
+
const envVars = { ...process.env, PGPASSWORD: password, PGSSLMODE: sslMode };
|
|
133
|
+
const pgDump = spawn('pg_dump', dumpArgs, { env: envVars });
|
|
134
|
+
|
|
135
|
+
// Forward pg_dump errors to console
|
|
136
|
+
pgDump.stderr.on('data', (data) => {
|
|
137
|
+
process.stderr.write(data);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Handle process exit
|
|
141
|
+
pgDump.on('close', (code) => {
|
|
142
|
+
if (code === 0) {
|
|
143
|
+
console.log("✅ Backup completed successfully.");
|
|
144
|
+
} else {
|
|
145
|
+
console.error(`❌ pg_dump exited with code ${code}. Check error output above for details.`);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
})();
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// apps/nextblock/scripts/restore.js
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { spawn } = require('child_process');
|
|
5
|
+
const readline = require('readline');
|
|
6
|
+
|
|
7
|
+
// Load target database connection from env
|
|
8
|
+
require('dotenv').config({ path: '.env.local' }); // ensure this has the new DB credentials
|
|
9
|
+
|
|
10
|
+
const dbUrl = process.env.POSTGRES_URL || process.env.DATABASE_URL;
|
|
11
|
+
if (!dbUrl) {
|
|
12
|
+
console.error("❌ No database connection URL found for restore.");
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Parse connection URL for target DB (similar to backup.js)
|
|
17
|
+
let connectionUrl;
|
|
18
|
+
try {
|
|
19
|
+
connectionUrl = new URL(dbUrl);
|
|
20
|
+
} catch (err) {
|
|
21
|
+
if (!dbUrl.startsWith('postgresql://') && dbUrl.startsWith('postgres://')) {
|
|
22
|
+
connectionUrl = new URL(dbUrl.replace(/^postgres:\/\//, 'postgresql://'));
|
|
23
|
+
} else {
|
|
24
|
+
console.error("❌ Invalid database URL format:", err.message);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const host = connectionUrl.hostname;
|
|
29
|
+
const port = connectionUrl.port || '5432';
|
|
30
|
+
const dbName = connectionUrl.pathname.replace(/^\//, '');
|
|
31
|
+
const user = connectionUrl.username;
|
|
32
|
+
const password = connectionUrl.password;
|
|
33
|
+
const sslMode = connectionUrl.searchParams.get('sslmode') || 'require';
|
|
34
|
+
|
|
35
|
+
// List available backup folders
|
|
36
|
+
const backupsPath = path.join(__dirname, '../backups');
|
|
37
|
+
if (!fs.existsSync(backupsPath)) {
|
|
38
|
+
console.error("❌ Backups directory not found.");
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
const backupDirs = fs.readdirSync(backupsPath, { withFileTypes: true })
|
|
42
|
+
.filter(dirent => dirent.isDirectory())
|
|
43
|
+
.map(dirent => dirent.name);
|
|
44
|
+
// Sort by name (timestamp in name means lexicographical sort is chronological)
|
|
45
|
+
backupDirs.sort().reverse(); // latest first
|
|
46
|
+
|
|
47
|
+
if (backupDirs.length === 0) {
|
|
48
|
+
console.error("❌ No backups found in the backups directory.");
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Show list of backups
|
|
53
|
+
console.log("Available backups:");
|
|
54
|
+
backupDirs.forEach((dir, index) => {
|
|
55
|
+
console.log(`${index + 1}. ${dir}`);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Prompt user to choose a backup
|
|
59
|
+
const rl = readline.createInterface({
|
|
60
|
+
input: process.stdin,
|
|
61
|
+
output: process.stdout
|
|
62
|
+
});
|
|
63
|
+
rl.question("Enter the number of the backup to restore: ", (answer) => {
|
|
64
|
+
rl.close();
|
|
65
|
+
const choice = parseInt(answer.trim(), 10);
|
|
66
|
+
if (isNaN(choice) || choice < 1 || choice > backupDirs.length) {
|
|
67
|
+
console.error("❌ Invalid selection. Exiting.");
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const selectedDir = backupDirs[choice - 1];
|
|
72
|
+
const dumpFile = path.join(backupsPath, selectedDir, 'dump.sql');
|
|
73
|
+
if (!fs.existsSync(dumpFile)) {
|
|
74
|
+
console.error(`❌ dump.sql not found in backup folder: ${selectedDir}`);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
console.log(`🔄 Restoring database from backup "${selectedDir}"...`);
|
|
79
|
+
|
|
80
|
+
// Spawn psql to execute the dump file
|
|
81
|
+
const restoreArgs = [
|
|
82
|
+
'-h', host,
|
|
83
|
+
'-U', user,
|
|
84
|
+
'-p', port,
|
|
85
|
+
'-d', dbName,
|
|
86
|
+
'-f', dumpFile
|
|
87
|
+
];
|
|
88
|
+
const envVars = { ...process.env, PGPASSWORD: password, PGSSLMODE: sslMode };
|
|
89
|
+
const psql = spawn('psql', restoreArgs, { env: envVars });
|
|
90
|
+
|
|
91
|
+
// Forward output (especially errors)
|
|
92
|
+
psql.stdout.on('data', data => process.stdout.write(data));
|
|
93
|
+
psql.stderr.on('data', data => process.stderr.write(data));
|
|
94
|
+
|
|
95
|
+
psql.on('close', code => {
|
|
96
|
+
if (code === 0) {
|
|
97
|
+
console.log("✅ Restore completed successfully.");
|
|
98
|
+
} else {
|
|
99
|
+
console.error(`❌ psql exited with code ${code}. The restore may have errors.`);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
});
|