first commit
This commit is contained in:
19
packages/web/.eslintrc.json
Normal file
19
packages/web/.eslintrc.json
Normal 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
33
packages/web/Dockerfile
Normal 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
13
packages/web/index.html
Normal 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
32
packages/web/nginx.conf
Normal 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
33
packages/web/package.json
Normal 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
226
packages/web/src/App.css
Normal 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
41
packages/web/src/App.tsx
Normal 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;
|
||||
9
packages/web/src/main.tsx
Normal file
9
packages/web/src/main.tsx
Normal 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>
|
||||
);
|
||||
118
packages/web/src/pages/RecipeDetail.tsx
Normal file
118
packages/web/src/pages/RecipeDetail.tsx
Normal 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;
|
||||
130
packages/web/src/pages/RecipeImport.tsx
Normal file
130
packages/web/src/pages/RecipeImport.tsx
Normal 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;
|
||||
72
packages/web/src/pages/RecipeList.tsx
Normal file
72
packages/web/src/pages/RecipeList.tsx
Normal 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;
|
||||
58
packages/web/src/services/api.ts
Normal file
58
packages/web/src/services/api.ts
Normal 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;
|
||||
25
packages/web/tsconfig.json
Normal file
25
packages/web/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
packages/web/tsconfig.node.json
Normal file
10
packages/web/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
19
packages/web/vite.config.ts
Normal file
19
packages/web/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user