Initial Commit
This commit is contained in:
17
frontend/.editorconfig
Normal file
17
frontend/.editorconfig
Normal 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
42
frontend/.gitignore
vendored
Normal 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
4
frontend/.vscode/extensions.json
vendored
Normal 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
20
frontend/.vscode/launch.json
vendored
Normal 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
42
frontend/.vscode/tasks.json
vendored
Normal 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
59
frontend/README.md
Normal 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
124
frontend/angular.json
Normal 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
15312
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
frontend/package.json
Normal file
37
frontend/package.json
Normal 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
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
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;
|
||||
}
|
||||
15
frontend/tsconfig.app.json
Normal file
15
frontend/tsconfig.app.json
Normal 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
27
frontend/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
15
frontend/tsconfig.spec.json
Normal file
15
frontend/tsconfig.spec.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user