Initial commit for resumeformatter project

This commit is contained in:
Laxmi Khilnani
2025-10-14 19:51:35 +05:30
commit ee030b70bc
43 changed files with 1668 additions and 0 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
# Application name used for routing and Docker image name
APP_NAME=NEW_APP_NAME

View File

@@ -0,0 +1,87 @@
name: Profile Linker Docker Build
on:
push:
branches: [dev, main]
pull_request:
branches: [dev, main]
env:
APP_NAME: NEW_APP_NAME
REGISTRY_USERNAME: ${{ vars.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_TOKEN }}
jobs:
deploy:
runs-on: ubuntu-latest
env:
BRANCH_NAME: ${{ gitea.ref_name }}
name: Build and push Docker image
steps:
- name: Install required packages
run: |
if command -v apk >/dev/null 2>&1; then
apk add --no-cache nodejs npm git docker
elif command -v apt-get >/dev/null 2>&1; then
apt-get update && apt-get install -y nodejs npm git docker.io
fi
- name: Checkout code
uses: actions/checkout@v4
- name: Set deployment variables
id: vars
run: |
BRANCH="$BRANCH_NAME"
if [[ "$BRANCH" == "main" ]]; then
tag="latest"
elif [[ "$BRANCH" == "dev" ]]; then
tag="dev"
else
tag="test"
fi
# Extract APP_NAME from .env file if it exists
if [ -f .env ]; then
APP_NAME=$(grep APP_NAME .env | cut -d '=' -f2)
fi
echo "tag=$tag" >> $GITEA_OUTPUT
echo "app_name=$APP_NAME" >> $GITEA_OUTPUT
# Login to Docker Hub before building to allow pulling base images
- name: Login to Docker Hub
run: |
echo "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USERNAME" --password-stdin
# Pull base images explicitly before building
- name: Pull base images
run: |
docker pull node:20-alpine
docker pull python:3.11-alpine
- name: Build Docker image
run: |
APP_NAME="${{ steps.vars.outputs.app_name }}"
TAG="${{ steps.vars.outputs.tag }}"
# Build with no-cache to avoid authentication issues
docker build --no-cache -t docker.io/$REGISTRY_USERNAME/$APP_NAME:$TAG \
-t docker.io/$REGISTRY_USERNAME/$APP_NAME:${{ gitea.sha }} \
-f ./backend/Dockerfile \
.
- name: Push Docker image
if: gitea.event_name != 'pull_request'
run: |
APP_NAME="${{ steps.vars.outputs.app_name }}"
TAG="${{ steps.vars.outputs.tag }}"
docker push docker.io/$REGISTRY_USERNAME/$APP_NAME:$TAG
docker push docker.io/$REGISTRY_USERNAME/$APP_NAME:${{ gitea.sha }}
echo "=== 🏗️ Build Summary ==="
echo "📦 Image Built: docker.io/$REGISTRY_USERNAME/$APP_NAME:${{ gitea.sha }}"
echo "====================="

76
.gitignore vendored Normal file
View File

@@ -0,0 +1,76 @@
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
.pytest_cache/
.coverage
htmlcov/
.tox/
.nox/
.hypothesis/
.venv
venv/
ENV/
# Node.js / Frontend
node_modules/
npm-debug.log
yarn-debug.log
yarn-error.log
.pnpm-debug.log
.env.local
.env.development.local
.env.test.local
.env.production.local
.cache/
coverage/
# IDE specific files
.idea/
.vscode/
*.swp
*.swo
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
*.sublime-project
# Docker
.docker/
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.env

130
README.md Normal file
View File

@@ -0,0 +1,130 @@
# Profile Linker Application
This project consists of a React frontend and a FastAPI backend for managing a list of people's profiles.
## Getting Started with a New Project
This repository serves as a template for creating new projects. To create a new project:
1. Clone this repository
2. Run the initialization script with your desired app name:
```bash
./new_project.sh YourAppName
```
The script will:
- Replace all references to "ResumeFormatter" with your app name
- Delete the Git history and initialize a new Git repository
- Create a .env file with your app name
- Rename the project folder to your app name (lowercase)
- Delete itself after completion
## Project Structure
```
/
├── frontend/ # React frontend
│ ├── components/ # React components
│ ├── services/ # API services
│ └── ... # Other frontend files
├── backend/ # FastAPI backend
│ ├── app/ # FastAPI application
│ │ ├── api/ # API endpoints
│ │ ├── core/ # Core application components
│ │ ├── crud/ # CRUD operations
│ │ ├── db/ # Database components
│ │ ├── models/ # Database models
│ │ ├── schemas/ # Pydantic schemas
│ │ └── main.py # FastAPI application
│ ├── main.py # Entry point
│ └── requirements.txt # Python dependencies
├── .gitea/ # Gitea configuration
│ └── workflows/ # Gitea Actions workflows
├── docker-compose.yml # Docker Compose configuration
└── README.md # This file
```
## Environment Variables
The application uses environment variables for configuration. Create a `.env` file in the root directory with the following variables:
```
APP_NAME=ResumeFormatter
```
You can also use the provided `.env.example` file as a template.
## URL Structure
The application follows a specific URL structure based on the APP_NAME environment variable:
- Frontend: `http://localhost:8080/{APP_NAME}`
- API: `http://localhost:8080/{APP_NAME}/api`
- API Documentation: `http://localhost:8080/{APP_NAME}/api/docs`
- Static Assets: `http://localhost:8080/{APP_NAME}/assets`
This structure allows for multiple applications to be hosted under the same domain with different paths.
## Running the Application with Docker
1. Build and start the containers:
```bash
docker-compose up --build
```
2. Access the application at http://localhost:8080/ResumeFormatter
3. Access the API at http://localhost:8080/ResumeFormatter/api
4. Access the API documentation at http://localhost:8080/ResumeFormatter/api/docs
## API Endpoints
The API provides the following endpoints:
- `GET /ResumeFormatter/api/people`: Get all people
- `POST /ResumeFormatter/api/people`: Create a new person
## Frontend Integration
The backend serves the frontend as static assets. The frontend is configured to use the APP_NAME as the base path for all assets and API calls. Key integration points:
1. **Vite Configuration**: The frontend uses Vite with a base path set to `/{APP_NAME}/` in `vite.config.ts`
2. **API Service**: The frontend API service uses `/{APP_NAME}/api` as the base URL for all API calls
3. **Static Assets**: All static assets are served under the `/{APP_NAME}/assets` path
## Database
The application uses SQLite for data storage. The database file is created at `./ResumeFormatter.db` in the root directory.
## Continuous Integration
This project includes a Gitea Actions workflow for building and pushing Docker images to Docker Hub. The workflow is triggered on pushes and pull requests to the `dev` branch.
### Gitea Actions Workflow
The workflow performs the following steps:
1. Installs required packages (Node.js, Git, Docker)
2. Checks out the code
3. Sets deployment variables based on branch name and .env file
4. Logs in to Docker Hub using the provided variables and secrets
5. Builds and pushes the Docker image with tags:
- `latest` (for main branch) or `dev` (for dev branch)
- The commit SHA
### Required Variables and Secrets
The following variables and secrets need to be set in the Gitea repository:
- `REGISTRY_USERNAME` (variable): Your Docker Hub username
- `REGISTRY_TOKEN` (secret): Your Docker Hub access token
### Image Naming
The Docker image will be named using the APP_NAME from the .env file:
```
{REGISTRY_USERNAME}/{APP_NAME}:latest
{REGISTRY_USERNAME}/{APP_NAME}:{commit-sha}
```

85
backend/Dockerfile Normal file
View File

@@ -0,0 +1,85 @@
FROM node:20-alpine as builder
WORKDIR /app
# Add build arg to bust cache
ARG CACHEBUST=1
# Copy package files from frontend directory
COPY frontend/package.json ./
# Install dependencies
RUN npm install
# Copy the frontend application
COPY frontend/ ./
# Build the application with cache busting
RUN echo "Building with cache bust: $CACHEBUST" && npm run build
# Verify build output
RUN ls -la /app/dist || echo "Dist directory not created"
RUN ls -la /app/dist/assets || echo "Assets directory not created"
# Production stage with FastAPI
FROM python:3.11-alpine as production
WORKDIR /app
# Install system dependencies
RUN apk add --no-cache curl
# Copy Python requirements and install dependencies
COPY backend/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Copy built frontend assets from the builder stage
COPY --from=builder /app/dist /app/dist
# Copy FastAPI application
COPY backend/app /app/app
COPY backend/main.py ./
# Expose the port the app runs on
EXPOSE 8080
# Add health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8080/api/health || exit 1
# Command to run the application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
# Development stage with FastAPI
FROM python:3.11-alpine as development
WORKDIR /app
# Install system dependencies
RUN apk add --no-cache curl nodejs npm
# Copy Python requirements and install dependencies
COPY backend/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Copy built frontend assets from the builder stage
COPY --from=builder /app/dist /app/dist
# Verify the copied frontend build
RUN ls -la /app/dist || echo "Dist directory not created"
RUN ls -la /app/dist/assets || echo "Assets directory not created"
# Copy FastAPI application
COPY backend/app /app/app
COPY backend/main.py ./
# Expose the ports the app runs on (FastAPI and Vite dev server)
EXPOSE 8080 5173
# Add health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8080/api/health || exit 1
# Command to run the application (FastAPI only in development)
# Frontend will be built and served by FastAPI
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--reload"]

80
backend/README.md Normal file
View File

@@ -0,0 +1,80 @@
# Profile Linker API
This is a FastAPI backend for the Profile Linker application. It provides API endpoints for managing a list of people's profiles.
## Project Structure
The project follows a modular structure:
```
backend/
├── app/
│ ├── api/ # API endpoints
│ │ ├── endpoints/ # API endpoint modules
│ │ └── api.py # API router
│ ├── core/ # Core application components
│ │ └── config.py # Application settings
│ ├── crud/ # CRUD operations
│ │ └── person.py # Person CRUD operations
│ ├── db/ # Database components
│ │ ├── base.py # Database base
│ │ ├── base_class.py # Base class for database models
│ │ └── session.py # Database session
│ ├── models/ # Database models
│ │ └── person.py # Person model
│ ├── schemas/ # Pydantic schemas
│ │ └── person.py # Person schemas
│ └── main.py # FastAPI application
├── main.py # Entry point
└── requirements.txt # Python dependencies
```
## API Endpoints
The API provides the following endpoints:
- `GET /api/people`: Get all people
- `POST /api/people`: Create a new person
## Development
### Prerequisites
- Python 3.11+
- Docker and Docker Compose
### Running the Application
#### Using Docker Compose
1. Build and start the containers:
```bash
docker-compose up --build
```
2. Access the API at http://localhost:8080/api
3. Access the API documentation at http://localhost:8080/docs
#### Running Locally
1. Install dependencies:
```bash
cd backend
pip install -r requirements.txt
```
2. Run the application:
```bash
cd backend
python main.py
```
3. Access the API at http://localhost:8080/api
4. Access the API documentation at http://localhost:8080/docs
## Frontend Integration
The backend serves the frontend as static assets. The frontend should be built and placed in the `dist` directory. The Docker setup handles this automatically.

0
backend/app/__init__.py Normal file
View File

View File

6
backend/app/api/api.py Normal file
View File

@@ -0,0 +1,6 @@
from fastapi import APIRouter
from app.api.endpoints import people
api_router = APIRouter()
api_router.include_router(people.router, prefix="/people", tags=["people"])

View File

View File

@@ -0,0 +1,35 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from app.db.session import get_db
from app.schemas.person import Person, PersonCreate
from app.crud import person
router = APIRouter()
@router.get("/", response_model=List[Person])
def get_all_people(db: Session = Depends(get_db)):
"""
Get all people
"""
return person.get_all(db)
@router.post("/", response_model=Person, status_code=201)
def create_person(
*,
db: Session = Depends(get_db),
person_in: PersonCreate
):
"""
Create a new person
"""
try:
return person.create(db=db, obj_in=person_in)
except Exception as e:
raise HTTPException(
status_code=500,
detail="Failed to create the person."
)

View File

@@ -0,0 +1,25 @@
from typing import Optional, List
import os
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
# Simple settings class without using BaseSettings
class Settings:
"""
Application settings
"""
APP_NAME: str = os.getenv("APP_NAME", "ResumeFormatter")
API_V1_STR: str = f"/{APP_NAME}/api"
PROJECT_NAME: str = "Profile Linker API"
# CORS settings
BACKEND_CORS_ORIGINS: List[str] = ["*"]
# Database settings - using in-memory database by default
# In a production environment, you would use a real database connection string
DATABASE_URL: Optional[str] = None
settings = Settings()

View File

@@ -0,0 +1 @@
from . import person

View File

@@ -0,0 +1,27 @@
from sqlalchemy.orm import Session
from app.models.person import Person
from app.schemas.person import PersonCreate
import uuid
def get_all(db: Session) -> list[Person]:
"""
Get all people from the database
"""
return db.query(Person).all()
def create(db: Session, *, obj_in: PersonCreate) -> Person:
"""
Create a new person in the database
"""
db_obj = Person(
id=str(uuid.uuid4()),
firstName=obj_in.firstName,
lastName=obj_in.lastName,
linkedinUrl=obj_in.linkedinUrl
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj

3
backend/app/db/base.py Normal file
View File

@@ -0,0 +1,3 @@
# Import all the models, so that Base has them before being imported by Alembic
from app.db.base_class import Base # noqa
from app.models.person import Person # noqa

View File

@@ -0,0 +1,13 @@
from typing import Any
from sqlalchemy.ext.declarative import as_declarative, declared_attr
@as_declarative()
class Base:
id: Any
__name__: str
# Generate __tablename__ automatically based on class name
@declared_attr
def __tablename__(cls) -> str:
return cls.__name__.lower()

19
backend/app/db/session.py Normal file
View File

@@ -0,0 +1,19 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.db.base_class import Base
# Use a file-based SQLite database instead of in-memory
SQLALCHEMY_DATABASE_URL = "sqlite:///./ResumeFormatter.db"
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Dependency to get DB session
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

195
backend/app/main.py Normal file
View File

@@ -0,0 +1,195 @@
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.middleware.cors import CORSMiddleware
import os
import pathlib
from dotenv import load_dotenv
from app.api.api import api_router
from app.core.config import settings
from app.db.session import engine
from app.db.base import Base
# Load environment variables
load_dotenv()
# Create tables in the database
Base.metadata.create_all(bind=engine)
app = FastAPI(
title=settings.PROJECT_NAME,
openapi_url=f"{settings.API_V1_STR}/openapi.json",
docs_url=f"{settings.API_V1_STR}/docs",
redoc_url=f"{settings.API_V1_STR}/redoc"
)
# Set up CORS
if settings.BACKEND_CORS_ORIGINS:
app.add_middleware(
CORSMiddleware,
allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include API router
app.include_router(api_router, prefix=settings.API_V1_STR)
# Check if dist directory exists - use absolute path
dist_path = pathlib.Path("/app/dist")
if dist_path.exists():
# Mount static files from the dist directory if it exists
assets_path = dist_path / "assets"
if assets_path.exists():
# Mount assets with APP_NAME prefix
app.mount(f"/{settings.APP_NAME}/assets", StaticFiles(directory=str(assets_path)), name="assets")
print(f"Successfully mounted assets from {assets_path} at /{settings.APP_NAME}/assets")
else:
print(f"Warning: Dist directory {dist_path} does not exist")
# Try local development path with frontend folder
local_dist_path = pathlib.Path("frontend/dist")
if local_dist_path.exists():
local_assets_path = local_dist_path / "assets"
if local_assets_path.exists():
# Mount assets with APP_NAME prefix
app.mount(f"/{settings.APP_NAME}/assets", StaticFiles(directory=str(local_assets_path)), name="assets")
print(f"Successfully mounted assets from {local_assets_path} at /{settings.APP_NAME}/assets")
@app.get("/api/health")
async def health_check():
return {"status": "ok"}
# Redirect root to APP_NAME
@app.get("/")
async def redirect_to_app():
return RedirectResponse(f"/{settings.APP_NAME}")
# Handle index.css request
@app.get("/index.css")
async def serve_css():
# Try to serve from /app/dist (container path)
css_path = pathlib.Path("/app/dist/index.css")
if css_path.exists():
return FileResponse(css_path)
# Try to serve from frontend/dist (local development path)
local_css_path = pathlib.Path("frontend/dist/index.css")
if local_css_path.exists():
return FileResponse(local_css_path)
return {"error": "CSS file not found"}
# Handle vite.svg request
@app.get("/vite.svg")
async def serve_favicon():
# Try to serve from /app/dist (container path)
favicon_path = pathlib.Path("/app/dist/vite.svg")
if favicon_path.exists():
return FileResponse(favicon_path)
# Try to serve from frontend/dist (local development path)
local_favicon_path = pathlib.Path("frontend/dist/vite.svg")
if local_favicon_path.exists():
return FileResponse(local_favicon_path)
return {"error": "Favicon not found"}
# Serve index.html for the APP_NAME route
@app.get("/{app_name}")
async def serve_app(app_name: str):
if app_name != settings.APP_NAME:
return {"error": "App not found"}
# Try to serve from /app/dist (container path)
index_path = pathlib.Path("/app/dist/index.html")
if index_path.exists():
return FileResponse(index_path)
# Try to serve from frontend/dist (local development path)
local_index_path = pathlib.Path("frontend/dist/index.html")
if local_index_path.exists():
return FileResponse(local_index_path)
# If neither exists, return a simple HTML response
html_content = """
<!DOCTYPE html>
<html>
<head>
<title>Profile Linker API</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; line-height: 1.6; }
h1 { color: #333; }
.container { max-width: 800px; margin: 0 auto; }
.message { background-color: #f8f9fa; border-left: 4px solid #007bff; padding: 15px; }
code { background-color: #f1f1f1; padding: 2px 5px; border-radius: 3px; }
</style>
</head>
<body>
<div class="container">
<h1>FastAPI Server Running</h1>
<div class="message">
<p>The FastAPI server is running correctly, but the frontend build files are not available.</p>
<p>To see the frontend, make sure to build it first with <code>npm run build</code>.</p>
<p>API endpoints are available at <a href="/docs">/docs</a>.</p>
</div>
</div>
</body>
</html>
"""
return HTMLResponse(content=html_content, status_code=200)
# Serve index.html for all other routes to support SPA routing
@app.get("/{app_name}/{full_path:path}")
async def serve_spa(app_name: str, full_path: str):
if app_name != settings.APP_NAME:
return {"error": "App not found"}
# Check if the path is an API route
if full_path.startswith("api/"):
return {"error": "API route not found"}
# Try to serve from /app/dist (container path)
index_path = pathlib.Path("/app/dist/index.html")
if index_path.exists():
return FileResponse(index_path)
# Try to serve from frontend/dist (local development path)
local_index_path = pathlib.Path("frontend/dist/index.html")
if local_index_path.exists():
return FileResponse(local_index_path)
# If neither exists, return a simple HTML response
html_content = """
<!DOCTYPE html>
<html>
<head>
<title>Profile Linker API</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; line-height: 1.6; }
h1 { color: #333; }
.container { max-width: 800px; margin: 0 auto; }
.message { background-color: #f8f9fa; border-left: 4px solid #007bff; padding: 15px; }
code { background-color: #f1f1f1; padding: 2px 5px; border-radius: 3px; }
</style>
</head>
<body>
<div class="container">
<h1>FastAPI Server Running</h1>
<div class="message">
<p>The FastAPI server is running correctly, but the frontend build files are not available.</p>
<p>To see the frontend, make sure to build it first with <code>npm run build</code>.</p>
<p>API endpoints are available at <a href="/docs">/docs</a>.</p>
</div>
</div>
</body>
</html>
"""
return HTMLResponse(content=html_content, status_code=200)

View File

View File

@@ -0,0 +1,12 @@
from sqlalchemy import Column, String
from app.db.base_class import Base
class Person(Base):
"""
Database model for a person
"""
id = Column(String, primary_key=True, index=True)
firstName = Column(String, nullable=False)
lastName = Column(String, nullable=False)
linkedinUrl = Column(String, nullable=False)

View File

View File

@@ -0,0 +1,34 @@
from pydantic import BaseModel, Field, HttpUrl
class PersonBase(BaseModel):
"""
Base schema for a person
"""
firstName: str
lastName: str
linkedinUrl: str
class PersonCreate(PersonBase):
"""
Schema for creating a new person
"""
pass
class PersonInDBBase(PersonBase):
"""
Base schema for a person in the database
"""
id: str
class Config:
from_attributes = True
class Person(PersonInDBBase):
"""
Schema for returning a person
"""
pass

8
backend/main.py Normal file
View File

@@ -0,0 +1,8 @@
import uvicorn
import os
if __name__ == "__main__":
# Use environment variables or default values
host = os.getenv("HOST", "0.0.0.0")
port = int(os.getenv("PORT", "8080"))
uvicorn.run("app.main:app", host=host, port=port, reload=True)

7
backend/requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
fastapi==0.110.0
uvicorn==0.29.0
python-dotenv==1.0.1
pydantic==2.6.1
sqlalchemy==2.0.27
uuid==1.30
pydantic-settings==2.1.0

51
backend/test_api.py Normal file
View File

@@ -0,0 +1,51 @@
import requests
import json
import time
# Base URL for the API
BASE_URL = "http://localhost:8080/api"
def test_get_people():
"""Test the GET /api/people endpoint"""
response = requests.get(f"{BASE_URL}/people")
print("GET /api/people")
print(f"Status Code: {response.status_code}")
print(f"Response: {json.dumps(response.json(), indent=2)}")
print("-" * 50)
return response.json()
def test_create_person():
"""Test the POST /api/people endpoint"""
new_person = {
"firstName": "Alan",
"lastName": "Turing",
"linkedinUrl": "https://www.linkedin.com/in/alan-turing"
}
response = requests.post(f"{BASE_URL}/people", json=new_person)
print("POST /api/people")
print(f"Status Code: {response.status_code}")
print(f"Response: {json.dumps(response.json(), indent=2)}")
print("-" * 50)
return response.json()
def run_tests():
"""Run all tests"""
print("Testing API endpoints...")
print("=" * 50)
# Test GET /api/people
people = test_get_people()
# Test POST /api/people
new_person = test_create_person()
# Test GET /api/people again to see the new person
updated_people = test_get_people()
print("Tests completed!")
if __name__ == "__main__":
# Wait a bit for the server to start
print("Waiting for server to start...")
time.sleep(2)
run_tests()

28
docker-compose.yml Normal file
View File

@@ -0,0 +1,28 @@
version: '3.8'
services:
ResumeFormatter:
build:
context: .
dockerfile: backend/Dockerfile
target: development
args:
# Add a build arg with current timestamp to force rebuild
CACHEBUST: ${CACHEBUST:-$(date +%s)}
ports:
- "8080:8080" # FastAPI port
- "5173:5173" # Vite dev server port (if needed)
volumes:
# Mount backend code for development
- ./backend:/app/backend
# Don't mount frontend - it's built during docker build
environment:
- NODE_ENV=development
# FastAPI environment variables
- HOST=0.0.0.0
- PORT=8080
restart: unless-stopped

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

105
frontend/App.tsx Normal file
View File

@@ -0,0 +1,105 @@
import React, { useState, useEffect, useCallback } from 'react';
import type { Person, NewPerson } from './types';
import { getPeople, addPerson } from './services/apiService';
import PersonList from './components/PersonList';
import AddPersonForm from './components/AddPersonForm';
import Modal from './components/Modal';
import { PlusIcon, UsersIcon } from './components/Icons';
const App: React.FC = () => {
const [people, setPeople] = useState<Person[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const fetchPeople = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
const data = await getPeople();
setPeople(data);
} catch (err) {
setError('Failed to fetch people. Please try again later.');
console.error(err);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchPeople();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleAddPerson = async (newPerson: NewPerson) => {
try {
const addedPerson = await addPerson(newPerson);
setPeople((prevPeople) => [...prevPeople, addedPerson]);
setIsModalOpen(false);
} catch (err) {
setError('Failed to add person. Please try again.');
console.error(err);
}
};
const renderContent = () => {
if (isLoading) {
return <p className="text-center text-gray-500 dark:text-gray-400 mt-8">Loading profiles...</p>;
}
if (error) {
return <p className="text-center text-red-500 dark:text-red-400 mt-8">{error}</p>;
}
if (people.length === 0) {
return (
<div className="text-center py-16 px-6 bg-white dark:bg-gray-800 rounded-lg shadow-md">
<UsersIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-lg font-medium text-gray-900 dark:text-white">No profiles yet</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">Get started by adding a new profile.</p>
<div className="mt-6">
<button
onClick={() => setIsModalOpen(true)}
type="button"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<PlusIcon className="-ml-1 mr-2 h-5 w-5" />
Add Profile
</button>
</div>
</div>
);
}
return <PersonList people={people} />;
};
return (
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-200 font-sans">
<div className="container mx-auto p-4 sm:p-6 lg:p-8">
<header className="flex items-center justify-between mb-8 pb-4 border-b border-gray-200 dark:border-gray-700">
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-gray-900 dark:text-white">
Profile Linker
</h1>
{people.length > 0 && (
<button
onClick={() => setIsModalOpen(true)}
className="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
>
<PlusIcon className="w-5 h-5 mr-2 -ml-1" />
<span>Add Profile</span>
</button>
)}
</header>
<main>
{renderContent()}
</main>
</div>
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} title="Add New Profile">
<AddPersonForm onSubmit={handleAddPerson} onCancel={() => setIsModalOpen(false)} />
</Modal>
</div>
);
};
export default App;

20
frontend/README.md Normal file
View File

@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://gitea.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1fGMiOc3HONBnOp0qMiclJ_huKwQg8ukW
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

110
frontend/api_spec.md Normal file
View File

@@ -0,0 +1,110 @@
# Profile Linker API Specification
This document outlines the API endpoints required for the Profile Linker application. The API is responsible for managing a list of people's profiles.
## Base URL
The API endpoints are relative to a base URL, e.g., `https://api.example.com`.
## Authentication
All API endpoints could be protected by an authentication mechanism (e.g., OAuth 2.0, API Key in header), which is not detailed in this specification. For this example, we assume endpoints are publicly accessible.
## Data Models
### Person Object
Represents a single person's profile.
| Field | Type | Description | Example |
|---------------|--------|----------------------------------------|----------------------------------------|
| `id` | string | Unique identifier for the person | `"a1b2c3d4-e5f6-7890-1234-567890abcdef"` |
| `firstName` | string | The person's first name | `"Jane"` |
| `lastName` | string | The person's last name | `"Doe"` |
| `linkedinUrl` | string | Full URL to the person's LinkedIn profile | `"https://www.linkedin.com/in/janedoe"` |
---
## Endpoints
### 1. Get All People
Retrieves a list of all people in the database.
- **URL:** `/api/people`
- **Method:** `GET`
- **Success Response:**
- **Code:** `200 OK`
- **Content:** An array of Person objects.
```json
[
{
"id": "1",
"firstName": "Ada",
"lastName": "Lovelace",
"linkedinUrl": "https://www.linkedin.com/in/ada-lovelace"
},
{
"id": "2",
"firstName": "Grace",
"lastName": "Hopper",
"linkedinUrl": "https://www.linkedin.com/in/grace-hopper"
}
]
```
- **Error Response:**
- **Code:** `500 Internal Server Error`
- **Content:**
```json
{
"error": "An unexpected error occurred."
}
```
---
### 2. Add a New Person
Creates a new person record in the database.
- **URL:** `/api/people`
- **Method:** `POST`
- **Request Body:** A JSON object containing the new person's details (excluding the `id`).
```json
{
"firstName": "Margaret",
"lastName": "Hamilton",
"linkedinUrl": "https://www.linkedin.com/in/margaret-hamilton"
}
```
- **Success Response:**
- **Code:** `201 Created`
- **Content:** The newly created Person object, including its server-generated `id`.
```json
{
"id": "3",
"firstName": "Margaret",
"lastName": "Hamilton",
"linkedinUrl": "https://www.linkedin.com/in/margaret-hamilton"
}
```
- **Error Responses:**
- **Code:** `400 Bad Request` (e.g., for missing fields or invalid data)
- **Content:**
```json
{
"error": "Validation failed.",
"details": {
"firstName": "First name is required.",
"linkedinUrl": "A valid URL is required."
}
}
```
- **Code:** `500 Internal Server Error`
- **Content:**
```json
{
"error": "Failed to create the person."
}
```

View File

@@ -0,0 +1,104 @@
import React, { useState } from 'react';
import type { NewPerson } from '../types';
interface AddPersonFormProps {
onSubmit: (person: NewPerson) => void;
onCancel: () => void;
}
const AddPersonForm: React.FC<AddPersonFormProps> = ({ onSubmit, onCancel }) => {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [linkedinUrl, setLinkedinUrl] = useState('');
const [error, setError] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!firstName || !lastName || !linkedinUrl) {
setError('All fields are required.');
return;
}
// Basic URL validation
try {
new URL(linkedinUrl);
} catch (_) {
setError('Please enter a valid LinkedIn URL.');
return;
}
setError('');
onSubmit({ firstName, lastName, linkedinUrl });
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{error && <div className="p-3 bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300 rounded-md text-sm">{error}</div>}
<div>
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
First Name
</label>
<div className="mt-1">
<input
type="text"
id="firstName"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className="appearance-none block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
required
/>
</div>
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Last Name
</label>
<div className="mt-1">
<input
type="text"
id="lastName"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className="appearance-none block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
required
/>
</div>
</div>
<div>
<label htmlFor="linkedinUrl" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
LinkedIn Profile URL
</label>
<div className="mt-1">
<input
type="url"
id="linkedinUrl"
value={linkedinUrl}
onChange={(e) => setLinkedinUrl(e.target.value)}
placeholder="https://www.linkedin.com/in/..."
className="appearance-none block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
required
/>
</div>
</div>
<div className="flex justify-end space-x-4 pt-2">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white dark:bg-gray-600 dark:text-gray-200 border border-gray-300 dark:border-gray-500 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Save Profile
</button>
</div>
</form>
);
};
export default AddPersonForm;

View File

@@ -0,0 +1,27 @@
import React from 'react';
export const PlusIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
);
export const LinkedInIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" {...props}>
<path d="M20.5 2h-17A1.5 1.5 0 002 3.5v17A1.5 1.5 0 003.5 22h17a1.5 1.5 0 001.5-1.5v-17A1.5 1.5 0 0020.5 2zM8 19H5v-9h3zM6.5 8.25A1.75 1.75 0 118.25 6.5 1.75 1.75 0 016.5 8.25zM19 19h-3v-4.74c0-1.42-.6-1.93-1.38-1.93-.94 0-1.62.61-1.62 1.93V19h-3v-9h2.9v1.3a3.11 3.11 0 012.7-1.4c1.55 0 3.38.96 3.38 3.3 V19z"></path>
</svg>
);
export const UsersIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-4.684v.005c.317.055.635.101.954.142m-1.58-2.318a2.25 2.25 0 00-3.814-1.625A2.25 2.25 0 006 13.5a2.25 2.25 0 00-1.58 2.318m1.58-2.318l-.003.004a2.25 2.25 0 01-3.814-1.625a2.25 2.25 0 013.814-1.625l.003.004m0 0a2.25 2.25 0 013.814 1.625a2.25 2.25 0 01-3.814-1.625M9 13.5a2.25 2.25 0 012.25-2.25A2.25 2.25 0 0113.5 13.5a2.25 2.25 0 01-2.25 2.25A2.25 2.25 0 019 13.5z" />
</svg>
);
export const CloseIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);

View File

@@ -0,0 +1,62 @@
import React, { Fragment } from 'react';
import { CloseIcon } from './Icons';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
if (!isOpen) {
return null;
}
return (
<div
className="fixed inset-0 z-10 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
>
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
{/* Background overlay */}
<div
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
aria-hidden="true"
onClick={onClose}
></div>
{/* This element is to trick the browser into centering the modal contents. */}
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
{/* Modal panel */}
<div className="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start w-full">
<div className="mt-3 text-center sm:mt-0 sm:text-left w-full">
<div className="flex justify-between items-center">
<h3 className="text-lg leading-6 font-medium text-gray-900 dark:text-white" id="modal-title">
{title}
</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300">
<CloseIcon className="h-6 w-6" />
</button>
</div>
<div className="mt-4">
{children}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default Modal;

View File

@@ -0,0 +1,57 @@
import React from 'react';
import type { Person } from '../types';
import { LinkedInIcon } from './Icons';
interface PersonListProps {
people: Person[];
}
const PersonList: React.FC<PersonListProps> = ({ people }) => {
return (
<div className="overflow-hidden bg-white dark:bg-gray-800 shadow-md rounded-lg">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
First Name
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Last Name
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
LinkedIn Profile
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{people.map((person) => (
<tr key={person.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
{person.firstName}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
{person.lastName}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300">
<a
href={person.linkedinUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 font-medium group"
>
<LinkedInIcon className="w-5 h-5 mr-2 text-gray-400 group-hover:text-indigo-500 dark:group-hover:text-indigo-400" />
View Profile
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
export default PersonList;

24
frontend/index.html Normal file
View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Profile Linker</title>
<script src="https://cdn.tailwindcss.com"></script>
<script type="importmap">
{
"imports": {
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/",
"react/": "https://aistudiocdn.com/react@^19.2.0/",
"react": "https://aistudiocdn.com/react@^19.2.0"
}
}
</script>
<link rel="stylesheet" href="./index.css">
</head>
<body class="bg-gray-100 dark:bg-gray-900">
<div id="root"></div>
<script type="module" src="./index.tsx"></script>
</body>
</html>

16
frontend/index.tsx Normal file
View File

@@ -0,0 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

5
frontend/metadata.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "Profile Linker",
"description": "An application to manage a list of contacts with their first name, last name, and LinkedIn profile URL. It features the ability to add new contacts through a mocked backend service, designed for easy integration with a real API.",
"requestFramePermissions": []
}

21
frontend/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "ResumeFormatter",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react-dom": "^19.2.0",
"react": "^19.2.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

View File

@@ -0,0 +1,52 @@
import type { Person, NewPerson } from '../types';
// Get APP_NAME from environment or use default
const APP_NAME = import.meta.env.VITE_APP_NAME || 'ResumeFormatter';
const API_BASE_URL = `/${APP_NAME}/api`;
// Function to get all people
export const getPeople = async (): Promise<Person[]> => {
console.log('Fetching people from API...');
try {
const response = await fetch(`${API_BASE_URL}/people/`);
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const data = await response.json();
console.log('... Data loaded:', data);
return data;
} catch (error) {
console.error('Error fetching people:', error);
throw error;
}
};
// Function to add a new person
export const addPerson = async (newPerson: NewPerson): Promise<Person> => {
console.log('Adding person to API:', newPerson);
if (!newPerson.firstName || !newPerson.lastName || !newPerson.linkedinUrl) {
throw new Error('All fields are required.');
}
try {
const response = await fetch(`${API_BASE_URL}/people/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newPerson),
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const data = await response.json();
console.log('... Person added:', data);
return data;
} catch (error) {
console.error('Error adding person:', error);
throw error;
}
};

29
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

9
frontend/types.ts Normal file
View File

@@ -0,0 +1,9 @@
export interface Person {
id: string;
firstName: string;
lastName: string;
linkedinUrl: string;
}
export type NewPerson = Omit<Person, 'id'>;

31
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,31 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
const appName = env.VITE_APP_NAME || 'ResumeFormatter';
return {
base: `/${appName}/`,
build: {
assetsDir: 'assets',
outDir: 'dist',
emptyOutDir: true,
},
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});

48
new_project.sh Executable file
View File

@@ -0,0 +1,48 @@
#!/bin/bash
# Check if APP_NAME parameter is provided
if [ -z "$1" ]; then
echo "Error: APP_NAME parameter is required"
echo "Usage: ./new_project.sh <APP_NAME>"
exit 1
fi
# Store the APP_NAME parameter
APP_NAME="$1"
APP_NAME_LOWER=$(echo "$APP_NAME" | tr '[:upper:]' '[:lower:]')
echo "Creating new project with name: $APP_NAME"
# Delete the .git folder
echo "Removing Git history..."
rm -rf .git
# Replace all references to NEW_APP_NAME with the provided APP_NAME
echo "Replacing NEW_APP_NAME with $APP_NAME in all files..."
find . -type f -not -path "*/\.*" -not -path "./node_modules/*" -not -path "./venv/*" -not -name "new_project.sh" -exec grep -l "NEW_APP_NAME" {} \; | xargs -I{} sed -i '' "s/NEW_APP_NAME/$APP_NAME/g" {}
# Create .env file with APP_NAME
echo "Creating .env file..."
echo "APP_NAME=$APP_NAME" > .env
# Initialize a new git repository
echo "Initializing new Git repository..."
git init
git add .
git commit -m "Initial commit for $APP_NAME project"
# Get the current directory name
CURRENT_DIR=$(basename "$(pwd)")
PARENT_DIR=$(dirname "$(pwd)")
# Move up one folder and rename the folder to the app name in lowercase
echo "Renaming project folder to $APP_NAME_LOWER..."
cd ..
mv "$CURRENT_DIR" "$APP_NAME_LOWER"
echo "Project setup complete!"
echo "Your new project is available at: $PARENT_DIR/$APP_NAME_LOWER"
echo "You can now cd into $APP_NAME_LOWER to start working on your project"
# The script will delete itself when executed from the new directory
# This is handled by the script not including itself in the files to be kept