changed to a non nx monorepo

This commit is contained in:
2025-02-14 18:35:39 -06:00
parent a4f77ac63a
commit 2f16c30dad
57 changed files with 20069 additions and 272 deletions

View File

@@ -0,0 +1,546 @@
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);
}
}