feat: Convert to fully static site with pre-generated routes

- Remove API routes (not needed for static export)
- Remove old [id] dynamic route
- Add generateStaticParams() to templates/[slug] route
- Enable output: 'export' for static site generation
- Pre-generate all 47 pages at build time (40 templates + 7 static pages)
- Ready for GitHub Pages deployment
This commit is contained in:
Hanzo Dev
2025-10-28 19:57:11 -07:00
parent 263e72e617
commit 978ad432e6
6 changed files with 44 additions and 680 deletions
-249
View File
@@ -1,249 +0,0 @@
'use client';
import Image from 'next/image';
import Link from 'next/link';
import { useParams } from 'next/navigation';
interface Template {
id: number;
name: string;
displayName: string;
path: string;
framework: string;
rating: number;
features: string[];
components: string;
easeOfSetup: number;
useCase: string;
tier: number;
hasScreenshot: boolean;
description?: string;
updatedDate?: string;
setupInstructions?: string[];
}
// Same templates array from main page (in production, this would come from a shared data file)
const templates: Template[] = [
// Tier 1 - Excellent (5 stars)
{
id: 1,
name: 'brainwave-update-May2024',
displayName: 'Brainwave',
path: 'brainwave-update-May2024',
framework: 'Next.js 14.2 + TS',
rating: 5,
features: ['AI Chat', 'Audio/Video', 'Photo Edit', 'Pricing'],
components: '50+ comp',
easeOfSetup: 5,
useCase: 'AI SaaS platforms',
tier: 1,
hasScreenshot: true,
description: 'Modern AI SaaS platform with chat, audio/video processing, and photo editing capabilities. Built with Next.js 14.2 and TypeScript for optimal performance.',
updatedDate: '2024-05-01',
setupInstructions: [
'cd /Users/z/work/hanzo/templates/brainwave-update-May2024',
'npm install',
'npm run dev',
'Open http://localhost:3000'
],
},
// Add other templates here (abbreviated for brevity)
];
export default function TemplateDetail() {
const params = useParams();
const templateId = parseInt(params.id as string);
const template = templates.find((t) => t.id === templateId);
if (!template) {
return (
<div className="min-h-screen bg-[#0a0a0a] text-white flex items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4">Template Not Found</h1>
<Link
href="/"
className="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors inline-block"
>
Back to Gallery
</Link>
</div>
</div>
);
}
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
alert('Copied to clipboard!');
};
return (
<div className="min-h-screen bg-[#0a0a0a] text-white">
<div className="container mx-auto px-4 py-8 max-w-6xl">
{/* Back Button */}
<Link
href="/"
className="inline-flex items-center gap-2 px-4 py-2 bg-white/5 border border-white/10 text-white rounded-lg hover:bg-white/10 transition-colors mb-8"
>
Back to Gallery
</Link>
{/* Header */}
<div className="mb-8">
<div className="flex items-start justify-between mb-4">
<div>
<h1 className="text-5xl font-bold mb-2 bg-gradient-to-r from-blue-400 via-purple-400 to-pink-400 bg-clip-text text-transparent">
{template.displayName}
</h1>
<p className="text-xl text-gray-400">{template.framework}</p>
</div>
<div className="flex gap-2">
{[...Array(5)].map((_, i) => (
<span key={i} className={`text-2xl ${i < template.rating ? 'text-yellow-400' : 'text-gray-600'}`}>
</span>
))}
</div>
</div>
{/* Tags */}
<div className="flex flex-wrap gap-2 mb-4">
<span className="px-3 py-1 bg-blue-500/20 border border-blue-500/30 rounded-lg text-blue-300 text-sm">
Tier {template.tier}
</span>
<span className="px-3 py-1 bg-purple-500/20 border border-purple-500/30 rounded-lg text-purple-300 text-sm">
{template.components}
</span>
<span className="px-3 py-1 bg-green-500/20 border border-green-500/30 rounded-lg text-green-300 text-sm">
Setup: {template.easeOfSetup}/5
</span>
</div>
</div>
{/* Main Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
{/* Screenshot */}
<div className="bg-white/5 backdrop-blur-lg rounded-xl border border-white/10 overflow-hidden">
<div className="relative aspect-video">
{template.hasScreenshot ? (
<Image
src={`/screenshots/${template.name}.png`}
alt={template.displayName}
fill
className="object-cover"
/>
) : (
<div className="flex items-center justify-center h-full text-gray-500">
Screenshot pending
</div>
)}
</div>
</div>
{/* Info Panel */}
<div className="space-y-6">
{/* Description */}
<div className="bg-white/5 backdrop-blur-lg p-6 rounded-xl border border-white/10">
<h2 className="text-2xl font-bold mb-3 text-white">Description</h2>
<p className="text-gray-300 leading-relaxed">
{template.description || 'No description available.'}
</p>
</div>
{/* Use Case */}
<div className="bg-white/5 backdrop-blur-lg p-6 rounded-xl border border-white/10">
<h2 className="text-2xl font-bold mb-3 text-white">Best For</h2>
<p className="text-gray-300">{template.useCase}</p>
</div>
{/* Action Buttons */}
<div className="flex flex-col gap-3">
<button
onClick={() => copyToClipboard(`/Users/z/work/hanzo/templates/${template.path}`)}
className="w-full px-6 py-4 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors font-medium text-lg flex items-center justify-center gap-2"
>
📋 Copy Template Path
</button>
<button
onClick={() => {
alert('Fork on Hanzo feature coming soon! This would deploy the template to Hanzo Platform.');
}}
className="w-full px-6 py-4 bg-gradient-to-r from-purple-500 to-pink-500 text-white rounded-lg hover:from-purple-600 hover:to-pink-600 transition-all font-medium text-lg flex items-center justify-center gap-2"
>
🚀 Fork on Hanzo
</button>
</div>
</div>
</div>
{/* Features */}
<div className="bg-white/5 backdrop-blur-lg p-6 rounded-xl border border-white/10 mb-8">
<h2 className="text-2xl font-bold mb-4 text-white">Features</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{template.features.map((feature, i) => (
<div
key={i}
className="px-4 py-3 bg-white/5 border border-white/10 rounded-lg text-center text-gray-300 hover:bg-white/10 transition-colors"
>
{feature}
</div>
))}
</div>
</div>
{/* Setup Instructions */}
{template.setupInstructions && (
<div className="bg-white/5 backdrop-blur-lg p-6 rounded-xl border border-white/10 mb-8">
<h2 className="text-2xl font-bold mb-4 text-white">Setup Instructions</h2>
<div className="space-y-3">
{template.setupInstructions.map((instruction, i) => (
<div key={i} className="flex items-start gap-3">
<span className="flex-shrink-0 w-8 h-8 bg-blue-500/20 border border-blue-500/30 rounded-full flex items-center justify-center text-blue-300 text-sm">
{i + 1}
</span>
<code className="flex-1 px-4 py-2 bg-black/30 rounded-lg text-gray-300 font-mono text-sm">
{instruction}
</code>
<button
onClick={() => copyToClipboard(instruction)}
className="flex-shrink-0 px-3 py-2 bg-white/5 border border-white/10 text-white rounded-lg hover:bg-white/10 transition-colors text-sm"
>
📋
</button>
</div>
))}
</div>
</div>
)}
{/* Tech Stack */}
<div className="bg-white/5 backdrop-blur-lg p-6 rounded-xl border border-white/10">
<h2 className="text-2xl font-bold mb-4 text-white">Tech Stack</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h3 className="text-lg font-semibold text-gray-300 mb-2">Framework</h3>
<p className="text-blue-400">{template.framework}</p>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-300 mb-2">Components</h3>
<p className="text-gray-300">{template.components}</p>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-300 mb-2">Last Updated</h3>
<p className="text-gray-300">{template.updatedDate || 'N/A'}</p>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-300 mb-2">Ease of Setup</h3>
<div className="flex gap-1">
{[...Array(5)].map((_, i) => (
<span key={i} className={i < template.easeOfSetup ? 'text-green-400' : 'text-gray-600'}>
</span>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
}
-169
View File
@@ -1,169 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { exec } from 'child_process';
import { promisify } from 'util';
import fs from 'fs';
import path from 'path';
const execAsync = promisify(exec);
const TEMPLATES_ROOT = path.resolve(process.cwd(), '../');
const PREVIEWS_DIR = path.resolve(process.cwd(), 'public/previews');
interface BuildConfig {
buildCmd: string | null;
outputDir: string;
installCmd: string | null;
}
const BUILD_CONFIGS: Record<string, BuildConfig> = {
'HTML/Gulp': {
buildCmd: './node_modules/.bin/gulp build || npx gulp build',
outputDir: 'build',
installCmd: 'npm install',
},
'HTML/CSS': {
buildCmd: null, // Just copy files
outputDir: '.',
installCmd: null,
},
'React': {
buildCmd: 'npm run build',
outputDir: 'build',
installCmd: 'npm install --legacy-peer-deps',
},
'Next.js': {
buildCmd: 'npm run build && npm run export',
outputDir: 'out',
installCmd: 'npm install',
},
};
function getBuildConfig(framework: string): BuildConfig {
if (framework.includes('Gulp')) return BUILD_CONFIGS['HTML/Gulp'];
if (framework.includes('Next')) return BUILD_CONFIGS['Next.js'];
if (framework.includes('React')) return BUILD_CONFIGS['React'];
if (framework.includes('HTML')) return BUILD_CONFIGS['HTML/CSS'];
return BUILD_CONFIGS['HTML/CSS'];
}
function copyDir(src: string, dest: string) {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDir(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
export async function POST(request: NextRequest) {
try {
const { templateName, templatePath, framework } = await request.json();
if (!templateName || !templatePath || !framework) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
}
const previewPath = path.join(PREVIEWS_DIR, templateName);
// Check if already built
if (fs.existsSync(previewPath) && fs.existsSync(path.join(previewPath, 'index.html'))) {
return NextResponse.json({
success: true,
cached: true,
previewUrl: `/previews/${templateName}/index.html`,
});
}
const templateFullPath = path.join(TEMPLATES_ROOT, templatePath);
// Check if template path exists
if (!fs.existsSync(templateFullPath)) {
return NextResponse.json(
{ error: `Template path not found: ${templatePath}` },
{ status: 404 }
);
}
const config = getBuildConfig(framework);
const buildOutput = path.join(templateFullPath, config.outputDir);
// Check if template is pre-built (build directory exists but no package.json)
const packageJson = path.join(templateFullPath, 'package.json');
const isPreBuilt = fs.existsSync(buildOutput) && !fs.existsSync(packageJson);
if (!isPreBuilt) {
// Install dependencies if needed
if (config.installCmd && fs.existsSync(packageJson)) {
try {
await execAsync(config.installCmd, {
cwd: templateFullPath,
timeout: 300000, // 5 minutes
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
return NextResponse.json(
{ error: `Dependency installation failed: ${message}` },
{ status: 500 }
);
}
}
// Build the template
if (config.buildCmd) {
try {
await execAsync(config.buildCmd, {
cwd: templateFullPath,
timeout: 600000, // 10 minutes
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
return NextResponse.json(
{ error: `Build failed: ${message}` },
{ status: 500 }
);
}
}
}
if (!fs.existsSync(buildOutput)) {
return NextResponse.json(
{ error: `Build output directory not found: ${config.outputDir}` },
{ status: 500 }
);
}
// Remove existing preview if it exists
if (fs.existsSync(previewPath)) {
fs.rmSync(previewPath, { recursive: true, force: true });
}
copyDir(buildOutput, previewPath);
return NextResponse.json({
success: true,
cached: false,
previewUrl: `/previews/${templateName}/index.html`,
});
} catch (error: unknown) {
console.error('Build error:', error);
const message = error instanceof Error ? error.message : 'Build failed';
return NextResponse.json(
{ error: message },
{ status: 500 }
);
}
}
-153
View File
@@ -1,153 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { templates } from '../../../templates-data';
import archiver from 'archiver';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { DownloadParamsSchema } from '@/app/lib/validation';
interface RouteParams {
params: Promise<{
template: string;
}>;
}
export async function GET(
request: NextRequest,
{ params }: RouteParams
) {
try {
const { template: templateName } = await params;
// Validate template identifier
try {
DownloadParamsSchema.parse({ template: templateName });
} catch {
return NextResponse.json(
{ error: 'Invalid template identifier' },
{ status: 400 }
);
}
// Find the template by slug
const foundTemplate = templates.find(t => t.slug === decodeURIComponent(templateName));
if (!foundTemplate) {
return NextResponse.json(
{ error: 'Template not found' },
{ status: 404 }
);
}
// Template path (absolute)
// Use environment variable or resolve relative to project root
const TEMPLATES_ROOT = process.env.TEMPLATES_ROOT || path.resolve(process.cwd(), '../../');
const templatePath = path.join(TEMPLATES_ROOT, foundTemplate.path);
// Check if path exists
if (!fs.existsSync(templatePath)) {
return NextResponse.json(
{ error: 'Template path does not exist', path: templatePath },
{ status: 404 }
);
}
// Create a temporary directory for the zip
// Use environment variable or system temp directory
const tmpDir = process.env.TEMP_DIR || path.join(os.tmpdir(), 'hanzo-template-downloads');
if (!fs.existsSync(tmpDir)) {
fs.mkdirSync(tmpDir, { recursive: true });
}
// Sanitize filename
const safeTemplateName = foundTemplate.name.replace(/[^a-zA-Z0-9-_]/g, '-');
const zipFileName = `${safeTemplateName}-hanzo.zip`;
const zipPath = path.join(tmpDir, zipFileName);
// Remove old zip if exists
if (fs.existsSync(zipPath)) {
fs.unlinkSync(zipPath);
}
// Create zip using archiver (safer than shell commands)
const output = fs.createWriteStream(zipPath);
const archive = archiver('zip', {
zlib: { level: parseInt(process.env.ZIP_COMPRESSION_LEVEL || '9') }
});
// Handle events
output.on('close', () => {
console.log(`Created zip: ${archive.pointer()} total bytes`);
});
archive.on('error', (err) => {
throw err;
});
// Pipe archive to output file
archive.pipe(output);
// Add template directory with exclusions
archive.glob('**/*', {
cwd: templatePath,
ignore: [
'node_modules/**',
'.next/**',
'.git/**',
'build/**',
'dist/**',
'**/.DS_Store',
'**/*.log',
'**/.env.local',
'**/.env'
]
});
// Finalize the archive and wait for it to complete
try {
await archive.finalize();
} catch (error) {
console.error('Zip creation error:', error);
return NextResponse.json(
{ error: 'Failed to create zip file', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
);
}
// Wait for the output stream to finish writing
await new Promise<void>((resolve, reject) => {
output.on('finish', resolve);
output.on('error', reject);
});
// Read the zip file
const zipBuffer = fs.readFileSync(zipPath);
// Clean up the zip file after a delay
setTimeout(() => {
try {
if (fs.existsSync(zipPath)) {
fs.unlinkSync(zipPath);
}
} catch (error) {
console.error('Failed to clean up zip file:', error);
}
}, 60000); // Clean up after 1 minute
// Return the zip file
return new NextResponse(zipBuffer, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${zipFileName}"`,
'Content-Length': zipBuffer.length.toString(),
},
});
} catch (error) {
console.error('Download error:', error);
return NextResponse.json(
{ error: 'Internal server error', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
);
}
}
-74
View File
@@ -1,74 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
const PREVIEWS_DIR = path.resolve(process.cwd(), 'public/previews');
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const templateName = searchParams.get('template');
if (!templateName) {
return NextResponse.json(
{ error: 'Template name required' },
{ status: 400 }
);
}
const previewPath = path.join(PREVIEWS_DIR, templateName);
// Check if preview exists
if (!fs.existsSync(previewPath)) {
return NextResponse.json(
{ error: 'Template not built yet', pages: [] },
{ status: 404 }
);
}
try {
// Find all HTML files recursively
const htmlFiles: string[] = [];
function findHtmlFiles(dir: string, baseDir: string = dir) {
const files = fs.readdirSync(dir, { withFileTypes: true });
for (const file of files) {
const fullPath = path.join(dir, file.name);
if (file.isDirectory()) {
// Skip common build artifact directories
if (['node_modules', '.next', 'dist', 'build'].includes(file.name)) {
continue;
}
findHtmlFiles(fullPath, baseDir);
} else if (file.name.endsWith('.html')) {
// Get relative path from preview directory
const relativePath = path.relative(baseDir, fullPath);
htmlFiles.push(relativePath);
}
}
}
findHtmlFiles(previewPath);
// Sort files: index.html first, then alphabetically
htmlFiles.sort((a, b) => {
if (a === 'index.html') return -1;
if (b === 'index.html') return 1;
return a.localeCompare(b);
});
return NextResponse.json({
success: true,
pages: htmlFiles.length > 0 ? htmlFiles : ['index.html'],
count: htmlFiles.length,
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
return NextResponse.json(
{ error: message, pages: [] },
{ status: 500 }
);
}
}
+43 -35
View File
@@ -1,35 +1,43 @@
import { templates } from '../../templates-data';
import { notFound } from 'next/navigation';
import { getTemplateVariants, getUniqueTemplates } from '../../lib/template-utils';
import { TemplatePageClient } from './TemplatePageClient';
interface PageProps {
params: Promise<{ slug: string }>;
}
export default async function TemplatePage({ params }: PageProps) {
const { slug } = await params;
const allVariants = getTemplateVariants(templates, slug);
if (allVariants.length === 0) {
notFound();
}
// Get unique templates for navigation
const uniqueTemplates = getUniqueTemplates(templates);
const currentIndex = uniqueTemplates.findIndex(t => t.slug === slug);
const prevTemplate = currentIndex > 0 ? uniqueTemplates[currentIndex - 1] : null;
const nextTemplate = currentIndex < uniqueTemplates.length - 1 ? uniqueTemplates[currentIndex + 1] : null;
return (
<TemplatePageClient
variants={allVariants}
prevTemplate={prevTemplate}
nextTemplate={nextTemplate}
currentIndex={currentIndex + 1}
totalTemplates={uniqueTemplates.length}
allTemplates={uniqueTemplates}
/>
);
}
import { templates } from '../../templates-data';
import { notFound } from 'next/navigation';
import { getTemplateVariants, getUniqueTemplates } from '../../lib/template-utils';
import { TemplatePageClient } from './TemplatePageClient';
interface PageProps {
params: Promise<{ slug: string }>;
}
// Generate static params for all templates at build time
export async function generateStaticParams() {
const uniqueTemplates = getUniqueTemplates(templates);
return uniqueTemplates.map((template) => ({
slug: template.slug,
}));
}
export default async function TemplatePage({ params }: PageProps) {
const { slug } = await params;
const allVariants = getTemplateVariants(templates, slug);
if (allVariants.length === 0) {
notFound();
}
// Get unique templates for navigation
const uniqueTemplates = getUniqueTemplates(templates);
const currentIndex = uniqueTemplates.findIndex(t => t.slug === slug);
const prevTemplate = currentIndex > 0 ? uniqueTemplates[currentIndex - 1] : null;
const nextTemplate = currentIndex < uniqueTemplates.length - 1 ? uniqueTemplates[currentIndex + 1] : null;
return (
<TemplatePageClient
variants={allVariants}
prevTemplate={prevTemplate}
nextTemplate={nextTemplate}
currentIndex={currentIndex + 1}
totalTemplates={uniqueTemplates.length}
allTemplates={uniqueTemplates}
/>
);
}
+1
View File
@@ -1,6 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: 'export',
images: {
unoptimized: true,
},