vokabeltraining/src/app/deck-list.component.ts

547 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
}