Compare commits
3 Commits
b66f290d84
...
f673db572e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f673db572e | ||
|
|
7b49e5f9d9 | ||
|
|
2bb206b8bf |
@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"arrowParens": "avoid",
|
"arrowParens": "avoid",
|
||||||
|
"printWidth": 120,
|
||||||
"semi": true,
|
"semi": true,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"tabWidth": 4,
|
"tabWidth": 4,
|
||||||
|
|||||||
@ -25,7 +25,7 @@
|
|||||||
"input": "public"
|
"input": "public"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": ["src/styles.css"]
|
"styles": ["src/material-theme.scss", "src/styles.css"]
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
|
|||||||
38
package-lock.json
generated
38
package-lock.json
generated
@ -8,10 +8,12 @@
|
|||||||
"name": "meals-made-easy-app",
|
"name": "meals-made-easy-app",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@angular/cdk": "^21.0.6",
|
||||||
"@angular/common": "^21.0.0",
|
"@angular/common": "^21.0.0",
|
||||||
"@angular/compiler": "^21.0.0",
|
"@angular/compiler": "^21.0.0",
|
||||||
"@angular/core": "^21.0.0",
|
"@angular/core": "^21.0.0",
|
||||||
"@angular/forms": "^21.0.0",
|
"@angular/forms": "^21.0.0",
|
||||||
|
"@angular/material": "^21.0.6",
|
||||||
"@angular/platform-browser": "^21.0.0",
|
"@angular/platform-browser": "^21.0.0",
|
||||||
"@angular/router": "^21.0.0",
|
"@angular/router": "^21.0.0",
|
||||||
"@fortawesome/angular-fontawesome": "^4.0.0",
|
"@fortawesome/angular-fontawesome": "^4.0.0",
|
||||||
@ -424,6 +426,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@angular/cdk": {
|
||||||
|
"version": "21.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.0.6.tgz",
|
||||||
|
"integrity": "sha512-5Gw8mXtKXvcvDMWEciPLRYB6Ja5vsikLAidZsdCEIF6Bc51GmoqT5Tk/Ke+ciCd5Hq9Aco/IcHxT1RC3470lZg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"parse5": "^8.0.0",
|
||||||
|
"tslib": "^2.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@angular/common": "^21.0.0 || ^22.0.0",
|
||||||
|
"@angular/core": "^21.0.0 || ^22.0.0",
|
||||||
|
"rxjs": "^6.5.3 || ^7.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@angular/cli": {
|
"node_modules/@angular/cli": {
|
||||||
"version": "21.0.2",
|
"version": "21.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.0.2.tgz",
|
||||||
@ -555,6 +573,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.0.3.tgz",
|
||||||
"integrity": "sha512-W60auwyDmsglIlHAbP/eol0LyzQ6FCz8LHghNx2B4RjIpuIMyjBLBZfC0JHU0gyiKB/JfX8W4FdphvyT7I4sIw==",
|
"integrity": "sha512-W60auwyDmsglIlHAbP/eol0LyzQ6FCz8LHghNx2B4RjIpuIMyjBLBZfC0JHU0gyiKB/JfX8W4FdphvyT7I4sIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
@ -569,6 +588,23 @@
|
|||||||
"rxjs": "^6.5.3 || ^7.4.0"
|
"rxjs": "^6.5.3 || ^7.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@angular/material": {
|
||||||
|
"version": "21.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@angular/material/-/material-21.0.6.tgz",
|
||||||
|
"integrity": "sha512-BSbqFkVIjpXS+UGD7R1jDnuKArMCtLSKHL/1f/9mvHM4AntRFC88MQJMjS0k+pHCofN+MBMEpzBVrDOOqL+46A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@angular/cdk": "21.0.6",
|
||||||
|
"@angular/common": "^21.0.0 || ^22.0.0",
|
||||||
|
"@angular/core": "^21.0.0 || ^22.0.0",
|
||||||
|
"@angular/forms": "^21.0.0 || ^22.0.0",
|
||||||
|
"@angular/platform-browser": "^21.0.0 || ^22.0.0",
|
||||||
|
"rxjs": "^6.5.3 || ^7.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@angular/platform-browser": {
|
"node_modules/@angular/platform-browser": {
|
||||||
"version": "21.0.3",
|
"version": "21.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.0.3.tgz",
|
||||||
@ -7157,7 +7193,6 @@
|
|||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
|
||||||
"integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
|
"integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"entities": "^6.0.0"
|
"entities": "^6.0.0"
|
||||||
@ -7211,7 +7246,6 @@
|
|||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.12"
|
"node": ">=0.12"
|
||||||
|
|||||||
@ -9,8 +9,7 @@
|
|||||||
"test": "ng test"
|
"test": "ng test"
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"printWidth": 100,
|
"printWidth": 120,
|
||||||
"singleQuote": true,
|
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"files": "*.html",
|
"files": "*.html",
|
||||||
@ -23,10 +22,12 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "npm@11.6.2",
|
"packageManager": "npm@11.6.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@angular/cdk": "^21.0.6",
|
||||||
"@angular/common": "^21.0.0",
|
"@angular/common": "^21.0.0",
|
||||||
"@angular/compiler": "^21.0.0",
|
"@angular/compiler": "^21.0.0",
|
||||||
"@angular/core": "^21.0.0",
|
"@angular/core": "^21.0.0",
|
||||||
"@angular/forms": "^21.0.0",
|
"@angular/forms": "^21.0.0",
|
||||||
|
"@angular/material": "^21.0.6",
|
||||||
"@angular/platform-browser": "^21.0.0",
|
"@angular/platform-browser": "^21.0.0",
|
||||||
"@angular/router": "^21.0.0",
|
"@angular/router": "^21.0.0",
|
||||||
"@fortawesome/angular-fontawesome": "^4.0.0",
|
"@fortawesome/angular-fontawesome": "^4.0.0",
|
||||||
|
|||||||
@ -14,7 +14,7 @@ export const routes: Routes = [
|
|||||||
component: RecipesSearchPage,
|
component: RecipesSearchPage,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'recipes-upload',
|
path: 'recipe-upload',
|
||||||
component: RecipeUploadPage,
|
component: RecipeUploadPage,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -16,7 +16,7 @@ article {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#star {
|
#star-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
column-gap: 3px;
|
column-gap: 3px;
|
||||||
|
|||||||
@ -4,10 +4,12 @@
|
|||||||
<div>
|
<div>
|
||||||
<h1>{{ recipe.title }}</h1>
|
<h1>{{ recipe.title }}</h1>
|
||||||
@if (isLoggedIn()) {
|
@if (isLoggedIn()) {
|
||||||
<button id="star" (click)="starMutation.mutate()">
|
<button id="star" matButton="filled" (click)="starMutation.mutate()">
|
||||||
<fa-icon [icon]="faStar" />
|
<div id="star-label">
|
||||||
<span>Star</span>
|
<fa-icon [icon]="faStar" />
|
||||||
<span id="star-count">{{ recipe.starCount }}</span>
|
<span>Star</span>
|
||||||
|
<span id="star-count">{{ recipe.starCount }}</span>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
} @else {
|
} @else {
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -7,10 +7,11 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|||||||
import { RecipeService } from '../../../shared/services/RecipeService';
|
import { RecipeService } from '../../../shared/services/RecipeService';
|
||||||
import { AuthService } from '../../../shared/services/AuthService';
|
import { AuthService } from '../../../shared/services/AuthService';
|
||||||
import { RecipeCommentsList } from '../../../shared/components/recipe-comments-list/recipe-comments-list';
|
import { RecipeCommentsList } from '../../../shared/components/recipe-comments-list/recipe-comments-list';
|
||||||
|
import { MatButton } from '@angular/material/button';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-recipe-page-content',
|
selector: 'app-recipe-page-content',
|
||||||
imports: [FaIconComponent, RecipeCommentsList],
|
imports: [FaIconComponent, RecipeCommentsList, MatButton],
|
||||||
templateUrl: './recipe-page-content.html',
|
templateUrl: './recipe-page-content.html',
|
||||||
styleUrl: './recipe-page-content.css',
|
styleUrl: './recipe-page-content.css',
|
||||||
})
|
})
|
||||||
|
|||||||
@ -21,15 +21,15 @@ form {
|
|||||||
column-gap: 5px;
|
column-gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
/*button {*/
|
||||||
width: 100%;
|
/* width: 100%;*/
|
||||||
}
|
/*}*/
|
||||||
|
|
||||||
input[type='text'],
|
/*input[type="text"],*/
|
||||||
textarea {
|
/*textarea {*/
|
||||||
padding: 10px;
|
/* padding: 10px;*/
|
||||||
font-size: 16px;
|
/* font-size: 16px;*/
|
||||||
}
|
/*}*/
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
@ -1,16 +1,33 @@
|
|||||||
<div id="recipe-upload-container">
|
<div id="recipe-upload-container">
|
||||||
<h1>Upload Recipe</h1>
|
<h1>Upload Recipe</h1>
|
||||||
|
<app-recipe-upload-trail
|
||||||
|
[displayStep]="displayStep()"
|
||||||
|
[inProgressStep]="inProgressStep()"
|
||||||
|
[includeInfer]="includeInfer()"
|
||||||
|
(stepClick)="onStepClick($event)"
|
||||||
|
></app-recipe-upload-trail>
|
||||||
|
|
||||||
|
@if (displayStep() === RecipeUploadStep.START) {
|
||||||
|
<app-ai-or-manual
|
||||||
|
[sourceFile]="sourceFile()"
|
||||||
|
(sourceFileChange)="onSourceFileChange($event)"
|
||||||
|
(submitStep)="onAiOrManualSubmit($event)"
|
||||||
|
></app-ai-or-manual>
|
||||||
|
} @else if (displayStep() === RecipeUploadStep.INFER) {
|
||||||
|
<app-infer></app-infer>
|
||||||
|
} @else if (displayStep() === RecipeUploadStep.ENTER_DATA) {
|
||||||
|
<app-enter-recipe-data [model]="model()"></app-enter-recipe-data>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!--
|
||||||
<section>
|
<section>
|
||||||
<h2>Auto-Complete Recipe (Optional)</h2>
|
<h2>Auto-Complete Recipe (Optional)</h2>
|
||||||
<p>
|
<p>Choose a photo of a recipe from your files, and AI will fill out the form below for you.</p>
|
||||||
Choose a photo of a recipe from your files, and AI will fill out the form below for you.
|
|
||||||
</p>
|
|
||||||
<form id="recipe-upload-form" [formGroup]="recipeUploadForm" (ngSubmit)="onFileSubmit()">
|
<form id="recipe-upload-form" [formGroup]="recipeUploadForm" (ngSubmit)="onFileSubmit()">
|
||||||
<input id="file" type="file" (change)="onFileChange($event)" />
|
<input id="file" type="file" (change)="onFileChange($event)" />
|
||||||
<div class="action-buttons-container">
|
<div class="action-buttons-container">
|
||||||
<button type="button" (click)="onClear()">Clear</button>
|
<button matButton="outlined" type="button" (click)="onClear()">Clear</button>
|
||||||
<button type="submit" [disabled]="!recipeUploadForm.valid || inferenceInProgress()">
|
<button matButton="filled" type="submit" [disabled]="!recipeUploadForm.valid || inferenceInProgress()">
|
||||||
AI Auto-Complete
|
AI Auto-Complete
|
||||||
</button>
|
</button>
|
||||||
<app-spinner [enabled]="inferenceInProgress()"></app-spinner>
|
<app-spinner [enabled]="inferenceInProgress()"></app-spinner>
|
||||||
@ -22,21 +39,20 @@
|
|||||||
<h2>Recipe Form</h2>
|
<h2>Recipe Form</h2>
|
||||||
<div id="recipe-form-and-source">
|
<div id="recipe-form-and-source">
|
||||||
<form [formGroup]="recipeForm" (ngSubmit)="onRecipeSubmit()">
|
<form [formGroup]="recipeForm" (ngSubmit)="onRecipeSubmit()">
|
||||||
<input
|
<mat-form-field>
|
||||||
id="recipe-title"
|
<mat-label>Recipe Title</mat-label>
|
||||||
type="text"
|
<input matInput id="recipe-title" type="text" formControlName="title" />
|
||||||
formControlName="title"
|
</mat-form-field>
|
||||||
placeholder="Recipe Title"
|
<mat-form-field>
|
||||||
/>
|
<mat-label>Recipe Text</mat-label>
|
||||||
<textarea
|
<textarea
|
||||||
id="recipe-text"
|
matInput
|
||||||
formControlName="recipeText"
|
id="recipe-text"
|
||||||
(input)="onRecipeTextChange($event)"
|
formControlName="recipeText"
|
||||||
placeholder="Recipe text"
|
(input)="onRecipeTextChange($event)"
|
||||||
></textarea>
|
></textarea>
|
||||||
<button type="submit" [disabled]="!recipeForm.valid || inferenceInProgress()">
|
</mat-form-field>
|
||||||
Add Recipe
|
<button matButton="filled" type="submit" [disabled]="!recipeForm.valid || inferenceInProgress()">Add Recipe</button>
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
<div>
|
<div>
|
||||||
@if (sourceRecipeImage()) {
|
@if (sourceRecipeImage()) {
|
||||||
@ -45,4 +61,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
-->
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,106 +1,212 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
import { Component, computed, inject, OnInit, signal } from '@angular/core';
|
||||||
import {
|
import { ReactiveFormsModule } from '@angular/forms';
|
||||||
FormBuilder,
|
import { AiOrManual } from './steps/ai-or-manual/ai-or-manual';
|
||||||
FormControl,
|
import { AIOrManualSubmitEvent } from './steps/ai-or-manual/AIOrManualSubmitEvent';
|
||||||
FormGroup,
|
import { Infer } from './steps/infer/infer';
|
||||||
ReactiveFormsModule,
|
import { EnterRecipeData } from './steps/enter-recipe-data/enter-recipe-data';
|
||||||
Validators,
|
import { RecipeUploadTrail } from './recipe-upload-trail/recipe-upload-trail';
|
||||||
} from '@angular/forms';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { SseClient } from 'ngx-sse-client';
|
import { StepClickEvent } from './recipe-upload-trail/StepClickEvent';
|
||||||
import { Spinner } from '../../shared/components/spinner/spinner';
|
import { RecipeUploadModel } from '../../shared/client-models/RecipeUploadModel';
|
||||||
|
import { RecipeUploadService } from '../../shared/services/RecipeUploadService';
|
||||||
|
import { RecipeUploadStep } from '../../shared/client-models/RecipeUploadStep';
|
||||||
|
import { FileUploadEvent } from '../../shared/components/file-upload/FileUploadEvent';
|
||||||
|
import { tryMaybeInt } from '../../shared/util';
|
||||||
|
import { from, map, switchMap, tap } from 'rxjs';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-recipe-upload-page',
|
selector: 'app-recipe-upload-page',
|
||||||
imports: [ReactiveFormsModule, Spinner],
|
imports: [ReactiveFormsModule, AiOrManual, Infer, EnterRecipeData, RecipeUploadTrail],
|
||||||
templateUrl: './recipe-upload-page.html',
|
templateUrl: './recipe-upload-page.html',
|
||||||
styleUrl: './recipe-upload-page.css',
|
styleUrl: './recipe-upload-page.css',
|
||||||
})
|
})
|
||||||
export class RecipeUploadPage {
|
export class RecipeUploadPage implements OnInit {
|
||||||
private readonly sseClient = inject(SseClient);
|
protected readonly model = signal<RecipeUploadModel>({
|
||||||
private readonly formBuilder = inject(FormBuilder);
|
inProgressStep: RecipeUploadStep.START,
|
||||||
|
|
||||||
protected readonly sourceRecipeImage = signal<string | null>(null);
|
|
||||||
protected readonly inferenceInProgress = signal(false);
|
|
||||||
|
|
||||||
protected readonly recipeUploadForm = this.formBuilder.group({
|
|
||||||
file: this.formBuilder.control<File | null>(null, [Validators.required]),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
protected readonly recipeForm = new FormGroup({
|
protected readonly displayStep = signal<number>(RecipeUploadStep.START);
|
||||||
title: new FormControl('', [Validators.required]),
|
protected readonly inProgressStep = computed(() => this.model().inProgressStep);
|
||||||
recipeText: new FormControl('', Validators.required),
|
protected readonly includeInfer = signal(false);
|
||||||
});
|
protected readonly sourceFile = computed(() => this.model().sourceFile ?? null);
|
||||||
|
|
||||||
protected onClear() {
|
private readonly router = inject(Router);
|
||||||
this.recipeUploadForm.reset();
|
private readonly activatedRoute = inject(ActivatedRoute);
|
||||||
this.sourceRecipeImage.set(null);
|
private readonly recipeUploadService = inject(RecipeUploadService);
|
||||||
|
|
||||||
|
public ngOnInit(): void {
|
||||||
|
this.activatedRoute.queryParamMap
|
||||||
|
.pipe(
|
||||||
|
map((paramMap) => {
|
||||||
|
const draftIdParam: string | null = paramMap.get('draftId');
|
||||||
|
const draftId = tryMaybeInt(draftIdParam);
|
||||||
|
const stepParam: string | null = paramMap.get('step');
|
||||||
|
const step = tryMaybeInt(stepParam);
|
||||||
|
return [draftId, step];
|
||||||
|
}),
|
||||||
|
switchMap(([draftId, step]) => {
|
||||||
|
const currentModel = this.model();
|
||||||
|
if (draftId !== null && currentModel.id !== draftId) {
|
||||||
|
return this.recipeUploadService.getRecipeUploadModel(draftId).pipe(
|
||||||
|
tap((updatedModel) => {
|
||||||
|
this.model.set(updatedModel);
|
||||||
|
}),
|
||||||
|
switchMap((updatedModel) => {
|
||||||
|
if (step !== null && step <= updatedModel.inProgressStep) {
|
||||||
|
return from(this.changeDisplayStep(step));
|
||||||
|
} else {
|
||||||
|
return from(this.changeDisplayStep(updatedModel.inProgressStep));
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else if (step !== null && step <= currentModel.inProgressStep) {
|
||||||
|
return from(this.changeDisplayStep(step));
|
||||||
|
} else {
|
||||||
|
return from(this.changeDisplayStep(RecipeUploadStep.START));
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onFileChange(event: Event) {
|
private async changeDisplayStep(targetStep: number): Promise<void> {
|
||||||
const fileInput = event.target as HTMLInputElement;
|
this.displayStep.set(targetStep);
|
||||||
if (fileInput.files && fileInput.files.length) {
|
await this.router.navigate([], {
|
||||||
const file = fileInput.files[0];
|
relativeTo: this.activatedRoute,
|
||||||
this.recipeUploadForm.controls.file.setValue(file);
|
queryParams: {
|
||||||
this.recipeUploadForm.controls.file.markAsTouched();
|
step: targetStep,
|
||||||
this.recipeUploadForm.controls.file.updateValueAndValidity();
|
draftId: this.model().id,
|
||||||
|
},
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// set source image
|
protected async onStepClick(event: StepClickEvent): Promise<void> {
|
||||||
this.sourceRecipeImage.set(URL.createObjectURL(file));
|
await this.changeDisplayStep(event.step);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onSourceFileChange(event: FileUploadEvent) {
|
||||||
|
if (event._tag === 'file-add-event') {
|
||||||
|
this.model.update((model) => ({
|
||||||
|
...model,
|
||||||
|
sourceFile: event.file,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
this.model.update((model) => ({
|
||||||
|
...model,
|
||||||
|
sourceFile: null,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onFileSubmit() {
|
protected async onAiOrManualSubmit(event: AIOrManualSubmitEvent): Promise<void> {
|
||||||
const rawValue = this.recipeUploadForm.getRawValue();
|
if (event.mode === 'manual') {
|
||||||
|
this.model.update((model) => ({
|
||||||
this.inferenceInProgress.set(true);
|
...model,
|
||||||
|
sourceFile: null,
|
||||||
// upload form data
|
inProgressStep: RecipeUploadStep.ENTER_DATA,
|
||||||
const formData = new FormData();
|
}));
|
||||||
formData.append('recipeImageFile', rawValue.file!, rawValue.file!.name);
|
await this.changeDisplayStep(RecipeUploadStep.ENTER_DATA);
|
||||||
this.sseClient
|
this.includeInfer.set(false);
|
||||||
.stream(
|
} else {
|
||||||
`http://localhost:8080/inferences/recipe-extract-stream`,
|
this.model.update((model) => ({
|
||||||
{
|
...model,
|
||||||
keepAlive: false,
|
sourceFile: this.sourceFile(),
|
||||||
reconnectionDelay: 1000,
|
inProgressStep: RecipeUploadStep.INFER,
|
||||||
responseType: 'event',
|
}));
|
||||||
},
|
await this.changeDisplayStep(RecipeUploadStep.INFER);
|
||||||
{
|
this.includeInfer.set(true);
|
||||||
body: formData,
|
this.recipeUploadService.doInference(this.model()).subscribe((updatedModel) => {
|
||||||
},
|
this.model.set(updatedModel);
|
||||||
'PUT',
|
this.changeDisplayStep(RecipeUploadStep.ENTER_DATA);
|
||||||
)
|
|
||||||
.subscribe({
|
|
||||||
next: (event) => {
|
|
||||||
if (event.type === 'error') {
|
|
||||||
const errorEvent = event as ErrorEvent;
|
|
||||||
console.error(errorEvent.error, errorEvent.message);
|
|
||||||
} else {
|
|
||||||
const messageEvent = event as MessageEvent;
|
|
||||||
const data: { delta: string } = JSON.parse(messageEvent.data);
|
|
||||||
this.recipeForm.patchValue({
|
|
||||||
recipeText: this.recipeForm.value.recipeText + data.delta,
|
|
||||||
});
|
|
||||||
|
|
||||||
// must do this so we auto-resize the textarea
|
|
||||||
document
|
|
||||||
.getElementById('recipe-text')
|
|
||||||
?.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
complete: () => {
|
|
||||||
this.inferenceInProgress.set(false);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onRecipeSubmit() {
|
// private readonly sseClient = inject(SseClient);
|
||||||
console.log(this.recipeForm.value);
|
// private readonly formBuilder = inject(FormBuilder);
|
||||||
}
|
//
|
||||||
|
// protected readonly sourceRecipeImage = signal<string | null>(null);
|
||||||
protected onRecipeTextChange(event: Event) {
|
// protected readonly inferenceInProgress = signal(false);
|
||||||
const textarea = event.target as HTMLTextAreaElement;
|
//
|
||||||
textarea.style.height = 'auto';
|
// protected readonly recipeUploadForm = this.formBuilder.group({
|
||||||
textarea.style.height = textarea.scrollHeight + 'px';
|
// file: this.formBuilder.control<File | null>(null, [Validators.required]),
|
||||||
}
|
// });
|
||||||
|
//
|
||||||
|
// protected readonly recipeForm = new FormGroup({
|
||||||
|
// title: new FormControl('', [Validators.required]),
|
||||||
|
// recipeText: new FormControl('', Validators.required),
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// protected onClear() {
|
||||||
|
// this.recipeUploadForm.reset();
|
||||||
|
// this.sourceRecipeImage.set(null);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// protected onFileChange(event: Event) {
|
||||||
|
// const fileInput = event.target as HTMLInputElement;
|
||||||
|
// if (fileInput.files && fileInput.files.length) {
|
||||||
|
// const file = fileInput.files[0];
|
||||||
|
// this.recipeUploadForm.controls.file.setValue(file);
|
||||||
|
// this.recipeUploadForm.controls.file.markAsTouched();
|
||||||
|
// this.recipeUploadForm.controls.file.updateValueAndValidity();
|
||||||
|
//
|
||||||
|
// // set source image
|
||||||
|
// this.sourceRecipeImage.set(URL.createObjectURL(file));
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// protected onFileSubmit() {
|
||||||
|
// const rawValue = this.recipeUploadForm.getRawValue();
|
||||||
|
//
|
||||||
|
// this.inferenceInProgress.set(true);
|
||||||
|
//
|
||||||
|
// // upload form data
|
||||||
|
// const formData = new FormData();
|
||||||
|
// formData.append('recipeImageFile', rawValue.file!, rawValue.file!.name);
|
||||||
|
// this.sseClient
|
||||||
|
// .stream(
|
||||||
|
// `http://localhost:8080/inferences/recipe-extract-stream`,
|
||||||
|
// {
|
||||||
|
// keepAlive: false,
|
||||||
|
// reconnectionDelay: 1000,
|
||||||
|
// responseType: 'event',
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// body: formData,
|
||||||
|
// },
|
||||||
|
// 'PUT',
|
||||||
|
// )
|
||||||
|
// .subscribe({
|
||||||
|
// next: (event) => {
|
||||||
|
// if (event.type === 'error') {
|
||||||
|
// const errorEvent = event as ErrorEvent;
|
||||||
|
// console.error(errorEvent.error, errorEvent.message);
|
||||||
|
// } else {
|
||||||
|
// const messageEvent = event as MessageEvent;
|
||||||
|
// const data: { delta: string } = JSON.parse(messageEvent.data);
|
||||||
|
// this.recipeForm.patchValue({
|
||||||
|
// recipeText: this.recipeForm.value.recipeText + data.delta,
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// // must do this so we auto-resize the textarea
|
||||||
|
// document.getElementById('recipe-text')?.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// complete: () => {
|
||||||
|
// this.inferenceInProgress.set(false);
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// protected onRecipeSubmit() {
|
||||||
|
// console.log(this.recipeForm.value);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// protected onRecipeTextChange(event: Event) {
|
||||||
|
// const textarea = event.target as HTMLTextAreaElement;
|
||||||
|
// textarea.style.height = 'auto';
|
||||||
|
// textarea.style.height = textarea.scrollHeight + 'px';
|
||||||
|
// }
|
||||||
|
protected readonly RecipeUploadStep = RecipeUploadStep;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,3 @@
|
|||||||
|
export interface StepClickEvent {
|
||||||
|
step: number;
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
#recipe-upload-steps {
|
||||||
|
display: flex;
|
||||||
|
list-style-type: none;
|
||||||
|
margin-block: 0;
|
||||||
|
padding-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#recipe-upload-steps li {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
#recipe-upload-steps li:not(:first-child)::before {
|
||||||
|
content: ">";
|
||||||
|
padding-inline: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-complete,
|
||||||
|
.step-in-progress {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-in-progress {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-incomplete {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-displayed {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
<ul id="recipe-upload-steps">
|
||||||
|
@for (step of steps(); track step.index) {
|
||||||
|
<li
|
||||||
|
[class]="{
|
||||||
|
'step-complete': step.completed,
|
||||||
|
'step-in-progress': step.inProgress,
|
||||||
|
'step-incomplete': !step.completed,
|
||||||
|
'step-displayed': displayStep() === step.index
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
@if (step.completed || step.inProgress) {
|
||||||
|
<a (click)="onStepClick(step.index)">{{ step.name }}</a>
|
||||||
|
} @else {
|
||||||
|
{{ step.name }}
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { RecipeUploadTrail } from './recipe-upload-trail';
|
||||||
|
|
||||||
|
describe('RecipeUploadTrail', () => {
|
||||||
|
let component: RecipeUploadTrail;
|
||||||
|
let fixture: ComponentFixture<RecipeUploadTrail>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [RecipeUploadTrail],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(RecipeUploadTrail);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await fixture.whenStable();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
import { Component, computed, input, output } from '@angular/core';
|
||||||
|
import { StepClickEvent } from './StepClickEvent';
|
||||||
|
import { RecipeUploadStep } from '../../../shared/client-models/RecipeUploadStep';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-recipe-upload-trail',
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './recipe-upload-trail.html',
|
||||||
|
styleUrl: './recipe-upload-trail.css',
|
||||||
|
})
|
||||||
|
export class RecipeUploadTrail {
|
||||||
|
public readonly displayStep = input.required<RecipeUploadStep>();
|
||||||
|
public readonly inProgressStep = input.required<RecipeUploadStep>();
|
||||||
|
public readonly includeInfer = input.required<boolean>();
|
||||||
|
|
||||||
|
public readonly stepClick = output<StepClickEvent>();
|
||||||
|
|
||||||
|
protected readonly steps = computed(() => {
|
||||||
|
const base: {
|
||||||
|
index: RecipeUploadStep;
|
||||||
|
name: string;
|
||||||
|
completed: boolean;
|
||||||
|
inProgress: boolean;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
index: RecipeUploadStep.START,
|
||||||
|
name: 'Start',
|
||||||
|
completed: this.inProgressStep() > RecipeUploadStep.START,
|
||||||
|
inProgress: this.inProgressStep() === RecipeUploadStep.START,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: RecipeUploadStep.ENTER_DATA,
|
||||||
|
name: 'Enter Recipe',
|
||||||
|
completed: this.inProgressStep() > RecipeUploadStep.ENTER_DATA,
|
||||||
|
inProgress: this.inProgressStep() === RecipeUploadStep.ENTER_DATA,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (this.includeInfer()) {
|
||||||
|
base.push({
|
||||||
|
index: RecipeUploadStep.INFER,
|
||||||
|
name: 'Infer',
|
||||||
|
completed: this.inProgressStep() > RecipeUploadStep.INFER,
|
||||||
|
inProgress: this.inProgressStep() === RecipeUploadStep.INFER,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
base.sort((a, b) => a.index - b.index);
|
||||||
|
return base;
|
||||||
|
});
|
||||||
|
|
||||||
|
protected onStepClick(stepIndex: number) {
|
||||||
|
this.stepClick.emit({ step: stepIndex });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
export interface AIOrManualSubmitEvent {
|
||||||
|
mode: 'manual' | 'ai-assist';
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
<section>
|
||||||
|
<h2>Start</h2>
|
||||||
|
<p>Either upload a photo of a recipe and AI will assist you, or enter your recipe manually.</p>
|
||||||
|
<form id="ai-or-manual-form">
|
||||||
|
<app-file-upload [files]="sourceFilesArray()" (fileChange)="onFileChange($event)"></app-file-upload>
|
||||||
|
<div id="ai-or-manual-buttons">
|
||||||
|
<button matButton="outlined" type="button" (click)="onFormSubmit('manual')">Enter Manually</button>
|
||||||
|
<button matButton="filled" type="button" [disabled]="!sourceFile()" (click)="onFormSubmit('ai-assist')">Use AI Assist</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { AiOrManual } from './ai-or-manual';
|
||||||
|
|
||||||
|
describe('AiOrManual', () => {
|
||||||
|
let component: AiOrManual;
|
||||||
|
let fixture: ComponentFixture<AiOrManual>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [AiOrManual],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(AiOrManual);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await fixture.whenStable();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
import { Component, computed, input, output } from '@angular/core';
|
||||||
|
import { MatButton } from '@angular/material/button';
|
||||||
|
import { ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { AIOrManualSubmitEvent } from './AIOrManualSubmitEvent';
|
||||||
|
import { FileUpload } from '../../../../shared/components/file-upload/file-upload';
|
||||||
|
import { FileUploadEvent } from '../../../../shared/components/file-upload/FileUploadEvent';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-ai-or-manual',
|
||||||
|
imports: [MatButton, ReactiveFormsModule, FileUpload],
|
||||||
|
templateUrl: './ai-or-manual.html',
|
||||||
|
styleUrl: './ai-or-manual.css',
|
||||||
|
})
|
||||||
|
export class AiOrManual {
|
||||||
|
public sourceFile = input.required<File | null>();
|
||||||
|
public sourceFileChange = output<FileUploadEvent>();
|
||||||
|
public submitStep = output<AIOrManualSubmitEvent>();
|
||||||
|
|
||||||
|
protected readonly sourceFilesArray = computed(() => {
|
||||||
|
const maybeSourceFile = this.sourceFile();
|
||||||
|
if (maybeSourceFile) {
|
||||||
|
return [maybeSourceFile];
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
protected onFileChange(event: FileUploadEvent) {
|
||||||
|
this.sourceFileChange.emit(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onFormSubmit(mode: 'manual' | 'ai-assist') {
|
||||||
|
this.submitStep.emit({ mode });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 60ch;
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
<h2>Enter Recipe</h2>
|
||||||
|
<form [formGroup]="recipeFormGroup">
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>Title</mat-label>
|
||||||
|
<input matInput [formControl]="recipeFormGroup.controls.title">
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>Slug</mat-label>
|
||||||
|
<input matInput [formControl]="recipeFormGroup.controls.slug">
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>Recipe Text</mat-label>
|
||||||
|
<textarea matInput [formControl]="recipeFormGroup.controls.text"></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
</form>
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { EnterRecipeData } from './enter-recipe-data';
|
||||||
|
|
||||||
|
describe('EnterRecipeData', () => {
|
||||||
|
let component: EnterRecipeData;
|
||||||
|
let fixture: ComponentFixture<EnterRecipeData>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [EnterRecipeData],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(EnterRecipeData);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await fixture.whenStable();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
import { Component, input, OnInit } from '@angular/core';
|
||||||
|
import { RecipeUploadModel } from '../../../../shared/client-models/RecipeUploadModel';
|
||||||
|
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { MatFormField, MatInput, MatLabel } from '@angular/material/input';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-enter-recipe-data',
|
||||||
|
imports: [ReactiveFormsModule, MatFormField, MatLabel, MatInput],
|
||||||
|
templateUrl: './enter-recipe-data.html',
|
||||||
|
styleUrl: './enter-recipe-data.css',
|
||||||
|
})
|
||||||
|
export class EnterRecipeData implements OnInit {
|
||||||
|
public readonly model = input.required<RecipeUploadModel>();
|
||||||
|
|
||||||
|
public ngOnInit(): void {
|
||||||
|
const model = this.model();
|
||||||
|
this.recipeFormGroup.patchValue({
|
||||||
|
title: model.userTitle ?? model.inferredTitle ?? '',
|
||||||
|
slug: model.userSlug ?? model.inferredSlug ?? '',
|
||||||
|
text: model.userText ?? model.inferredText ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected readonly recipeFormGroup = new FormGroup({
|
||||||
|
title: new FormControl('', Validators.required),
|
||||||
|
slug: new FormControl('', Validators.required),
|
||||||
|
text: new FormControl('', Validators.required),
|
||||||
|
});
|
||||||
|
}
|
||||||
2
src/app/pages/recipe-upload-page/steps/infer/infer.html
Normal file
2
src/app/pages/recipe-upload-page/steps/infer/infer.html
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
<p>Using AI to read your recipe...</p>
|
||||||
|
<app-spinner></app-spinner>
|
||||||
22
src/app/pages/recipe-upload-page/steps/infer/infer.spec.ts
Normal file
22
src/app/pages/recipe-upload-page/steps/infer/infer.spec.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { Infer } from './infer';
|
||||||
|
|
||||||
|
describe('Infer', () => {
|
||||||
|
let component: Infer;
|
||||||
|
let fixture: ComponentFixture<Infer>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [Infer],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(Infer);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await fixture.whenStable();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
10
src/app/pages/recipe-upload-page/steps/infer/infer.ts
Normal file
10
src/app/pages/recipe-upload-page/steps/infer/infer.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { Spinner } from '../../../../shared/components/spinner/spinner';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-infer',
|
||||||
|
imports: [Spinner],
|
||||||
|
templateUrl: './infer.html',
|
||||||
|
styleUrl: './infer.css',
|
||||||
|
})
|
||||||
|
export class Infer {}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
#search-recipes-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 66%;
|
||||||
|
}
|
||||||
@ -1,8 +1,10 @@
|
|||||||
<h1>Search Recipes</h1>
|
<h1>Search Recipes</h1>
|
||||||
<form id="search-recipes-form" [formGroup]="searchRecipesForm" (ngSubmit)="onPromptSubmit()">
|
<form id="search-recipes-form" [formGroup]="searchRecipesForm" (ngSubmit)="onPromptSubmit()">
|
||||||
<label for="prompt">Search Prompt:</label>
|
<mat-form-field>
|
||||||
<input id="prompt" formControlName="prompt" type="text" />
|
<mat-label>Search Prompt</mat-label>
|
||||||
<button type="submit" [disabled]="!searchRecipesForm.valid">Search</button>
|
<input matInput id="prompt" formControlName="prompt" type="text" />
|
||||||
|
</mat-form-field>
|
||||||
|
<button matButton="filled" type="submit" [disabled]="!searchRecipesForm.valid">Search</button>
|
||||||
</form>
|
</form>
|
||||||
@if (givenPrompt() !== null) {
|
@if (givenPrompt() !== null) {
|
||||||
@if (resultsQuery.isLoading()) {
|
@if (resultsQuery.isLoading()) {
|
||||||
|
|||||||
@ -4,10 +4,12 @@ import { injectQuery } from '@tanstack/angular-query-experimental';
|
|||||||
import { RecipeService } from '../../shared/services/RecipeService';
|
import { RecipeService } from '../../shared/services/RecipeService';
|
||||||
import { RecipeCardGrid } from '../../shared/components/recipe-card-grid/recipe-card-grid';
|
import { RecipeCardGrid } from '../../shared/components/recipe-card-grid/recipe-card-grid';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { MatButton } from '@angular/material/button';
|
||||||
|
import { MatFormField, MatInput, MatLabel } from '@angular/material/input';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-recipes-search-page',
|
selector: 'app-recipes-search-page',
|
||||||
imports: [ReactiveFormsModule, RecipeCardGrid],
|
imports: [ReactiveFormsModule, RecipeCardGrid, MatButton, MatFormField, MatInput, MatLabel],
|
||||||
templateUrl: './recipes-search-page.html',
|
templateUrl: './recipes-search-page.html',
|
||||||
styleUrl: './recipes-search-page.css',
|
styleUrl: './recipes-search-page.css',
|
||||||
})
|
})
|
||||||
|
|||||||
@ -0,0 +1,5 @@
|
|||||||
|
export interface RecipeUploadIngredientModel {
|
||||||
|
amount: string | null;
|
||||||
|
name: string;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
19
src/app/shared/client-models/RecipeUploadModel.ts
Normal file
19
src/app/shared/client-models/RecipeUploadModel.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { RecipeUploadIngredientModel } from './RecipeUploadIngredientModel';
|
||||||
|
import { RecipeUploadStep } from './RecipeUploadStep';
|
||||||
|
|
||||||
|
export interface RecipeUploadModel {
|
||||||
|
inProgressStep: RecipeUploadStep;
|
||||||
|
|
||||||
|
id?: number | null;
|
||||||
|
sourceFile?: File | null;
|
||||||
|
|
||||||
|
inferredText?: string | null;
|
||||||
|
inferredIngredients?: RecipeUploadIngredientModel[] | null;
|
||||||
|
inferredTitle?: string | null;
|
||||||
|
inferredSlug?: string | null;
|
||||||
|
|
||||||
|
userText?: string | null;
|
||||||
|
userIngredients?: RecipeUploadIngredientModel[] | null;
|
||||||
|
userTitle?: string | null;
|
||||||
|
userSlug?: string | null;
|
||||||
|
}
|
||||||
5
src/app/shared/client-models/RecipeUploadStep.ts
Normal file
5
src/app/shared/client-models/RecipeUploadStep.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export enum RecipeUploadStep {
|
||||||
|
START,
|
||||||
|
INFER,
|
||||||
|
ENTER_DATA,
|
||||||
|
}
|
||||||
11
src/app/shared/components/file-upload/FileUploadEvent.ts
Normal file
11
src/app/shared/components/file-upload/FileUploadEvent.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export type FileUploadEvent = FileAddEvent | FileRemoveEvent;
|
||||||
|
|
||||||
|
export interface FileAddEvent {
|
||||||
|
_tag: 'file-add-event';
|
||||||
|
file: File;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileRemoveEvent {
|
||||||
|
_tag: 'file-remove-event';
|
||||||
|
fileName: string;
|
||||||
|
}
|
||||||
14
src/app/shared/components/file-upload/file-upload.css
Normal file
14
src/app/shared/components/file-upload/file-upload.css
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
.file-input-container {
|
||||||
|
display: flex;
|
||||||
|
column-gap: 10px;
|
||||||
|
padding-block: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
display: flex;
|
||||||
|
column-gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
11
src/app/shared/components/file-upload/file-upload.html
Normal file
11
src/app/shared/components/file-upload/file-upload.html
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<input #fileInput type="file" (change)="onFileChange($event)" style="display: none" />
|
||||||
|
<div class="file-input-container">
|
||||||
|
<fa-icon [icon]="faFileUpload" size="3x" (click)="onFileUploadIconClick(fileInput)"></fa-icon>
|
||||||
|
@if (fileNames().length) {
|
||||||
|
@for (fileName of fileNames(); track $index) {
|
||||||
|
<p class="file-name"><fa-icon [icon]="faCancel" (click)="onClear(fileName)"></fa-icon>{{ fileName }}</p>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<p>Click the icon to choose a file.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
22
src/app/shared/components/file-upload/file-upload.spec.ts
Normal file
22
src/app/shared/components/file-upload/file-upload.spec.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { FileUpload } from './file-upload';
|
||||||
|
|
||||||
|
describe('FileUpload', () => {
|
||||||
|
let component: FileUpload;
|
||||||
|
let fixture: ComponentFixture<FileUpload>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [FileUpload],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(FileUpload);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await fixture.whenStable();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
42
src/app/shared/components/file-upload/file-upload.ts
Normal file
42
src/app/shared/components/file-upload/file-upload.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { Component, computed, input, output } from '@angular/core';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { faCancel, faFileUpload } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { FileUploadEvent } from './FileUploadEvent';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-file-upload',
|
||||||
|
imports: [FaIconComponent],
|
||||||
|
templateUrl: './file-upload.html',
|
||||||
|
styleUrl: './file-upload.css',
|
||||||
|
})
|
||||||
|
export class FileUpload {
|
||||||
|
public readonly files = input<File[]>([]);
|
||||||
|
public readonly fileChange = output<FileUploadEvent>();
|
||||||
|
|
||||||
|
protected fileNames = computed(() => this.files().map((file) => file.name));
|
||||||
|
|
||||||
|
protected onFileUploadIconClick(target: HTMLInputElement) {
|
||||||
|
target.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onClear(fileName: string): void {
|
||||||
|
this.fileChange.emit({
|
||||||
|
_tag: 'file-remove-event',
|
||||||
|
fileName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onFileChange(event: Event) {
|
||||||
|
const fileInput = event.target as HTMLInputElement;
|
||||||
|
if (fileInput.files && fileInput.files.length) {
|
||||||
|
this.fileChange.emit({
|
||||||
|
_tag: 'file-add-event',
|
||||||
|
file: fileInput.files[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fileInput.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected readonly faFileUpload = faFileUpload;
|
||||||
|
protected readonly faCancel = faCancel;
|
||||||
|
}
|
||||||
@ -3,11 +3,11 @@
|
|||||||
@if (isLoggedIn()) {
|
@if (isLoggedIn()) {
|
||||||
<div id="header-login">
|
<div id="header-login">
|
||||||
<p>Welcome {{ username() }}!</p>
|
<p>Welcome {{ username() }}!</p>
|
||||||
<button (click)="logoutClick()">Logout</button>
|
<button matButton="elevated" (click)="logoutClick()">Logout</button>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div id="header-login">
|
<div id="header-login">
|
||||||
<button (click)="loginClick()">Login</button>
|
<button matButton="elevated" (click)="loginClick()">Login</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { Component, computed, inject } from '@angular/core';
|
import { Component, computed, inject } from '@angular/core';
|
||||||
import { AuthService } from '../../services/AuthService';
|
import { AuthService } from '../../services/AuthService';
|
||||||
|
import { MatButton } from '@angular/material/button';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-header',
|
selector: 'app-header',
|
||||||
imports: [],
|
imports: [MatButton],
|
||||||
templateUrl: './header.html',
|
templateUrl: './header.html',
|
||||||
styleUrl: './header.css',
|
styleUrl: './header.css',
|
||||||
})
|
})
|
||||||
|
|||||||
@ -3,6 +3,6 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li><a [routerLink]="'/'">Browse Recipes</a></li>
|
<li><a [routerLink]="'/'">Browse Recipes</a></li>
|
||||||
<li><a [routerLink]="'/recipes-search'">Search Recipes</a></li>
|
<li><a [routerLink]="'/recipes-search'">Search Recipes</a></li>
|
||||||
<li><a [routerLink]="'/recipes-upload'">Upload Recipe</a></li>
|
<li><a [routerLink]="'/recipe-upload'">Upload Recipe</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@ -31,9 +31,7 @@
|
|||||||
<li class="comment">
|
<li class="comment">
|
||||||
<div class="comment-username-time">
|
<div class="comment-username-time">
|
||||||
<span class="comment-username">{{ recipeComment.owner.username }}</span>
|
<span class="comment-username">{{ recipeComment.owner.username }}</span>
|
||||||
<span class="comment-time">{{
|
<span class="comment-time">{{ recipeComment.created | dateTimeFormat }}</span>
|
||||||
recipeComment.created | dateTimeFormat
|
|
||||||
}}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-text" [innerHTML]="recipeComment.text"></div>
|
<div class="comment-text" [innerHTML]="recipeComment.text"></div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@ -7,5 +7,5 @@ import { Component, input } from '@angular/core';
|
|||||||
styleUrl: './spinner.css',
|
styleUrl: './spinner.css',
|
||||||
})
|
})
|
||||||
export class Spinner {
|
export class Spinner {
|
||||||
public readonly enabled = input.required<boolean>();
|
public readonly enabled = input(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,9 +36,7 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
|||||||
} else if (error.status === 401 && error.url?.endsWith('auth/refresh')) {
|
} else if (error.status === 401 && error.url?.endsWith('auth/refresh')) {
|
||||||
// our refresh token is expired
|
// our refresh token is expired
|
||||||
// redirect to login page
|
// redirect to login page
|
||||||
return from(router.navigate(['/'])).pipe(
|
return from(router.navigate(['/'])).pipe(switchMap(() => throwError(() => error)));
|
||||||
switchMap(() => throwError(() => error)),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
return throwError(() => error);
|
return throwError(() => error);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,11 +6,7 @@ import { QueryParams } from '../models/Query.model';
|
|||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class EndpointService {
|
export class EndpointService {
|
||||||
public getUrl(
|
public getUrl(endpoint: keyof typeof Endpoints, pathParams?: string[], queryParams?: QueryParams): string {
|
||||||
endpoint: keyof typeof Endpoints,
|
|
||||||
pathParams?: string[],
|
|
||||||
queryParams?: QueryParams,
|
|
||||||
): string {
|
|
||||||
const urlSearchParams = new URLSearchParams();
|
const urlSearchParams = new URLSearchParams();
|
||||||
if (queryParams?.page !== undefined) {
|
if (queryParams?.page !== undefined) {
|
||||||
urlSearchParams.set('page', queryParams.page.toString());
|
urlSearchParams.set('page', queryParams.page.toString());
|
||||||
|
|||||||
@ -19,16 +19,12 @@ export class RecipeService {
|
|||||||
|
|
||||||
public getRecipes(): Promise<Recipe[]> {
|
public getRecipes(): Promise<Recipe[]> {
|
||||||
return firstValueFrom(
|
return firstValueFrom(
|
||||||
this.http
|
this.http.get<RecipeInfoViews>('http://localhost:8080/recipes').pipe(map((res) => res.content)),
|
||||||
.get<RecipeInfoViews>('http://localhost:8080/recipes')
|
|
||||||
.pipe(map((res) => res.content)),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getRecipeView(username: string, slug: string): Promise<RecipeView> {
|
public getRecipeView(username: string, slug: string): Promise<RecipeView> {
|
||||||
return firstValueFrom(
|
return firstValueFrom(this.http.get<RecipeView>(`http://localhost:8080/recipes/${username}/${slug}`));
|
||||||
this.http.get<RecipeView>(`http://localhost:8080/recipes/${username}/${slug}`),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getRecipeUrl(recipeView: RecipeView): string {
|
private getRecipeUrl(recipeView: RecipeView): string {
|
||||||
@ -50,11 +46,7 @@ export class RecipeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public getComments(
|
public getComments(username: string, slug: string, queryParams?: QueryParams): Promise<RecipeComments> {
|
||||||
username: string,
|
|
||||||
slug: string,
|
|
||||||
queryParams?: QueryParams,
|
|
||||||
): Promise<RecipeComments> {
|
|
||||||
return firstValueFrom(
|
return firstValueFrom(
|
||||||
this.http.get<RecipeComments>(
|
this.http.get<RecipeComments>(
|
||||||
this.endpointService.getUrl('recipes', [username, slug, 'comments'], queryParams),
|
this.endpointService.getUrl('recipes', [username, slug, 'comments'], queryParams),
|
||||||
@ -62,18 +54,11 @@ export class RecipeService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async addComment(
|
public async addComment(username: string, slug: string, commentText: string): Promise<RecipeComment> {
|
||||||
username: string,
|
|
||||||
slug: string,
|
|
||||||
commentText: string,
|
|
||||||
): Promise<RecipeComment> {
|
|
||||||
const comment = await firstValueFrom(
|
const comment = await firstValueFrom(
|
||||||
this.http.post<RecipeComment>(
|
this.http.post<RecipeComment>(`http://localhost:8080/recipes/${username}/${slug}/comments`, {
|
||||||
`http://localhost:8080/recipes/${username}/${slug}/comments`,
|
text: commentText,
|
||||||
{
|
}),
|
||||||
text: commentText,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
await this.queryClient.invalidateQueries({
|
await this.queryClient.invalidateQueries({
|
||||||
queryKey: ['recipeComments', username, slug],
|
queryKey: ['recipeComments', username, slug],
|
||||||
|
|||||||
31
src/app/shared/services/RecipeUploadService.ts
Normal file
31
src/app/shared/services/RecipeUploadService.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { inject, Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { delay, Observable, of } from 'rxjs';
|
||||||
|
import { RecipeUploadModel } from '../client-models/RecipeUploadModel';
|
||||||
|
import { RecipeUploadStep } from '../client-models/RecipeUploadStep';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class RecipeUploadService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
public getRecipeUploadModel(draftId: number): Observable<RecipeUploadModel> {
|
||||||
|
return of({
|
||||||
|
inProgressStep: RecipeUploadStep.ENTER_DATA,
|
||||||
|
id: 42
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public doInference(model: RecipeUploadModel): Observable<RecipeUploadModel> {
|
||||||
|
return of({
|
||||||
|
inProgressStep: RecipeUploadStep.ENTER_DATA,
|
||||||
|
id: 16,
|
||||||
|
inferredTitle: 'Some recipe',
|
||||||
|
inferredSlug: 'some-recipe',
|
||||||
|
inferredText: 'Some text.',
|
||||||
|
inferredIngredients: []
|
||||||
|
}).pipe(delay(5_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
23
src/app/shared/util.ts
Normal file
23
src/app/shared/util.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export const tryInt = (s: string): number | null => {
|
||||||
|
try {
|
||||||
|
return parseInt(s);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const tryMaybeInt = (maybeString: string | null): number | null => {
|
||||||
|
if (maybeString) {
|
||||||
|
try {
|
||||||
|
return parseInt(maybeString);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hasValue = <T>(value?: T | null): value is T => {
|
||||||
|
return value !== undefined && value !== null;
|
||||||
|
};
|
||||||
@ -14,6 +14,7 @@
|
|||||||
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
|
|||||||
45
src/material-theme.scss
Normal file
45
src/material-theme.scss
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
// Include theming for Angular Material with `mat.theme()`.
|
||||||
|
// This Sass mixin will define CSS variables that are used for styling Angular Material
|
||||||
|
// components according to the Material 3 design spec.
|
||||||
|
// Learn more about theming and how to use it for your application's
|
||||||
|
// custom components at https://material.angular.dev/guide/theming
|
||||||
|
@use "@angular/material" as mat;
|
||||||
|
@use "theme-colors" as themeColors;
|
||||||
|
|
||||||
|
html {
|
||||||
|
height: 100%;
|
||||||
|
@include mat.theme(
|
||||||
|
(
|
||||||
|
color: (
|
||||||
|
primary: themeColors.$primary-palette,
|
||||||
|
tertiary: themeColors.$tertiary-palette,
|
||||||
|
),
|
||||||
|
typography: Inter,
|
||||||
|
density: 0,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mat.form-field-overrides(
|
||||||
|
(
|
||||||
|
filled-container-color: var(--mat-sys-surface-variant),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
body {
|
||||||
|
// Default the application to a light color theme. This can be changed to
|
||||||
|
// `dark` to enable the dark color theme, or to `light dark` to defer to the
|
||||||
|
// user's system settings.
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
|
// Set a default background, font and text colors for the application using
|
||||||
|
// Angular Material's system-level CSS variables. Learn more about these
|
||||||
|
// variables at https://material.angular.dev/guide/system-variables
|
||||||
|
background-color: var(--mat-sys-surface);
|
||||||
|
color: var(--mat-sys-on-surface);
|
||||||
|
font: var(--mat-sys-body-medium);
|
||||||
|
|
||||||
|
// Reset the user agent margin.
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap');
|
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--primary-font-family: 'Inter';
|
--primary-font-family: "Inter";
|
||||||
--primary-white: #ffffff;
|
--primary-white: #ffffff;
|
||||||
--primary-red: #91351d;
|
--primary-red: #91351d;
|
||||||
--primary-yellow: #ffb61d;
|
--primary-yellow: #ffb61d;
|
||||||
@ -34,17 +34,17 @@ a:hover {
|
|||||||
color: hsl(from var(--primary-red) h s l / 0.9);
|
color: hsl(from var(--primary-red) h s l / 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
/*button {*/
|
||||||
background-color: var(--off-white-2);
|
/* background-color: var(--off-white-2);*/
|
||||||
border: none;
|
/* border: none;*/
|
||||||
border-radius: 5px;
|
/* border-radius: 5px;*/
|
||||||
padding: 10px;
|
/* padding: 10px;*/
|
||||||
}
|
/*}*/
|
||||||
|
|
||||||
button:hover:not(:disabled) {
|
/*button:hover:not(:disabled) {*/
|
||||||
background-color: var(--off-white-1);
|
/* background-color: var(--off-white-1);*/
|
||||||
cursor: pointer;
|
/* cursor: pointer;*/
|
||||||
}
|
/*}*/
|
||||||
|
|
||||||
fa-icon {
|
fa-icon {
|
||||||
color: var(--primary-red);
|
color: var(--primary-red);
|
||||||
|
|||||||
137
src/theme-colors.scss
Normal file
137
src/theme-colors.scss
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
// This file was generated by running 'ng generate @angular/material:theme-color'.
|
||||||
|
// Proceed with caution if making changes to this file.
|
||||||
|
|
||||||
|
@use "sass:map";
|
||||||
|
@use "@angular/material" as mat;
|
||||||
|
|
||||||
|
// Note: Color palettes are generated from primary: #91351d, secondary: #ffb61d, neutral: #aaa, neutral variant: #252525
|
||||||
|
$_palettes: (
|
||||||
|
primary: (
|
||||||
|
0: #000000,
|
||||||
|
10: #3c0800,
|
||||||
|
20: #611200,
|
||||||
|
25: #711e07,
|
||||||
|
30: #802912,
|
||||||
|
35: #90341c,
|
||||||
|
40: #a04027,
|
||||||
|
50: #c0573c,
|
||||||
|
60: #e07053,
|
||||||
|
70: #ff8b6d,
|
||||||
|
80: #ffb4a2,
|
||||||
|
90: #ffdbd2,
|
||||||
|
95: #ffede9,
|
||||||
|
98: #fff8f6,
|
||||||
|
99: #fffbff,
|
||||||
|
100: #ffffff,
|
||||||
|
),
|
||||||
|
secondary: (
|
||||||
|
0: #000000,
|
||||||
|
10: #281900,
|
||||||
|
20: #432c00,
|
||||||
|
25: #513600,
|
||||||
|
30: #5f4100,
|
||||||
|
35: #6f4c00,
|
||||||
|
40: #7e5700,
|
||||||
|
50: #9e6e00,
|
||||||
|
60: #bf8600,
|
||||||
|
70: #e29f00,
|
||||||
|
80: #ffba36,
|
||||||
|
90: #ffdeac,
|
||||||
|
95: #ffeed9,
|
||||||
|
98: #fff8f3,
|
||||||
|
99: #fffbff,
|
||||||
|
100: #ffffff,
|
||||||
|
),
|
||||||
|
tertiary: (
|
||||||
|
0: #000000,
|
||||||
|
10: #241a00,
|
||||||
|
20: #3d2f00,
|
||||||
|
25: #4a3900,
|
||||||
|
30: #584400,
|
||||||
|
35: #665000,
|
||||||
|
40: #735b0d,
|
||||||
|
50: #8e7426,
|
||||||
|
60: #aa8e3e,
|
||||||
|
70: #c6a855,
|
||||||
|
80: #e3c36d,
|
||||||
|
90: #ffe08e,
|
||||||
|
95: #ffefce,
|
||||||
|
98: #fff8f1,
|
||||||
|
99: #fffbff,
|
||||||
|
100: #ffffff,
|
||||||
|
),
|
||||||
|
neutral: (
|
||||||
|
0: #000000,
|
||||||
|
10: #1a1c1c,
|
||||||
|
20: #2f3131,
|
||||||
|
25: #3a3c3c,
|
||||||
|
30: #464747,
|
||||||
|
35: #525253,
|
||||||
|
40: #5e5e5f,
|
||||||
|
50: #767777,
|
||||||
|
60: #909191,
|
||||||
|
70: #ababab,
|
||||||
|
80: #c7c6c6,
|
||||||
|
90: #e3e2e2,
|
||||||
|
95: #f1f0f0,
|
||||||
|
98: #faf9f9,
|
||||||
|
99: #fdfcfc,
|
||||||
|
100: #ffffff,
|
||||||
|
4: #0d0e0f,
|
||||||
|
6: #121414,
|
||||||
|
12: #1e2020,
|
||||||
|
17: #292a2a,
|
||||||
|
22: #343535,
|
||||||
|
24: #38393a,
|
||||||
|
87: #dadada,
|
||||||
|
92: #e9e8e8,
|
||||||
|
94: #eeeeed,
|
||||||
|
96: #f4f3f3,
|
||||||
|
),
|
||||||
|
neutral-variant: (
|
||||||
|
0: #000000,
|
||||||
|
10: #1b1c1c,
|
||||||
|
20: #303030,
|
||||||
|
25: #3c3b3b,
|
||||||
|
30: #474746,
|
||||||
|
35: #535252,
|
||||||
|
40: #5f5e5e,
|
||||||
|
50: #787776,
|
||||||
|
60: #929090,
|
||||||
|
70: #adabaa,
|
||||||
|
80: #c8c6c5,
|
||||||
|
90: #e4e2e1,
|
||||||
|
95: #f3f0ef,
|
||||||
|
98: #fcf9f8,
|
||||||
|
99: #fffbfb,
|
||||||
|
100: #ffffff,
|
||||||
|
),
|
||||||
|
error: (
|
||||||
|
0: #000000,
|
||||||
|
10: #410002,
|
||||||
|
20: #690005,
|
||||||
|
25: #7e0007,
|
||||||
|
30: #93000a,
|
||||||
|
35: #a80710,
|
||||||
|
40: #ba1a1a,
|
||||||
|
50: #de3730,
|
||||||
|
60: #ff5449,
|
||||||
|
70: #ff897d,
|
||||||
|
80: #ffb4ab,
|
||||||
|
90: #ffdad6,
|
||||||
|
95: #ffedea,
|
||||||
|
98: #fff8f7,
|
||||||
|
99: #fffbff,
|
||||||
|
100: #ffffff,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
$_rest: (
|
||||||
|
secondary: map.get($_palettes, secondary),
|
||||||
|
neutral: map.get($_palettes, neutral),
|
||||||
|
neutral-variant: map.get($_palettes, neutral-variant),
|
||||||
|
error: map.get($_palettes, error),
|
||||||
|
);
|
||||||
|
|
||||||
|
$primary-palette: map.merge(map.get($_palettes, primary), $_rest);
|
||||||
|
$tertiary-palette: map.merge(map.get($_palettes, tertiary), $_rest);
|
||||||
Loading…
Reference in New Issue
Block a user