MME-8 Add image select, WIP.
This commit is contained in:
parent
9040a61edb
commit
b07a8fd90b
@ -87,6 +87,9 @@
|
|||||||
<h3>Images</h3>
|
<h3>Images</h3>
|
||||||
<button matButton="outlined" (click)="openImageUploadDialog()" type="button">Upload Image</button>
|
<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>
|
||||||
|
|
||||||
<h3>Recipe Text</h3>
|
<h3>Recipe Text</h3>
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<mat-label>Recipe Text</mat-label>
|
<mat-label>Recipe Text</mat-label>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
afterNextRender,
|
afterNextRender,
|
||||||
Component,
|
Component,
|
||||||
|
computed,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
inject,
|
inject,
|
||||||
Injector,
|
Injector,
|
||||||
@ -37,6 +38,8 @@ import {
|
|||||||
import { IngredientDraftClientModel } from '../../../../shared/client-models/IngredientDraftClientModel';
|
import { IngredientDraftClientModel } from '../../../../shared/client-models/IngredientDraftClientModel';
|
||||||
import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop';
|
import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||||
import { DatePipe } from '@angular/common';
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { ImageSelect } from './image-select/image-select';
|
||||||
|
import { ImageView } from '../../../../shared/models/ImageView.model';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-enter-recipe-data',
|
selector: 'app-enter-recipe-data',
|
||||||
@ -63,6 +66,7 @@ import { DatePipe } from '@angular/common';
|
|||||||
CdkDropList,
|
CdkDropList,
|
||||||
CdkDrag,
|
CdkDrag,
|
||||||
DatePipe,
|
DatePipe,
|
||||||
|
ImageSelect,
|
||||||
],
|
],
|
||||||
templateUrl: './enter-recipe-data.html',
|
templateUrl: './enter-recipe-data.html',
|
||||||
styleUrl: './enter-recipe-data.css',
|
styleUrl: './enter-recipe-data.css',
|
||||||
@ -85,6 +89,16 @@ export class EnterRecipeData implements OnInit {
|
|||||||
protected readonly ingredientsTable = viewChild<MatTable<unknown>>('ingredientsTable');
|
protected readonly ingredientsTable = viewChild<MatTable<unknown>>('ingredientsTable');
|
||||||
protected readonly ingredientModels = signal<IngredientDraftClientModel[]>([]);
|
protected readonly ingredientModels = signal<IngredientDraftClientModel[]>([]);
|
||||||
|
|
||||||
|
private readonly mainImage = signal<ImageView | null>(null);
|
||||||
|
protected readonly mainImageUsernameFilename = computed(() => {
|
||||||
|
const mainImage = this.mainImage();
|
||||||
|
if (mainImage) {
|
||||||
|
return [mainImage.owner.username, mainImage.filename] as const;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
private readonly injector = inject(Injector);
|
private readonly injector = inject(Injector);
|
||||||
|
|
||||||
public ngOnInit(): void {
|
public ngOnInit(): void {
|
||||||
@ -209,6 +223,10 @@ export class EnterRecipeData implements OnInit {
|
|||||||
this.dialog.open(ImageUploadDialog);
|
this.dialog.open(ImageUploadDialog);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected onMainImageSelect(imageView: ImageView | null): void {
|
||||||
|
this.mainImage.set(imageView);
|
||||||
|
}
|
||||||
|
|
||||||
protected readonly faEllipsis = faEllipsis;
|
protected readonly faEllipsis = faEllipsis;
|
||||||
protected readonly faBars = faBars;
|
protected readonly faBars = faBars;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,17 @@
|
|||||||
|
.image-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-grid > img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-image {
|
||||||
|
border: 5px solid var(--primary-black);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
@if (imageViewsQuery.isLoading()) {
|
||||||
|
<app-spinner></app-spinner>
|
||||||
|
} @else if (imageViewsQuery.isError()) {
|
||||||
|
<p>There was an error loading images.</p>
|
||||||
|
} @else if (imageViewsQuery.isSuccess()) {
|
||||||
|
<div class="image-grid">
|
||||||
|
@for (imageQuery of imageQueries(); track $index) {
|
||||||
|
@if (imageQuery.isLoading()) {
|
||||||
|
<app-spinner></app-spinner>
|
||||||
|
} @else if (imageQuery.isError()) {
|
||||||
|
<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) }"
|
||||||
|
>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<mat-paginator [length]="imageCount()" [pageSize]="pageSize()" [pageSizeOptions]="[3, 6, 9, 12]" (page)="onPage($event)"></mat-paginator>
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ImageSelect } from './image-select';
|
||||||
|
|
||||||
|
describe('ImageSelect', () => {
|
||||||
|
let component: ImageSelect;
|
||||||
|
let fixture: ComponentFixture<ImageSelect>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ImageSelect]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ImageSelect);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await fixture.whenStable();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
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 { 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';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-image-select',
|
||||||
|
imports: [MatPaginator, Spinner, NgClass],
|
||||||
|
templateUrl: './image-select.html',
|
||||||
|
styleUrl: './image-select.css',
|
||||||
|
})
|
||||||
|
export class ImageSelect {
|
||||||
|
public readonly select = output<ImageView | null>();
|
||||||
|
public readonly selectedUsernameFilename = input<readonly [string, string] | null>(null);
|
||||||
|
|
||||||
|
protected readonly currentPage = signal(0);
|
||||||
|
protected readonly pageSize = signal(9);
|
||||||
|
|
||||||
|
private readonly imageService = inject(ImageService);
|
||||||
|
|
||||||
|
protected readonly imageViewsQuery = injectQuery(() => ({
|
||||||
|
queryKey: ['image-views', this.currentPage(), this.pageSize()],
|
||||||
|
queryFn: () =>
|
||||||
|
this.imageService.getOwnedImages({
|
||||||
|
page: this.currentPage(),
|
||||||
|
sort: [
|
||||||
|
{
|
||||||
|
property: 'created',
|
||||||
|
order: 'DESC',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
size: this.pageSize(),
|
||||||
|
}),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
}));
|
||||||
|
|
||||||
|
protected readonly imageCount = computed(() => {
|
||||||
|
if (this.imageViewsQuery.isSuccess()) {
|
||||||
|
return this.imageViewsQuery.data()!.count;
|
||||||
|
} else {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly imageQueries = injectQueries(() => ({
|
||||||
|
queries:
|
||||||
|
this.imageViewsQuery.data()?.content.map((imageView) => {
|
||||||
|
return {
|
||||||
|
queryKey: ['images', imageView.owner.username, imageView.filename],
|
||||||
|
queryFn: async () => {
|
||||||
|
const blobUrl = await this.imageService.getImage(imageView.url);
|
||||||
|
return {
|
||||||
|
blobUrl,
|
||||||
|
imageView,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}) ?? [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
protected onPage(pageEvent: PageEvent): void {
|
||||||
|
if (pageEvent.pageIndex < this.currentPage()) {
|
||||||
|
// backward
|
||||||
|
this.currentPage.update((old) => Math.max(old - 1, 0));
|
||||||
|
} else {
|
||||||
|
// forward
|
||||||
|
this.currentPage.update((old) => (this.imageViewsQuery.data()?.slice.hasNext ? old + 1 : old));
|
||||||
|
}
|
||||||
|
this.pageSize.set(pageEvent.pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onImageClick(imageView: ImageView): void {
|
||||||
|
if (this.isSelected(imageView)) {
|
||||||
|
this.select.emit(null);
|
||||||
|
} else {
|
||||||
|
this.select.emit(imageView);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected isSelected(imageView: ImageView): boolean {
|
||||||
|
const selectedUsernameFilename = this.selectedUsernameFilename();
|
||||||
|
if (selectedUsernameFilename) {
|
||||||
|
const [username, filename] = selectedUsernameFilename;
|
||||||
|
return imageView.owner.username === username && imageView.filename === filename;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,8 +3,9 @@ import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angula
|
|||||||
import { RecipeService } from '../../services/RecipeService';
|
import { RecipeService } from '../../services/RecipeService';
|
||||||
import { injectInfiniteQuery, injectMutation } from '@tanstack/angular-query-experimental';
|
import { injectInfiniteQuery, injectMutation } from '@tanstack/angular-query-experimental';
|
||||||
import { AuthService } from '../../services/AuthService';
|
import { AuthService } from '../../services/AuthService';
|
||||||
import { RecipeComments } from '../../models/RecipeComment.model';
|
|
||||||
import { DateTimeFormatPipe } from '../../pipes/dateTimeFormat.pipe';
|
import { DateTimeFormatPipe } from '../../pipes/dateTimeFormat.pipe';
|
||||||
|
import { SliceView } from '../../models/SliceView.model';
|
||||||
|
import { RecipeComment } from '../../models/RecipeComment.model';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-recipe-comments-list',
|
selector: 'app-recipe-comments-list',
|
||||||
@ -24,7 +25,7 @@ export class RecipeCommentsList {
|
|||||||
|
|
||||||
protected commentsQuery = injectInfiniteQuery(() => ({
|
protected commentsQuery = injectInfiniteQuery(() => ({
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
getNextPageParam: (previousPage: RecipeComments) =>
|
getNextPageParam: (previousPage: SliceView<RecipeComment>) =>
|
||||||
previousPage.slice.hasNext ? previousPage.slice.number + 1 : undefined,
|
previousPage.slice.hasNext ? previousPage.slice.number + 1 : undefined,
|
||||||
queryKey: ['recipeComments', this.recipeUsername(), this.recipeSlug()],
|
queryKey: ['recipeComments', this.recipeUsername(), this.recipeSlug()],
|
||||||
queryFn: ({ pageParam }) => {
|
queryFn: ({ pageParam }) => {
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
export interface QueryParams {
|
export interface QueryParams<T extends readonly string[] = any> {
|
||||||
page?: number;
|
page?: number;
|
||||||
size?: number;
|
size?: number;
|
||||||
sort?: Array<string | Sort>;
|
sort?: Array<string | Sort<T>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Sort {
|
export interface Sort<T extends readonly string[] = any> {
|
||||||
property: string;
|
property: T[number];
|
||||||
order?: 'ASC' | 'DESC';
|
order?: 'ASC' | 'DESC';
|
||||||
ignoreCase?: boolean;
|
ignoreCase?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,4 @@
|
|||||||
import { ResourceOwner } from './ResourceOwner.model';
|
import { ResourceOwner } from './ResourceOwner.model';
|
||||||
import { SliceView } from './SliceView.model';
|
|
||||||
|
|
||||||
export interface RecipeComments {
|
|
||||||
slice: SliceView;
|
|
||||||
content: RecipeComment[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecipeComment {
|
export interface RecipeComment {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@ -1,4 +1,10 @@
|
|||||||
export interface SliceView {
|
export interface SliceView<T> {
|
||||||
|
content: T[];
|
||||||
|
count: number;
|
||||||
|
slice: SliceViewMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SliceViewMeta {
|
||||||
hasNext: boolean;
|
hasNext: boolean;
|
||||||
number: number;
|
number: number;
|
||||||
size: number;
|
size: number;
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { environment } from '../../../environments/environment';
|
|||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class EndpointService {
|
export class EndpointService {
|
||||||
public getUrl(endpoint: keyof typeof Endpoints, pathParts?: string[], queryParams?: QueryParams): string {
|
public getUrl<P extends readonly string[] = []>(endpoint: keyof typeof Endpoints, pathParts?: string[], queryParams?: QueryParams<P>): 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());
|
||||||
|
|||||||
@ -3,14 +3,37 @@ import { HttpClient } from '@angular/common/http';
|
|||||||
import { firstValueFrom, map } from 'rxjs';
|
import { firstValueFrom, map } from 'rxjs';
|
||||||
import { EndpointService } from './EndpointService';
|
import { EndpointService } from './EndpointService';
|
||||||
import { ImageView } from '../models/ImageView.model';
|
import { ImageView } from '../models/ImageView.model';
|
||||||
|
import { SliceView } from '../models/SliceView.model';
|
||||||
|
import { QueryParams } from '../models/Query.model';;
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class ImageService {
|
export class ImageService {
|
||||||
|
public static ImageProps = [
|
||||||
|
'id',
|
||||||
|
'created',
|
||||||
|
'modified',
|
||||||
|
'userFilename',
|
||||||
|
'mimeType',
|
||||||
|
'alt',
|
||||||
|
'caption',
|
||||||
|
'objectName',
|
||||||
|
'height',
|
||||||
|
'width',
|
||||||
|
'owner',
|
||||||
|
'viewers'
|
||||||
|
] as const;
|
||||||
|
|
||||||
private readonly httpClient = inject(HttpClient);
|
private readonly httpClient = inject(HttpClient);
|
||||||
private readonly endpointService = inject(EndpointService);
|
private readonly endpointService = inject(EndpointService);
|
||||||
|
|
||||||
|
public getOwnedImages(queryParams?: QueryParams<typeof ImageService.ImageProps>): Promise<SliceView<ImageView>> {
|
||||||
|
return firstValueFrom(
|
||||||
|
this.httpClient.get<SliceView<ImageView>>(this.endpointService.getUrl('images', [], queryParams))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TODO: this api should not accept null as an input
|
* TODO: this api should not accept null as an input
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -4,9 +4,10 @@ import { firstValueFrom, lastValueFrom, map } from 'rxjs';
|
|||||||
import { Recipe, RecipeInfoViews, RecipeView } from '../models/Recipe.model';
|
import { Recipe, RecipeInfoViews, RecipeView } from '../models/Recipe.model';
|
||||||
import { AuthService } from './AuthService';
|
import { AuthService } from './AuthService';
|
||||||
import { QueryClient } from '@tanstack/angular-query-experimental';
|
import { QueryClient } from '@tanstack/angular-query-experimental';
|
||||||
import { RecipeComment, RecipeComments } from '../models/RecipeComment.model';
|
import { RecipeComment } from '../models/RecipeComment.model';
|
||||||
import { QueryParams } from '../models/Query.model';
|
import { QueryParams } from '../models/Query.model';
|
||||||
import { EndpointService } from './EndpointService';
|
import { EndpointService } from './EndpointService';
|
||||||
|
import { SliceView } from '../models/SliceView.model';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
@ -46,9 +47,9 @@ export class RecipeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public getComments(username: string, slug: string, queryParams?: QueryParams): Promise<RecipeComments> {
|
public getComments(username: string, slug: string, queryParams?: QueryParams): Promise<SliceView<RecipeComment>> {
|
||||||
return firstValueFrom(
|
return firstValueFrom(
|
||||||
this.http.get<RecipeComments>(
|
this.http.get<SliceView<RecipeComment>>(
|
||||||
this.endpointService.getUrl('recipes', [username, slug, 'comments'], queryParams),
|
this.endpointService.getUrl('recipes', [username, slug, 'comments'], queryParams),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user