547 lines
18 KiB
TypeScript
547 lines
18 KiB
TypeScript
import { CommonModule } from '@angular/common';
|
||
import { HttpClient } from '@angular/common/http';
|
||
import { ChangeDetectorRef, Component, ElementRef, OnInit, ViewChild } from '@angular/core';
|
||
import { FormsModule } from '@angular/forms';
|
||
import { firstValueFrom } from 'rxjs';
|
||
import { CreateDeckModalComponent } from './create-deck-modal/create-deck-modal.component';
|
||
import { EditImageModalComponent } from './edit-image-modal/edit-image-modal.component';
|
||
import { MoveImageModalComponent } from './move-image-modal/move-image-modal.component';
|
||
import { Box, Deck, DeckImage, DeckService, OcrResult } from './services/deck.service';
|
||
import { PopoverService } from './services/popover.service';
|
||
import { TrainingComponent } from './training/training.component';
|
||
|
||
@Component({
|
||
selector: 'app-deck-list',
|
||
templateUrl: './deck-list.component.html',
|
||
standalone: true,
|
||
styles: `
|
||
.popover {
|
||
padding: 1rem;
|
||
border: 1px solid #ccc;
|
||
border-radius: 0.5rem;
|
||
background-color: white;
|
||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||
max-width: 300px;
|
||
}
|
||
`,
|
||
imports: [
|
||
CommonModule,
|
||
CreateDeckModalComponent,
|
||
TrainingComponent,
|
||
EditImageModalComponent,
|
||
MoveImageModalComponent, // Adding the new component
|
||
FormsModule,
|
||
],
|
||
})
|
||
export class DeckListComponent implements OnInit {
|
||
decks: Deck[] = [];
|
||
trainingsDeck: Deck | null = null;
|
||
activeDeck: Deck | null = null;
|
||
loading: boolean = false;
|
||
|
||
@ViewChild(CreateDeckModalComponent)
|
||
createDeckModal!: CreateDeckModalComponent;
|
||
@ViewChild(EditImageModalComponent) editModal!: EditImageModalComponent;
|
||
@ViewChild('imageFile') imageFileElement!: ElementRef;
|
||
|
||
imageData: {
|
||
imageSrc: string | ArrayBuffer | null;
|
||
deckImage: DeckImage;
|
||
} | null = null;
|
||
|
||
// Set to track expanded decks
|
||
expandedDecks: Set<string> = new Set<string>();
|
||
|
||
// State for moving images
|
||
imageToMove: { image: DeckImage; sourceDeck: Deck } | null = null;
|
||
|
||
constructor(private deckService: DeckService, private cdr: ChangeDetectorRef, private http: HttpClient, private popoverService: PopoverService) {}
|
||
|
||
ngOnInit(): void {
|
||
this.loadExpandedDecks();
|
||
this.loadDecks();
|
||
}
|
||
|
||
loadDecks(): void {
|
||
this.deckService.getDecks().subscribe({
|
||
next: data => {
|
||
this.decks = data;
|
||
// Versuche, das zuvor gespeicherte aktive Deck zu laden
|
||
const storedActiveDeckName = localStorage.getItem('activeDeckName');
|
||
if (storedActiveDeckName) {
|
||
const foundDeck = this.decks.find(deck => deck.name === storedActiveDeckName);
|
||
if (foundDeck) {
|
||
this.activeDeck = foundDeck;
|
||
} else if (this.decks.length > 0) {
|
||
this.activeDeck = this.decks[0];
|
||
localStorage.setItem('activeDeckName', this.activeDeck.name);
|
||
}
|
||
} else if (this.decks.length > 0) {
|
||
this.activeDeck = this.decks[0];
|
||
localStorage.setItem('activeDeckName', this.activeDeck.name);
|
||
} else {
|
||
this.activeDeck = null;
|
||
}
|
||
},
|
||
error: err => console.error('Error loading decks', err),
|
||
});
|
||
}
|
||
|
||
// Updated toggle method
|
||
toggleDeckExpansion(deck: Deck): void {
|
||
this.activeDeck = deck;
|
||
localStorage.setItem('activeDeckName', deck.name);
|
||
}
|
||
|
||
// Method to open the delete confirmation popover
|
||
openDeletePopover(deckName: string): void {
|
||
this.popoverService.show({
|
||
title: 'Delete Deck',
|
||
message: 'Are you sure you want to delete this deck?',
|
||
confirmText: 'Delete',
|
||
onConfirm: () => this.confirmDelete(deckName),
|
||
});
|
||
}
|
||
|
||
// Method to check if a deck is active
|
||
isDeckActive(deck: Deck): boolean {
|
||
return this.activeDeck?.name === deck.name;
|
||
}
|
||
|
||
// Method to confirm the deletion of a deck
|
||
confirmDelete(deckName: string): void {
|
||
this.deckService.deleteDeck(deckName).subscribe({
|
||
next: () => {
|
||
this.loadDecks();
|
||
this.activeDeck = this.decks.length > 0 ? this.decks[0] : null;
|
||
},
|
||
error: err => console.error('Error deleting deck', err),
|
||
});
|
||
}
|
||
|
||
// Method to open the rename popover
|
||
openRenamePopover(deck: Deck): void {
|
||
this.popoverService.showWithInput({
|
||
title: 'Rename Deck',
|
||
message: 'Enter the new name for the deck:',
|
||
confirmText: 'Rename',
|
||
inputValue: deck.name,
|
||
onConfirm: (inputValue?: string) => this.confirmRename(deck, inputValue),
|
||
});
|
||
}
|
||
|
||
// Method to confirm the renaming of a deck
|
||
confirmRename(deck: Deck, newName?: string): void {
|
||
if (newName && newName.trim() !== '' && newName !== deck.name) {
|
||
this.deckService.renameDeck(deck.name, newName).subscribe({
|
||
next: () => {
|
||
if (this.activeDeck?.name === deck.name) {
|
||
this.activeDeck.name = newName;
|
||
}
|
||
this.loadDecks();
|
||
},
|
||
error: err => console.error('Error renaming deck', err),
|
||
});
|
||
} else {
|
||
// Optional: Handle ungültigen neuen Namen
|
||
console.warn('Invalid new deck name.');
|
||
}
|
||
}
|
||
|
||
openRenameImagePopover(image: DeckImage): void {
|
||
this.popoverService.showWithInput({
|
||
title: 'Rename Deck',
|
||
message: 'Enter the new name for the deck:',
|
||
confirmText: 'Rename',
|
||
inputValue: image.name,
|
||
onConfirm: (inputValue?: string) => this.confirmRenameImage(image, inputValue),
|
||
});
|
||
}
|
||
// Method to confirm the renaming of a deck
|
||
confirmRenameImage(image: DeckImage, newName?: string): void {
|
||
if (newName && newName.trim() !== '' && newName !== image.name) {
|
||
this.deckService.renameImage(image.bildid, newName).subscribe({
|
||
next: () => {
|
||
this.loadDecks();
|
||
},
|
||
error: err => console.error('Error renaming image', err),
|
||
});
|
||
} else {
|
||
// Optional: Handle ungültigen neuen Namen
|
||
console.warn('Invalid new image name.');
|
||
}
|
||
}
|
||
// Delete-Image Methoden ersetzen
|
||
deleteImage(deck: Deck, image: DeckImage): void {
|
||
this.popoverService.show({
|
||
title: 'Delete Image',
|
||
message: `Are you sure you want to delete the image ${image.name}?`,
|
||
confirmText: 'Delete',
|
||
showCancel: true,
|
||
onConfirm: () => this.confirmImageDelete(deck, image),
|
||
});
|
||
}
|
||
|
||
confirmImageDelete(deck: Deck, image: DeckImage): void {
|
||
const imageId = image.bildid;
|
||
|
||
this.deckService.deleteImage(imageId).subscribe({
|
||
next: () => {
|
||
this.loadDecks();
|
||
if (this.activeDeck) {
|
||
this.activeDeck.images = this.activeDeck.images.filter(img => img.bildid !== imageId);
|
||
this.cdr.detectChanges();
|
||
}
|
||
},
|
||
error: err => console.error('Error deleting image', err),
|
||
});
|
||
}
|
||
|
||
// Method to edit an image in a deck
|
||
editImage(deck: Deck, image: DeckImage): void {
|
||
let imageSrc = null;
|
||
fetch(`/images/${image.bildid}/original.webp`)
|
||
.then(response => {
|
||
if (!response.ok) {
|
||
throw new Error('Network response was not ok');
|
||
}
|
||
return response.blob();
|
||
})
|
||
.then(blob => {
|
||
const reader = new FileReader();
|
||
reader.onloadend = () => {
|
||
imageSrc = reader.result; // Base64 string
|
||
this.imageData = { imageSrc, deckImage: image };
|
||
};
|
||
reader.readAsDataURL(blob);
|
||
})
|
||
.catch(error => {
|
||
console.error('Error loading image:', error);
|
||
});
|
||
}
|
||
|
||
// Method to open the training component
|
||
openTraining(deck: Deck): void {
|
||
this.trainingsDeck = deck;
|
||
}
|
||
|
||
// Method to close the training component
|
||
closeTraining(): void {
|
||
this.trainingsDeck = null;
|
||
this.loadDecks();
|
||
}
|
||
|
||
// Method to open the create deck modal
|
||
openCreateDeckModal(): void {
|
||
this.createDeckModal.open();
|
||
}
|
||
|
||
// Method to check if a deck is expanded
|
||
isDeckExpanded(deckName: string): boolean {
|
||
return this.expandedDecks.has(deckName);
|
||
}
|
||
|
||
// Method to load expanded decks from sessionStorage
|
||
loadExpandedDecks(): void {
|
||
const stored = sessionStorage.getItem('expandedDecks');
|
||
if (stored) {
|
||
try {
|
||
const parsed: string[] = JSON.parse(stored);
|
||
this.expandedDecks = new Set<string>(parsed);
|
||
} catch (e) {
|
||
console.error('Error parsing expanded decks from sessionStorage', e);
|
||
}
|
||
} else {
|
||
// If no data is stored, do not expand any decks by default
|
||
this.expandedDecks = new Set<string>();
|
||
}
|
||
}
|
||
|
||
// Method to save expanded decks to sessionStorage
|
||
saveExpandedDecks(): void {
|
||
const expandedArray = Array.from(this.expandedDecks);
|
||
sessionStorage.setItem('expandedDecks', JSON.stringify(expandedArray));
|
||
}
|
||
|
||
// Handler for the imageUploaded event
|
||
onImageUploaded(imageData: any): void {
|
||
this.imageData = imageData;
|
||
}
|
||
|
||
onClosed() {
|
||
this.imageData = null;
|
||
}
|
||
|
||
async onImageSaved() {
|
||
// Handle saving the image data, e.g., update the list of images
|
||
this.imageData = null;
|
||
|
||
// Lade die Decks neu
|
||
this.decks = await firstValueFrom(this.deckService.getDecks());
|
||
|
||
// Aktualisiere den activeDeck, falls dieser der aktuelle Deck ist
|
||
if (this.activeDeck) {
|
||
const updatedDeck = this.decks.find(deck => deck.name === this.activeDeck?.name);
|
||
if (updatedDeck) {
|
||
this.activeDeck = updatedDeck;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Method to open the MoveImageModal
|
||
openMoveImageModal(deck: Deck, image: DeckImage): void {
|
||
this.imageToMove = { image, sourceDeck: deck };
|
||
}
|
||
|
||
// Handler for the moveCompleted event
|
||
onImageMoved(): void {
|
||
this.imageToMove = null;
|
||
// Speichere den Namen des aktiven Decks
|
||
const activeDeckName = this.activeDeck?.name;
|
||
|
||
this.deckService.getDecks().subscribe({
|
||
next: decks => {
|
||
this.decks = decks;
|
||
// Aktualisiere den activeDeck mit den neuen Daten
|
||
if (activeDeckName) {
|
||
this.activeDeck = this.decks.find(deck => deck.name === activeDeckName) || null;
|
||
}
|
||
// Force change detection
|
||
this.cdr.detectChanges();
|
||
},
|
||
error: err => console.error('Error loading decks', err),
|
||
});
|
||
}
|
||
|
||
onFileChange(event: any): void {
|
||
const file: File = event.target.files[0];
|
||
if (!file) return;
|
||
|
||
// Erlaubte Dateitypen
|
||
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||
|
||
// Prüfe den Dateityp
|
||
if (!allowedTypes.includes(file.type)) {
|
||
this.popoverService.show({
|
||
title: 'Information',
|
||
message: 'Only JPG, PNG, GIF and WebP images are allowed',
|
||
});
|
||
this.resetFileInput();
|
||
return;
|
||
}
|
||
|
||
// Prüfe die Dateigröße (5MB = 5 * 1024 * 1024 Bytes)
|
||
const maxSize = 5 * 1024 * 1024; // 5MB in Bytes
|
||
if (file.size > maxSize) {
|
||
this.popoverService.show({
|
||
title: 'Information',
|
||
message: 'Image file size must not exceed 5MB',
|
||
});
|
||
this.resetFileInput();
|
||
return;
|
||
}
|
||
|
||
const fileNameElement = document.getElementById('fileName');
|
||
if (fileNameElement) {
|
||
fileNameElement.textContent = file.name;
|
||
}
|
||
|
||
this.loading = true;
|
||
|
||
const reader = new FileReader();
|
||
reader.onload = async e => {
|
||
const imageSrc = e.target?.result;
|
||
|
||
// Image as Base64 string without prefix (data:image/...)
|
||
const imageBase64 = (imageSrc as string).split(',')[1];
|
||
|
||
try {
|
||
const response = await this.http.post<any>('/api/ocr', { image: imageBase64 }).toPromise();
|
||
|
||
if (!response || !response.results) {
|
||
this.loading = false;
|
||
return;
|
||
}
|
||
|
||
this.loading = false;
|
||
|
||
// Emit event with image data and OCR results
|
||
const imageName = file?.name ?? '';
|
||
const imageId = response.results.length > 0 ? response.results[0].name : null;
|
||
const boxes: Box[] = [];
|
||
response.results.forEach((result: OcrResult) => {
|
||
const box = result.box;
|
||
|
||
const xs = box.map((point: number[]) => point[0]);
|
||
const ys = box.map((point: number[]) => point[1]);
|
||
const xMin = Math.min(...xs);
|
||
const xMax = Math.max(...xs);
|
||
const yMin = Math.min(...ys);
|
||
const yMax = Math.max(...ys);
|
||
boxes.push({ x1: xMin, x2: xMax, y1: yMin, y2: yMax, inserted: null, updated: null });
|
||
});
|
||
const deckImage: DeckImage = { name: imageName, bildid: imageId, boxes };
|
||
this.imageData = { imageSrc, deckImage };
|
||
|
||
this.resetFileInput();
|
||
} catch (error) {
|
||
console.error('Error with OCR service:', error);
|
||
this.loading = false;
|
||
}
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
|
||
/**
|
||
* Resets the file input field so the same file can be selected again.
|
||
*/
|
||
resetFileInput(): void {
|
||
if (this.imageFileElement && this.imageFileElement.nativeElement) {
|
||
this.imageFileElement.nativeElement.value = '';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Liest das aktuelle Bild aus der Zwischenablage und setzt imageData, sodass die Edit-Image-Komponente
|
||
* mit dem eingefügten Bild startet.
|
||
*/
|
||
pasteImage(): void {
|
||
if (!navigator.clipboard || !navigator.clipboard.read) {
|
||
this.popoverService.show({
|
||
title: 'Fehler',
|
||
message: 'Das Clipboard-API wird in diesem Browser nicht unterstützt.',
|
||
});
|
||
return;
|
||
}
|
||
|
||
navigator.clipboard
|
||
.read()
|
||
.then(items => {
|
||
// Suche im Clipboard nach einem Element, das ein Bild enthält
|
||
for (const item of items) {
|
||
for (const type of item.types) {
|
||
if (type.startsWith('image/')) {
|
||
// Hole den Blob des Bildes
|
||
item.getType(type).then(blob => {
|
||
const reader = new FileReader();
|
||
this.loading = true;
|
||
reader.onload = async e => {
|
||
const imageSrc = e.target?.result;
|
||
// Extrahiere den Base64-String (ähnlich wie in onFileChange)
|
||
const imageBase64 = (imageSrc as string).split(',')[1];
|
||
|
||
try {
|
||
// Optional: OCR-Request wie im File-Upload
|
||
const response = await this.http.post<any>('/api/ocr', { image: imageBase64 }).toPromise();
|
||
let deckImage: DeckImage;
|
||
if (response && response.results) {
|
||
const boxes: Box[] = [];
|
||
response.results.forEach((result: OcrResult) => {
|
||
const box = result.box;
|
||
const xs = box.map((point: number[]) => point[0]);
|
||
const ys = box.map((point: number[]) => point[1]);
|
||
const xMin = Math.min(...xs);
|
||
const xMax = Math.max(...xs);
|
||
const yMin = Math.min(...ys);
|
||
const yMax = Math.max(...ys);
|
||
boxes.push({ x1: xMin, x2: xMax, y1: yMin, y2: yMax, inserted: null, updated: null });
|
||
});
|
||
deckImage = {
|
||
name: 'Pasted Image',
|
||
bildid: response.results.length > 0 ? response.results[0].name : null,
|
||
boxes,
|
||
};
|
||
} else {
|
||
// Falls kein OCR-Ergebnis vorliegt, lege leere Boxen an
|
||
deckImage = {
|
||
name: 'Pasted Image',
|
||
bildid: null,
|
||
boxes: [],
|
||
};
|
||
}
|
||
// Setze imageData – dadurch wird in der Template der <app-edit-image-modal> eingeblendet
|
||
this.imageData = { imageSrc, deckImage };
|
||
} catch (error) {
|
||
console.error('Error with OCR service:', error);
|
||
this.popoverService.show({
|
||
title: 'Error',
|
||
message: 'Error with OCR service.',
|
||
});
|
||
} finally {
|
||
this.loading = false;
|
||
}
|
||
};
|
||
reader.readAsDataURL(blob);
|
||
});
|
||
return; // Beende die Schleife, sobald ein Bild gefunden wurde.
|
||
}
|
||
}
|
||
}
|
||
// Falls kein Bild gefunden wurde:
|
||
this.popoverService.show({
|
||
title: 'Information',
|
||
message: 'Keine Bilddaten im Clipboard gefunden.',
|
||
});
|
||
})
|
||
.catch(err => {
|
||
console.error('Fehler beim Zugriff auf das Clipboard:', err);
|
||
this.popoverService.show({
|
||
title: 'Fehler',
|
||
message: 'Fehler beim Zugriff auf das Clipboard.',
|
||
});
|
||
});
|
||
}
|
||
|
||
// Methode zur Berechnung des nächsten Trainingsdatums
|
||
getNextTrainingDate(deck: Deck): number {
|
||
const today = this.getTodayInDays();
|
||
const dueDates = deck.images.flatMap(image => image.boxes.map(box => (box.due ? box.due : null)));
|
||
if (dueDates.includes(null)) {
|
||
return today;
|
||
}
|
||
//const futureDueDates = dueDates.filter(date => date && date >= now);
|
||
if (dueDates.length > 0) {
|
||
const nextDate = dueDates.reduce((a, b) => (a < b ? a : b));
|
||
return nextDate;
|
||
}
|
||
return today;
|
||
}
|
||
getNextTrainingString(deck: Deck): string {
|
||
return this.daysSinceEpochToLocalDateString(this.getNextTrainingDate(deck));
|
||
}
|
||
// In deiner Component TypeScript Datei
|
||
isToday(epochDays: number): boolean {
|
||
return this.getTodayInDays() - epochDays === 0;
|
||
}
|
||
isBeforeToday(epochDays: number): boolean {
|
||
return this.getTodayInDays() - epochDays > 0;
|
||
}
|
||
// Methode zur Berechnung der Anzahl der zu bearbeitenden Wörter
|
||
getWordsToReview(deck: Deck): number {
|
||
// const nextTraining = this.getNextTrainingDate(deck);
|
||
// return deck.images.flatMap(image => image.boxes.filter(box => (box.due && new Date(box.due * 86400000) <= new Date(nextTraining)) || !box.due)).length;
|
||
const today = this.getTodayInDays();
|
||
return deck.images.flatMap(image => image.boxes.filter(box => (box.due && box.due <= today) || !box.due)).length;
|
||
// this.currentImageData.boxes.filter(box => box.due === undefined || box.due <= today);
|
||
}
|
||
getTodayInDays(): number {
|
||
const epoch = new Date(1970, 0, 1); // Anki uses UNIX epoch
|
||
const today = new Date();
|
||
return Math.floor((today.getTime() - epoch.getTime()) / (1000 * 60 * 60 * 24));
|
||
}
|
||
daysSinceEpochToLocalDateString(days: number): string {
|
||
const msPerDay = 24 * 60 * 60 * 1000;
|
||
// Erstelle ein Datum, das den exakten UTC-Zeitpunkt (Mitternacht UTC) repräsentiert:
|
||
const utcDate = new Date(days * msPerDay);
|
||
|
||
// Formatiere das Datum: Mit timeZone: 'UTC' wird der UTC-Wert genutzt,
|
||
// aber das Ausgabeformat (z. B. "4.2.2025" oder "2/4/2025") richtet sich nach der Locale.
|
||
return new Intl.DateTimeFormat(undefined, {
|
||
timeZone: 'UTC',
|
||
day: 'numeric',
|
||
month: 'numeric',
|
||
year: 'numeric',
|
||
}).format(utcDate);
|
||
}
|
||
}
|