422 lines
16 KiB
HTML
422 lines
16 KiB
HTML
<h2>Recipe Editor</h2>
|
|
|
|
@if (itemResource.isLoading()) {
|
|
<app-skeleton-loader type="card" [count]="1" />
|
|
<app-skeleton-loader type="line" [count]="10" />
|
|
} @else if (itemResource.error()) {
|
|
<app-error-state [message]="'Failed to load recipe detail.'" (retryAction)="itemResource.reload()" />
|
|
} @else {
|
|
<div class="section-nav row-container wrapped">
|
|
<button mat-stroked-button (click)="go('basics')" [class.active]="selectedSection === 'basics'">Basics</button>
|
|
<button mat-stroked-button (click)="go('ingredients')" [class.active]="selectedSection === 'ingredients'">
|
|
Ingredients
|
|
</button>
|
|
<button mat-stroked-button (click)="go('steps')" [class.active]="selectedSection === 'steps'">Steps</button>
|
|
<button mat-stroked-button (click)="go('photos')" [class.active]="selectedSection === 'photos'">Photos</button>
|
|
<button mat-stroked-button (click)="go('notes')" [class.active]="selectedSection === 'notes'">Notes</button>
|
|
<button mat-raised-button color="primary" (click)="go('preview')" [class.active]="selectedSection === 'preview'">
|
|
Preview
|
|
</button>
|
|
</div>
|
|
|
|
<form class="flex-col wrapped" [formRoot]="form">
|
|
@if (selectedSection === 'basics') {
|
|
<div class="flex-col wrapped">
|
|
<h3>Basics</h3>
|
|
<div class="row-container">
|
|
<mat-form-field class="flex-auto">
|
|
<mat-label>Date</mat-label>
|
|
<input matInput [matDatepicker]="date" [formField]="form.basics.date" autocomplete="off" />
|
|
<mat-datepicker-toggle matSuffix [for]="date"></mat-datepicker-toggle>
|
|
<mat-datepicker #date></mat-datepicker>
|
|
</mat-form-field>
|
|
<mat-form-field class="flex-auto">
|
|
<mat-label>Source</mat-label>
|
|
<input type="text" matInput [formField]="form.basics.source" autocomplete="off" />
|
|
</mat-form-field>
|
|
</div>
|
|
|
|
<div class="row-container">
|
|
<mat-form-field class="flex-auto basis-4-5">
|
|
<mat-label>Dish / Product</mat-label>
|
|
<input
|
|
type="text"
|
|
matInput
|
|
#productElement
|
|
[matAutocomplete]="autoP"
|
|
[formField]="form.basics.product"
|
|
autocomplete="off"
|
|
/>
|
|
<mat-autocomplete
|
|
#autoP="matAutocomplete"
|
|
autoActiveFirstOption
|
|
[displayWith]="displayProduct"
|
|
(optionSelected)="productSelected($event)"
|
|
>
|
|
@for (product of products(); track product) {
|
|
<mat-option [value]="product">{{ product.name }}</mat-option>
|
|
}
|
|
</mat-autocomplete>
|
|
</mat-form-field>
|
|
|
|
<mat-form-field class="flex-auto basis-1-5">
|
|
<mat-label>Yield</mat-label>
|
|
<input type="text" matInput [formField]="form.basics.recipeYield" autocomplete="off" />
|
|
</mat-form-field>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@if (selectedSection === 'ingredients') {
|
|
<div class="flex-col wrapped">
|
|
<h3>Ingredients</h3>
|
|
|
|
<div class="row-container">
|
|
<mat-form-field class="flex-auto basis-2-5">
|
|
<mat-label>Ingredient</mat-label>
|
|
<input
|
|
type="text"
|
|
matInput
|
|
#ingredientElement
|
|
[matAutocomplete]="autoI"
|
|
[formField]="form.addRow.ingredient"
|
|
autocomplete="off"
|
|
/>
|
|
<mat-autocomplete #autoI="matAutocomplete" autoActiveFirstOption [displayWith]="displayIngredient">
|
|
@for (product of ingredients(); track product) {
|
|
<mat-option [value]="product">{{ product.name }} ({{ product.fractionUnits }})</mat-option>
|
|
}
|
|
</mat-autocomplete>
|
|
</mat-form-field>
|
|
|
|
<mat-form-field class="flex-auto basis-1-5">
|
|
<mat-label>Quantity</mat-label>
|
|
<input type="text" matInput [formField]="form.addRow.quantity" autocomplete="off" />
|
|
</mat-form-field>
|
|
|
|
<mat-form-field class="flex-auto basis-30">
|
|
<mat-label>Description</mat-label>
|
|
<input type="text" matInput [formField]="form.addRow.description" autocomplete="off" />
|
|
</mat-form-field>
|
|
|
|
<button mat-raised-button color="primary" type="button" (click)="addRow()" class="flex-auto basis-10">
|
|
Add
|
|
</button>
|
|
</div>
|
|
|
|
<mat-table
|
|
#table
|
|
[dataSource]="form.ingredients().value() ?? []"
|
|
matSort
|
|
(matSortChange)="sortData($event)"
|
|
aria-label="Ingredients"
|
|
>
|
|
<ng-container matColumnDef="product">
|
|
<mat-header-cell *matHeaderCellDef>Product</mat-header-cell>
|
|
<mat-cell *matCellDef="let row">{{ row.product.name }} {{ row.description }}</mat-cell>
|
|
</ng-container>
|
|
|
|
<ng-container matColumnDef="quantity">
|
|
<mat-header-cell *matHeaderCellDef class="right">Quantity</mat-header-cell>
|
|
<mat-cell *matCellDef="let row" class="right">{{ row.quantity | number: '1.2-2' }}</mat-cell>
|
|
</ng-container>
|
|
|
|
<ng-container matColumnDef="units">
|
|
<mat-header-cell *matHeaderCellDef>Units</mat-header-cell>
|
|
<mat-cell *matCellDef="let row">{{ row.product.fractionUnits }}</mat-cell>
|
|
</ng-container>
|
|
|
|
<ng-container matColumnDef="action">
|
|
<mat-header-cell *matHeaderCellDef class="center">Action</mat-header-cell>
|
|
<mat-cell *matCellDef="let row" class="center">
|
|
<button mat-icon-button tabindex="-1" color="warn" type="button" (click)="deleteRow(row)">
|
|
<mat-icon>delete</mat-icon>
|
|
</button>
|
|
</mat-cell>
|
|
</ng-container>
|
|
|
|
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
|
|
<mat-row *matRowDef="let row; columns: displayedColumns"></mat-row>
|
|
</mat-table>
|
|
</div>
|
|
}
|
|
|
|
@if (selectedSection === 'steps') {
|
|
<div class="flex-col wrapped">
|
|
<h3>Steps</h3>
|
|
<p class="hint">Add steps and reorder them using ↑ / ↓. Keep titles short and chef-friendly.</p>
|
|
|
|
<div class="row-container">
|
|
<button mat-raised-button color="primary" type="button" (click)="addStep()">
|
|
<mat-icon>add</mat-icon>
|
|
Add Step
|
|
</button>
|
|
</div>
|
|
|
|
@for (step of form.steps; track step; let i = $index) {
|
|
<div class="step-card">
|
|
<div class="row-container space-between aligned">
|
|
<div class="row-container aligned">
|
|
<span class="step-number">Step {{ i + 1 }}</span>
|
|
</div>
|
|
<div class="row-container aligned">
|
|
<button mat-icon-button type="button" (click)="moveStepUp(i)" [disabled]="i === 0">
|
|
<mat-icon>arrow_upward</mat-icon>
|
|
</button>
|
|
<button
|
|
mat-icon-button
|
|
type="button"
|
|
(click)="moveStepDown(i)"
|
|
[disabled]="i === form.steps.length - 1"
|
|
>
|
|
<mat-icon>arrow_downward</mat-icon>
|
|
</button>
|
|
<button mat-icon-button type="button" color="warn" (click)="removeStep(i)">
|
|
<mat-icon>delete</mat-icon>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row-container">
|
|
<mat-form-field class="flex-auto basis-1-5">
|
|
<mat-label>Phase</mat-label>
|
|
<select matNativeControl [formField]="step.phase">
|
|
@for (p of phases; track p) {
|
|
<option [value]="p">{{ p }}</option>
|
|
}
|
|
</select>
|
|
</mat-form-field>
|
|
|
|
<mat-form-field class="flex-auto basis-3-5">
|
|
<mat-label>Title</mat-label>
|
|
<input matInput [formField]="step.title" autocomplete="off" />
|
|
</mat-form-field>
|
|
|
|
<mat-form-field class="flex-auto basis-1-5">
|
|
<mat-label>Duration (min)</mat-label>
|
|
<input matInput type="number" [formField]="step.durationMinutes" />
|
|
</mat-form-field>
|
|
</div>
|
|
|
|
<div class="row-container">
|
|
<mat-form-field class="flex-auto">
|
|
<mat-label>Step Text</mat-label>
|
|
<textarea matInput matAutosizeMinRows="3" [formField]="step.text"></textarea>
|
|
</mat-form-field>
|
|
</div>
|
|
|
|
<div class="row-container">
|
|
<mat-form-field class="flex-auto">
|
|
<mat-label>Notes (optional)</mat-label>
|
|
<textarea matInput matAutosizeMinRows="2" [formField]="step.notes"></textarea>
|
|
</mat-form-field>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
@if (selectedSection === 'photos') {
|
|
<div class="flex-col wrapped">
|
|
<h3>Photos</h3>
|
|
<div class="row-container aligned wrapped">
|
|
<label class="file-btn">
|
|
<input type="file" accept="image/*" multiple (change)="onPhotosSelected($event)" />
|
|
<span>
|
|
<mat-icon>upload</mat-icon>
|
|
Add Photos
|
|
</span>
|
|
</label>
|
|
<span class="hint">Captions are optional. Reorder with ↑ / ↓.</span>
|
|
</div>
|
|
|
|
@if (form.photos.length === 0) {
|
|
<p class="hint">No photos yet.</p>
|
|
}
|
|
|
|
@for (p of form.photos; track p; let i = $index) {
|
|
<div class="photo-card">
|
|
<div class="row-container space-between aligned">
|
|
<div class="row-container aligned">
|
|
<img class="thumb" [src]="photoThumbSrc(formModel().photos[i])" alt="Recipe photo" />
|
|
</div>
|
|
<div class="row-container aligned">
|
|
<button mat-icon-button type="button" (click)="movePhotoUp(i)" [disabled]="i === 0">
|
|
<mat-icon>arrow_upward</mat-icon>
|
|
</button>
|
|
<button
|
|
mat-icon-button
|
|
type="button"
|
|
(click)="movePhotoDown(i)"
|
|
[disabled]="i === form.photos.length - 1"
|
|
>
|
|
<mat-icon>arrow_downward</mat-icon>
|
|
</button>
|
|
<button mat-icon-button color="warn" type="button" (click)="removePhoto(i)">
|
|
<mat-icon>delete</mat-icon>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="row-container">
|
|
<mat-form-field class="flex-auto">
|
|
<mat-label>Caption</mat-label>
|
|
<input matInput [formField]="p.caption" autocomplete="off" />
|
|
</mat-form-field>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
@if (selectedSection === 'notes') {
|
|
<div class="flex-col wrapped">
|
|
<h3>Notes</h3>
|
|
<div class="row-container">
|
|
<mat-form-field class="flex-auto">
|
|
<mat-label>Garnishing</mat-label>
|
|
<textarea matInput matAutosizeMinRows="3" [formField]="form.notes.garnishing"></textarea>
|
|
</mat-form-field>
|
|
</div>
|
|
<div class="row-container">
|
|
<mat-form-field class="flex-auto">
|
|
<mat-label>Plating</mat-label>
|
|
<textarea matInput matAutosizeMinRows="3" [formField]="form.notes.plating"></textarea>
|
|
</mat-form-field>
|
|
</div>
|
|
<div class="row-container">
|
|
<mat-form-field class="flex-auto">
|
|
<mat-label>Additional Notes</mat-label>
|
|
<textarea matInput matAutosizeMinRows="4" [formField]="form.notes.notes"></textarea>
|
|
</mat-form-field>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@if (selectedSection === 'preview') {
|
|
<div class="flex-col wrapped">
|
|
<div class="row-container space-between aligned">
|
|
<h3>Preview</h3>
|
|
<div class="row-container aligned">
|
|
<button mat-stroked-button type="button" (click)="exportPdf()">
|
|
<mat-icon>picture_as_pdf</mat-icon>
|
|
Export PDF
|
|
</button>
|
|
<button mat-stroked-button type="button" (click)="print()">
|
|
<mat-icon>print</mat-icon>
|
|
Print
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="recipe-print" #previewElement>
|
|
<div class="recipe-print__header">
|
|
<h1 class="recipe-print__title">{{ dishName }}</h1>
|
|
<div class="recipe-print__meta">
|
|
<span><strong>Date:</strong> {{ formModel().basics.date }}</span>
|
|
<span><strong>Source:</strong> {{ formModel().basics.source }}</span>
|
|
<span>
|
|
<strong>Yield:</strong>
|
|
{{ yieldText }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<hr class="recipe-print__hr" />
|
|
|
|
<section class="recipe-print__section">
|
|
<h2>Ingredients</h2>
|
|
<ul class="recipe-print__ingredients">
|
|
@for (ing of formModel().ingredients; track ing) {
|
|
<li>
|
|
<div>
|
|
<span class="name">{{ ing.product.name }}</span>
|
|
@if (ing.description) {
|
|
<span class="desc">— {{ ing.description }}</span>
|
|
}
|
|
</div>
|
|
<span class="qty">{{ ing.quantity | number: '1.2-2' }} {{ ing.product.fractionUnits }}</span>
|
|
</li>
|
|
}
|
|
</ul>
|
|
</section>
|
|
|
|
@if (formModel().steps.length > 0) {
|
|
<section class="recipe-print__section">
|
|
<h2>Steps</h2>
|
|
<ol class="recipe-print__steps">
|
|
@for (step of formModel().steps; track step; let i = $index) {
|
|
<li class="recipe-print__step-item">
|
|
<div class="recipe-print__step-header">
|
|
Step {{ i + 1 }} - {{ step.phase }}: {{ step.title }}
|
|
@if (step.durationMinutes) {
|
|
({{ step.durationMinutes }} min)
|
|
}
|
|
</div>
|
|
<p class="recipe-print__step-text">{{ step.text }}</p>
|
|
@if (step.notes) {
|
|
<p class="recipe-print__step-notes">Note: {{ step.notes }}</p>
|
|
}
|
|
</li>
|
|
}
|
|
</ol>
|
|
</section>
|
|
}
|
|
|
|
@if (formModel().photos.length > 0) {
|
|
<section class="recipe-print__section">
|
|
<h2>Photos</h2>
|
|
<div class="recipe-print__photos">
|
|
@for (p of formModel().photos; track p; let i = $index) {
|
|
<div class="recipe-print__photo-item">
|
|
<img
|
|
class="recipe-print__photo-img"
|
|
[src]="photoThumbSrc(formModel().photos[i])"
|
|
alt="Recipe photo"
|
|
/>
|
|
@if (p.caption) {
|
|
<div class="recipe-print__photo-caption">{{ p.caption }}</div>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
</section>
|
|
}
|
|
|
|
@if (formModel().notes.garnishing || formModel().notes.plating || formModel().notes.notes) {
|
|
<section class="recipe-print__section">
|
|
<h2>Notes</h2>
|
|
@if (formModel().notes.garnishing) {
|
|
<div class="recipe-print__notes-block">
|
|
<div class="recipe-print__notes-title">Garnishing</div>
|
|
<p class="recipe-print__notes-text">{{ formModel().notes.garnishing }}</p>
|
|
</div>
|
|
}
|
|
@if (formModel().notes.plating) {
|
|
<div class="recipe-print__notes-block">
|
|
<div class="recipe-print__notes-title">Plating</div>
|
|
<p class="recipe-print__notes-text">{{ formModel().notes.plating }}</p>
|
|
</div>
|
|
}
|
|
@if (formModel().notes.notes) {
|
|
<div class="recipe-print__notes-block">
|
|
<div class="recipe-print__notes-title">Additional Notes</div>
|
|
<p class="recipe-print__notes-text">{{ formModel().notes.notes }}</p>
|
|
</div>
|
|
}
|
|
</section>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</form>
|
|
}
|
|
|
|
<div class="row-container aligned wrapped">
|
|
<button mat-raised-button color="primary" (click)="save()">
|
|
{{ id() ? 'Update' : 'Save' }}
|
|
</button>
|
|
@if (id()) {
|
|
<button mat-raised-button type="button" color="warn" (click)="confirmDelete()">Delete</button>
|
|
}
|
|
</div>
|