first commit

This commit is contained in:
2025-10-21 22:04:03 -06:00
commit 4e71ef9c66
36 changed files with 2271 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
{
"parser": "@typescript-eslint/parser",
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended"
],
"plugins": ["react-refresh"],
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"rules": {
"react-refresh/only-export-components": [
"warn",
{ "allowConstantExport": true }
]
}
}

33
packages/web/Dockerfile Normal file
View File

@@ -0,0 +1,33 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy workspace root files
COPY package*.json ./
COPY packages/shared ./packages/shared
COPY packages/web ./packages/web
# Install dependencies
RUN npm install
# Build shared package
WORKDIR /app/packages/shared
RUN npm run build
# Build web app
WORKDIR /app/packages/web
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built files to nginx
COPY --from=builder /app/packages/web/dist /usr/share/nginx/html
# Copy nginx configuration
COPY packages/web/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

13
packages/web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/basil.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Basil - Recipe Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

32
packages/web/nginx.conf Normal file
View File

@@ -0,0 +1,32 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Serve static files
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API requests to backend
location /api/ {
proxy_pass http://basil-api:3001;
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 uploads requests to backend
location /uploads/ {
proxy_pass http://basil-api:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}

33
packages/web/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "@basil/web",
"version": "1.0.0",
"description": "Basil web application",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx"
},
"keywords": ["basil", "web"],
"license": "MIT",
"dependencies": {
"@basil/shared": "^1.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.1",
"axios": "^1.6.5"
},
"devDependencies": {
"@types/react": "^18.2.47",
"@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.17.0",
"@typescript-eslint/parser": "^6.17.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"typescript": "^5.3.3",
"vite": "^5.0.10"
}
}

226
packages/web/src/App.css Normal file
View File

@@ -0,0 +1,226 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
}
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
width: 100%;
}
.header {
background-color: #2d5016;
color: white;
padding: 1rem 0;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header .container {
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.5rem;
font-weight: bold;
}
nav {
display: flex;
gap: 1.5rem;
}
nav a {
color: white;
text-decoration: none;
font-weight: 500;
}
nav a:hover {
text-decoration: underline;
}
.main {
flex: 1;
padding: 2rem 0;
}
.footer {
background-color: #2d5016;
color: white;
padding: 1rem 0;
text-align: center;
margin-top: auto;
}
.recipe-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
margin-top: 2rem;
}
.recipe-card {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
transition: transform 0.2s;
cursor: pointer;
}
.recipe-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.recipe-card img {
width: 100%;
height: 200px;
object-fit: cover;
}
.recipe-card-content {
padding: 1rem;
}
.recipe-card h3 {
margin-bottom: 0.5rem;
color: #2d5016;
}
.recipe-card p {
color: #666;
font-size: 0.9rem;
}
.recipe-detail {
background: white;
border-radius: 8px;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.recipe-detail img {
width: 100%;
max-height: 400px;
object-fit: cover;
border-radius: 8px;
margin-bottom: 2rem;
}
.recipe-detail h2 {
color: #2d5016;
margin-bottom: 1rem;
}
.recipe-meta {
display: flex;
gap: 2rem;
margin-bottom: 2rem;
color: #666;
}
.ingredients, .instructions {
margin-bottom: 2rem;
}
.ingredients h3, .instructions h3 {
color: #2d5016;
margin-bottom: 1rem;
}
.ingredients ul {
list-style: none;
padding: 0;
}
.ingredients li {
padding: 0.5rem 0;
border-bottom: 1px solid #eee;
}
.instructions ol {
padding-left: 1.5rem;
}
.instructions li {
margin-bottom: 1rem;
line-height: 1.6;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #333;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.form-group textarea {
min-height: 100px;
resize: vertical;
}
button {
background-color: #2d5016;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
}
button:hover {
background-color: #1f3710;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.error {
color: #d32f2f;
background-color: #ffebee;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.loading {
text-align: center;
padding: 2rem;
color: #666;
}

41
packages/web/src/App.tsx Normal file
View File

@@ -0,0 +1,41 @@
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
import RecipeList from './pages/RecipeList';
import RecipeDetail from './pages/RecipeDetail';
import RecipeImport from './pages/RecipeImport';
import './App.css';
function App() {
return (
<Router>
<div className="app">
<header className="header">
<div className="container">
<h1 className="logo">🌿 Basil</h1>
<nav>
<Link to="/">Recipes</Link>
<Link to="/import">Import Recipe</Link>
</nav>
</div>
</header>
<main className="main">
<div className="container">
<Routes>
<Route path="/" element={<RecipeList />} />
<Route path="/recipes/:id" element={<RecipeDetail />} />
<Route path="/import" element={<RecipeImport />} />
</Routes>
</div>
</main>
<footer className="footer">
<div className="container">
<p>Basil - Your Recipe Manager</p>
</div>
</footer>
</div>
</Router>
);
}
export default App;

View File

@@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,118 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Recipe } from '@basil/shared';
import { recipesApi } from '../services/api';
function RecipeDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [recipe, setRecipe] = useState<Recipe | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (id) {
loadRecipe(id);
}
}, [id]);
const loadRecipe = async (recipeId: string) => {
try {
setLoading(true);
const response = await recipesApi.getById(recipeId);
setRecipe(response.data || null);
setError(null);
} catch (err) {
setError('Failed to load recipe');
console.error(err);
} finally {
setLoading(false);
}
};
const handleDelete = async () => {
if (!id || !confirm('Are you sure you want to delete this recipe?')) {
return;
}
try {
await recipesApi.delete(id);
navigate('/');
} catch (err) {
setError('Failed to delete recipe');
console.error(err);
}
};
if (loading) {
return <div className="loading">Loading recipe...</div>;
}
if (error) {
return <div className="error">{error}</div>;
}
if (!recipe) {
return <div className="error">Recipe not found</div>;
}
return (
<div className="recipe-detail">
<button onClick={() => navigate('/')}> Back to Recipes</button>
<button onClick={handleDelete} style={{ marginLeft: '1rem', backgroundColor: '#d32f2f' }}>
Delete Recipe
</button>
{recipe.imageUrl && <img src={recipe.imageUrl} alt={recipe.title} />}
<h2>{recipe.title}</h2>
{recipe.description && <p>{recipe.description}</p>}
<div className="recipe-meta">
{recipe.prepTime && <span>Prep: {recipe.prepTime} min</span>}
{recipe.cookTime && <span>Cook: {recipe.cookTime} min</span>}
{recipe.totalTime && <span>Total: {recipe.totalTime} min</span>}
{recipe.servings && <span>Servings: {recipe.servings}</span>}
</div>
{recipe.sourceUrl && (
<p>
<strong>Source: </strong>
<a href={recipe.sourceUrl} target="_blank" rel="noopener noreferrer">
{recipe.sourceUrl}
</a>
</p>
)}
{recipe.ingredients && recipe.ingredients.length > 0 && (
<div className="ingredients">
<h3>Ingredients</h3>
<ul>
{recipe.ingredients.map((ingredient, index) => (
<li key={index}>
{ingredient.amount && `${ingredient.amount} `}
{ingredient.unit && `${ingredient.unit} `}
{ingredient.name}
{ingredient.notes && ` (${ingredient.notes})`}
</li>
))}
</ul>
</div>
)}
{recipe.instructions && recipe.instructions.length > 0 && (
<div className="instructions">
<h3>Instructions</h3>
<ol>
{recipe.instructions.map((instruction) => (
<li key={instruction.step}>{instruction.text}</li>
))}
</ol>
</div>
)}
</div>
);
}
export default RecipeDetail;

View File

@@ -0,0 +1,130 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Recipe } from '@basil/shared';
import { recipesApi } from '../services/api';
function RecipeImport() {
const navigate = useNavigate();
const [url, setUrl] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [importedRecipe, setImportedRecipe] = useState<Partial<Recipe> | null>(null);
const handleImport = async (e: React.FormEvent) => {
e.preventDefault();
if (!url) {
setError('Please enter a URL');
return;
}
try {
setLoading(true);
setError(null);
const response = await recipesApi.importFromUrl(url);
if (response.success && response.recipe) {
setImportedRecipe(response.recipe);
} else {
setError(response.error || 'Failed to import recipe');
}
} catch (err) {
setError('Failed to import recipe from URL');
console.error(err);
} finally {
setLoading(false);
}
};
const handleSave = async () => {
if (!importedRecipe) return;
try {
setLoading(true);
const response = await recipesApi.create(importedRecipe);
if (response.data) {
navigate(`/recipes/${response.data.id}`);
}
} catch (err) {
setError('Failed to save recipe');
console.error(err);
} finally {
setLoading(false);
}
};
return (
<div>
<h2>Import Recipe from URL</h2>
<form onSubmit={handleImport}>
<div className="form-group">
<label htmlFor="url">Recipe URL</label>
<input
type="url"
id="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com/recipe"
disabled={loading}
/>
</div>
<button type="submit" disabled={loading}>
{loading ? 'Importing...' : 'Import Recipe'}
</button>
</form>
{error && <div className="error">{error}</div>}
{importedRecipe && (
<div className="recipe-detail" style={{ marginTop: '2rem' }}>
<h3>Imported Recipe Preview</h3>
{importedRecipe.imageUrl && (
<img src={importedRecipe.imageUrl} alt={importedRecipe.title} />
)}
<h2>{importedRecipe.title}</h2>
{importedRecipe.description && <p>{importedRecipe.description}</p>}
<div className="recipe-meta">
{importedRecipe.prepTime && <span>Prep: {importedRecipe.prepTime} min</span>}
{importedRecipe.cookTime && <span>Cook: {importedRecipe.cookTime} min</span>}
{importedRecipe.totalTime && <span>Total: {importedRecipe.totalTime} min</span>}
{importedRecipe.servings && <span>Servings: {importedRecipe.servings}</span>}
</div>
{importedRecipe.ingredients && importedRecipe.ingredients.length > 0 && (
<div className="ingredients">
<h3>Ingredients</h3>
<ul>
{importedRecipe.ingredients.map((ingredient, index) => (
<li key={index}>{ingredient.name}</li>
))}
</ul>
</div>
)}
{importedRecipe.instructions && importedRecipe.instructions.length > 0 && (
<div className="instructions">
<h3>Instructions</h3>
<ol>
{importedRecipe.instructions.map((instruction) => (
<li key={instruction.step}>{instruction.text}</li>
))}
</ol>
</div>
)}
<button onClick={handleSave} disabled={loading}>
Save Recipe
</button>
</div>
)}
</div>
);
}
export default RecipeImport;

View File

@@ -0,0 +1,72 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Recipe } from '@basil/shared';
import { recipesApi } from '../services/api';
function RecipeList() {
const [recipes, setRecipes] = useState<Recipe[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
useEffect(() => {
loadRecipes();
}, []);
const loadRecipes = async () => {
try {
setLoading(true);
const response = await recipesApi.getAll();
setRecipes(response.data);
setError(null);
} catch (err) {
setError('Failed to load recipes');
console.error(err);
} finally {
setLoading(false);
}
};
if (loading) {
return <div className="loading">Loading recipes...</div>;
}
if (error) {
return <div className="error">{error}</div>;
}
return (
<div>
<h2>My Recipes</h2>
{recipes.length === 0 ? (
<p>No recipes yet. Import one from a URL or create your own!</p>
) : (
<div className="recipe-grid">
{recipes.map((recipe) => (
<div
key={recipe.id}
className="recipe-card"
onClick={() => navigate(`/recipes/${recipe.id}`)}
>
{recipe.imageUrl && (
<img src={recipe.imageUrl} alt={recipe.title} />
)}
<div className="recipe-card-content">
<h3>{recipe.title}</h3>
{recipe.description && (
<p>{recipe.description.substring(0, 100)}...</p>
)}
<div className="recipe-meta">
{recipe.totalTime && <span>{recipe.totalTime} min</span>}
{recipe.servings && <span>{recipe.servings} servings</span>}
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
export default RecipeList;

View File

@@ -0,0 +1,58 @@
import axios from 'axios';
import { Recipe, RecipeImportRequest, RecipeImportResponse, ApiResponse, PaginatedResponse } from '@basil/shared';
const api = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
});
export const recipesApi = {
getAll: async (params?: {
page?: number;
limit?: number;
search?: string;
cuisine?: string;
category?: string;
}): Promise<PaginatedResponse<Recipe>> => {
const response = await api.get('/recipes', { params });
return response.data;
},
getById: async (id: string): Promise<ApiResponse<Recipe>> => {
const response = await api.get(`/recipes/${id}`);
return response.data;
},
create: async (recipe: Partial<Recipe>): Promise<ApiResponse<Recipe>> => {
const response = await api.post('/recipes', recipe);
return response.data;
},
update: async (id: string, recipe: Partial<Recipe>): Promise<ApiResponse<Recipe>> => {
const response = await api.put(`/recipes/${id}`, recipe);
return response.data;
},
delete: async (id: string): Promise<ApiResponse<void>> => {
const response = await api.delete(`/recipes/${id}`);
return response.data;
},
uploadImage: async (id: string, file: File): Promise<ApiResponse<{ url: string }>> => {
const formData = new FormData();
formData.append('image', file);
const response = await api.post(`/recipes/${id}/images`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
},
importFromUrl: async (url: string): Promise<RecipeImportResponse> => {
const response = await api.post('/recipes/import', { url } as RecipeImportRequest);
return response.data;
},
};
export default api;

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/uploads': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
});