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.
Files changed (42) hide show
  1. package/bin/create-nextblock.js +1193 -920
  2. package/package.json +6 -2
  3. package/scripts/sync-template.js +279 -276
  4. package/templates/nextblock-template/.env.example +1 -14
  5. package/templates/nextblock-template/README.md +1 -1
  6. package/templates/nextblock-template/app/[slug]/PageClientContent.tsx +67 -40
  7. package/templates/nextblock-template/app/[slug]/page.tsx +45 -10
  8. package/templates/nextblock-template/app/[slug]/page.utils.ts +92 -45
  9. package/templates/nextblock-template/app/api/revalidate/route.ts +15 -15
  10. package/templates/nextblock-template/app/{blog → article}/[slug]/PostClientContent.tsx +45 -43
  11. package/templates/nextblock-template/app/{blog → article}/[slug]/page.tsx +108 -98
  12. package/templates/nextblock-template/app/{blog → article}/[slug]/page.utils.ts +10 -3
  13. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +25 -19
  14. package/templates/nextblock-template/app/cms/blocks/actions.ts +1 -1
  15. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +1 -1
  16. package/templates/nextblock-template/app/cms/posts/actions.ts +47 -44
  17. package/templates/nextblock-template/app/cms/posts/page.tsx +2 -2
  18. package/templates/nextblock-template/app/cms/settings/languages/actions.ts +16 -15
  19. package/templates/nextblock-template/app/layout.tsx +9 -9
  20. package/templates/nextblock-template/app/lib/sitemap-utils.ts +52 -52
  21. package/templates/nextblock-template/app/sitemap.xml/route.ts +2 -2
  22. package/templates/nextblock-template/components/ResponsiveNav.tsx +22 -16
  23. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -7
  24. package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +25 -26
  25. package/templates/nextblock-template/package.json +1 -1
  26. package/templates/nextblock-template/proxy.ts +4 -4
  27. package/templates/nextblock-template/public/images/NBcover.webp +0 -0
  28. package/templates/nextblock-template/public/images/developer.webp +0 -0
  29. package/templates/nextblock-template/public/images/nextblock-logo-small.webp +0 -0
  30. package/templates/nextblock-template/public/images/nx-graph.webp +0 -0
  31. package/templates/nextblock-template/public/images/programmer-upscaled.webp +0 -0
  32. package/templates/nextblock-template/scripts/backup.js +142 -47
  33. package/templates/nextblock-template/scripts/restore-working.js +102 -0
  34. package/templates/nextblock-template/scripts/restore.js +434 -0
  35. package/templates/nextblock-template/app/blog/page.tsx +0 -77
  36. package/templates/nextblock-template/backup/backup_2025-06-19.sql +0 -8057
  37. package/templates/nextblock-template/backup/backup_2025-06-20.sql +0 -8159
  38. package/templates/nextblock-template/backup/backup_2025-07-08.sql +0 -8411
  39. package/templates/nextblock-template/backup/backup_2025-07-09.sql +0 -8442
  40. package/templates/nextblock-template/backup/backup_2025-07-10.sql +0 -8442
  41. package/templates/nextblock-template/backup/backup_2025-10-01.sql +0 -8803
  42. 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: `/blog/${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
- }
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: '/blog', lastModified: new Date().toISOString() },
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 || 'Home'}
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
- <span className="text-xl font-semibold text-foreground">
272
- {siteTitle}
273
- </span>
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
- initialPosts = (postsData as any)?.map((p: any) => {
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 PostsGridClient: React.FC<PostsGridClientProps> = ({
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={`/blog/${post.slug}`} key={post.id} className="block group">
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 && typeof post.feature_image_width === 'number' && typeof post.feature_image_height === 'number' && post.feature_image_width > 0 && post.feature_image_height > 0 ? (
122
- <div className="aspect-video overflow-hidden"> {/* Or other aspect ratio as desired, e.g., aspect-[16/9] or aspect-square */}
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
- ) : post.feature_image_url ? (
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;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextblock-cms/template",
3
- "version": "0.0.0",
3
+ "version": "0.1.0",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "dev": "next dev",
@@ -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 === '/blog') {
163
- finalResponse.headers.set('X-Page-Type', 'blog-index');
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('/blog/')) {
166
- finalResponse.headers.set('X-Page-Type', 'blog-post');
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);
@@ -1,53 +1,148 @@
1
- const { exec } = require('child_process');
2
- const { URL } = require('url');
3
- const fs = require('fs');
4
- const path = require('path');
5
- require('dotenv').config({ path: '.env.local' });
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
- // Backup directory
8
- const backupDir = path.join(__dirname, '..', 'backup');
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
- // Create backup directory if it doesn't exist
11
- if (!fs.existsSync(backupDir)) {
12
- fs.mkdirSync(backupDir);
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
- // Backup file name
16
- const backupFile = path.join(backupDir, `backup_${new Date().toISOString().slice(0, 10)}.sql`);
17
-
18
- // Function to perform the backup
19
- const backupDatabase = () => {
20
- const connectionString = process.env.POSTGRES_URL;
21
-
22
- if (!connectionString) {
23
- console.error('Error: Missing POSTGRES_URL in .env.local file.');
24
- return;
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
- // Parse the URL and remove non-standard parameters for pg_dump
28
- const url = new URL(connectionString);
29
- url.searchParams.delete('supa'); // Remove supa parameter
30
- const finalConnectionString = url.toString();
31
-
32
- const command = `pg_dump "${finalConnectionString}" > "${backupFile}"`;
33
-
34
- console.log('Starting database backup...');
35
-
36
- exec(command, (error, stdout, stderr) => {
37
- if (error) {
38
- console.error(`Error executing backup: ${error.message}`);
39
- if (stderr) {
40
- console.error(`pg_dump error: ${stderr}`);
41
- }
42
- return;
43
- }
44
- if (stderr && stderr.toLowerCase().includes('error')) {
45
- console.error(`Error: ${stderr}`);
46
- return;
47
- }
48
- console.log(`Backup successful! File saved as ${backupFile}`);
49
- });
50
- };
51
-
52
- // Run the backup
53
- backupDatabase();
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
+ });