Working react with docker deployment

This commit is contained in:
Nico 2025-11-15 23:06:46 -08:00
parent 1908b2aa70
commit e3b83786c5
17 changed files with 162 additions and 179 deletions

5
backend/.dockerignore Normal file
View File

@ -0,0 +1,5 @@
node_modules
.git
dist
build
*.log

12
backend/Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 5000
CMD ["node", "server.js"]

View File

@ -1,11 +1,11 @@
{
"name": "Costco-Grocery-List",
"name": "backend",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "Costco-Grocery-List",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"pg": "^8.16.0"
@ -904,6 +904,18 @@
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"dev": true
},
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/cpx": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/cpx/-/cpx-1.5.0.tgz",
@ -2144,6 +2156,14 @@
"node": ">=0.10.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-copy": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz",

View File

@ -1,5 +1,6 @@
{
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"pg": "^8.16.0"

View File

@ -1,9 +1,11 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const { Pool } = require('pg');
const app = express();
const port = 5001;
const port = 5000;
const pool = new Pool({
user: process.env.DB_USER,
@ -14,10 +16,22 @@ const pool = new Pool({
});
app.use(express.json());
// app.use(express.static('public'));
app.use(cors({
origin: "http://localhost:3000",
methods: ["GET", "POST"],
}));
app.get('/', async (req, res) => {
const { query } = req.query;
const { rows } = await pool.query(
'SELECT DISTINCT item_name FROM grocery_list WHERE item_name ILIKE $1 LIMIT 10',
[`%${query}%`]
);
res.status(200).send('Grocery List API is running.');
});
app.get('/api/suggest', async (req, res) => {
app.get('/suggest', async (req, res) => {
const { query } = req.query;
const { rows } = await pool.query(
'SELECT DISTINCT item_name FROM grocery_list WHERE item_name ILIKE $1 LIMIT 10',
@ -27,7 +41,7 @@ app.get('/api/suggest', async (req, res) => {
});
app.post('/api/add', async (req, res) => {
app.post('/add', async (req, res) => {
const { item_name, quantity } = req.body;
const result = await pool.query(
'SELECT id, bought FROM grocery_list WHERE item_name = $1',
@ -58,14 +72,14 @@ app.post('/api/add', async (req, res) => {
});
app.post('/api/mark-bought', async (req, res) => {
app.post('/mark-bought', async (req, res) => {
const { id } = req.body;
await pool.query('UPDATE grocery_list SET bought = TRUE WHERE id = $1', [id]);
res.json({ message: 'Item marked as bought.' });
});
app.get('/api/list', async (req, res) => {
app.get('/list', async (req, res) => {
const { rows } = await pool.query('SELECT * FROM grocery_list WHERE bought = FALSE');
res.json(rows);
});

21
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,21 @@
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.dev
environment:
- NODE_ENV=development
volumes:
- ./frontend:/app
- /app/node_modules
ports:
- "3000:5173"
depends_on:
- backend
backend:
build: ./backend
ports:
- "5000:5000"
env_file:
- ./backend/.env

16
docker-compose.prod.yml Normal file
View File

@ -0,0 +1,16 @@
services:
frontend:
build: ./frontend
environment:
- MODE_ENV=production
ports:
- "3000:80"
depends_on:
- backend
backend:
build: ./backend
ports:
- "5000:5000"
env_file:
- ./backend/.env

5
frontend/.dockerignore Normal file
View File

@ -0,0 +1,5 @@
node_modules
.git
dist
build
*.log

15
frontend/Dockerfile Normal file
View File

@ -0,0 +1,15 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

16
frontend/Dockerfile.dev Normal file
View File

@ -0,0 +1,16 @@
# FROM node:20-alpine
FROM node:20-slim
# FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
ENV CHOKIDAR_USEPOLLING=true
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

View File

@ -14,7 +14,7 @@
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"eslint": "^9.39.1",
@ -1766,9 +1766,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001754",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz",
"integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==",
"version": "1.0.30001755",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001755.tgz",
"integrity": "sha512-44V+Jm6ctPj7R52Na4TLi3Zri4dWUljJd+RDm+j8LtNCc/ihLCT+X1TzoOAkRETEWqjuLnh9581Tl80FvK7jVA==",
"dev": true,
"funding": [
{
@ -1846,9 +1846,9 @@
}
},
"node_modules/csstype": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.0.tgz",
"integrity": "sha512-si++xzRAY9iPp60roQiFta7OFbhrgvcthrhlNAGeQptSY25uJjkfUV8OArC3KLocB8JT8ohz+qgxWCmz8RhjIg==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.1.tgz",
"integrity": "sha512-98XGutrXoh75MlgLihlNxAGbUuFQc7l1cqcnEZlLNKc0UrVdPndgmaDmYTDDh929VS/eqTZV0rozmhu2qqT1/g==",
"dev": true
},
"node_modules/debug": {
@ -1875,9 +1875,9 @@
"dev": true
},
"node_modules/electron-to-chromium": {
"version": "1.5.253",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.253.tgz",
"integrity": "sha512-O0tpQ/35rrgdiGQ0/OFWhy1itmd9A6TY9uQzlqj3hKSu/aYpe7UIn5d7CU2N9myH6biZiWF3VMZVuup8pw5U9w==",
"version": "1.5.254",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.254.tgz",
"integrity": "sha512-DcUsWpVhv9svsKRxnSCZ86SjD+sp32SGidNB37KpqXJncp1mfUgKbHvBomE89WJDbfVKw1mdv5+ikrvd43r+Bg==",
"dev": true
},
"node_modules/esbuild": {

View File

@ -16,7 +16,7 @@
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"eslint": "^9.39.1",

View File

@ -40,3 +40,5 @@
.read-the-docs {
color: #888;
}

View File

@ -3,6 +3,9 @@ import SuggestionList from "./components/SuggestionList";
import GroceryItem from "./components/GroceryItem";
import type { GroceryItemType } from "./types";
const API_BASE_URL = import.meta.env.VITE_API_URL;
console.log("API_BASE_URL:", API_BASE_URL);
export default function App() {
const [items, setItems] = useState<GroceryItemType[]>([]);
const [suggestions, setSuggestions] = useState<string[]>([]);
@ -11,7 +14,7 @@ export default function App() {
// Load grocery list
const loadList = async () => {
const res = await fetch("/api/list");
const res = await fetch(`${API_BASE_URL}/list`);
const data: GroceryItemType[] = await res.json();
setItems(data);
};
@ -26,7 +29,7 @@ export default function App() {
if (!query.trim()) return setSuggestions([]);
const res = await fetch("/api/suggest?query=" + encodeURIComponent(query));
const res = await fetch(`${API_BASE_URL}/suggest?query=${encodeURIComponent(query)}`);
const data: string[] = await res.json();
setSuggestions(data);
};
@ -42,7 +45,7 @@ export default function App() {
if (existing) {
if (!confirm("Item already exists. Add more?")) return;
await fetch("/api/add", {
await fetch(`${API_BASE_URL}/add`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@ -51,7 +54,7 @@ export default function App() {
}),
});
} else {
await fetch("/api/add", {
await fetch(`${API_BASE_URL}/add`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ item_name: itemInput, quantity }),
@ -68,7 +71,7 @@ export default function App() {
const markBought = async (id: number) => {
if (!confirm("Mark this item as bought?")) return;
await fetch("/api/mark-bought", {
await fetch(`${API_BASE_URL}/mark-bought`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id }),

1
frontend/src/config.ts Normal file
View File

@ -0,0 +1 @@
export const API_BASE_URL = import.meta.env.VITE_API_URL || "/api";

View File

@ -4,9 +4,14 @@ import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
watch: {
usePolling: true,
},
host: true,
strictPort: true,
proxy: {
'/api': {
target: 'http://localhost:5001',
target: 'http://localhost:5000',
changeOrigin: true,
secure: false,
},

View File

@ -1,153 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Costco Grocery List</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 1em;
background: #f8f9fa;
}
.container {
max-width: 480px;
margin: auto;
background: white;
padding: 1em;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
h1 {
text-align: center;
font-size: 1.5em;
}
input,
button,
select {
font-size: 1em;
margin: 0.3em 0;
padding: 0.5em;
width: 100%;
box-sizing: border-box;
}
ul {
list-style: none;
padding: 0;
}
li {
padding: 0.5em;
background: #e9ecef;
margin-bottom: 0.5em;
border-radius: 4px;
cursor: pointer;
}
li:hover {
background: #dee2e6;
}
</style>
</head>
<body>
<div class="container">
<h1>Costco Grocery List</h1>
<input type="text" id="itemInput" placeholder="Item name" list="suggestions" autocomplete="off">
<!-- <ul id="suggestionList" style="display:none; background:#fff; border:1px solid #ccc; max-height:150px; overflow-y:auto; position:absolute; z-index:1000; width:calc(100% - 2em);"></ul> -->
<ul id="suggestionList"
style="display:none; background:#fff; border:1px solid #ccc; max-height:150px; overflow-y:auto; position:absolute; z-index:1000; left:1em; right:1em;">
</ul>
<input type="number" id="quantityInput" placeholder="Quantity" value="1" min="1">
<button onclick="addItem()">Add Item</button>
<ul id="groceryList"></ul>
</div>
<script>
async function loadList() {
const res = await fetch('/list');
const list = await res.json();
const ul = document.getElementById('groceryList');
ul.innerHTML = '';
list.forEach(item => {
const li = document.createElement('li');
li.textContent = `${item.item_name} (${item.quantity})`;
li.onclick = () => markBought(item.id);
ul.appendChild(li);
});
}
async function suggest(query) {
if (!query.trim()) return document.getElementById('suggestionList').style.display = 'none';
const res = await fetch('/suggest?query=' + encodeURIComponent(query));
const items = await res.json();
const suggestionBox = document.getElementById('suggestionList');
suggestionBox.innerHTML = '';
items.forEach(i => {
const li = document.createElement('li');
li.textContent = i;
li.style.padding = '0.5em';
li.onclick = () => {
document.getElementById('itemInput').value = i;
suggestionBox.style.display = 'none';
};
suggestionBox.appendChild(li);
});
suggestionBox.style.display = items.length ? 'block' : 'none';
}
document.getElementById('itemInput').addEventListener('input', (e) => suggest(e.target.value));
async function addItem() {
const item = document.getElementById('itemInput').value.trim();
const quantity = parseInt(document.getElementById('quantityInput').value, 10);
if (!item || isNaN(quantity)) return;
const check = await fetch('/list');
const existing = await check.json();
const match = existing.find(i => i.item_name.toLowerCase() === item.toLowerCase());
if (match) {
const confirmAdd = confirm("Item already exists. Add more?");
if (!confirmAdd) return;
await fetch('/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ item_name: item, quantity: match.quantity + quantity })
});
} else {
await fetch('/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ item_name: item, quantity })
});
}
document.getElementById('itemInput').value = '';
document.getElementById('quantityInput').value = 1;
document.getElementById('suggestionList').style.display = 'none';
loadList();
}
async function markBought(id) {
const confirmBuy = confirm("Mark this item as bought?");
if (!confirmBuy) return;
await fetch('/mark-bought', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
});
loadList();
}
loadList();
</script>
</body>
</html>