Initial Commit
This commit is contained in:
64
frontend/src/app/app.component.css
Normal file
64
frontend/src/app/app.component.css
Normal file
@@ -0,0 +1,64 @@
|
||||
.navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #2d6a4f;
|
||||
padding: 0 2rem;
|
||||
height: 64px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
color: #fff;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: #d8f3dc;
|
||||
text-decoration: none;
|
||||
font-size: 1rem;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: #40916c;
|
||||
color: #fff;
|
||||
padding: 0.45rem 1.1rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: #52b788;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
max-width: 1100px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
14
frontend/src/app/app.component.html
Normal file
14
frontend/src/app/app.component.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<nav class="navbar">
|
||||
<div class="nav-brand">
|
||||
<span class="nav-icon">🍳</span>
|
||||
<a routerLink="/recipes" class="nav-title">Recipe Organizer</a>
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a routerLink="/recipes" class="nav-link">All Recipes</a>
|
||||
<a routerLink="/recipes/new" class="nav-link nav-btn">+ Add Recipe</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="main-content">
|
||||
<router-outlet />
|
||||
</div>
|
||||
12
frontend/src/app/app.component.ts
Normal file
12
frontend/src/app/app.component.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet, RouterLink } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet, RouterLink],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.css'
|
||||
})
|
||||
export class AppComponent {
|
||||
title = 'Recipe Organizer';
|
||||
}
|
||||
12
frontend/src/app/app.config.ts
Normal file
12
frontend/src/app/app.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { routes } from './app.routes';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||
provideRouter(routes),
|
||||
provideHttpClient()
|
||||
]
|
||||
};
|
||||
12
frontend/src/app/app.routes.ts
Normal file
12
frontend/src/app/app.routes.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { RecipeListComponent } from './components/recipe-list/recipe-list.component';
|
||||
import { RecipeFormComponent } from './components/recipe-form/recipe-form.component';
|
||||
import { RecipeDetailComponent } from './components/recipe-detail/recipe-detail.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', redirectTo: '/recipes', pathMatch: 'full' },
|
||||
{ path: 'recipes', component: RecipeListComponent },
|
||||
{ path: 'recipes/new', component: RecipeFormComponent },
|
||||
{ path: 'recipes/:id', component: RecipeDetailComponent },
|
||||
{ path: 'recipes/:id/edit', component: RecipeFormComponent }
|
||||
];
|
||||
@@ -0,0 +1,173 @@
|
||||
.status-msg {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.status-msg.error { color: #e63946; }
|
||||
|
||||
.detail-page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: #40916c;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.back-link:hover { text-decoration: underline; }
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1.2rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover { opacity: 0.85; }
|
||||
.btn-edit { background: #b7e4c7; color: #1b4332; }
|
||||
.btn-delete { background: #e63946; color: #fff; }
|
||||
|
||||
.detail-card {
|
||||
background: #fff;
|
||||
border: 1.5px solid #d8f3dc;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.detail-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.category-badge {
|
||||
background: #d8f3dc;
|
||||
color: #2d6a4f;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding: 0.3rem 0.8rem;
|
||||
border-radius: 20px;
|
||||
display: inline-block;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.recipe-title {
|
||||
font-size: 2rem;
|
||||
color: #1b4332;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.recipe-desc {
|
||||
color: #555;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rating-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.star { color: #f4a522; font-size: 1.3rem; }
|
||||
|
||||
.rating-label {
|
||||
font-size: 0.9rem;
|
||||
color: #777;
|
||||
margin-left: 0.2rem;
|
||||
}
|
||||
|
||||
.time-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.time-chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: #f8f9fa;
|
||||
border: 1.5px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
padding: 0.7rem 1.4rem;
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.time-chip.total {
|
||||
background: #d8f3dc;
|
||||
border-color: #b7e4c7;
|
||||
}
|
||||
|
||||
.time-label {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.time-value {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #2d6a4f;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.ingredients-section h2,
|
||||
.instructions-section h2 {
|
||||
font-size: 1.15rem;
|
||||
color: #2d6a4f;
|
||||
margin: 0 0 0.8rem;
|
||||
padding-bottom: 0.4rem;
|
||||
border-bottom: 2px solid #d8f3dc;
|
||||
}
|
||||
|
||||
.ingredients-list {
|
||||
padding-left: 1.2rem;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.ingredients-list li {
|
||||
color: #333;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.instructions-text {
|
||||
color: #333;
|
||||
line-height: 1.7;
|
||||
white-space: pre-line;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<div *ngIf="loading" class="status-msg">Loading recipe...</div>
|
||||
<div *ngIf="errorMessage" class="status-msg error">{{ errorMessage }}</div>
|
||||
|
||||
<div *ngIf="recipe && !loading" class="detail-page">
|
||||
|
||||
<div class="detail-header">
|
||||
<a routerLink="/recipes" class="back-link">← Back to Recipes</a>
|
||||
<div class="header-actions">
|
||||
<a [routerLink]="['/recipes', recipe._id, 'edit']" class="btn btn-edit">Edit</a>
|
||||
<button type="button" class="btn btn-delete" (click)="deleteRecipe()">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-card">
|
||||
<div class="detail-top">
|
||||
<div>
|
||||
<span class="category-badge">{{ recipe.category }}</span>
|
||||
<h1 class="recipe-title">{{ recipe.title }}</h1>
|
||||
<p class="recipe-desc" *ngIf="recipe.description">{{ recipe.description }}</p>
|
||||
</div>
|
||||
<div class="rating-block">
|
||||
<span *ngFor="let star of getStars(recipe.rating)" class="star">{{ star }}</span>
|
||||
<span class="rating-label">{{ recipe.rating }}/5</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="time-row">
|
||||
<div class="time-chip">
|
||||
<span class="time-label">Prep</span>
|
||||
<span class="time-value">{{ recipe.prepTime }} min</span>
|
||||
</div>
|
||||
<div class="time-chip">
|
||||
<span class="time-label">Cook</span>
|
||||
<span class="time-value">{{ recipe.cookTime }} min</span>
|
||||
</div>
|
||||
<div class="time-chip total">
|
||||
<span class="time-label">Total</span>
|
||||
<span class="time-value">{{ (recipe.prepTime || 0) + (recipe.cookTime || 0) }} min</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-body">
|
||||
<section class="ingredients-section">
|
||||
<h2>Ingredients</h2>
|
||||
<ul class="ingredients-list">
|
||||
<li *ngFor="let item of recipe.ingredients">{{ item }}</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="instructions-section">
|
||||
<h2>Instructions</h2>
|
||||
<p class="instructions-text">{{ recipe.instructions }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { RecipeService, Recipe } from '../../services/recipe.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-recipe-detail',
|
||||
imports: [CommonModule, RouterLink],
|
||||
templateUrl: './recipe-detail.component.html',
|
||||
styleUrl: './recipe-detail.component.css'
|
||||
})
|
||||
export class RecipeDetailComponent implements OnInit {
|
||||
recipe: Recipe | null = null;
|
||||
loading: boolean = true;
|
||||
errorMessage: string = '';
|
||||
|
||||
constructor(
|
||||
private recipeService: RecipeService,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
const id = this.route.snapshot.paramMap.get('id') || '';
|
||||
this.recipeService.getRecipe(id).subscribe({
|
||||
next: (data) => {
|
||||
this.recipe = data;
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.errorMessage = 'Recipe not found.';
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteRecipe(): void {
|
||||
if (!this.recipe?._id) return;
|
||||
if (confirm('Delete this recipe?')) {
|
||||
this.recipeService.deleteRecipe(this.recipe._id).subscribe({
|
||||
next: () => this.router.navigate(['/recipes']),
|
||||
error: () => alert('Failed to delete recipe.')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getStars(rating: number): string[] {
|
||||
return Array(5).fill('').map((_, i) => i < rating ? '★' : '☆');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
.form-page {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: #40916c;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
display: inline-block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.back-link:hover { text-decoration: underline; }
|
||||
|
||||
.form-header h1 {
|
||||
font-size: 1.8rem;
|
||||
color: #1b4332;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
background: #ffe0e0;
|
||||
color: #c0392b;
|
||||
padding: 0.8rem 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.recipe-form {
|
||||
background: #fff;
|
||||
border: 1.5px solid #d8f3dc;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: #2d6a4f;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
padding: 0.6rem 0.9rem;
|
||||
border: 1.5px solid #b7e4c7;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-control:focus { border-color: #52b788; }
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.ingredient-input-row {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.ingredient-input-row .form-control {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
padding: 0.6rem 1.1rem;
|
||||
background: #52b788;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-add:hover { background: #40916c; }
|
||||
|
||||
.ingredient-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.5rem 0 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ingredient-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
background: #d8f3dc;
|
||||
color: #1b4332;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #2d6a4f;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.remove-btn:hover { color: #e63946; }
|
||||
|
||||
.hint {
|
||||
font-size: 0.82rem;
|
||||
color: #aaa;
|
||||
margin: 0.3rem 0 0;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.6rem 1.4rem;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover { opacity: 0.85; }
|
||||
|
||||
.btn-cancel {
|
||||
background: #f1f3f5;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
background: #2d6a4f;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<div class="form-page">
|
||||
<div class="form-header">
|
||||
<a routerLink="/recipes" class="back-link">← Back to Recipes</a>
|
||||
<h1>{{ isEditMode ? 'Edit Recipe' : 'Add New Recipe' }}</h1>
|
||||
</div>
|
||||
|
||||
<div *ngIf="errorMessage" class="error-banner">{{ errorMessage }}</div>
|
||||
|
||||
<form class="recipe-form" (ngSubmit)="onSubmit()">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="title">Title *</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
class="form-control"
|
||||
[(ngModel)]="recipe.title"
|
||||
name="title"
|
||||
placeholder="e.g. Classic Spaghetti Bolognese"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
class="form-control"
|
||||
[(ngModel)]="recipe.description"
|
||||
name="description"
|
||||
rows="2"
|
||||
placeholder="A short description of the dish..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="category">Category *</label>
|
||||
<select id="category" class="form-control" [(ngModel)]="recipe.category" name="category">
|
||||
<option *ngFor="let cat of categories" [value]="cat">{{ cat }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="rating">Rating (1–5)</label>
|
||||
<input
|
||||
id="rating"
|
||||
type="number"
|
||||
class="form-control"
|
||||
[(ngModel)]="recipe.rating"
|
||||
name="rating"
|
||||
min="1"
|
||||
max="5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="prepTime">Prep Time (min)</label>
|
||||
<input
|
||||
id="prepTime"
|
||||
type="number"
|
||||
class="form-control"
|
||||
[(ngModel)]="recipe.prepTime"
|
||||
name="prepTime"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="cookTime">Cook Time (min)</label>
|
||||
<input
|
||||
id="cookTime"
|
||||
type="number"
|
||||
class="form-control"
|
||||
[(ngModel)]="recipe.cookTime"
|
||||
name="cookTime"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Ingredients *</label>
|
||||
<div class="ingredient-input-row">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
[(ngModel)]="ingredientInput"
|
||||
name="ingredientInput"
|
||||
placeholder="e.g. 2 cups flour"
|
||||
(keydown)="onIngredientKeydown($event)"
|
||||
/>
|
||||
<button type="button" class="btn btn-add" (click)="addIngredient()">Add</button>
|
||||
</div>
|
||||
<ul class="ingredient-list" *ngIf="recipe.ingredients.length > 0">
|
||||
<li *ngFor="let item of recipe.ingredients; let i = index" class="ingredient-item">
|
||||
<span>{{ item }}</span>
|
||||
<button type="button" class="remove-btn" (click)="removeIngredient(i)" title="Remove">✕</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="hint" *ngIf="recipe.ingredients.length === 0">Add at least one ingredient.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="instructions">Instructions *</label>
|
||||
<textarea
|
||||
id="instructions"
|
||||
class="form-control"
|
||||
[(ngModel)]="recipe.instructions"
|
||||
name="instructions"
|
||||
rows="6"
|
||||
placeholder="Step-by-step cooking instructions..."
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<a routerLink="/recipes" class="btn btn-cancel">Cancel</a>
|
||||
<button type="submit" class="btn btn-submit" [disabled]="!isFormValid() || submitting">
|
||||
{{ submitting ? 'Saving...' : (isEditMode ? 'Update Recipe' : 'Save Recipe') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { RecipeService, Recipe } from '../../services/recipe.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-recipe-form',
|
||||
imports: [CommonModule, FormsModule, RouterLink],
|
||||
templateUrl: './recipe-form.component.html',
|
||||
styleUrl: './recipe-form.component.css'
|
||||
})
|
||||
export class RecipeFormComponent implements OnInit {
|
||||
isEditMode: boolean = false;
|
||||
recipeId: string = '';
|
||||
ingredientInput: string = '';
|
||||
categories: string[] = ['Breakfast', 'Lunch', 'Dinner', 'Dessert', 'Snack', 'Other'];
|
||||
submitting: boolean = false;
|
||||
errorMessage: string = '';
|
||||
|
||||
recipe: Recipe = {
|
||||
title: '',
|
||||
description: '',
|
||||
category: 'Dinner',
|
||||
ingredients: [],
|
||||
instructions: '',
|
||||
prepTime: 0,
|
||||
cookTime: 0,
|
||||
rating: 3
|
||||
};
|
||||
|
||||
constructor(
|
||||
private recipeService: RecipeService,
|
||||
private router: Router,
|
||||
private route: ActivatedRoute
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.recipeId = this.route.snapshot.paramMap.get('id') || '';
|
||||
this.isEditMode = !!this.recipeId && this.router.url.includes('/edit');
|
||||
|
||||
if (this.isEditMode) {
|
||||
this.recipeService.getRecipe(this.recipeId).subscribe({
|
||||
next: (data) => { this.recipe = data; },
|
||||
error: () => { this.errorMessage = 'Could not load recipe.'; }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
addIngredient(): void {
|
||||
const trimmed = this.ingredientInput.trim();
|
||||
if (trimmed) {
|
||||
this.recipe.ingredients = [...this.recipe.ingredients, trimmed];
|
||||
this.ingredientInput = '';
|
||||
}
|
||||
}
|
||||
|
||||
removeIngredient(index: number): void {
|
||||
this.recipe.ingredients = this.recipe.ingredients.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
onIngredientKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
this.addIngredient();
|
||||
}
|
||||
}
|
||||
|
||||
isFormValid(): boolean {
|
||||
return !!this.recipe.title.trim() &&
|
||||
!!this.recipe.category &&
|
||||
this.recipe.ingredients.length > 0 &&
|
||||
!!this.recipe.instructions.trim();
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (!this.isFormValid()) return;
|
||||
this.submitting = true;
|
||||
|
||||
const action = this.isEditMode
|
||||
? this.recipeService.updateRecipe(this.recipeId, this.recipe)
|
||||
: this.recipeService.addRecipe(this.recipe);
|
||||
|
||||
action.subscribe({
|
||||
next: () => this.router.navigate(['/recipes']),
|
||||
error: () => {
|
||||
this.errorMessage = 'Failed to save recipe. Make sure the backend is running.';
|
||||
this.submitting = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
.list-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.list-header h1 {
|
||||
font-size: 2rem;
|
||||
color: #1b4332;
|
||||
margin: 0 0 0.3rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #6c757d;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 0.6rem 1rem;
|
||||
border: 1.5px solid #b7e4c7;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #52b788;
|
||||
}
|
||||
|
||||
.category-select {
|
||||
padding: 0.6rem 1rem;
|
||||
border: 1.5px solid #b7e4c7;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.category-select:focus {
|
||||
border-color: #52b788;
|
||||
}
|
||||
|
||||
.recipe-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.recipe-card {
|
||||
background: #fff;
|
||||
border: 1.5px solid #d8f3dc;
|
||||
border-radius: 12px;
|
||||
padding: 1.4rem;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s, transform 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.recipe-card:hover {
|
||||
box-shadow: 0 6px 20px rgba(45, 106, 79, 0.15);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.category-badge {
|
||||
background: #d8f3dc;
|
||||
color: #2d6a4f;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.7rem;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.rating .star {
|
||||
color: #f4a522;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.2rem;
|
||||
color: #1b4332;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
color: #555;
|
||||
font-size: 0.92rem;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 0.7rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.45rem 1rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover { opacity: 0.85; }
|
||||
|
||||
.btn-primary { background: #2d6a4f; color: #fff; }
|
||||
.btn-secondary { background: #b7e4c7; color: #1b4332; }
|
||||
.btn-danger { background: #e63946; color: #fff; }
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 1.5px solid #adb5bd;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.status-msg {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.status-msg.error { color: #e63946; }
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: #888;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<div class="list-header">
|
||||
<h1>My Recipes</h1>
|
||||
<p class="subtitle">{{ filteredRecipes.length }} recipe{{ filteredRecipes.length !== 1 ? 's' : '' }} found</p>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Search recipes..."
|
||||
[(ngModel)]="searchTerm"
|
||||
(ngModelChange)="onSearchChange()"
|
||||
/>
|
||||
<select class="category-select" title="Filter by category" [(ngModel)]="selectedCategory" (ngModelChange)="onCategoryChange()">
|
||||
<option value="">All Categories</option>
|
||||
<option *ngFor="let cat of categories" [value]="cat">{{ cat }}</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-outline" (click)="clearFilters()" *ngIf="searchTerm || selectedCategory">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div *ngIf="loading" class="status-msg">Loading recipes...</div>
|
||||
<div *ngIf="errorMessage" class="status-msg error">{{ errorMessage }}</div>
|
||||
|
||||
<div *ngIf="!loading && !errorMessage && filteredRecipes.length === 0" class="empty-state">
|
||||
<p>No recipes found.</p>
|
||||
<a routerLink="/recipes/new" class="btn btn-primary">Add Your First Recipe</a>
|
||||
</div>
|
||||
|
||||
<div class="recipe-grid" *ngIf="!loading && !errorMessage">
|
||||
<div class="recipe-card" *ngFor="let recipe of filteredRecipes" [routerLink]="['/recipes', recipe._id]">
|
||||
<div class="card-header">
|
||||
<span class="category-badge">{{ recipe.category }}</span>
|
||||
<span class="rating">
|
||||
<span *ngFor="let star of getStars(recipe.rating)" class="star">{{ star }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="card-title">{{ recipe.title }}</h2>
|
||||
<p class="card-desc">{{ recipe.description }}</p>
|
||||
<div class="card-meta">
|
||||
<span>⏱ {{ getTotalTime(recipe) }} min total</span>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<a [routerLink]="['/recipes', recipe._id, 'edit']" class="btn btn-secondary" (click)="$event.stopPropagation()">Edit</a>
|
||||
<button type="button" class="btn btn-danger" (click)="deleteRecipe(recipe._id!, $event)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { RecipeService, Recipe } from '../../services/recipe.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-recipe-list',
|
||||
imports: [CommonModule, FormsModule, RouterLink],
|
||||
templateUrl: './recipe-list.component.html',
|
||||
styleUrl: './recipe-list.component.css'
|
||||
})
|
||||
export class RecipeListComponent implements OnInit {
|
||||
recipes: Recipe[] = [];
|
||||
filteredRecipes: Recipe[] = [];
|
||||
searchTerm: string = '';
|
||||
selectedCategory: string = '';
|
||||
categories: string[] = ['Breakfast', 'Lunch', 'Dinner', 'Dessert', 'Snack', 'Other'];
|
||||
loading: boolean = true;
|
||||
errorMessage: string = '';
|
||||
|
||||
constructor(private recipeService: RecipeService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadRecipes();
|
||||
}
|
||||
|
||||
loadRecipes(): void {
|
||||
this.loading = true;
|
||||
this.recipeService.getRecipes().subscribe({
|
||||
next: (data) => {
|
||||
this.recipes = data;
|
||||
this.applyFilter();
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.errorMessage = 'Could not load recipes. Make sure the backend server is running.';
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyFilter(): void {
|
||||
this.filteredRecipes = this.recipes.filter(r => {
|
||||
const matchesSearch = r.title.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
|
||||
r.description.toLowerCase().includes(this.searchTerm.toLowerCase());
|
||||
const matchesCategory = this.selectedCategory === '' || r.category === this.selectedCategory;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
}
|
||||
|
||||
onSearchChange(): void {
|
||||
this.applyFilter();
|
||||
}
|
||||
|
||||
onCategoryChange(): void {
|
||||
this.applyFilter();
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.searchTerm = '';
|
||||
this.selectedCategory = '';
|
||||
this.applyFilter();
|
||||
}
|
||||
|
||||
deleteRecipe(id: string, event: Event): void {
|
||||
event.stopPropagation();
|
||||
if (confirm('Are you sure you want to delete this recipe?')) {
|
||||
this.recipeService.deleteRecipe(id).subscribe({
|
||||
next: () => this.loadRecipes(),
|
||||
error: () => alert('Failed to delete recipe.')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getStars(rating: number): string[] {
|
||||
return Array(5).fill('').map((_, i) => i < rating ? '★' : '☆');
|
||||
}
|
||||
|
||||
getTotalTime(recipe: Recipe): number {
|
||||
return (recipe.prepTime || 0) + (recipe.cookTime || 0);
|
||||
}
|
||||
}
|
||||
45
frontend/src/app/services/recipe.service.ts
Normal file
45
frontend/src/app/services/recipe.service.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export interface Recipe {
|
||||
_id?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
ingredients: string[];
|
||||
instructions: string;
|
||||
prepTime: number;
|
||||
cookTime: number;
|
||||
rating: number;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class RecipeService {
|
||||
private baseUrl = 'http://localhost:5038/api/recipes';
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
getRecipes(): Observable<Recipe[]> {
|
||||
return this.http.get<Recipe[]>(`${this.baseUrl}/GetRecipes`);
|
||||
}
|
||||
|
||||
getRecipe(id: string): Observable<Recipe> {
|
||||
return this.http.get<Recipe>(`${this.baseUrl}/GetRecipe/${id}`);
|
||||
}
|
||||
|
||||
addRecipe(recipe: Recipe): Observable<any> {
|
||||
return this.http.post(`${this.baseUrl}/AddRecipe`, recipe);
|
||||
}
|
||||
|
||||
updateRecipe(id: string, recipe: Recipe): Observable<any> {
|
||||
return this.http.put(`${this.baseUrl}/UpdateRecipe/${id}`, recipe);
|
||||
}
|
||||
|
||||
deleteRecipe(id: string): Observable<any> {
|
||||
return this.http.delete(`${this.baseUrl}/DeleteRecipe/${id}`);
|
||||
}
|
||||
}
|
||||
13
frontend/src/index.html
Normal file
13
frontend/src/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Frontend</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
6
frontend/src/main.ts
Normal file
6
frontend/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { AppComponent } from './app/app.component';
|
||||
|
||||
bootstrapApplication(AppComponent, appConfig)
|
||||
.catch((err) => console.error(err));
|
||||
14
frontend/src/styles.css
Normal file
14
frontend/src/styles.css
Normal file
@@ -0,0 +1,14 @@
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #f4f9f4;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
margin: 0;
|
||||
}
|
||||
Reference in New Issue
Block a user