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 = new Set(); // 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(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(); } } // 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('/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('/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 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); } }