Add microservices architecture with separate frontend/backend
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Created backend/Dockerfile for Express API server - Created frontend/Dockerfile with Nginx for static files - Added nginx.conf to proxy /api/* to backend - Created docker-compose.microservices.yml for multi-container setup - Added .dockerignore files for both frontend and backend - Updated .woodpecker.yaml to fix registry URL and use separate Dockerfiles - Added CORS support to backend for microservices mode - Updated README with dual-mode deployment instructions - Frontend copies to frontend/public/ for Nginx serving - CI/CD pipeline now builds separate images for frontend and backend
This commit is contained in:
parent
5a7585f755
commit
15b8334f2a
@ -29,7 +29,7 @@ steps:
|
||||
path:
|
||||
include: [ backend/** ]
|
||||
settings:
|
||||
registry: harbor-core.dev-tools.svc.cluster.local
|
||||
registry: harbor.dvirlabs.com
|
||||
repo: my-apps/${CI_REPO_NAME}-backend
|
||||
dockerfile: backend/Dockerfile
|
||||
context: backend
|
||||
|
||||
100
README.md
100
README.md
@ -44,17 +44,43 @@ oramap/
|
||||
├── backend/
|
||||
│ ├── server.js # Express server
|
||||
│ ├── package.json # Backend dependencies
|
||||
│ ├── Dockerfile # Backend container image
|
||||
│ └── data/
|
||||
│ └── families.json # Family location data
|
||||
├── frontend/
|
||||
│ ├── Dockerfile # Frontend Nginx container
|
||||
│ ├── nginx.conf # Nginx configuration
|
||||
│ └── public/
|
||||
│ ├── index.html # Frontend HTML
|
||||
│ ├── script.js # Frontend JavaScript
|
||||
│ └── style.css # Styles
|
||||
├── public/
|
||||
│ ├── index.html # Frontend HTML
|
||||
│ ├── script.js # Frontend JavaScript
|
||||
│ └── style.css # Styles
|
||||
├── Dockerfile # Multi-stage Docker build
|
||||
├── docker-compose.yml # Docker Compose configuration
|
||||
└── .dockerignore # Docker ignore rules
|
||||
│ ├── index.html # Shared frontend files
|
||||
│ ├── script.js
|
||||
│ └── style.css
|
||||
├── Dockerfile # Monolith Docker build
|
||||
├── docker-compose.yml # Monolith deployment
|
||||
├── docker-compose.microservices.yml # Microservices deployment
|
||||
├── .woodpecker.yaml # CI/CD pipeline config
|
||||
└── .dockerignore # Docker ignore rules
|
||||
```
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
The application supports two deployment modes:
|
||||
|
||||
### 1. **Monolith Mode** (Simple)
|
||||
- Single container with Express serving both API and static files
|
||||
- Best for: Development, simple deployments
|
||||
- Use: `docker-compose up`
|
||||
|
||||
### 2. **Microservices Mode** (Production)
|
||||
- **Frontend**: Nginx serving static files (Port 80)
|
||||
- **Backend**: Express API server (Internal Port 3000)
|
||||
- Nginx proxies `/api/*` requests to backend
|
||||
- Best for: Production, scalability, CI/CD pipelines
|
||||
- Use: `docker-compose -f docker-compose.microservices.yml up`
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
@ -79,40 +105,76 @@ Navigate to `http://localhost:3000`
|
||||
|
||||
## 🐳 Docker Deployment
|
||||
|
||||
### Using Docker Compose (Recommended)
|
||||
### Monolith Mode (Recommended for Development)
|
||||
|
||||
1. **Build and start the application:**
|
||||
1. **Build and start:**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
2. **View logs:**
|
||||
```bash
|
||||
docker-compose logs -f
|
||||
```
|
||||
2. **Access:** http://localhost:3000
|
||||
|
||||
3. **Stop the application:**
|
||||
3. **Stop:**
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
### Using Docker directly
|
||||
### Microservices Mode (Recommended for Production)
|
||||
|
||||
1. **Build the image:**
|
||||
1. **Build and start:**
|
||||
```bash
|
||||
docker build -t oramap:latest .
|
||||
docker-compose -f docker-compose.microservices.yml up -d
|
||||
```
|
||||
|
||||
2. **Run the container:**
|
||||
2. **Access:** http://localhost (Port 80)
|
||||
|
||||
3. **View logs:**
|
||||
```bash
|
||||
docker logs oramap-frontend
|
||||
docker logs oramap-backend
|
||||
```
|
||||
|
||||
4. **Stop:**
|
||||
```bash
|
||||
docker-compose -f docker-compose.microservices.yml down
|
||||
```
|
||||
|
||||
### Using Docker directly
|
||||
|
||||
**Monolith:**
|
||||
```bash
|
||||
docker build -t oramap:latest .
|
||||
docker run -d -p 3000:3000 --name oramap-app oramap:latest
|
||||
```
|
||||
|
||||
3. **Check health:**
|
||||
**Microservices:**
|
||||
```bash
|
||||
docker ps
|
||||
# Build images
|
||||
docker build -t oramap-backend -f backend/Dockerfile backend/
|
||||
docker build -t oramap-frontend -f frontend/Dockerfile frontend/
|
||||
|
||||
# Run with network
|
||||
docker network create oramap-network
|
||||
docker run -d --name oramap-backend --network oramap-network oramap-backend
|
||||
docker run -d --name oramap-frontend --network oramap-network -p 80:80 oramap-frontend
|
||||
```
|
||||
|
||||
## 🔄 CI/CD Pipeline
|
||||
|
||||
The project includes a Woodpecker CI configuration (`.woodpecker.yaml`) that automatically:
|
||||
|
||||
1. **Builds** separate frontend and backend Docker images on push
|
||||
2. **Tags** images with branch name and commit SHA
|
||||
3. **Pushes** to Harbor registry (`harbor.dvirlabs.com`)
|
||||
4. **Updates** Kubernetes manifests with new image tags
|
||||
5. **Triggers** on changes to `frontend/**` or `backend/**` paths
|
||||
|
||||
### Pipeline Steps:
|
||||
- `build-frontend`: Build and push frontend Nginx image
|
||||
- `build-backend`: Build and push backend API image
|
||||
- `update-values-frontend`: Update frontend image tag in values.yaml
|
||||
- `update-values-backend`: Update backend image tag in values.yaml
|
||||
|
||||
## 💻 Development
|
||||
|
||||
### Running in Development Mode
|
||||
|
||||
28
backend/.dockerignore
Normal file
28
backend/.dockerignore
Normal file
@ -0,0 +1,28 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
|
||||
# Environment
|
||||
.env*
|
||||
|
||||
# Build artifacts
|
||||
dist/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
|
||||
# Data not needed in image (using volume or copy)
|
||||
# Keep data/ folder as it's needed
|
||||
30
backend/Dockerfile
Normal file
30
backend/Dockerfile
Normal file
@ -0,0 +1,30 @@
|
||||
# Backend Dockerfile for Ora Map
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Production stage
|
||||
FROM base AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install production dependencies
|
||||
RUN npm install --production
|
||||
|
||||
# Copy application files
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Set environment variables
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "server.js"]
|
||||
35
backend/server-microservice.js
Normal file
35
backend/server-microservice.js
Normal file
@ -0,0 +1,35 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const app = express();
|
||||
const families = require('./data/families.json');
|
||||
|
||||
// Enable CORS for frontend
|
||||
app.use(cors());
|
||||
|
||||
// Serve static files from the public directory (for standalone mode)
|
||||
if (process.env.SERVE_STATIC === 'true') {
|
||||
app.use(express.static(path.join(__dirname, '../public')));
|
||||
}
|
||||
|
||||
// API endpoint for family search
|
||||
app.get('/api/search', (req, res) => {
|
||||
const query = req.query.family?.toLowerCase();
|
||||
if (!query) {
|
||||
return res.json([]);
|
||||
}
|
||||
const matches = families.filter(fam => fam.family.toLowerCase().includes(query));
|
||||
res.json(matches);
|
||||
});
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
app.listen(port, '0.0.0.0', () => {
|
||||
console.log(`🗺️ Ora Map Backend API running at http://localhost:${port}`);
|
||||
console.log(`📍 Search endpoint: http://localhost:${port}/api/search`);
|
||||
console.log(`💚 Health endpoint: http://localhost:${port}/api/health`);
|
||||
});
|
||||
48
docker-compose.microservices.yml
Normal file
48
docker-compose.microservices.yml
Normal file
@ -0,0 +1,48 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Backend API Service
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
image: oramap-backend:latest
|
||||
container_name: oramap-backend
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
networks:
|
||||
- oramap-network
|
||||
|
||||
# Frontend Nginx Service
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
image: oramap-frontend:latest
|
||||
container_name: oramap-frontend
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
networks:
|
||||
- oramap-network
|
||||
|
||||
networks:
|
||||
oramap-network:
|
||||
driver: bridge
|
||||
31
frontend/.dockerignore
Normal file
31
frontend/.dockerignore
Normal file
@ -0,0 +1,31 @@
|
||||
# Frontend .dockerignore
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Source files (not needed for production)
|
||||
src/
|
||||
|
||||
# Build files
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
*.md
|
||||
18
frontend/Dockerfile
Normal file
18
frontend/Dockerfile
Normal file
@ -0,0 +1,18 @@
|
||||
# Frontend Dockerfile for Ora Map (Static files with Nginx)
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy static files to nginx html directory
|
||||
COPY public/ /usr/share/nginx/html/
|
||||
|
||||
# Copy custom nginx configuration
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
34
frontend/nginx.conf
Normal file
34
frontend/nginx.conf
Normal file
@ -0,0 +1,34 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
# Main location
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API proxy to backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
26
frontend/public/index.html
Normal file
26
frontend/public/index.html
Normal file
@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>🗺️ Ora</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>🗺️ Ora</h1>
|
||||
<div class="search-bar">
|
||||
<input type="text" id="searchInput" placeholder="Enter family name..." />
|
||||
<button onclick="searchFamily()">Search</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div id="map"></div>
|
||||
</main>
|
||||
|
||||
<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/fuse.js/dist/fuse.min.js"></script>
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
frontend/public/logo-shay.png
Normal file
BIN
frontend/public/logo-shay.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 975 KiB |
BIN
frontend/public/logo.png
Normal file
BIN
frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
124
frontend/public/script.js
Normal file
124
frontend/public/script.js
Normal file
@ -0,0 +1,124 @@
|
||||
let map = L.map('map').setView([15.5527, 48.5164], 6);
|
||||
|
||||
// Define the two tile layers
|
||||
const voyager = L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© OpenStreetMap contributors © CARTO',
|
||||
subdomains: 'abcd',
|
||||
maxZoom: 19
|
||||
});
|
||||
|
||||
const openStreetMap = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: 'Map data © OpenStreetMap contributors',
|
||||
maxZoom: 19
|
||||
});
|
||||
|
||||
// Add the default layer (Voyager)
|
||||
voyager.addTo(map);
|
||||
|
||||
// Group the layers for switching
|
||||
const baseMaps = {
|
||||
"English Map (CartoDB Voyager)": voyager,
|
||||
"Original Map (OpenStreetMap)": openStreetMap
|
||||
};
|
||||
|
||||
// Add the layer control to the map
|
||||
L.control.layers(baseMaps).addTo(map);
|
||||
|
||||
async function searchFamily() {
|
||||
const familyName = document.getElementById('searchInput').value.trim();
|
||||
if (!familyName) {
|
||||
alert('Please enter a family name.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/search?family=${encodeURIComponent(familyName)}`);
|
||||
const familyResult = await response.json();
|
||||
|
||||
if (!familyResult.length) {
|
||||
alert('No matching family found.');
|
||||
return;
|
||||
}
|
||||
|
||||
clearMarkers();
|
||||
|
||||
familyResult.forEach(record => {
|
||||
L.marker([record.lat, record.lng]).addTo(map)
|
||||
.bindPopup(`<strong>${record.family}</strong><br>City: ${record.city}`)
|
||||
.openPopup();
|
||||
});
|
||||
|
||||
const first = familyResult[0];
|
||||
map.setView([first.lat, first.lng], 7);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
alert('Something went wrong while searching. Please try again later.');
|
||||
}
|
||||
}
|
||||
|
||||
function clearMarkers() {
|
||||
map.eachLayer(layer => {
|
||||
if (layer instanceof L.Marker) {
|
||||
map.removeLayer(layer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const familyNames = [
|
||||
"Kafe (קאפח)", "Shiheb (שחב-שבח)", "Eraki (עראקי)", "Salumi (סלומי-שלומי)",
|
||||
"Afgin (עפג'ין)", "Uzeyri (עזירי-עוזרי)"
|
||||
// Add more families here if you like
|
||||
];
|
||||
|
||||
// Initialize Fuse.js
|
||||
const fuse = new Fuse(familyNames, {
|
||||
includeScore: true,
|
||||
threshold: 0.4 // Lower = stricter matching
|
||||
});
|
||||
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const suggestionsBox = document.createElement('div');
|
||||
suggestionsBox.classList.add('suggestions');
|
||||
searchInput.parentNode.style.position = 'relative'; // Make parent relative
|
||||
searchInput.parentNode.appendChild(suggestionsBox);
|
||||
|
||||
searchInput.addEventListener('input', () => {
|
||||
const value = searchInput.value.trim();
|
||||
suggestionsBox.innerHTML = '';
|
||||
|
||||
if (!value) return;
|
||||
|
||||
const results = fuse.search(value);
|
||||
|
||||
results.forEach(result => {
|
||||
const option = document.createElement('div');
|
||||
option.textContent = result.item;
|
||||
option.onclick = () => {
|
||||
searchInput.value = result.item;
|
||||
suggestionsBox.innerHTML = '';
|
||||
};
|
||||
suggestionsBox.appendChild(option);
|
||||
});
|
||||
});
|
||||
|
||||
L.control.logo = function (opts) {
|
||||
return new L.Control.Logo(opts);
|
||||
};
|
||||
|
||||
L.Control.Logo = L.Control.extend({
|
||||
onAdd: function () {
|
||||
const div = L.DomUtil.create('div', 'custom-logo');
|
||||
div.innerHTML = `
|
||||
<img src="logo.png" alt="Logo" style="height: 40px; vertical-align: middle;">
|
||||
<span style="margin-left: 8px; font-weight: bold; color: #333;">Shevach</span>
|
||||
`;
|
||||
return div;
|
||||
},
|
||||
|
||||
onRemove: function () {
|
||||
// Nothing to clean up
|
||||
}
|
||||
});
|
||||
|
||||
L.control.logo({ position: 'bottomleft' }).addTo(map);
|
||||
98
frontend/public/style.css
Normal file
98
frontend/public/style.css
Normal file
@ -0,0 +1,98 @@
|
||||
/* Reset some basic styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #f4f4f4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Header styles */
|
||||
header {
|
||||
background-color: #2c3e50;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
padding: 10px;
|
||||
width: 250px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.search-bar button {
|
||||
padding: 10px 15px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
background: #363251;
|
||||
border: 1px solid #ccc;
|
||||
position: absolute;
|
||||
top: 110%; /* Just below the input */
|
||||
left: 48%;
|
||||
transform: translateX(-50%);
|
||||
max-width: fit-content;
|
||||
min-width: 200px; /* Optional: minimum size */
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.suggestions div {
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.suggestions div:hover {
|
||||
background-color: #95a1a8;
|
||||
}
|
||||
|
||||
.custom-logo {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
/* Map container */
|
||||
#map {
|
||||
flex-grow: 1;
|
||||
height: 80vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Main container */
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
45
frontend/src/components/Search.jsx
Normal file
45
frontend/src/components/Search.jsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
function Search() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (query.trim() === '') {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchSuggestions = async () => {
|
||||
try {
|
||||
const res = await fetch(`/search?q=${encodeURIComponent(query)}`);
|
||||
const data = await res.json();
|
||||
setResults(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch suggestions:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSuggestions();
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<div className="search-container">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
{results.length > 0 && (
|
||||
<div className="autocomplete-box">
|
||||
{results.map((res, i) => (
|
||||
<div key={i} className="suggestion">{res}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Search;
|
||||
Loading…
x
Reference in New Issue
Block a user