Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<div mat-dialog-title fxLayout="row" fxLayoutAlign="start center">
<div fxFlex="100" class="title">Dodajanje s kamero</div>
</div>

<mat-dialog-content>
<form
class="form"
[formGroup]="form"
(submit)="submit()"
fxLayout="column"
fxLayoutGap="16px"
>
<div fxLayout="row" fxLayoutAlign="space-between center" fxLayoutGap="8px">
<div>
<button
type="button"
mat-flat-button
color="primary"
(click)="fileInput.click()"
>
Izberi datoteko
</button>
</div>
<div class="filename">{{ fileToUpload?.name }}</div>
<input
id="file"
type="file"
#fileInput
hidden
accept="image/*"
capture="environment"
(change)="onFileSelected($event)"
/>
</div>

<mat-form-field appearance="outline">
<mat-label>Dodatna navodila</mat-label>
<textarea
matInput
formControlName="prompt"
placeholder="Vnesite dodatna navodila za AI (npr. 'Opazuj predvsem desno stran fotografije...')"
rows="4"
></textarea>
<mat-hint
>Navodila bodo poslana AI za dodatno pomoč pri prepoznavanju
smeri</mat-hint
>
<button
*ngIf="form.get('prompt').value"
matSuffix
mat-icon-button
type="button"
aria-label="Clear"
(click)="clearSavedPrompt()">
<mat-icon>close</mat-icon>
</button>
</mat-form-field>
</form>
</mat-dialog-content>

<mat-dialog-actions align="end">
<button *ngIf="!loading" type="button" mat-button [mat-dialog-close]="null">
Prekliči
</button>
<button
*ngIf="!loading"
type="submit"
mat-button
color="primary"
cdkFocusInitial
[disabled]="!fileToUpload"
(click)="submit()"
>
Potrdi
</button>
<div
*ngIf="loading"
class="w-100"
fxLayout="row"
fxLayoutAlign="center center"
>
<mat-spinner diameter="30"></mat-spinner>
</div>
</mat-dialog-actions>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
.filename {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}

.w-100 {
width: 100%;
}

.title {
font-size: 20px;
font-weight: 500;
}
242 changes: 242 additions & 0 deletions src/app/management/forms/guidebook-photo/guidebook-photo.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import { Component, Inject, OnInit } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { HttpClient } from '@angular/common/http';
import { environment } from 'src/environments/environment';
import { ManagementCreateRouteGQL } from 'src/generated/graphql';

interface RouteData {
name: string;
difficulty: number;
isProject?: boolean;
}

export interface GuidebookPhotoData {
sectorId: string;
maxRoutePosition: number;
}

@Component({
selector: 'app-guidebook-photo',
templateUrl: './guidebook-photo.component.html',
styleUrls: ['./guidebook-photo.component.scss'],
})
/**
* Component for handling guidebook photo uploads and AI analysis
* Features:
* - Allows uploading photos of climbing routes for AI analysis
* - Resizes images before upload to optimize size
* - Supports adding custom prompt instructions for the AI
* - Automatically saves and retrieves the last used prompt from localStorage
*/
export class GuidebookPhotoComponent implements OnInit {
fileToUpload: File;
loading = false;
form = new FormGroup({
image: new FormControl(),
prompt: new FormControl(''),
});

// Configuration for image resizing
maxWidth = 1200;
maxHeight = 1200;
imageQuality = 0.8;

// Key for storing the prompt in localStorage
private readonly STORAGE_KEY = 'guidebookPhotoPrompt';

constructor(
@Inject(MAT_DIALOG_DATA) public data: GuidebookPhotoData,
public dialogRef: MatDialogRef<GuidebookPhotoComponent>,
private snackBar: MatSnackBar,
private http: HttpClient,
private createGQL: ManagementCreateRouteGQL
) {}

ngOnInit(): void {
// Load the last used prompt from localStorage
const savedPrompt = localStorage.getItem(this.STORAGE_KEY);
if (savedPrompt) {
this.form.get('prompt').setValue(savedPrompt);
}
}

onFileSelected(event: Event) {
this.fileToUpload = (<HTMLInputElement>event.target).files.item(0);
}

/**
* Clears the saved prompt from localStorage and resets the form field
*/
clearSavedPrompt() {
localStorage.removeItem(this.STORAGE_KEY);
this.form.get('prompt').setValue('');
}

/**
* Resizes an image file and returns a promise that resolves with the resized image as a Blob
* @param file The image file to resize
* @returns Promise<Blob> A promise that resolves with the resized image as a Blob
*/
private resizeImage(file: File): Promise<Blob> {
return new Promise((resolve, reject) => {
// Create an image element to load the file
const img = new Image();
img.src = URL.createObjectURL(file);

img.onload = () => {
// Calculate new dimensions maintaining aspect ratio
let width = img.width;
let height = img.height;

if (width > this.maxWidth) {
height = Math.round(height * (this.maxWidth / width));
width = this.maxWidth;
}

if (height > this.maxHeight) {
width = Math.round(width * (this.maxHeight / height));
height = this.maxHeight;
}

// Create a canvas and draw the resized image
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;

const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);

// Convert canvas to blob
canvas.toBlob(
(blob) => {
// Revoke the object URL to free memory
URL.revokeObjectURL(img.src);
resolve(blob);
},
file.type,
this.imageQuality
);
};

img.onerror = (error) => {
URL.revokeObjectURL(img.src);
reject(error);
};
});
}

submit() {
if (!this.fileToUpload) {
this.snackBar.open('Prosim izberite fotografijo.', null, {
duration: 3000,
panelClass: 'error',
});
return;
}

this.loading = true;

// Resize the image before uploading
this.resizeImage(this.fileToUpload)
.then((resizedImageBlob) => {
// Create a new File object from the Blob
const resizedImageFile = new File(
[resizedImageBlob],
this.fileToUpload.name,
{
type: this.fileToUpload.type,
lastModified: this.fileToUpload.lastModified,
}
);

// Create a FormData object to send the file
const formData = new FormData();
formData.append('image', resizedImageFile);

// Get the prompt from the form and add it to the request if it's not empty
const prompt = this.form.get('prompt').value;
if (prompt) {
formData.append('prompt', prompt);
// Save the prompt to localStorage for future use
localStorage.setItem(this.STORAGE_KEY, prompt);
}

// Include the sector ID in the request if available
if (this.data && this.data.sectorId) {
formData.append('sectorId', this.data.sectorId);
}

// Make request to GPT-4 mini API
this.http
.post<{ output: RouteData[] }>(environment.guidebookScanURL, formData)
.subscribe({
next: async (response) => {
let position = this.data?.maxRoutePosition || 0;
for (const route of response.output) {
position++;
await this.createGQL
.mutate({
input: {
name: route.name,
baseDifficulty: route.difficulty,
sectorId: this.data.sectorId,
defaultGradingSystemId: 'font',
routeTypeId: 'boulder',
isProject: route.isProject,
position,
publishStatus: 'draft',
},
})
.toPromise();
}
this.loading = false;
// Return both the file and the extracted routes data
this.dialogRef.close({
file: resizedImageFile,
routesData: response.output,
sectorId: this.data?.sectorId,
maxRoutePosition: this.data?.maxRoutePosition,
});

this.snackBar.open('Smeri so bile dodane.', null, {
duration: 3000,
});
},
error: (error) => {
this.loading = false;

console.error('Error analyzing photo:', error);
this.snackBar.open(
'Napaka pri analizi fotografije. Poskusite znova.',
null,
{
duration: 3000,
panelClass: 'error',
}
);

// Just return the file without route data in case of error
this.dialogRef.close({
file: resizedImageFile,
sectorId: this.data?.sectorId,
maxRoutePosition: this.data?.maxRoutePosition,
});
},
});
})
.catch((error) => {
this.loading = false;
console.error('Error resizing image:', error);
this.snackBar.open(
'Napaka pri obdelavi fotografije. Poskusite znova.',
null,
{
duration: 3000,
panelClass: 'error',
}
);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface RouteFormComponentData {
}

export interface RouteFormValues {
name?: string;
position?: number;
sectorId?: string;
defaultGradingSystemId?: string;
Expand Down
2 changes: 2 additions & 0 deletions src/app/management/management.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { MoveSectorFormComponent } from './forms/move-sector-form/move-sector-fo
import { MoveRouteFormComponent } from './forms/move-route-form/move-route-form.component';
import { MatRadioModule } from '@angular/material/radio';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { GuidebookPhotoComponent } from './forms/guidebook-photo/guidebook-photo.component';

@NgModule({
declarations: [
Expand All @@ -53,6 +54,7 @@ import { MatAutocompleteModule } from '@angular/material/autocomplete';
AreaFormComponent,
MoveSectorFormComponent,
MoveRouteFormComponent,
GuidebookPhotoComponent,
],
imports: [
CommonModule,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ <h1>{{ heading }}</h1>
class="route ghost mt-8"
>
<div fxFlex class="tools" fxLayout="row" fxLayoutAlign="end center">
<div fxFlex></div>
<i>Dodaj smer na začetek</i>
<button
mat-icon-button
Expand Down Expand Up @@ -164,5 +165,15 @@ <h1>{{ heading }}</h1>
</button>
</ng-template>
</mat-menu>

<button
*ngIf="developerMode"
mat-icon-button
class="beta-feature"
(click)="openGuidebookPhotoDialog()"
matTooltip="Slikaj vodniček (beta funkcija)"
>
<mat-icon class="small-icon">photo_camera</mat-icon>
</button>
</div>
</div>
Loading