Initial Commit

This commit is contained in:
Ben Mosley
2026-05-05 16:07:48 -05:00
commit 0a6e2a2aae
1180 changed files with 200620 additions and 0 deletions

View 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;
}

View 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>

View 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';
}

View 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()
]
};

View 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 }
];

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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 ? '★' : '☆');
}
}

View File

@@ -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;
}

View File

@@ -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 (15)</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>

View File

@@ -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;
}
});
}
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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);
}
}

View 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
View 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
View 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
View 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;
}