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

17
frontend/.editorconfig Normal file
View File

@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

42
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db

4
frontend/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

20
frontend/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

42
frontend/.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
}
]
}

59
frontend/README.md Normal file
View File

@@ -0,0 +1,59 @@
# Frontend
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 19.2.25.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

124
frontend/angular.json Normal file
View File

@@ -0,0 +1,124 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"frontend": {
"projectType": "application",
"schematics": {
"@schematics/angular:class": {
"skipTests": true
},
"@schematics/angular:component": {
"skipTests": true
},
"@schematics/angular:directive": {
"skipTests": true
},
"@schematics/angular:guard": {
"skipTests": true
},
"@schematics/angular:interceptor": {
"skipTests": true
},
"@schematics/angular:pipe": {
"skipTests": true
},
"@schematics/angular:resolver": {
"skipTests": true
},
"@schematics/angular:service": {
"skipTests": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/frontend",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.css"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "frontend:build:production"
},
"development": {
"buildTarget": "frontend:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.css"
],
"scripts": []
}
}
}
}
},
"cli": {
"analytics": false
}
}

15312
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
frontend/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "frontend",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0",
"@angular/platform-browser": "^19.2.0",
"@angular/platform-browser-dynamic": "^19.2.0",
"@angular/router": "^19.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.2.25",
"@angular/cli": "^19.2.25",
"@angular/compiler-cli": "^19.2.0",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.6.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.7.2"
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

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

View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts"
],
"include": [
"src/**/*.d.ts"
]
}

27
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}