Compare commits

...

2 Commits

Author SHA1 Message Date
Jesse Brault
76ed65a144 MME-8 Add basic editing action for image data to main image select grid, WIP. 2026-02-06 13:57:49 -06:00
Jesse Brault
9b90e1033e MME-8 Main image selected and passed to backend. 2026-02-06 13:19:01 -06:00
15 changed files with 252 additions and 36 deletions

View File

@ -1,3 +1,5 @@
import { ImageView } from '../../../../shared/models/ImageView.model';
export interface EnterRecipeDataSubmitEvent {
title: string;
slug: string;
@ -6,5 +8,6 @@ export interface EnterRecipeDataSubmitEvent {
name: string;
notes?: string | null;
}>;
mainImage: ImageView | null;
rawText: string;
}

View File

@ -88,7 +88,10 @@
<button matButton="outlined" (click)="openImageUploadDialog()" type="button">Upload Image</button>
<h4>Select Main Image</h4>
<app-image-select (select)="onMainImageSelect($event)" [selectedUsernameFilename]="mainImageUsernameFilename()"></app-image-select>
<app-image-select
(select)="onMainImageSelect($event)"
[selectedUsernameFilename]="mainImageUsernameFilename()"
></app-image-select>
<h3>Recipe Text</h3>
<mat-form-field>

View File

@ -211,6 +211,7 @@ export class EnterRecipeData implements OnInit {
title: value.title!,
slug: value.slug!,
ingredients: this.ingredientModels().map((ingredientModel) => ingredientModel.draft),
mainImage: this.mainImage(),
rawText: value.text!,
});
}

View File

@ -0,0 +1,5 @@
form {
display: flex;
flex-direction: column;
row-gap: 10px;
}

View File

@ -0,0 +1,17 @@
<app-dialog-container title="Edit Image">
<form (submit)="onSubmit($event)">
<mat-form-field>
<mat-label>Filename</mat-label>
<input matInput [formControl]="imageForm.controls.filename" />
</mat-form-field>
<mat-form-field>
<mat-label>Alt</mat-label>
<input matInput [formControl]="imageForm.controls.alt" />
</mat-form-field>
<mat-form-field>
<mat-label>Caption</mat-label>
<input matInput [formControl]="imageForm.controls.caption" />
</mat-form-field>
<button type="submit" matButton="filled">Submit</button>
</form>
</app-dialog-container>

View File

@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { EditImageDialog } from './edit-image-dialog';
describe('EditImageDialog', () => {
let component: EditImageDialog;
let fixture: ComponentFixture<EditImageDialog>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [EditImageDialog],
}).compileComponents();
fixture = TestBed.createComponent(EditImageDialog);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,54 @@
import { Component, inject, input, OnInit } from '@angular/core';
import { DialogContainer } from '../../../../../../shared/components/dialog-container/dialog-container';
import { MatFormField, MatInput, MatLabel } from '@angular/material/input';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { ImageView } from '../../../../../../shared/models/ImageView.model';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatButton } from '@angular/material/button';
import { ImageService } from '../../../../../../shared/services/ImageService';
import { QueryClient } from '@tanstack/angular-query-experimental';
@Component({
selector: 'app-edit-image-dialog',
imports: [DialogContainer, MatFormField, MatLabel, MatInput, ReactiveFormsModule, MatButton],
templateUrl: './edit-image-dialog.html',
styleUrl: './edit-image-dialog.css',
})
export class EditImageDialog implements OnInit {
protected readonly imageView: ImageView = inject(MAT_DIALOG_DATA);
private readonly imageService = inject(ImageService);
private readonly queryClient = inject(QueryClient);
private readonly dialogRef = inject(MatDialogRef);
protected readonly imageForm = new FormGroup({
filename: new FormControl(
{
value: '',
disabled: true,
},
Validators.required,
),
alt: new FormControl(''),
caption: new FormControl(''),
});
public ngOnInit(): void {
this.imageForm.patchValue({
filename: this.imageView.filename,
alt: this.imageView.alt,
caption: this.imageView.alt,
});
}
public async onSubmit(event: SubmitEvent): Promise<void> {
event.preventDefault();
await this.imageService.updateImage(this.imageView.owner.username, this.imageView.filename, {
alt: this.imageForm.value.alt,
caption: this.imageForm.value.caption,
});
await this.queryClient.invalidateQueries({
queryKey: ['image-views'],
});
this.dialogRef.close();
}
}

View File

@ -4,14 +4,17 @@
gap: 5px;
}
.image-grid > img {
.image-grid-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.selected-image {
border: 5px solid var(--primary-black);
border-radius: 10px;
box-sizing: border-box;
mat-card-content p {
overflow-wrap: anywhere;
}
mat-card-actions {
display: flex;
justify-content: space-between;
}

View File

@ -11,14 +11,41 @@
<p>There was an error loading this image.</p>
} @else {
@let imageData = imageQuery.data();
<img
[src]="imageData!.blobUrl"
alt="imageData!.imageView.alt"
(click)="onImageClick(imageData!.imageView)"
[ngClass]="{ 'selected-image': isSelected(imageData!.imageView) }"
>
<mat-card>
<img
mat-card-image
[src]="imageData!.blobUrl"
alt="imageData!.imageView.alt"
(click)="onImageClick(imageData!.imageView)"
class="image-grid-image"
/>
<mat-card-content>
<p>{{ imageData!.imageView.filename }}</p>
</mat-card-content>
<mat-card-actions>
<mat-checkbox
[checked]="isSelected(imageData!.imageView)"
(click)="onImageClick(imageData!.imageView)"
>Main?</mat-checkbox
>
<button matButton="text" type="button" [matMenuTriggerFor]="imageActionsMenu">
<fa-icon [icon]="faEllipsis"></fa-icon>
</button>
<mat-menu #imageActionsMenu>
<button mat-menu-item type="button" (click)="editImage(imageData!.imageView)">Edit</button>
<button mat-menu-item type="button" (click)="deleteImage(imageData!.imageView)">
Delete
</button>
</mat-menu>
</mat-card-actions>
</mat-card>
}
}
</div>
<mat-paginator [length]="imageCount()" [pageSize]="pageSize()" [pageSizeOptions]="[3, 6, 9, 12]" (page)="onPage($event)"></mat-paginator>
<mat-paginator
[length]="imageCount()"
[pageSize]="pageSize()"
[pageSizeOptions]="[3, 6, 9, 12]"
(page)="onPage($event)"
></mat-paginator>
}

View File

@ -3,21 +3,20 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ImageSelect } from './image-select';
describe('ImageSelect', () => {
let component: ImageSelect;
let fixture: ComponentFixture<ImageSelect>;
let component: ImageSelect;
let fixture: ComponentFixture<ImageSelect>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ImageSelect]
})
.compileComponents();
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ImageSelect],
}).compileComponents();
fixture = TestBed.createComponent(ImageSelect);
component = fixture.componentInstance;
await fixture.whenStable();
});
fixture = TestBed.createComponent(ImageSelect);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,15 +1,35 @@
import { Component, computed, inject, input, output, signal } from '@angular/core';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { ImageService } from '../../../../../shared/services/ImageService';
import { injectQuery, keepPreviousData } from '@tanstack/angular-query-experimental';
import { injectQuery, keepPreviousData, QueryClient } from '@tanstack/angular-query-experimental';
import { Spinner } from '../../../../../shared/components/spinner/spinner';
import { ImageView } from '../../../../../shared/models/ImageView.model';
import { injectQueries } from '@tanstack/angular-query-experimental/inject-queries-experimental'
import { NgClass } from '@angular/common';
import { injectQueries } from '@tanstack/angular-query-experimental/inject-queries-experimental';
import { MatCard, MatCardActions, MatCardContent, MatCardImage } from '@angular/material/card';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { faEllipsis } from '@fortawesome/free-solid-svg-icons';
import { MatButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { EditImageDialog } from './edit-image-dialog/edit-image-dialog';
@Component({
selector: 'app-image-select',
imports: [MatPaginator, Spinner, NgClass],
imports: [
MatPaginator,
Spinner,
MatCard,
MatCardImage,
MatCardContent,
MatCardActions,
MatCheckbox,
MatMenuTrigger,
MatMenu,
FaIconComponent,
MatButton,
MatMenuItem,
],
templateUrl: './image-select.html',
styleUrl: './image-select.css',
})
@ -21,6 +41,8 @@ export class ImageSelect {
protected readonly pageSize = signal(9);
private readonly imageService = inject(ImageService);
private readonly dialog = inject(MatDialog);
private readonly queryClient = inject(QueryClient);
protected readonly imageViewsQuery = injectQuery(() => ({
queryKey: ['image-views', this.currentPage(), this.pageSize()],
@ -89,4 +111,22 @@ export class ImageSelect {
}
return false;
}
protected editImage(imageView: ImageView): void {
this.dialog.open(EditImageDialog, {
data: imageView,
});
}
protected async deleteImage(imageView: ImageView): Promise<void> {
await this.imageService.deleteImage(imageView.owner.username, imageView.filename);
await this.queryClient.invalidateQueries({
queryKey: ['image-views'],
});
await this.queryClient.invalidateQueries({
queryKey: ['images'],
});
}
protected readonly faEllipsis = faEllipsis;
}

View File

@ -0,0 +1,4 @@
export interface SetImageBody {
username: string;
userFilename: string;
}

View File

@ -7,7 +7,11 @@ import { environment } from '../../../environments/environment';
providedIn: 'root',
})
export class EndpointService {
public getUrl<P extends readonly string[] = []>(endpoint: keyof typeof Endpoints, pathParts?: string[], queryParams?: QueryParams<P>): string {
public getUrl<P extends readonly string[] = []>(
endpoint: keyof typeof Endpoints,
pathParts?: string[],
queryParams?: QueryParams<P>,
): string {
const urlSearchParams = new URLSearchParams();
if (queryParams?.page !== undefined) {
urlSearchParams.set('page', queryParams.page.toString());

View File

@ -4,7 +4,7 @@ import { firstValueFrom, map } from 'rxjs';
import { EndpointService } from './EndpointService';
import { ImageView } from '../models/ImageView.model';
import { SliceView } from '../models/SliceView.model';
import { QueryParams } from '../models/Query.model';;
import { QueryParams } from '../models/Query.model';
@Injectable({
providedIn: 'root',
@ -22,7 +22,7 @@ export class ImageService {
'height',
'width',
'owner',
'viewers'
'viewers',
] as const;
private readonly httpClient = inject(HttpClient);
@ -30,7 +30,7 @@ export class ImageService {
public getOwnedImages(queryParams?: QueryParams<typeof ImageService.ImageProps>): Promise<SliceView<ImageView>> {
return firstValueFrom(
this.httpClient.get<SliceView<ImageView>>(this.endpointService.getUrl('images', [], queryParams))
this.httpClient.get<SliceView<ImageView>>(this.endpointService.getUrl('images', [], queryParams)),
);
}
@ -81,4 +81,27 @@ export class ImageService {
.pipe(map((view) => view.exists)),
);
}
public deleteImage(username: string, filename: string): Promise<void> {
return firstValueFrom(
this.httpClient.delete<void>(this.endpointService.getUrl('images', [username, filename])),
);
}
public updateImage(
username: string,
filename: string,
data: {
alt?: string | null;
caption?: string | null;
isPublic?: boolean | null;
viewersToAdd?: string[] | null;
viewersToRemove?: string[] | null;
clearAllViewers?: boolean | null;
},
): Promise<ImageView> {
return firstValueFrom(
this.httpClient.put<ImageView>(this.endpointService.getUrl('images', [username, filename]), data),
);
}
}

View File

@ -7,6 +7,8 @@ import { RecipeDraftViewModel } from '../models/RecipeDraftView.model';
import { EndpointService } from './EndpointService';
import { WithStringDates } from '../util';
import { Recipe } from '../models/Recipe.model';
import { ImageView } from '../models/ImageView.model';
import { SetImageBody } from '../models/SetImageBody';
@Injectable({
providedIn: 'root',
@ -82,12 +84,21 @@ export class RecipeDraftService {
name: string;
notes?: string | null;
}>;
mainImage?: ImageView | null;
rawText?: string | null;
},
): Promise<RecipeUploadClientModel> {
return firstValueFrom(
this.http
.put<WithStringDates<RecipeDraftViewModel>>(this.endpointService.getUrl('recipeDrafts', [id]), data)
.put<WithStringDates<RecipeDraftViewModel>>(this.endpointService.getUrl('recipeDrafts', [id]), {
...data,
mainImage: data.mainImage
? ({
username: data.mainImage.owner.username,
userFilename: data.mainImage.filename,
} satisfies SetImageBody)
: undefined,
})
.pipe(
map((rawView) => this.hydrateView(rawView)),
map((draft) => ({