Add fileName property to models and update related components for file handling

This commit is contained in:
liosha84
2025-08-09 22:22:02 +03:00
parent ca73cf31df
commit d45fd83593
64 changed files with 1644 additions and 433 deletions
@@ -0,0 +1,19 @@
<p>dialog-cropp-image.component works!</p>
<mat-dialog-content class="mat-typography">
<image-cropper
[imageURL]="image"
style="width: 400px; height: 300px;"
[imageChangedEvent]="imageChangedEvent"
[maintainAspectRatio]="true"
[aspectRatio]="4 / 3"
format="png"
(imageCropped)="imageCropped($event)"
(imageLoaded)="imageLoaded($event)"
(cropperReady)="cropperReady()"
(loadImageFailed)="loadImageFailed()"
></image-cropper>
<img style="width: 358px; height: 200px" [src]="croppedImage" />
</mat-dialog-content>
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DialogCroppImageComponent } from './dialog-cropp-image.component';
describe('DialogCroppImageComponent', () => {
let component: DialogCroppImageComponent;
let fixture: ComponentFixture<DialogCroppImageComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DialogCroppImageComponent]
})
.compileComponents();
fixture = TestBed.createComponent(DialogCroppImageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,62 @@
import {Component, inject, input} from '@angular/core';
import {ImageCroppedEvent, ImageCropperComponent, LoadedImage} from 'ngx-image-cropper';
import {DomSanitizer, SafeUrl} from '@angular/platform-browser';
import {UserApiService} from '../user-api.service';
import {MAT_DIALOG_DATA, MatDialogContent} from '@angular/material/dialog';
@Component({
selector: 'app-dialog-cropp-image.component',
imports: [
ImageCropperComponent,
MatDialogContent
],
templateUrl: './dialog-cropp-image.component.html',
styleUrl: './dialog-cropp-image.component.scss'
})
export class DialogCroppImageComponent {
imageChangedEvent: Event | null = null;
croppedImage: SafeUrl = '';
image?: string;
imageCropUrl: string | undefined;
constructor(
private userApiService: UserApiService,
private sanitizer: DomSanitizer,
) {
this.cropImage(this.imageCropUrl);
}
///------------
fileChangeEvent(event: Event): void {
this.imageChangedEvent = event;
}
imageCropped(event: ImageCroppedEvent) {
if (event.objectUrl != null) {
this.croppedImage = this.sanitizer.bypassSecurityTrustUrl(event.objectUrl);
}
// event.blob can be used to upload the cropped image
}
imageLoaded(image: LoadedImage) {
// show cropper
}
cropperReady() {
// cropper ready
}
loadImageFailed() {
// show message
}
cropImage(url: string | undefined) {
if(url !== undefined){
this.userApiService.loadImageAsBase64(url).subscribe(data => {
this.image = data;
})
}
}
}
@@ -1,25 +1,122 @@
<p>images.component works!</p>
<div class="generate-image-view">
@for (fileInfo of fileInfos; track fileInfo) {
<mat-card>
<mat-card-header>
<div class="image-container">
<img src="{{fileInfo.url}}"
alt="" width="100%" height="100%">
<mat-toolbar class="image-toolbar">
<mat-toolbar-row>
<label class="image-file-name">{{fileInfo.name}}</label>
<span class="menu-spacer"></span>
<button matMiniFab matTooltip="Dawnload image" aria-label="Download" (click)="downloadImage(fileInfo.url)">
<mat-icon>download</mat-icon>
</button>
@if(fileInfo.mode !=="CropView"){
<img src="{{fileInfo.url}}"
alt="" width="100%" height="100%"/>
}@else{
<image-cropper
output = "base64"
[imageURL]="fileInfo.base64string"
style="width: 100%; height: 100%;"
[imageChangedEvent]="imageChangedEvent"
[maintainAspectRatio]="true"
[aspectRatio]="4 / 3"
[canvasRotation]="canvasRotation"
[cropperStaticWidth]="cropperStaticWidth"
[cropperStaticHeight]="cropperStaticHeight"
[(transform)]="transform"
format="png"
(imageCropped)="imageCropped($event)"
(imageLoaded)="imageLoaded($event)"
(cropperReady)="cropperReady()"
(loadImageFailed)="loadImageFailed()"
></image-cropper>
</mat-toolbar-row>
</mat-toolbar>
</div>
<img #imagePreview width="100%" height="100%" [src]="croppedImage" />
}
@if(fileInfo.mode !=="CropView"){
<mat-toolbar class="image-toolbar">
<mat-toolbar-row>
<label class="image-file-name">{{fileInfo.name}}</label>
<span class="menu-spacer"></span>
<button matMiniFab matTooltip="Crop image" aria-label="Crop" (click)="cropImage(fileInfo)">
<mat-icon>photo_size_select_small</mat-icon>
</button>
<button matMiniFab matTooltip="Dawnload image" aria-label="Download" (click)="downloadImage(fileInfo.url)">
<mat-icon>download</mat-icon>
</button>
</mat-toolbar-row>
</mat-toolbar>
}@else{
<mat-toolbar class="crop-toolbar">
<!-- Zoom -->
<mat-toolbar-row>
<button matMiniFab matTooltip="Size +" aria-label="Crop" (click)="zoomIn()">
<mat-icon>zoom_in</mat-icon>
</button>
<button matMiniFab matTooltip="Size -" aria-label="Download" (click)="zoomOut()">
<mat-icon>zoom_out</mat-icon>
</button>
</mat-toolbar-row>
<!-- Move -->
<mat-toolbar-row class="move-toolbar-row">
<table class="move-toolbar-table">
<tr style="display: flex; justify-content: center;">
<th colspan="2" >
<button matMiniFab matTooltip="Move up" aria-label="Crop" (click)="moveUp()">
<mat-icon>north</mat-icon>
</button>
</th>
</tr>
<tr>
<td>
<button matMiniFab matTooltip="Move left" aria-label="Download" (click)="moveLeft()">
<mat-icon>west</mat-icon>
</button>
</td>
<td>
<button matMiniFab matTooltip="Move right" aria-label="Crop" (click)="moveRight()">
<mat-icon>east</mat-icon>
</button>
</td>
</tr>
<tr style="display: flex; justify-content: center;">
<th colspan="2" >
<button matMiniFab matTooltip="Move down" aria-label="Download" (click)="moveDown()">
<mat-icon>south</mat-icon>
</button>
</th>
</tr>
</table>
</mat-toolbar-row>
<!-- Rotate -->
<mat-toolbar-row>
<button matMiniFab matTooltip="Rotate left" aria-label="Crop" (click)="rotateLeft()">
<mat-icon>rotate_left</mat-icon>
</button>
<button matMiniFab matTooltip="Rotate right" aria-label="Download" (click)="rotateRight()">
<mat-icon>rotate_right</mat-icon>
</button>
</mat-toolbar-row>
<!-- Flip -->
<mat-toolbar-row>
<button matMiniFab matTooltip="Flip vertical" aria-label="Crop" (click)="flipHorizontal()">
<mat-icon>flip</mat-icon>
</button>
<button matMiniFab matTooltip="Flip horizontal" aria-label="Download" (click)="flipVertical()">
<mat-icon style="transform: rotate(90deg)">flip</mat-icon>
</button>
</mat-toolbar-row>
<mat-toolbar-row>
<mat-chip-listbox class="mat-mdc-chip-set-stacked" aria-label="Cutest dog breeds">
<mat-chip-option (selectionChange)="cropFixedSize($event)" >Crop 716x400</mat-chip-option>
</mat-chip-listbox>
<span class="menu-spacer"></span>
</mat-toolbar-row>
<mat-toolbar-row>
<button matMiniFab matTooltip="Save to server" aria-label="Save" (click)="save()" >
<mat-icon>upload_file</mat-icon>
</button>
</mat-toolbar-row>
</mat-toolbar>
}
</div>
</mat-card-header>
<mat-card-content>
</mat-card-content>
@@ -11,13 +11,15 @@
color: white;
position: absolute;
left : 50%;
top: 5%;
top: 47px;
transform: translate(-50%, -50%);
-ms-transform: translate(-50%, -50%);
padding: 0px 2px;
border: none;
border-radius: 5px;
width: 100%;
}
.image-container .menu-spacer {
flex: 1 1 auto;
}
@@ -29,3 +31,33 @@
.mat-mdc-mini-fab{
margin: 5px;
}
.spacer {
flex: 1 1 auto;
}
.move-toolbar-row{
height: 140px;
}
.image-container .crop-toolbar{
background-color: rgba(153,153,153,0);
color: white;
position: absolute;
left : 59px;
top: 240px;
transform: translate(-50%, -50%);
-ms-transform: translate(-50%, -50%);
padding: 0px 2px;
border: none;
border-radius: 5px;
width: 10%;
}
.move-toolbar-table{
display: flex;
justify-content: center;
flex-direction: column;
}
@@ -1,12 +1,16 @@
import {Component, CUSTOM_ELEMENTS_SCHEMA, inject} from '@angular/core';
import {Component, ElementRef, inject, ViewChild} from '@angular/core';
import {MatCard, MatCardContent, MatCardHeader} from '@angular/material/card';
import {MatMiniFabButton} from '@angular/material/button';
import {MatToolbar, MatToolbarRow} from '@angular/material/toolbar';
import {MatTooltip} from '@angular/material/tooltip';
import {MatIcon} from '@angular/material/icon';
import {FileInfo} from '../../../models/file-info';
import {UserApiService} from '../user-api.service';
import {Base64ImagePayload, UploadImageResponse, UserApiService} from '../user-api.service';
import {CropService, ImageCroppedEvent, ImageCropperComponent, ImageTransform, LoadedImage} from 'ngx-image-cropper';
import {DomSanitizer, SafeUrl} from '@angular/platform-browser';
import {MatDialog} from '@angular/material/dialog';
import {DialogCroppImageComponent} from '../dialog-cropp-image.component/dialog-cropp-image.component';
import {MatChipListbox, MatChipOption, MatChipSelectionChange} from '@angular/material/chips';
@Component({
selector: 'app-images.component',
@@ -18,17 +22,49 @@ import {MatDialog} from '@angular/material/dialog';
MatMiniFabButton,
MatToolbar,
MatToolbarRow,
MatTooltip
MatTooltip,
ImageCropperComponent,
MatChipListbox,
MatChipOption
],
templateUrl: './images.component.html',
styleUrl: './images.component.scss',
schemas: [CUSTOM_ELEMENTS_SCHEMA]
styleUrl: './images.component.scss'
})
export class ImagesComponent {
fileInfos?: FileInfo[] = [];
readonly dialog = inject(MatDialog);
constructor(private userApiService: UserApiService) {
imageChangedEvent: Event | null = null;
croppedImage: string | null | undefined = '';
croppedImageBase64: string | null | undefined;
@ViewChild('imagePreview') imagePreview!: ElementRef;
canvasRotation = 0;
transform: ImageTransform = {
translateUnit: 'px',
scale: 1,
rotate: 0,
flipH: false,
flipV: false,
translateH: 0,
translateV: 0
};
cropperStaticWidth = 0;
cropperStaticHeight = 0;
loading = false;
currentCroppedFile: FileInfo | undefined;
constructor(
private userApiService: UserApiService,
private sanitizer: DomSanitizer,
) {
this.userApiService.getImages().subscribe(data =>{
this.fileInfos = data;
console.log(data);
@@ -36,6 +72,214 @@ export class ImagesComponent {
}
downloadImage(url: string | undefined) {
const link = document.createElement('a');
// @ts-ignore
link.download = url;
// @ts-ignore
link.href = url;
link.click();
}
openCropImageDialog(imageUrl: string | undefined, enterAnimationDuration: string, exitAnimationDuration: string) {
let dialogCropImageRef = this.dialog.open(DialogCroppImageComponent, {
height: '500px',
width: '800px',
enterAnimationDuration,
exitAnimationDuration,
}
);
dialogCropImageRef.componentInstance.cropImage(imageUrl);
// dialogCropImageRef.componentInstance.uploadClicked.subscribe(result => {
// dialogCropImageRef.close();
// this.openUploadImageDialog(enterAnimationDuration, exitAnimationDuration);
// })
// const dialogSelectSubscription = dialogCropImageRef.componentInstance.selectClicked
// .subscribe(result => {
// console.log('Got the data!', result);
//
// if (result == null) {
// return;
// }
// this.tutorial.titleimage = result.url;
//
// });
}
cropImage(fileInfo: FileInfo) {
// close the crop view for a previous cropped file
if(this.currentCroppedFile !== undefined){
this.currentCroppedFile.mode = "View";
}
fileInfo.mode = "CropView";
this.setBase64String(fileInfo);
this.currentCroppedFile = fileInfo;
}
setBase64String(fileInfo:FileInfo) {
if(fileInfo.url !== undefined){
this.userApiService.loadImageAsBase64(fileInfo.url).subscribe(data => {
fileInfo.base64string = data;
})
}
}
imageCropped(event: ImageCroppedEvent) {
if (event.objectUrl != null) {
// this.croppedImage = this.sanitizer.bypassSecurityTrustUrl(event.objectUrl);
}
else {
this.croppedImageBase64 = event.base64;
this.croppedImage = this.croppedImageBase64;
}
this.imagePreview.nativeElement.height = event.height;
this.imagePreview.nativeElement.width = event.width;
}
imageLoaded(image: LoadedImage) {
// show cropper
}
cropperReady() {
// cropper ready
}
loadImageFailed() {
// show message
}
zoomIn() {
this.transform = {
...this.transform,
scale: this.transform.scale! + .1
};
}
zoomOut() {
this.transform = {
...this.transform,
scale: this.transform.scale! - .1
};
}
moveLeft() {
this.transform = {
...this.transform,
translateH: this.transform.translateH! - 1
};
}
moveRight() {
this.transform = {
...this.transform,
translateH: this.transform.translateH! + 1
};
}
moveDown() {
this.transform = {
...this.transform,
translateV: this.transform.translateV! + 1
};
}
moveUp() {
this.transform = {
...this.transform,
translateV: this.transform.translateV! - 1
};
}
flipHorizontal() {
this.transform = {
...this.transform,
flipH: !this.transform.flipH
};
}
flipVertical() {
this.transform = {
...this.transform,
flipV: !this.transform.flipV
};
}
rotateLeft() {
this.loading = true;
setTimeout(() => { // Use timeout because rotating image is a heavy operation and will block the ui thread
this.canvasRotation--;
this.flipAfterRotate();
});
}
rotateRight() {
this.loading = true;
setTimeout(() => {
this.canvasRotation++;
this.flipAfterRotate();
});
}
private flipAfterRotate() {
const flippedH = this.transform.flipH;
const flippedV = this.transform.flipV;
this.transform = {
...this.transform,
flipH: flippedV,
flipV: flippedH,
translateH: 0,
translateV: 0
};
}
cropFixedSize($event: MatChipSelectionChange) {
if ($event.source.selected) {
this.cropperStaticWidth = 716;
this.cropperStaticHeight = 400;
}
else {
this.cropperStaticWidth = 0;
this.cropperStaticHeight = 0;
}
}
save() {
const payload = this.getBase64ImagePayload();
this.userApiService.uploadUserBase64Image(payload).subscribe({
next: (res:UploadImageResponse) =>{
const fileInfo: FileInfo = {
name: res.fileName,
url: res.url,
mode: "View",
base64string: undefined
}
this.fileInfos?.push(fileInfo);
console.log("Uploaded:", res);
} ,
error: (err) => console.error("Upload failed", err),
});
}
private getBase64ImagePayload(): Base64ImagePayload {
const match = this.croppedImageBase64?.match(/^data:(.+);base64,(.*)$/);
if(match)
{
const [, mime, base64] = match;
return {
mime: mime,
data: base64
};
}
else {
throw new Error("Invalid base64 string");
}
}
}
@@ -70,7 +70,7 @@
<mat-divider></mat-divider>
<mat-tab-group>
<mat-tab label="Markdown text | Result">
<textarea class="variable-textarea" [(ngModel)]="markdown"></textarea>
<textarea class="variable-textarea" [(ngModel)]="tutorial.body"></textarea>
<markdown class="variable-binding" [data]="tutorial.body"></markdown>
</mat-tab>
<mat-tab label="Editor">
@@ -70,7 +70,7 @@ const language = 'typescript';
### Blockquote
> Blockquote to the max`;
private id: string | null | undefined;
private fileName: string | null | undefined;
constructor(private userApiService: UserApiService,
private route: ActivatedRoute,
@@ -81,11 +81,11 @@ const language = 'typescript';
this.route.queryParams
.subscribe(params => {
console.log(params);
this.id = params['id'];
console.log(this.id);
this.fileName = params['fileName'];
console.log(this.fileName);
});
this.userApiService.getTutorial(this.id).subscribe(
this.userApiService.getTutorial(this.fileName).subscribe(
data=>{
this.tutorial = data;
}
@@ -148,7 +148,7 @@ const language = 'typescript';
}
updateTutorial(): void {
this.userApiService.update(this.id, this.tutorial)
this.userApiService.update(this.fileName, this.tutorial)
.subscribe(
response => {
console.log(response);
@@ -37,13 +37,13 @@
}
@case('isEdit') {
<div class="btn-edit" >
<button mat-button routerLink='../tutorial-edit' [queryParams]="{id:element.id}" (click)="element.isEdit = !element.isEdit">
<button mat-button routerLink='../tutorial-edit' [queryParams]="{fileName:element.fileName}" (click)="element.isEdit = !element.isEdit">
Edit
</button>
<button
mat-button
class="button-remove"
(click)="removeRow(element.id)"
(click)="removeRow(element.fileName)"
>
Delete
</button>
@@ -98,7 +98,7 @@
<button
mat-button
class="button-remove"
(click)="removeRow(element.id)"
(click)="removeRow(element.fileName)"
>
Delete
</button>
@@ -116,7 +116,7 @@
<button
mat-button
(click)="editRow(element)"
[disabled]="disableSubmit(element.id)"
[disabled]="disableSubmit(element.fileName)"
>
Done
</button>
@@ -125,17 +125,17 @@ import {MatSlideToggle} from '@angular/material/slide-toggle';
// })
}
removeRow(id: number) {
this.userApiService.deleteTutorial(id).subscribe(() => {
removeRow(fileName: number) {
this.userApiService.deleteTutorial(fileName).subscribe(() => {
this.dataSource.data = this.dataSource.data.filter(
(u: Tutorial) => u.id !== id,
(u: Tutorial) => u.fileName !== fileName,
)
})
}
publish(element: any, checked: boolean){
element.tobepublished = checked;
this.userApiService.update(element.id, element).subscribe(
this.userApiService.update(element.fileName, element).subscribe(
response => {
console.log(response);
},
@@ -1,9 +1,10 @@
import { Injectable } from '@angular/core';
import {forkJoin, Observable} from 'rxjs';
import {forkJoin, Observable, switchMap} from 'rxjs';
import {Tutorial} from '../../models/tutorial.model';
import {HttpClient, HttpEvent, HttpHeaders, HttpRequest} from '@angular/common/http';
import {GlobalConstants} from '../../global-constants';
import {FileInfo} from '../../models/file-info';
import {map} from 'rxjs/operators';
@Injectable({
providedIn: 'root'
@@ -15,6 +16,11 @@ export class UserApiService {
}
uploadUserBase64Image(base64Image: Base64ImagePayload): Observable<any> {
return this.http.post<UploadImageResponse>(`${this.baseUrl}/user-images/upload-base64-image`, base64Image, { headers: { "Content-Type": "application/json" } }
);
}
upload(file: File): Observable<HttpEvent<any>> {
const formData: FormData = new FormData();
formData.append('file', file);
@@ -36,32 +42,59 @@ export class UserApiService {
return this.http.get<FileInfo[]>(`${this.baseUrl}/getImages`);
}
loadImageAsBase64(imageUrl: string): Observable<string> {
return this.http.post<Base64ImageResponse>(`${this.baseUrl}/user-images/base64-image`, imageUrl)
.pipe(map(r => `data:${r.mime};base64,${(r.data || '').trim()}`));
}
getUserAllTutorials(): Observable<Tutorial[]> {
return this.http.get<Tutorial[]>(`${this.baseUrl}/tutorials`);
}
getTutorial(id: string | null | undefined): Observable<Tutorial> {
return this.http.get<Tutorial>(`${this.baseUrl}/tutorial-get/${id}`);
getTutorial(fileName: string | null | undefined): Observable<Tutorial> {
return this.http.get<Tutorial>(`${this.baseUrl}/tutorial-get/${fileName}`);
}
create(data: any): Observable<any> {
return this.http.post(`${this.baseUrl}/tutorial-add`, data);
}
update(id: any,data: any): Observable<any> {
return this.http.put(`${this.baseUrl}/tutorial-update/${id}`, data);
update(fileName: any,data: any): Observable<any> {
return this.http.put(`${this.baseUrl}/tutorial-update/${fileName}`, data);
}
deleteTutorials(tutorials: Tutorial[]): Observable<Tutorial[]> {
return forkJoin(
tutorials.map((tutorial) =>
this.http.delete<Tutorial>(`${this.baseUrl}/tutorials/${tutorial.id}`)
this.http.delete<Tutorial>(`${this.baseUrl}/tutorials/${tutorial.fileName}`)
)
);
}
deleteTutorial(id: number): Observable<Tutorial> {
return this.http.delete<Tutorial>(`${this.baseUrl}/tutorials/${id}`);
deleteTutorial(fileName: number): Observable<Tutorial> {
return this.http.delete<Tutorial>(`${this.baseUrl}/tutorials/${fileName}`);
}
}
export interface Base64ImagePayload {
mime: string; // e.g., "image/png"
data: string; // base64 string (no "data:" prefix)
}
export interface UploadImageResponse {
message: string;
fileName: string;
url: string; // URL to access the stored image
mime: string;
size: number; // bytes
}
export interface Base64ImageResponse {
message: string;
mime: string;
data: string; // Base64 without "data:" prefix
}