Initial commit for resumeformatter project
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Application name used for routing and Docker image name
|
||||||
|
APP_NAME=NEW_APP_NAME
|
||||||
87
.gitea/workflows/docker-build-push.yml
Normal file
87
.gitea/workflows/docker-build-push.yml
Normal 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
76
.gitignore
vendored
Normal 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
130
README.md
Normal 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
85
backend/Dockerfile
Normal 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
80
backend/README.md
Normal 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
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
6
backend/app/api/api.py
Normal file
6
backend/app/api/api.py
Normal 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"])
|
||||||
0
backend/app/api/endpoints/__init__.py
Normal file
0
backend/app/api/endpoints/__init__.py
Normal file
35
backend/app/api/endpoints/people.py
Normal file
35
backend/app/api/endpoints/people.py
Normal 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."
|
||||||
|
)
|
||||||
25
backend/app/core/config.py
Normal file
25
backend/app/core/config.py
Normal 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()
|
||||||
1
backend/app/crud/__init__.py
Normal file
1
backend/app/crud/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import person
|
||||||
27
backend/app/crud/person.py
Normal file
27
backend/app/crud/person.py
Normal 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
3
backend/app/db/base.py
Normal 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
|
||||||
13
backend/app/db/base_class.py
Normal file
13
backend/app/db/base_class.py
Normal 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
19
backend/app/db/session.py
Normal 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
195
backend/app/main.py
Normal 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)
|
||||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
12
backend/app/models/person.py
Normal file
12
backend/app/models/person.py
Normal 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)
|
||||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
34
backend/app/schemas/person.py
Normal file
34
backend/app/schemas/person.py
Normal 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
8
backend/main.py
Normal 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
7
backend/requirements.txt
Normal 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
51
backend/test_api.py
Normal 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
28
docker-compose.yml
Normal 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
24
frontend/.gitignore
vendored
Normal 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
105
frontend/App.tsx
Normal 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
20
frontend/README.md
Normal 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
110
frontend/api_spec.md
Normal 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."
|
||||||
|
}
|
||||||
|
```
|
||||||
104
frontend/components/AddPersonForm.tsx
Normal file
104
frontend/components/AddPersonForm.tsx
Normal 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;
|
||||||
27
frontend/components/Icons.tsx
Normal file
27
frontend/components/Icons.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
|
||||||
62
frontend/components/Modal.tsx
Normal file
62
frontend/components/Modal.tsx
Normal 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">
|
||||||
|
​
|
||||||
|
</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;
|
||||||
57
frontend/components/PersonList.tsx
Normal file
57
frontend/components/PersonList.tsx
Normal 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
24
frontend/index.html
Normal 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
16
frontend/index.tsx
Normal 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
5
frontend/metadata.json
Normal 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
21
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
52
frontend/services/apiService.ts
Normal file
52
frontend/services/apiService.ts
Normal 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
29
frontend/tsconfig.json
Normal 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
9
frontend/types.ts
Normal 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
31
frontend/vite.config.ts
Normal 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
48
new_project.sh
Executable 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
|
||||||
Reference in New Issue
Block a user