diff --git a/Dockerfile.agent.example b/Dockerfile.agent.example new file mode 100644 index 0000000..e1a416a --- /dev/null +++ b/Dockerfile.agent.example @@ -0,0 +1,16 @@ +FROM node:20-slim + +WORKDIR /app + +# Install dependencies first (for better caching) +COPY package*.json ./ +RUN npm install + +# Copy application files +COPY . . + +# Expose Next.js dev port +EXPOSE 3000 + +# Start Next.js in dev mode with hot reload +CMD ["npm", "run", "dev"] diff --git a/docker-compose.agent.example.yml b/docker-compose.agent.example.yml new file mode 100644 index 0000000..078aca3 --- /dev/null +++ b/docker-compose.agent.example.yml @@ -0,0 +1,73 @@ +# Example docker-compose.agent.yml for running the generated clone +# Copy and adapt this file to your project's needs +# +# Key things to customize: +# - POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB (database credentials) +# - The dump file name in db-init (e.g., postgres_backup.dump) +# - Volume mounts for your app structure + +services: + db: + image: postgres:16 + ports: + - "5433:5432" + environment: + POSTGRES_USER: myuser + POSTGRES_PASSWORD: mypassword + POSTGRES_DB: mydb + volumes: + - postgres_data_agent:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U myuser -d mydb"] + interval: 5s + timeout: 5s + retries: 10 + + db-init: + image: postgres:16 + depends_on: + db: + condition: service_healthy + volumes: + - .:/data:ro + environment: + PGPASSWORD: mypassword + command: > + sh -c " + TABLE_COUNT=$$(psql -h db -U myuser -d mydb -tAc \"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public';\" | tr -d '[:space:]'); + if [ \"$$TABLE_COUNT\" = \"0\" ] && [ -f /data/postgres_backup.dump ]; then + echo 'Empty database and dump file found. Restoring...'; + pg_restore -h db -U myuser -d mydb --no-owner --clean --if-exists /data/postgres_backup.dump 2>&1 || echo 'Restore completed'; + else + echo 'Database already initialized or no dump file found. TABLE_COUNT=$$TABLE_COUNT'; + fi" + + app: + build: + context: . + dockerfile: Dockerfile + ports: + - "3000:3000" + volumes: + - ./app:/app/app + - ./components:/app/components + - ./lib:/app/lib + - ./public:/app/public + - ./styles:/app/styles + environment: + NODE_ENV: development + DB_HOST: db + DB_PORT: 5432 + DB_USER: myuser + DB_PASSWORD: mypassword + DB_DATABASE: mydb + DATABASE_URL: postgresql://myuser:mypassword@db:5432/mydb + NEXT_PUBLIC_API_URL: "" + depends_on: + db: + condition: service_healthy + db-init: + condition: service_completed_successfully + +volumes: + postgres_data_agent: diff --git a/frontend-nextjs/app/api/images/products/[id]/route.ts b/frontend-nextjs/app/api/images/products/[id]/route.ts new file mode 100644 index 0000000..a7d9e3d --- /dev/null +++ b/frontend-nextjs/app/api/images/products/[id]/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Pool } from "pg"; + +const pool = new Pool({ + host: process.env.DB_HOST || "127.0.0.1", + port: parseInt(process.env.DB_PORT || "5432"), + user: process.env.DB_USER || "myuser", + password: process.env.DB_PASSWORD || "mypassword", + database: process.env.DB_DATABASE || "mydb", +}); + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const { id } = params; + + // Try to get image from database (stored as bytea or base64) + const result = await pool.query( + `SELECT image_blob, image_mime_type + FROM products + WHERE id = $1`, + [id] + ); + + if (result.rows.length === 0 || !result.rows[0].image_blob) { + // Return a placeholder image or 404 + return NextResponse.json( + { error: "Image not found" }, + { status: 404 } + ); + } + + const { image_blob, image_mime_type } = result.rows[0]; + const mimeType = image_mime_type || "image/jpeg"; + + // If stored as base64 string, decode it + let imageBuffer: Buffer; + if (typeof image_blob === "string") { + // Remove data URL prefix if present + const base64Data = image_blob.replace(/^data:image\/\w+;base64,/, ""); + imageBuffer = Buffer.from(base64Data, "base64"); + } else { + // Already a Buffer (bytea from Postgres) + imageBuffer = Buffer.from(image_blob); + } + + return new NextResponse(imageBuffer, { + headers: { + "Content-Type": mimeType, + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); + } catch (error) { + console.error("Error fetching image:", error); + return NextResponse.json( + { error: "Failed to fetch image" }, + { status: 500 } + ); + } +} diff --git a/frontend-nextjs/app/api/products/[id]/route.ts b/frontend-nextjs/app/api/products/[id]/route.ts new file mode 100644 index 0000000..74ca6f8 --- /dev/null +++ b/frontend-nextjs/app/api/products/[id]/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Pool } from "pg"; + +const pool = new Pool({ + host: process.env.DB_HOST || "127.0.0.1", + port: parseInt(process.env.DB_PORT || "5432"), + user: process.env.DB_USER || "myuser", + password: process.env.DB_PASSWORD || "mypassword", + database: process.env.DB_DATABASE || "mydb", +}); + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const { id } = params; + + const result = await pool.query( + `SELECT id, title, price_cents, slug, description + FROM products + WHERE id = $1`, + [id] + ); + + if (result.rows.length === 0) { + return NextResponse.json( + { error: "Product not found" }, + { status: 404 } + ); + } + + return NextResponse.json(result.rows[0]); + } catch (error) { + console.error("Error fetching product:", error); + return NextResponse.json( + { error: "Failed to fetch product" }, + { status: 500 } + ); + } +} diff --git a/frontend-nextjs/app/api/products/route.ts b/frontend-nextjs/app/api/products/route.ts new file mode 100644 index 0000000..7d0332d --- /dev/null +++ b/frontend-nextjs/app/api/products/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { Pool } from "pg"; + +// Database connection - uses environment variables +const pool = new Pool({ + host: process.env.DB_HOST || "127.0.0.1", + port: parseInt(process.env.DB_PORT || "5432"), + user: process.env.DB_USER || "myuser", + password: process.env.DB_PASSWORD || "mypassword", + database: process.env.DB_DATABASE || "mydb", +}); + +export async function GET() { + try { + const result = await pool.query(` + SELECT id, title, price_cents, slug + FROM products + ORDER BY id + LIMIT 100 + `); + + return NextResponse.json(result.rows); + } catch (error) { + console.error("Error fetching products:", error); + return NextResponse.json( + { error: "Failed to fetch products" }, + { status: 500 } + ); + } +} diff --git a/frontend-nextjs/app/globals.css b/frontend-nextjs/app/globals.css new file mode 100644 index 0000000..152175d --- /dev/null +++ b/frontend-nextjs/app/globals.css @@ -0,0 +1,15 @@ +:root { + color-scheme: light; +} + +html, body { + margin: 0; + padding: 0; + font-family: system-ui, -apple-system, Segoe UI, sans-serif; + background: #f7f7f5; + color: #1d1d1f; +} + +main { + min-height: 100vh; +} diff --git a/frontend-nextjs/app/layout.tsx b/frontend-nextjs/app/layout.tsx new file mode 100644 index 0000000..becf5d4 --- /dev/null +++ b/frontend-nextjs/app/layout.tsx @@ -0,0 +1,9 @@ +import "./globals.css"; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/frontend-nextjs/app/listing/[product_id]/[slug]/page.tsx b/frontend-nextjs/app/listing/[product_id]/[slug]/page.tsx new file mode 100644 index 0000000..32f174b --- /dev/null +++ b/frontend-nextjs/app/listing/[product_id]/[slug]/page.tsx @@ -0,0 +1,27 @@ +import Header from "@/components/Header"; +import Footer from "@/components/Footer"; +import { fetchProduct } from "@/lib/api"; + +export default async function Page({ params }: { params: { product_id: string; slug: string } }) { + const product = await fetchProduct(params.product_id); + + return ( +
+
+
+
+
+

{product.title}

+

${(product.price_cents / 100).toFixed(2)}

+
+
+
+ ); +} diff --git a/frontend-nextjs/app/page.tsx b/frontend-nextjs/app/page.tsx new file mode 100644 index 0000000..4694229 --- /dev/null +++ b/frontend-nextjs/app/page.tsx @@ -0,0 +1,23 @@ +import Header from "@/components/Header"; +import Footer from "@/components/Footer"; +import ProductCard from "@/components/ProductCard"; +import { fetchProducts } from "@/lib/api"; + +export default async function Page() { + const products = await fetchProducts(); + + return ( +
+
+
+

Featured products

+
+ {products.slice(0, 8).map((product) => ( + + ))} +
+
+
+ ); +} diff --git a/frontend-nextjs/components/Footer.tsx b/frontend-nextjs/components/Footer.tsx new file mode 100644 index 0000000..871ddaa --- /dev/null +++ b/frontend-nextjs/components/Footer.tsx @@ -0,0 +1,7 @@ +export default function Footer() { + return ( + + ); +} diff --git a/frontend-nextjs/components/Header.tsx b/frontend-nextjs/components/Header.tsx new file mode 100644 index 0000000..ccb79d7 --- /dev/null +++ b/frontend-nextjs/components/Header.tsx @@ -0,0 +1,13 @@ +export default function Header() { + return ( +
+
+
Brand
+ +
+
+ ); +} diff --git a/frontend-nextjs/components/ProductCard.tsx b/frontend-nextjs/components/ProductCard.tsx new file mode 100644 index 0000000..dc63535 --- /dev/null +++ b/frontend-nextjs/components/ProductCard.tsx @@ -0,0 +1,27 @@ +import { Product } from "@/lib/api"; + +export default function ProductCard({ product }: { product: Product }) { + return ( +
+
+

{product.title}

+
${(product.price_cents / 100).toFixed(2)}
+ View +
+ ); +} diff --git a/frontend-nextjs/lib/api.ts b/frontend-nextjs/lib/api.ts new file mode 100644 index 0000000..c3f2485 --- /dev/null +++ b/frontend-nextjs/lib/api.ts @@ -0,0 +1,22 @@ +export type Product = { + id: number; + title: string; + price_cents: number; + image_url: string; +}; + +export async function fetchProducts(): Promise { + const res = await fetch("/api/products", { cache: "no-store" }); + if (!res.ok) { + throw new Error("Failed to fetch products"); + } + return res.json(); +} + +export async function fetchProduct(productId: string): Promise { + const res = await fetch(`/api/products/${productId}`, { cache: "no-store" }); + if (!res.ok) { + throw new Error("Failed to fetch product"); + } + return res.json(); +} diff --git a/frontend-nextjs/lib/db.ts b/frontend-nextjs/lib/db.ts new file mode 100644 index 0000000..e008a65 --- /dev/null +++ b/frontend-nextjs/lib/db.ts @@ -0,0 +1,29 @@ +import { Pool } from "pg"; + +// Singleton database connection pool +// Uses environment variables for configuration + +const pool = new Pool({ + host: process.env.DB_HOST || "127.0.0.1", + port: parseInt(process.env.DB_PORT || "5432"), + user: process.env.DB_USER || "myuser", + password: process.env.DB_PASSWORD || "mypassword", + database: process.env.DB_DATABASE || "mydb", +}); + +export default pool; + +// Helper function for queries +export async function query(text: string, params?: unknown[]): Promise { + const result = await pool.query(text, params); + return result.rows as T[]; +} + +// Helper function for single row +export async function queryOne( + text: string, + params?: unknown[] +): Promise { + const result = await pool.query(text, params); + return result.rows[0] as T | null; +} diff --git a/frontend-nextjs/next-env.d.ts b/frontend-nextjs/next-env.d.ts new file mode 100644 index 0000000..6080add --- /dev/null +++ b/frontend-nextjs/next-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/frontend-nextjs/next.config.js b/frontend-nextjs/next.config.js new file mode 100644 index 0000000..6d32668 --- /dev/null +++ b/frontend-nextjs/next.config.js @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true +}; + +module.exports = nextConfig; diff --git a/frontend-nextjs/package.json b/frontend-nextjs/package.json new file mode 100644 index 0000000..569cde1 --- /dev/null +++ b/frontend-nextjs/package.json @@ -0,0 +1,22 @@ +{ + "name": "webclone-nextjs-sample", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "next": "^14.2.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "pg": "^8.11.0" + }, + "devDependencies": { + "typescript": "^5.5.0", + "@types/node": "^20.16.0", + "@types/react": "^18.3.0", + "@types/pg": "^8.11.0" + } +} diff --git a/frontend-nextjs/tsconfig.json b/frontend-nextjs/tsconfig.json new file mode 100644 index 0000000..52955fc --- /dev/null +++ b/frontend-nextjs/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +}