Compare commits

...

No commits in common. "srs" and "master" have entirely different histories.
srs ... master

62 changed files with 23385 additions and 4022 deletions

17
.editorconfig Normal file
View File

@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

5
.postcssrc.json Normal file
View File

@ -0,0 +1,5 @@
{
"plugins": {
"@tailwindcss/postcss": {}
}
}

18
.prettierrc Normal file
View File

@ -0,0 +1,18 @@
{
"arrowParens": "avoid",
"embeddedLanguageFormatting": "auto",
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxBracketSameLine": false,
"jsxSingleQuote": false,
"printWidth": 220,
"proseWrap": "always",
"quoteProps": "as-needed",
"requirePragma": false,
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all",
"useTabs": false,
"vueIndentScriptAndStyle": false
}

27
.vscode/launch.json vendored
View File

@ -3,11 +3,30 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "ng serve", "type": "node",
"type": "chrome",
"request": "launch", "request": "launch",
"preLaunchTask": "npm: start", "name": "Debug Nest Framework",
"url": "http://localhost:4200/" "runtimeExecutable": "npm",
"runtimeArgs": ["run", "start:debug", "--", "--inspect-brk"],
"autoAttachChildProcesses": true,
"restart": true,
"sourceMaps": true,
"stopOnEntry": false,
"console": "integratedTerminal",
"env": {
"HOST_NAME": "localhost"
}
},
{
"name": "Debug NestJS API",
"type": "node",
"request": "launch",
"runtimeExecutable": "/home/aknuth/.nvm/versions/node/v22.14.0/bin/npx",
"runtimeArgs": ["nest", "start", "--debug", "--watch"],
"cwd": "${workspaceFolder}/api",
"envFile": "${workspaceFolder}/api/.env",
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**"]
} }
] ]
} }

29
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,29 @@
{
"editor.suggestSelection": "first",
"vsintellicode.modify.editor.suggestSelection": "automaticallyOverrodeDefaultValue",
"explorer.confirmDelete": false,
"typescript.updateImportsOnFileMove.enabled": "always",
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"[scss]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
},
"prettier.printWidth": 240,
"git.autofetch": false,
"git.autorefresh": true
}

18
.vscode/tasks.json vendored
View File

@ -19,6 +19,24 @@
} }
} }
} }
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
} }
] ]
} }

View File

@ -1,27 +0,0 @@
# Vokabeltraining
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.2.12.
## Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
## Code scaffolding
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
## Build
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
## Running unit tests
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

View File

@ -3,12 +3,10 @@
"version": 1, "version": 1,
"newProjectRoot": "projects", "newProjectRoot": "projects",
"projects": { "projects": {
"vokabeltraining": { "haiky": {
"projectType": "application", "projectType": "application",
"schematics": { "schematics": {
"@schematics/angular:component": { "@schematics/angular:component": {
"inlineTemplate": true,
"inlineStyle": true,
"style": "scss", "style": "scss",
"skipTests": true "skipTests": true
}, },
@ -41,7 +39,7 @@
"build": { "build": {
"builder": "@angular-devkit/build-angular:application", "builder": "@angular-devkit/build-angular:application",
"options": { "options": {
"outputPath": "dist/vokabeltraining", "outputPath": "dist/haiky",
"index": "src/index.html", "index": "src/index.html",
"browser": "src/main.ts", "browser": "src/main.ts",
"polyfills": [ "polyfills": [
@ -53,12 +51,19 @@
{ {
"glob": "**/*", "glob": "**/*",
"input": "public" "input": "public"
} },
"src/assets"
], ],
"styles": [ "styles": [
"src/styles.scss" "src/styles.scss"
], ],
"scripts": [] "scripts": [],
"fileReplacements": [
{
"replace": "src/app/environments/environment.ts",
"with": "src/app/environments/environment.prod.ts"
}
]
}, },
"configurations": { "configurations": {
"production": { "production": {
@ -70,8 +75,8 @@
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
"maximumWarning": "2kB", "maximumWarning": "4kB",
"maximumError": "4kB" "maximumError": "8kB"
} }
], ],
"outputHashing": "all" "outputHashing": "all"
@ -87,20 +92,41 @@
"serve": { "serve": {
"builder": "@angular-devkit/build-angular:dev-server", "builder": "@angular-devkit/build-angular:dev-server",
"options": { "options": {
"proxyConfig": "src/proxy.conf.json" "proxyConfig": "proxy.conf.json"
}, },
"configurations": { "configurations": {
"production": { "production": {
"buildTarget": "vokabeltraining:build:production" "buildTarget": "haiky:build:production"
}, },
"development": { "development": {
"buildTarget": "vokabeltraining:build:development" "buildTarget": "haiky:build:development"
} }
}, },
"defaultConfiguration": "development" "defaultConfiguration": "development"
}, },
"extract-i18n": { "extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n" "builder": "@angular-devkit/build-angular:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
],
"scripts": []
}
} }
} }
} }
@ -108,4 +134,4 @@
"cli": { "cli": {
"analytics": false "analytics": false
} }
} }

56
api/.gitignore vendored Normal file
View File

@ -0,0 +1,56 @@
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

4
api/.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

5
api/drizzle.config.ts Normal file
View File

@ -0,0 +1,5 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql', // 'mysql' | 'sqlite' | 'turso'
schema: './src/db/schema.ts',
});

32
api/eslint.config.mjs Normal file
View File

@ -0,0 +1,32 @@
// @ts-check
// export default tseslint.config(
// {
// ignores: ['eslint.config.mjs'],
// },
// eslint.configs.recommended,
// ...tseslint.configs.recommendedTypeChecked,
// eslintPluginPrettierRecommended,
// {
// languageOptions: {
// globals: {
// ...globals.node,
// ...globals.jest,
// },
// ecmaVersion: 5,
// sourceType: 'module',
// parserOptions: {
// projectService: true,
// tsconfigRootDir: import.meta.dirname,
// },
// },
// },
// {
// rules: {
// '@typescript-eslint/no-explicit-any': 'off',
// 'eslint-disable-next-line prettier/prettier': 'off',
// // '@typescript-eslint/no-floating-promises': 'warn',
// // '@typescript-eslint/no-unsafe-argument': 'warn'
// },
// },
// );

8
api/nest-cli.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

12991
api/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

77
api/package.json Normal file
View File

@ -0,0 +1,77 @@
{
"name": "api",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"chalk": "^5.4.1",
"drizzle-kit": "^0.30.4",
"drizzle-orm": "^0.39.3",
"pg": "^8.13.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.6.0",
"@swc/core": "^1.10.7",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.10.7",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^15.14.0",
"jest": "^29.7.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

13
api/src/app.module.ts Normal file
View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { DecksController } from './decks.controller';
import { DrizzleService } from './drizzle.service';
import { ProxyController } from './proxy.controller';
import { SqlLoggerService } from './sql-logger.service';
import { UserController } from './user.controller';
@Module({
imports: [],
controllers: [DecksController, ProxyController, UserController],
providers: [DrizzleService, SqlLoggerService],
})
export class AppModule {}

129
api/src/db/schema.ts Normal file
View File

@ -0,0 +1,129 @@
import { relations } from 'drizzle-orm';
import * as t from 'drizzle-orm/pg-core';
import { pgEnum, pgTable as table } from 'drizzle-orm/pg-core';
export const rolesEnum = pgEnum('roles', ['admin', 'guest', 'pro']);
export const deck = table(
'deck',
{
id: t.integer('id').primaryKey().generatedAlwaysAsIdentity(),
deckname: t.varchar('deckname').notNull(),
bildname: t.varchar('bildname'),
bildid: t.varchar('bildid'),
x1: t.real('x1'),
x2: t.real('x2'),
y1: t.real('y1'),
y2: t.real('y2'),
due: t.integer('due'),
ivl: t.real('ivl'),
factor: t.real('factor'),
reps: t.integer('reps'),
lapses: t.integer('lapses'),
isGraduated: t.integer('isgraduated'),
user: t.varchar('user').notNull(),
inserted: t.timestamp('inserted', { mode: 'date' }).defaultNow(),
updated: t.timestamp('updated', { mode: 'date' }).defaultNow(),
},
(table) => [t.uniqueIndex('deck_idx').on(table.id)],
);
export type InsertDeck = typeof deck.$inferInsert;
export type SelectDeck = typeof deck.$inferSelect;
export const decks_table = table('decks', {
id: t.integer('id').primaryKey().generatedAlwaysAsIdentity(),
name: t.varchar('name').notNull(),
boxOrder: t.varchar('box_order').notNull().default('shuffle'),
user: t.varchar('user').notNull(),
inserted: t.timestamp('inserted', { mode: 'date' }).defaultNow(),
updated: t.timestamp('updated', { mode: 'date' }).defaultNow(),
});
export type InsertDecks = typeof decks_table.$inferInsert;
export type SelectDecks = typeof decks_table.$inferSelect;
export const images_table = table('images', {
id: t.integer('id').primaryKey().generatedAlwaysAsIdentity(),
deckId: t.integer('deck_id').references(() => decks_table.id),
name: t.varchar('name').notNull(),
bildid: t.varchar('bildid').notNull(),
inserted: t.timestamp('inserted', { mode: 'date' }).defaultNow(),
updated: t.timestamp('updated', { mode: 'date' }).defaultNow(),
});
export type InsertImages = typeof images_table.$inferInsert;
export type SelectImages = typeof images_table.$inferSelect;
export const boxes_table = table('boxes', {
id: t.integer('id').primaryKey().generatedAlwaysAsIdentity(),
imageId: t.integer('image_id').references(() => images_table.id),
x1: t.real('x1').notNull(),
x2: t.real('x2').notNull(),
y1: t.real('y1').notNull(),
y2: t.real('y2').notNull(),
due: t.integer('due'),
ivl: t.real('ivl'),
factor: t.real('factor'),
reps: t.integer('reps'),
lapses: t.integer('lapses'),
isGraduated: t.integer('is_graduated').notNull().default(0),
inserted: t.timestamp('inserted', { mode: 'date' }).defaultNow(),
updated: t.timestamp('updated', { mode: 'date' }).defaultNow(),
});
export type InsertBoxes = typeof boxes_table.$inferInsert;
export type SelectBoxes = typeof boxes_table.$inferSelect;
// Relations (optional, aber hilfreich für Abfragen)
export const decksRelations = relations(decks_table, ({ many }) => ({
images: many(images_table),
}));
export const imagesRelations = relations(images_table, ({ one, many }) => ({
deck: one(decks_table, {
fields: [images_table.deckId],
references: [decks_table.id],
}),
boxes: many(boxes_table),
}));
export const boxesRelations = relations(boxes_table, ({ one }) => ({
image: one(images_table, {
fields: [boxes_table.imageId],
references: [images_table.id],
}),
}));
// -------------------------------------
// USERS
// -------------------------------------
export const users = table(
'users',
{
id: t.integer().primaryKey().generatedAlwaysAsIdentity(),
name: t.varchar('name', { length: 256 }),
email: t.varchar().notNull(),
role: rolesEnum().default('guest'),
sign_in_provider: t.varchar('sign_in_provider', { length: 50 }),
lastLogin: t.timestamp('lastLogin', { mode: 'date' }).defaultNow(),
numberOfLogins: t.integer('numberOfLogins').default(1), // Neue Spalte
},
(table) => [t.uniqueIndex('users_idx').on(table.id)],
);
export type InsertUser = typeof users.$inferInsert;
export type SelectUser = typeof users.$inferSelect;
export interface User {
name: string;
picture: string;
iss: string;
aud: string;
auth_time: number;
user_id: string;
sub: string;
iat: number;
exp: number;
email: string;
email_verified: boolean;
firebase: {
identities: any;
sign_in_provider: string;
};
uid: string;
}

150
api/src/decks.controller.ts Normal file
View File

@ -0,0 +1,150 @@
// decks.controller.ts
import {
Body,
Controller,
Delete,
Get,
HttpException,
HttpStatus,
Param,
Post,
Put,
Request,
UseGuards,
} from '@nestjs/common';
import { User } from './db/schema';
import { DrizzleService } from './drizzle.service';
import { AuthGuard } from './service/auth.guard';
@Controller('decks')
@UseGuards(AuthGuard)
export class DecksController {
constructor(private readonly drizzleService: DrizzleService) {}
@Get()
async getDecks(@Request() req) {
const user: User = req['user'];
const entries = await this.drizzleService.getDecks(user);
return entries;
}
@Post()
async createDeck(@Request() req, @Body() data: { deckname: string }) {
if (!data.deckname) {
throw new HttpException('No deckname provided', HttpStatus.BAD_REQUEST);
}
const user: User = req['user'];
return this.drizzleService.createDeck(data.deckname, user);
}
@Delete(':deckname')
async deleteDeck(@Request() req, @Param('deckname') deckname: string) {
const user: User = req['user'];
return this.drizzleService.deleteDeck(deckname, user);
}
@Put(':oldDeckname/rename')
async renameDeck(
@Request() req,
@Param('oldDeckname') oldDeckname: string,
@Body() data: { newDeckName: string },
) {
if (!data.newDeckName) {
throw new HttpException(
'New deck name is required',
HttpStatus.BAD_REQUEST,
);
}
const user: User = req['user'];
return this.drizzleService.updateDeck(
oldDeckname,
{ newName: data.newDeckName },
user,
);
}
@Put(':oldDeckname/update')
async updateDeck(
@Request() req,
@Param('oldDeckname') oldDeckname: string,
@Body() data: { newDeckName: string; boxOrder?: 'shuffle' | 'position' },
) {
const user: User = req['user'];
return this.drizzleService.updateDeck(oldDeckname, data, user);
}
@Post('image')
async updateImage(@Request() req, @Body() data: any) {
if (!data) {
throw new HttpException('No data provided', HttpStatus.BAD_REQUEST);
}
const user: User = req['user'];
const requiredFields = ['deckname', 'bildname', 'bildid', 'boxes'];
if (!requiredFields.every((field) => field in data)) {
throw new HttpException('Missing fields in data', HttpStatus.BAD_REQUEST);
}
if (!Array.isArray(data.boxes) || data.boxes.length === 0) {
throw new HttpException(
"'boxes' must be a non-empty list",
HttpStatus.BAD_REQUEST,
);
}
return this.drizzleService.updateImage(data, user);
}
@Put('image/:bildid/rename')
async renameImage(
@Request() req,
@Param('bildid') bildid: string,
@Body() data: { newImageName: string },
) {
if (!data.newImageName) {
throw new HttpException(
'New image name is required',
HttpStatus.BAD_REQUEST,
);
}
const user: User = req['user'];
return this.drizzleService.renameImage(bildid, data.newImageName, user);
}
@Delete('image/:bildid')
async deleteImagesByBildId(@Request() req, @Param('bildid') bildid: string) {
const user: User = req['user'];
return this.drizzleService.deleteImagesByBildId(bildid, user);
}
@Post('images/:bildid/move')
async moveImage(
@Request() req,
@Param('bildid') bildid: string,
@Body() data: { targetDeckId: string },
) {
if (!data.targetDeckId) {
throw new HttpException(
'No targetDeckId provided',
HttpStatus.BAD_REQUEST,
);
}
const user: User = req['user'];
return this.drizzleService.moveImage(bildid, data.targetDeckId, user);
}
@Put('boxes/:id')
async updateBox(
@Request() req,
@Param('id') id: number,
@Body()
data: {
due?: number;
ivl?: number;
factor?: number;
reps?: number;
lapses?: number;
isGraduated?: boolean;
},
) {
const user: User = req['user'];
return this.drizzleService.updateBox(id, data, user);
}
}

917
api/src/drizzle.service.ts Normal file
View File

@ -0,0 +1,917 @@
// drizzle.service.ts
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/node-postgres';
import {
boxes_table,
decks_table,
images_table,
InsertUser,
User,
users,
} from './db/schema';
import { SqlLoggerService } from './sql-logger.service';
@Injectable()
export class DrizzleService {
// private readonly logger = new Logger(DrizzleService.name);
private db: any;
constructor(private sqlLogger: SqlLoggerService) {
this.db = drizzle(process.env['DATABASE_URL']!, {
logger: {
logQuery: (query: string, params: any[]) => {
this.sqlLogger.logQuery(query, params);
},
},
});
}
/**
* Hilfsmethode: Ermittelt die Rolle eines Users anhand der E-Mail.
*/
private async getUserRole(email: string): Promise<'guest' | 'pro' | 'admin'> {
const result = await this.db
.select({ role: users.role })
.from(users)
.where(eq(users.email, email))
.limit(1);
if (result.length === 0) {
// Falls der User nicht gefunden wird, gehen wir von "guest" aus.
return 'guest';
}
return result[0].role;
}
/**
* Methode zum Abrufen der Decks eines Benutzers.
* Hier wird unterschieden in Deck-Header (deck.bildid IS NULL) und
* zugehörige Bilder (deck.bildid IS NOT NULL). Zudem werden
* - für "guest" maximal 2 Decks und 20 Bilder pro Deck
* - für "pro" maximal 10 Decks und 40 Bilder pro Deck
* zurückgegeben.
*/
// async getDecks(user: User) {
// const role = await this.getUserRole(user.email);
// const maxDecks =
// role === 'guest' ? 2 : role === 'pro' ? 10 : Number.MAX_SAFE_INTEGER;
// const maxImages =
// role === 'guest' ? 20 : role === 'pro' ? 40 : Number.MAX_SAFE_INTEGER;
// // 1. User Decks abrufen
// const userDecks: Array<SelectDecks> = await this.db
// .select()
// .from(decks_table)
// .where(eq(decks_table.user, user.email))
// .limit(maxDecks);
// // 2. Hierarchische Struktur aufbauen
// const result = await Promise.all(
// userDecks.map(async (deck) => {
// // Images für dieses Deck laden
// const deckImages: Array<SelectImages> = await this.db
// .select()
// .from(images_table)
// .where(eq(images_table.deckId, deck.id))
// .limit(maxImages);
// // Boxes für jedes Image laden
// const imagesWithBoxes = await Promise.all(
// deckImages.map(async (image) => {
// const boxes: Array<SelectBoxes> = await this.db
// .select()
// .from(boxes_table)
// .where(eq(boxes_table.imageId, image.id));
// return {
// name: image.name,
// bildid: image.bildid,
// boxes: boxes.map((box) => ({
// id: box.id,
// x1: box.x1,
// x2: box.x2,
// y1: box.y1,
// y2: box.y2,
// due: box.due,
// ivl: box.ivl,
// factor: box.factor,
// reps: box.reps,
// lapses: box.lapses,
// isGraduated: Boolean(box.isGraduated),
// inserted: box.inserted,
// updated: box.updated,
// })),
// };
// }),
// );
// return {
// name: deck.name,
// boxOrder: deck.boxOrder,
// images: imagesWithBoxes,
// };
// }),
// );
// // 3. Nach inserted Datum sortieren (neueste zuerst)
// return result.sort(
// (a, b) =>
// new Date(b['inserted'] || 0).getTime() -
// new Date(a['inserted'] || 0).getTime(),
// );
// }
async getDecks(user: User) {
const role = await this.getUserRole(user.email);
const maxDecks =
role === 'guest' ? 2 : role === 'pro' ? 10 : Number.MAX_SAFE_INTEGER;
const maxImages =
role === 'guest' ? 20 : role === 'pro' ? 40 : Number.MAX_SAFE_INTEGER;
// 1. Decks mit begrenzter Anzahl abrufen
const userDecks = await this.db
.select()
.from(decks_table)
.where(eq(decks_table.user, user.email))
.orderBy(desc(decks_table.inserted))
.limit(maxDecks);
// 2. Alle relevanten Images in einer Abfrage holen
const deckIds = userDecks.map((deck) => deck.id);
const allImages =
deckIds.length > 0
? await this.db
.select()
.from(images_table)
.where(inArray(images_table.deckId, deckIds))
.orderBy(asc(images_table.deckId), desc(images_table.inserted))
: [];
// 3. Images nach Deck gruppieren und limitieren
const imagesByDeck = new Map<string, typeof allImages>();
for (const image of allImages) {
if (!imagesByDeck.has(image.deckId)) {
imagesByDeck.set(image.deckId, []);
}
const deckImages = imagesByDeck.get(image.deckId)!;
if (deckImages.length < maxImages) {
deckImages.push(image);
}
}
// 4. Alle relevanten Boxes in einer Abfrage holen
const imageIds = allImages.map((img) => img.id);
const allBoxes =
imageIds.length > 0
? await this.db
.select()
.from(boxes_table)
.where(inArray(boxes_table.imageId, imageIds))
: [];
// 5. Boxes nach Image gruppieren
const boxesByImage = new Map<string, typeof allBoxes>();
for (const box of allBoxes) {
if (!boxesByImage.has(box.imageId)) {
boxesByImage.set(box.imageId, []);
}
boxesByImage.get(box.imageId)!.push(box);
}
// 6. Hierarchische Struktur aufbauen
return userDecks
.map((deck) => {
const images = (imagesByDeck.get(deck.id) || []).map((image) => ({
name: image.name,
bildid: image.bildid,
boxes: (boxesByImage.get(image.id) || []).map((box) => ({
id: box.id,
x1: box.x1,
x2: box.x2,
y1: box.y1,
y2: box.y2,
due: box.due,
ivl: box.ivl,
factor: box.factor,
reps: box.reps,
lapses: box.lapses,
isGraduated: Boolean(box.isGraduated),
inserted: box.inserted,
updated: box.updated,
})),
}));
return {
name: deck.name,
boxOrder: deck.boxOrder,
images,
inserted: deck.inserted, // Für Sortierung behalten
};
})
.sort((a, b) => a.inserted.getTime() - b.inserted.getTime());
}
/**
* Methode zum Erstellen eines neuen Decks.
* Hier wird geprüft, ob der User (basierend auf seiner Rolle) bereits
* die maximale Anzahl an Decks erstellt hat.
*/
async createDeck(deckname: string, user: User) {
const role = await this.getUserRole(user.email);
const maxDecks =
role === 'guest' ? 2 : role === 'pro' ? 10 : Number.MAX_SAFE_INTEGER;
// Anzahl der vorhandenen Decks prüfen
const deckCountResult = await this.db
.select({ count: sql`count(*) as count` })
.from(decks_table)
.where(eq(decks_table.user, user.email));
const deckCount = Number(deckCountResult[0].count);
if (deckCount >= maxDecks) {
throw new HttpException(
`Maximale Anzahl an Decks (${maxDecks}) erreicht für Rolle "${role}"`,
HttpStatus.FORBIDDEN,
);
}
// Neues Deck erstellen
const result = await this.db
.insert(decks_table)
.values({
name: deckname,
user: user.email,
boxOrder: 'shuffle', // Default-Wert
})
.returning();
return {
status: 'success',
deck: {
name: result[0].name,
boxOrder: result[0].boxOrder,
images: [], // Leere Images-Array für Konsistenz
},
};
}
/**
* Methode zum Abrufen eines Decks nach Name.
* (Hinweis: Diese Methode wird intern z.B. in updateImage verwendet.
* Daher wird hier nicht die Limitierung angewendet.)
*/
async getDeckByName(deckname: string, user: User) {
// 1. Deck abfragen
const deckResult = await this.db
.select()
.from(decks_table)
.where(
and(eq(decks_table.name, deckname), eq(decks_table.user, user.email)),
);
if (deckResult.length === 0) {
return [];
}
const deck = deckResult[0];
// 2. Zugehörige Images abfragen
const images = await this.db
.select()
.from(images_table)
.where(eq(images_table.deckId, deck.id));
// 3. Boxes für jedes Image abfragen
const imagesWithBoxes = await Promise.all(
images.map(async (image) => {
const boxes = await this.db
.select()
.from(boxes_table)
.where(eq(boxes_table.imageId, image.id));
return {
name: image.name,
bildid: image.bildid,
boxes: boxes.map((box) => ({
id: box.id,
x1: box.x1,
x2: box.x2,
y1: box.y1,
y2: box.y2,
due: box.due,
ivl: box.ivl,
factor: box.factor,
reps: box.reps,
lapses: box.lapses,
isGraduated: Boolean(box.isGraduated),
inserted: box.inserted,
updated: box.updated,
})),
};
}),
);
// 4. Hierarchisches Objekt zurückgeben
return [
{
name: deck.name,
boxOrder: deck.boxOrder,
images: imagesWithBoxes,
},
];
}
/**
* Methode zum Löschen eines Decks.
*/
async deleteDeck(deckname: string, user: User) {
// 1. Existenz des Decks prüfen
const existingDeck = await this.db
.select()
.from(decks_table)
.where(
and(eq(decks_table.name, deckname), eq(decks_table.user, user.email)),
);
if (existingDeck.length === 0) {
throw new HttpException('Deck not found', HttpStatus.NOT_FOUND);
}
// 2. Transaktion für kaskadierendes Löschen
await this.db.transaction(async (tx) => {
// Alle Images des Decks finden
const deckImages = await tx
.select({ id: images_table.id })
.from(images_table)
.where(eq(images_table.deckId, existingDeck[0].id));
// Boxes der Images löschen (nur wenn Images existieren)
if (deckImages.length > 0) {
await tx.delete(boxes_table).where(
inArray(
boxes_table.imageId, // imageId statt image_id
deckImages.map((img) => img.id),
),
);
}
// Images löschen
await tx
.delete(images_table)
.where(eq(images_table.deckId, existingDeck[0].id));
// Deck löschen
await tx
.delete(decks_table)
.where(eq(decks_table.id, existingDeck[0].id));
});
return { status: 'success' };
}
/**
* Methode zum Umbenennen eines Decks.
*/
async updateDeck(
deckname: string,
updateData: {
newName?: string;
boxOrder?: 'shuffle' | 'position';
},
user: User,
) {
// 1. Existenz des Decks prüfen
const existingDeck = await this.db
.select()
.from(decks_table)
.where(
and(eq(decks_table.name, deckname), eq(decks_table.user, user.email)),
);
if (existingDeck.length === 0) {
throw new HttpException('Deck not found', HttpStatus.NOT_FOUND);
}
const deckId = existingDeck[0].id;
// 2. Prüfen ob neuer Name bereits existiert (falls umbenannt wird)
if (updateData.newName && updateData.newName !== deckname) {
const nameConflict = await this.db
.select()
.from(decks_table)
.where(
and(
eq(decks_table.name, updateData.newName),
eq(decks_table.user, user.email),
),
);
if (nameConflict.length > 0) {
throw new HttpException(
'Deck with the new name already exists',
HttpStatus.CONFLICT,
);
}
}
// 3. Update-Daten vorbereiten
const updatePayload: Record<string, any> = {
updated: new Date(),
};
if (updateData.newName) updatePayload.name = updateData.newName;
if (updateData.boxOrder) updatePayload.boxOrder = updateData.boxOrder;
// 4. Deck aktualisieren
await this.db
.update(decks_table)
.set(updatePayload)
.where(eq(decks_table.id, deckId));
// 5. Aktualisiertes Deck mit Images abrufen
const updatedDeck = await this.db
.select()
.from(decks_table)
.where(eq(decks_table.id, deckId));
const deckImages = await this.db
.select()
.from(images_table)
.where(eq(images_table.deckId, deckId));
return {
status: 'success',
message: updateData.newName
? `Deck renamed from "${deckname}" to "${updateData.newName}"`
: 'Deck updated successfully',
deck: {
name: updateData.newName || deckname,
boxOrder: updateData.boxOrder || existingDeck[0].boxOrder,
images: deckImages.map((image) => ({
name: image.name,
bildid: image.bildid,
boxes: [], // Leeres Array, kann bei Bedarf gefüllt werden
})),
},
};
}
/**
* Methode zum Aktualisieren eines Bildes innerhalb eines Decks.
* Hier wird vor dem Anlegen eines neuen Bildes geprüft, ob das
* maximale Limit pro Deck (basierend auf der User-Rolle) erreicht wurde.
*/
async updateImage(
data: {
deckname: string;
bildname: string;
bildid: string;
boxes: Array<{
x1: number;
x2: number;
y1: number;
y2: number;
id?: number;
}>;
},
user: User,
) {
// 1. Deck existenz prüfen
const deck = await this.db
.select()
.from(decks_table)
.where(
and(
eq(decks_table.name, data.deckname),
eq(decks_table.user, user.email),
),
);
if (deck.length === 0) {
throw new HttpException('Deck not found', HttpStatus.NOT_FOUND);
}
const deckId = deck[0].id;
// 2. Rollenbasierte Limitierung
const role = await this.getUserRole(user.email);
const maxImages =
role === 'guest' ? 20 : role === 'pro' ? 40 : Number.MAX_SAFE_INTEGER;
// 3. Prüfen ob Image existiert
const existingImage = await this.db
.select()
.from(images_table)
.where(
and(
eq(images_table.bildid, data.bildid),
eq(images_table.deckId, deckId),
),
);
// 4. Image Limit prüfen
if (existingImage.length === 0) {
const imageCount = await this.db
.select({ count: sql`count(*)` })
.from(images_table)
.where(eq(images_table.deckId, deckId));
if (imageCount[0].count >= maxImages) {
throw new HttpException(
`Maximale Anzahl an Bildern (${maxImages}) für Deck "${data.deckname}" erreicht`,
HttpStatus.FORBIDDEN,
);
}
}
// 5. Transaktion für alle Änderungen
return await this.db.transaction(async (tx) => {
let imageId: number;
// Image erstellen falls nicht existiert
if (existingImage.length === 0) {
const [newImage] = await tx
.insert(images_table)
.values({
deckId: deckId,
name: data.bildname,
bildid: data.bildid,
})
.returning();
imageId = newImage.id;
} else {
imageId = existingImage[0].id;
// Image Namen aktualisieren falls geändert
if (existingImage[0].name !== data.bildname) {
await tx
.update(images_table)
.set({ name: data.bildname })
.where(eq(images_table.id, imageId));
}
}
// 6. Bestehende Boxes abrufen
const existingBoxes = await tx
.select()
.from(boxes_table)
.where(eq(boxes_table.imageId, imageId));
// 7. Boxes aufteilen
const newBoxes = data.boxes.filter((b) => !b.id);
const updatedBoxes = data.boxes.filter((b) => b.id);
// 8. Neue Boxes einfügen
const insertedBoxes = await Promise.all(
newBoxes.map((box) =>
tx
.insert(boxes_table)
.values({
imageId: imageId,
x1: box.x1,
x2: box.x2,
y1: box.y1,
y2: box.y2,
})
.returning(),
),
);
// 9. Bestehende Boxes aktualisieren
const updatedBoxResults = await Promise.all(
updatedBoxes.map((box) => {
const existing = existingBoxes.find((b) => b.id === box.id);
if (!existing) {
throw new HttpException(
`Box with id ${box.id} not found`,
HttpStatus.NOT_FOUND,
);
}
return tx
.update(boxes_table)
.set({
x1: box.x1,
x2: box.x2,
y1: box.y1,
y2: box.y2,
updated: new Date(),
})
.where(eq(boxes_table.id, box.id!));
}),
);
// 10. Nicht mehr vorhandene Boxes löschen
const existingBoxIds = existingBoxes.map((b) => b.id);
const incomingBoxIds = updatedBoxes.map((b) => b.id!);
const boxesToDelete = existingBoxIds.filter(
(id) => !incomingBoxIds.includes(id),
);
if (boxesToDelete.length > 0) {
await tx
.delete(boxes_table)
.where(inArray(boxes_table.id, boxesToDelete));
}
return {
status: 'success',
inserted_boxes: insertedBoxes,
updated_boxes: updatedBoxResults.length,
};
});
}
/**
* Methode zum Löschen von Bildern anhand der bildid.
*/
async deleteImagesByBildId(bildid: string, user: User) {
// 1. Prüfen ob Image existiert und zum User gehört
const imageToDelete = await this.db
.select({
id: images_table.id,
deck_id: images_table.deckId,
deck_name: decks_table.name,
})
.from(images_table)
.innerJoin(decks_table, eq(images_table.deckId, decks_table.id))
.where(
and(eq(images_table.bildid, bildid), eq(decks_table.user, user.email)),
);
if (imageToDelete.length === 0) {
throw new HttpException(
'No entries found for the given image ID',
HttpStatus.NOT_FOUND,
);
}
// 2. Transaktion für sicheres Löschen
await this.db.transaction(async (tx) => {
// Zuerst alle Boxes des Images löschen
await tx
.delete(boxes_table)
.where(eq(boxes_table.imageId, imageToDelete[0].id));
// Dann das Image selbst löschen
await tx
.delete(images_table)
.where(eq(images_table.id, imageToDelete[0].id));
});
return {
status: 'success',
message: `Image with ID "${bildid}" and all associated boxes have been deleted from deck "${imageToDelete[0].deck_name}".`,
deleted_image: {
id: imageToDelete[0].id,
bildid,
deck_id: imageToDelete[0].deck_id,
},
};
}
/**
* Methode zum Verschieben eines Bildes in ein anderes Deck.
*/
async moveImage(bildid: string, targetDeckName: string, user: User) {
// 1. Prüfen ob das Image existiert und zum User gehört
const sourceImage = await this.db
.select({
imageId: images_table.id,
currentDeckId: images_table.deckId,
currentDeckName: decks_table.name,
})
.from(images_table)
.innerJoin(decks_table, eq(images_table.deckId, decks_table.id))
.where(
and(eq(images_table.bildid, bildid), eq(decks_table.user, user.email)),
)
.limit(1);
if (sourceImage.length === 0) {
throw new HttpException(
'No entries found for the given image ID',
HttpStatus.NOT_FOUND,
);
}
// 2. Ziel-Deck prüfen
const targetDeck = await this.db
.select()
.from(decks_table)
.where(
and(
eq(decks_table.name, targetDeckName),
eq(decks_table.user, user.email),
),
)
.limit(1);
if (targetDeck.length === 0) {
throw new HttpException(
'Target deck not found or not owned by user',
HttpStatus.NOT_FOUND,
);
}
// 3. Rollenbasierte Limitierung prüfen
const role = await this.getUserRole(user.email);
const maxImages =
role === 'guest' ? 20 : role === 'pro' ? 40 : Number.MAX_SAFE_INTEGER;
const imageCountInTarget = await this.db
.select({ count: sql`count(*)` })
.from(images_table)
.where(eq(images_table.deckId, targetDeck[0].id));
if (imageCountInTarget[0].count >= maxImages) {
throw new HttpException(
`Maximum number of images (${maxImages}) reached in target deck`,
HttpStatus.FORBIDDEN,
);
}
// 4. Image verschieben
await this.db
.update(images_table)
.set({
deckId: targetDeck[0].id,
updated: new Date(),
})
.where(eq(images_table.id, sourceImage[0].imageId));
return {
status: 'success',
message: `Image moved from "${sourceImage[0].currentDeckName}" to "${targetDeckName}"`,
moved_image: {
id: sourceImage[0].imageId,
bildid,
from_deck: sourceImage[0].currentDeckId,
to_deck: targetDeck[0].id,
},
};
}
/**
* Methode zum Umbenennen eines Bildes.
*/
async renameImage(bildId: string, newImagename: string, user: User) {
// 1. Image mit zugehörigem Deck finden (User-Zugehörigkeit prüfen)
const imageToRename = await this.db
.select({
imageId: images_table.id,
currentName: images_table.name,
deckName: decks_table.name,
})
.from(images_table)
.innerJoin(decks_table, eq(images_table.deckId, decks_table.id))
.where(
and(eq(images_table.bildid, bildId), eq(decks_table.user, user.email)),
);
if (imageToRename.length === 0) {
throw new HttpException(
'Image not found or not owned by user',
HttpStatus.NOT_FOUND,
);
}
// 2. Image umbenennen
const result = await this.db
.update(images_table)
.set({
name: newImagename,
updated: new Date(),
})
.where(eq(images_table.bildid, bildId))
.returning();
return {
status: 'success',
message: `Image renamed from "${imageToRename[0].currentName}" to "${newImagename}" in deck "${imageToRename[0].deckName}"`,
renamed_image: {
id: imageToRename[0].imageId,
bildid: bildId,
old_name: imageToRename[0].currentName,
new_name: newImagename,
},
};
}
/**
* Methode zum Aktualisieren einer Box.
*/
async updateBox(
id: number,
data: {
due?: number;
ivl?: number;
factor?: number;
reps?: number;
lapses?: number;
isGraduated?: boolean;
inserted?: string;
},
user: User,
) {
// 1. Box mit zugehörigem Deck finden (User-Zugehörigkeit prüfen)
const boxToUpdate = await this.db
.select({
boxId: boxes_table.id,
})
.from(boxes_table)
.innerJoin(images_table, eq(boxes_table.imageId, images_table.id))
.innerJoin(decks_table, eq(images_table.deckId, decks_table.id))
.where(and(eq(boxes_table.id, id), eq(decks_table.user, user.email)));
if (boxToUpdate.length === 0) {
throw new HttpException(
'Box not found or not owned by user',
HttpStatus.NOT_FOUND,
);
}
// 2. Update-Daten vorbereiten
const updateData: Record<string, any> = {
updated: new Date(),
};
if (data.due !== undefined) updateData.due = data.due;
if (data.ivl !== undefined) updateData.ivl = data.ivl;
if (data.factor !== undefined) updateData.factor = data.factor;
if (data.reps !== undefined) updateData.reps = data.reps;
if (data.lapses !== undefined) updateData.lapses = data.lapses;
if (typeof data.isGraduated === 'boolean') {
updateData.is_graduated = data.isGraduated ? 1 : 0;
}
if (data.inserted) {
updateData.inserted = new Date(data.inserted);
}
// 3. Box aktualisieren
const result = await this.db
.update(boxes_table)
.set(updateData)
.where(eq(boxes_table.id, id));
return {
status: 'success',
updated_box: {
id,
...updateData,
},
};
}
/**
* Methode zum Abrufen aller eindeutigen Bild-IDs aus der Datenbank.
*/
// async getDistinctBildIds(user: User): Promise<string[]> {
// try {
// const result = await this.db.selectDistinct([deck.bildid]).from(deck);
// const usedIds = result
// .map((row: any) => row['0'])
// .filter((id: string | null) => id !== null) as string[];
// console.log(usedIds);
// return usedIds;
// } catch (error) {
// this.sqlLogger.logQuery('Error fetching distinct bildids', []);
// throw new HttpException(
// `Fehler beim Abrufen der Bild-IDs - ${error}`,
// HttpStatus.INTERNAL_SERVER_ERROR,
// );
// }
// }
/**
* Führt den Login-Vorgang durch:
* - Existiert der Benutzer bereits (überprüft via E-Mail), wird das Feld `lastLogin`
* (und ggf. weitere Felder) aktualisiert.
* - Existiert der Benutzer nicht, wird ein neuer Datensatz angelegt.
*/
async logIn(createUserDto: InsertUser) {
const existingUser = await this.db
.select()
.from(users)
.where(eq(users.email, createUserDto.email))
.limit(1);
if (existingUser.length > 0) {
const updatedUser = await this.db
.update(users)
.set({
lastLogin: new Date(),
name: createUserDto.name,
sign_in_provider: createUserDto.sign_in_provider,
numberOfLogins: sql`${users.numberOfLogins} + 1`,
})
.where(eq(users.email, createUserDto.email))
.returning();
return updatedUser;
} else {
const insertedUser = await this.db
.insert(users)
.values({
name: createUserDto.name,
email: createUserDto.email,
sign_in_provider: createUserDto.sign_in_provider,
lastLogin: new Date(),
role: 'guest',
})
.returning();
return insertedUser;
}
}
}

21
api/src/main.ts Normal file
View File

@ -0,0 +1,21 @@
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { json, urlencoded } from 'express';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
logger: ['log', 'error', 'warn', 'debug', 'verbose'], // Aktiviere alle Log-Level
});
app.use(json({ limit: '50mb' }));
app.use(urlencoded({ limit: '50mb', extended: true }));
const globalPrefix = 'api';
app.setGlobalPrefix(globalPrefix);
const port = process.env['PORT'] || 3000;
await app.listen(port);
Logger.log(
`🚀 Application is running on: http://localhost:${port}/${globalPrefix}`,
);
}
void bootstrap();

133
api/src/proxy.controller.ts Normal file
View File

@ -0,0 +1,133 @@
// decks.controller.ts
import {
Body,
Controller,
HttpException,
HttpStatus,
Post,
Res,
UseGuards,
} from '@nestjs/common';
import express from 'express';
import { DrizzleService } from './drizzle.service';
import { AuthGuard } from './service/auth.guard';
@Controller('')
@UseGuards(AuthGuard)
export class ProxyController {
constructor(private readonly drizzleService: DrizzleService) {}
// --------------------
// Proxy Endpoints
// --------------------
@Post('ocr')
async ocrEndpoint(
@Body() data: { image: string },
@Res() res: express.Response,
) {
try {
if (!data || !data.image) {
throw new HttpException('No image provided', HttpStatus.BAD_REQUEST);
}
const response = await fetch('http://localhost:5000/api/ocr', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ image: data.image }),
});
const result = await response.json();
if (!response.ok) {
if (response.status === 400) {
throw new HttpException(result.error, HttpStatus.BAD_REQUEST);
}
throw new HttpException(
result.error || 'OCR processing failed',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
// Bei erfolgreicher Verarbeitung mit Warnung
if (result.warning) {
return res.status(HttpStatus.OK).json({
warning: result.warning,
debug_dir: result.debug_dir,
});
}
// Bei vollständig erfolgreicher Verarbeitung
return res.status(HttpStatus.OK).json({
status: result.status,
results: result.results,
});
} catch (error) {
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(
'Internal server error',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
// --------------------
// Cleanup Endpoint
// --------------------
// @Post('cleanup')
// async cleanupEndpoint(
// @Body() data: { dryrun?: boolean },
// @Res() res: express.Response,
// ) {
// try {
// const user = res.req['user']; // Benutzerinformationen aus dem Request
// // 2. Nur Benutzer mit der spezifischen E-Mail dürfen fortfahren
// if (user.email !== 'andreas.knuth@gmail.com') {
// throw new HttpException('Zugriff verweigert.', HttpStatus.FORBIDDEN);
// }
// // 1. Abrufen der distinct bildid aus der Datenbank
// const usedIds = await this.drizzleService.getDistinctBildIds(user);
// // 3. Verarbeitung des dryrun Parameters
// const dryrun = data.dryrun !== undefined ? data.dryrun : true;
// if (typeof dryrun !== 'boolean') {
// throw new HttpException(
// "'dryrun' muss ein boolescher Wert sein.",
// HttpStatus.BAD_REQUEST,
// );
// }
// // 4. Aufruf des Flask-Backend-Endpunkts
// const response = await fetch('http://localhost:5000/api/cleanup', {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// },
// body: JSON.stringify({ dryrun, usedIds }),
// });
// const result = await response.json();
// if (!response.ok) {
// throw new HttpException(
// result.error || 'Cleanup failed',
// response.status,
// );
// }
// // 5. Rückgabe der Ergebnisse an den Client
// return res.status(HttpStatus.OK).json(result);
// } catch (error) {
// if (error instanceof HttpException) {
// throw error;
// }
// throw new HttpException(
// 'Interner Serverfehler',
// HttpStatus.INTERNAL_SERVER_ERROR,
// );
// }
// }
}

View File

@ -0,0 +1,27 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import admin from './firebase-admin';
@Injectable()
export class AuthGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('No token provided');
}
try {
const decodedToken = await admin.auth().verifyIdToken(token);
request['user'] = decodedToken; // Fügen Sie die Benutzerdaten dem Request-Objekt hinzu
return true;
} catch (error) {
throw new UnauthorizedException('Invalid token');
}
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers['authorization']?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View File

@ -0,0 +1,16 @@
import * as admin from 'firebase-admin';
import { ServiceAccount } from 'firebase-admin';
const serviceAccount: ServiceAccount = {
projectId: process.env['FIREBASE_PROJECT_ID'],
clientEmail: process.env['FIREBASE_CLIENT_EMAIL'],
privateKey: process.env['FIREBASE_PRIVATE_KEY']?.replace(/\\n/g, '\n'), // Ersetzen Sie escaped newlines
};
if (!admin.apps.length) {
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
});
}
export default admin;

View File

@ -0,0 +1,36 @@
// sql-logger.service.ts
import { Injectable, Logger } from '@nestjs/common';
import type { ChalkInstance } from 'chalk';
import chalk from 'chalk';
@Injectable()
export class SqlLoggerService {
private readonly logger = new Logger(SqlLoggerService.name);
private chalkInstance: ChalkInstance;
constructor() {
this.chalkInstance = chalk;
}
logQuery(query: string, params: any[], sourceIp?: string) {
const timestamp = new Date().toISOString().replace('T', ' ').replace('Z', '');
let logMessage = `[DrizzleORM] Info\t${timestamp}`;
// Optional source IP
if (sourceIp) {
logMessage += ` IP: ${sourceIp}`;
}
// Colored query and params using chalk directly
const coloredQuery = chalk.blueBright(`Query: ${query}`);
const coloredParams = chalk.yellow(`Params: ${JSON.stringify(params)}`);
logMessage += ` - ${coloredQuery} - ${coloredParams}`;
// Add timing indicator
logMessage += chalk.gray(' +2ms');
console.log(logMessage);
}
}

View File

@ -0,0 +1,17 @@
// user.controller.ts
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import type { InsertUser } from './db/schema';
import { DrizzleService } from './drizzle.service';
import { AuthGuard } from './service/auth.guard';
@Controller('users')
@UseGuards(AuthGuard)
export class UserController {
constructor(private readonly drizzleService: DrizzleService) {}
@Post()
async createUser(@Body() createUserDto: InsertUser) {
// Hier kannst du zusätzliche Validierungen oder Logik einbauen.
return await this.drizzleService.logIn(createUserDto);
}
}

4
api/tsconfig.build.json Normal file
View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

21
api/tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}

9979
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,34 +1,49 @@
{ {
"name": "vokabeltraining", "name": "haiky",
"version": "0.0.0", "version": "0.0.0",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve", "start": "ng serve --port=4202",
"start-all": "concurrently 'ng serve --port=4202' 'http-server ../vocab-backend'",
"build": "ng build", "build": "ng build",
"watch": "ng build --watch --configuration development" "watch": "ng build --watch --configuration development",
"test": "ng test"
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "^18.2.0", "@angular/animations": "^19.1.0",
"@angular/common": "^18.2.0", "@angular/common": "^19.1.0",
"@angular/compiler": "^18.2.0", "@angular/compiler": "^19.1.0",
"@angular/core": "^18.2.0", "@angular/core": "^19.1.0",
"@angular/forms": "^18.2.0", "@angular/fire": "^19.0.0",
"@angular/platform-browser": "^18.2.0", "@angular/forms": "^19.1.0",
"@angular/platform-browser-dynamic": "^18.2.0", "@angular/platform-browser": "^19.1.0",
"@angular/router": "^18.2.0", "@angular/platform-browser-dynamic": "^19.1.0",
"@angular/router": "^19.1.0",
"@tailwindcss/postcss": "^4.0.6",
"fabric": "^5.4.1", "fabric": "^5.4.1",
"flowbite": "^2.5.2", "firebase": "^11.3.1",
"firebase-admin": "^13.1.0",
"flowbite": "^3.1.2",
"postcss": "^8.5.2",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tailwindcss": "^4.0.6",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"zone.js": "~0.14.10" "zone.js": "~0.15.0"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "^18.2.12", "@angular-devkit/build-angular": "^19.1.7",
"@angular/cli": "^18.2.12", "@angular/cli": "^19.1.7",
"@angular/compiler-cli": "^18.2.0", "@angular/compiler-cli": "^19.1.0",
"@types/fabric": "^5.3.9", "@types/jasmine": "~5.1.0",
"tailwindcss": "^3.4.15", "concurrently": "^9.1.2",
"typescript": "~5.5.2" "http-server": "^14.1.1",
"jasmine-core": "~5.5.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.7.2"
} }
} }

12
proxy.conf.json Normal file
View File

@ -0,0 +1,12 @@
{
"/api": {
"target": "http://localhost:3002",
"secure": false,
"changeOrigin": true
},
"/images": {
"target": "http://localhost:8080",
"secure": false,
"changeOrigin": true
}
}

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 B

BIN
public/favicon-48x48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 B

336
src/app/app.component.html Normal file
View File

@ -0,0 +1,336 @@
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
<!-- * * * * * * * to get started with your project! * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<style>
:host {
--bright-blue: oklch(51.01% 0.274 263.83);
--electric-violet: oklch(53.18% 0.28 296.97);
--french-violet: oklch(47.66% 0.246 305.88);
--vivid-pink: oklch(69.02% 0.277 332.77);
--hot-red: oklch(61.42% 0.238 15.34);
--orange-red: oklch(63.32% 0.24 31.68);
--gray-900: oklch(19.37% 0.006 300.98);
--gray-700: oklch(36.98% 0.014 302.71);
--gray-400: oklch(70.9% 0.015 304.04);
--red-to-pink-to-purple-vertical-gradient: linear-gradient(
180deg,
var(--orange-red) 0%,
var(--vivid-pink) 50%,
var(--electric-violet) 100%
);
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
90deg,
var(--orange-red) 0%,
var(--vivid-pink) 50%,
var(--electric-violet) 100%
);
--pill-accent: var(--bright-blue);
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol";
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1 {
font-size: 3.125rem;
color: var(--gray-900);
font-weight: 500;
line-height: 100%;
letter-spacing: -0.125rem;
margin: 0;
font-family: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol";
}
p {
margin: 0;
color: var(--gray-700);
}
main {
width: 100%;
min-height: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: 1rem;
box-sizing: inherit;
position: relative;
}
.angular-logo {
max-width: 9.2rem;
}
.content {
display: flex;
justify-content: space-around;
width: 100%;
max-width: 700px;
margin-bottom: 3rem;
}
.content h1 {
margin-top: 1.75rem;
}
.content p {
margin-top: 1.5rem;
}
.divider {
width: 1px;
background: var(--red-to-pink-to-purple-vertical-gradient);
margin-inline: 0.5rem;
}
.pill-group {
display: flex;
flex-direction: column;
align-items: start;
flex-wrap: wrap;
gap: 1.25rem;
}
.pill {
display: flex;
align-items: center;
--pill-accent: var(--bright-blue);
background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
color: var(--pill-accent);
padding-inline: 0.75rem;
padding-block: 0.375rem;
border-radius: 2.75rem;
border: 0;
transition: background 0.3s ease;
font-family: var(--inter-font);
font-size: 0.875rem;
font-style: normal;
font-weight: 500;
line-height: 1.4rem;
letter-spacing: -0.00875rem;
text-decoration: none;
}
.pill:hover {
background: color-mix(in srgb, var(--pill-accent) 15%, transparent);
}
.pill-group .pill:nth-child(6n + 1) {
--pill-accent: var(--bright-blue);
}
.pill-group .pill:nth-child(6n + 2) {
--pill-accent: var(--french-violet);
}
.pill-group .pill:nth-child(6n + 3),
.pill-group .pill:nth-child(6n + 4),
.pill-group .pill:nth-child(6n + 5) {
--pill-accent: var(--hot-red);
}
.pill-group svg {
margin-inline-start: 0.25rem;
}
.social-links {
display: flex;
align-items: center;
gap: 0.73rem;
margin-top: 1.5rem;
}
.social-links path {
transition: fill 0.3s ease;
fill: var(--gray-400);
}
.social-links a:hover svg path {
fill: var(--gray-900);
}
@media screen and (max-width: 650px) {
.content {
flex-direction: column;
width: max-content;
}
.divider {
height: 1px;
width: 100%;
background: var(--red-to-pink-to-purple-horizontal-gradient);
margin-block: 1.5rem;
}
}
</style>
<main class="main">
<div class="content">
<div class="left-side">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 982 239"
fill="none"
class="angular-logo"
>
<g clip-path="url(#a)">
<path
fill="url(#b)"
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
/>
<path
fill="url(#c)"
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
/>
</g>
<defs>
<radialGradient
id="c"
cx="0"
cy="0"
r="1"
gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#FF41F8" />
<stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" />
<stop offset="1" stop-color="#FF41F8" stop-opacity="0" />
</radialGradient>
<linearGradient
id="b"
x1="0"
x2="982"
y1="192"
y2="192"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#F0060B" />
<stop offset="0" stop-color="#F0070C" />
<stop offset=".526" stop-color="#CC26D5" />
<stop offset="1" stop-color="#7702FF" />
</linearGradient>
<clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath>
</defs>
</svg>
<h1>Hello, {{ title }}</h1>
<p>Congratulations! Your app is running. 🎉</p>
</div>
<div class="divider" role="separator" aria-label="Divider"></div>
<div class="right-side">
<div class="pill-group">
@for (item of [
{ title: 'Explore the Docs', link: 'https://angular.dev' },
{ title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' },
{ title: 'CLI Docs', link: 'https://angular.dev/tools/cli' },
{ title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' },
{ title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' },
]; track item.title) {
<a
class="pill"
[href]="item.link"
target="_blank"
rel="noopener"
>
<span>{{ item.title }}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
height="14"
viewBox="0 -960 960 960"
width="14"
fill="currentColor"
>
<path
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
/>
</svg>
</a>
}
</div>
<div class="social-links">
<a
href="https://github.com/angular/angular"
aria-label="Github"
target="_blank"
rel="noopener"
>
<svg
width="25"
height="24"
viewBox="0 0 25 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Github"
>
<path
d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z"
/>
</svg>
</a>
<a
href="https://twitter.com/angular"
aria-label="Twitter"
target="_blank"
rel="noopener"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Twitter"
>
<path
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
/>
</svg>
</a>
<a
href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw"
aria-label="Youtube"
target="_blank"
rel="noopener"
>
<svg
width="29"
height="20"
viewBox="0 0 29 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Youtube"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z"
/>
</svg>
</a>
</div>
</div>
</div>
</main>
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * -->
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * -->
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<router-outlet />

View File

View File

@ -1,23 +1,195 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component } from '@angular/core'; import { Component, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { Auth } from '@angular/fire/auth';
import { initFlowbite } from 'flowbite'; import { GoogleAuthProvider, signInWithPopup, UserCredential } from 'firebase/auth';
import { PopoverComponent } from './components/popover.component';
import { DeckListComponent } from './deck-list.component'; import { DeckListComponent } from './deck-list.component';
import { ClickOutsideDirective } from './service/click-outside.directive';
import { PopoverService } from './services/popover.service';
import { UserService } from './services/user.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
template: ` template: `
<div class="container mx-auto p-4"> <div *ngIf="!isLoggedIn" class="min-h-screen flex flex-col items-center justify-center" style="background: rgba(0, 119, 179, 0.1)">
<h1 class="text-3xl font-bold text-center mb-8">Vokabeltraining</h1> <div class="text-center">
<app-deck-list></app-deck-list> <h1 class="text-5xl font-bold mb-4">Master Your Learning</h1>
<p class="text-xl mb-8">Learn smarter, not harder. Start your journey today</p>
<button (click)="loginWithGoogle()" class="bg-white text-blue-600 px-6 py-3 rounded-lg shadow-lg hover:bg-gray-100 transition duration-300 flex items-center justify-center">
<svg class="w-6 h-6 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<path
fill="#FFC107"
d="M43.611 20.083H42V20H24v8h11.303c-1.649 4.657-6.08 8-11.303 8-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 12.955 4 4 12.955 4 24s8.955 20 20 20 20-8.955 20-20c0-1.341-.138-2.65-.389-3.917z"
/>
<path fill="#FF3D00" d="M6.306 14.691l6.571 4.819C14.655 15.108 18.961 12 24 12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 16.318 4 9.656 8.337 6.306 14.691z" />
<path fill="#4CAF50" d="M24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238A11.91 11.91 0 0124 36c-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025C9.505 39.556 16.227 44 24 44z" />
<path fill="#1976D2" d="M43.611 20.083H42V20H24v8h11.303a12.04 12.04 0 01-4.087 5.571l.003-.002 6.19 5.238C36.971 39.205 44 34 44 24c0-1.341-.138-2.65-.389-3.917z" />
</svg>
Login with Google
</button>
</div>
</div> </div>
<div *ngIf="isLoggedIn" class="bg-white shadow mb-4">
<div class="container mx-auto px-4 py-2 flex justify-between items-center">
<!-- Logo und Name -->
<div class="flex items-center space-x-2">
<img src="../assets/logo.svg" alt="Logo" class="w-10 h-10" />
<span class="text-xl font-bold">Haiky</span>
</div>
<!-- Navigation -->
<div class="hidden md:flex space-x-6">
<span class="text-xl font-bold">Spaced Repetition Training</span>
</div>
<!-- User-Bereich -->
<div appClickOutside class="relative" (clickOutside)="showDropdown = false">
<img *ngIf="photoURL" [src]="photoURL" alt="User Photo" class="w-10 h-10 rounded-full cursor-pointer" (click)="toggleDropdown()" referrerpolicy="no-referrer" crossorigin="anonymous" />
<div *ngIf="!photoURL" class="image-placeholder w-10 h-10 rounded-full cursor-pointer bg-gray-300"></div>
<!-- Dropdown -->
<div *ngIf="showDropdown" class="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg z-10">
<button (click)="logout()" class="block w-full text-left px-4 py-2 text-gray-700 hover:bg-gray-100">Logout</button>
</div>
</div>
</div>
</div>
<app-deck-list *ngIf="isLoggedIn"></app-deck-list>
<app-popover
[visible]="popoverVisible"
[title]="popoverTitle"
[message]="popoverMessage"
[showInput]="popoverShowInput"
[showCancel]="popoverShowCancel"
[inputValue]="popoverInputValue"
[confirmText]="popoverConfirmText"
(confirmed)="handleConfirm($event)"
(canceled)="handleCancel()"
></app-popover>
`, `,
standalone: true, standalone: true,
imports: [ CommonModule, DeckListComponent] styles: `
img {
border: 2px solid #fff;
transition: transform 0.2s;
}
img:hover {
transform: scale(1.1);
}
/* Stile für das Dropdown-Menü */
.dropdown {
display: none;
position: absolute;
right: 0;
margin-top: 0.5rem;
background-color: white;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.dropdown button {
width: 100%;
text-align: left;
padding: 0.5rem 1rem;
color: #4a5568;
}
.dropdown button:hover {
background-color: #f7fafc;
}
`,
imports: [CommonModule, DeckListComponent, ClickOutsideDirective, PopoverComponent],
}) })
export class AppComponent { export class AppComponent {
title = 'vokabeltraining'; isLoggedIn = false;
ngOnInit(): void { private auth: Auth = inject(Auth);
initFlowbite(); showDropdown = false;
photoURL: string = 'https://placehold.co/40';
// user: User | null = null;
popoverVisible = false;
popoverTitle = '';
popoverMessage = '';
popoverShowInput = false;
popoverShowCancel = true;
popoverInputValue = '';
popoverConfirmText = 'Confirm';
private confirmCallback?: (inputValue?: string) => void;
private cancelCallback?: () => void;
constructor(public popoverService: PopoverService, private userService: UserService) {
this.popoverService.popoverState$.subscribe(options => {
this.popoverVisible = true;
this.popoverTitle = options.title;
this.popoverMessage = options.message;
this.popoverShowInput = options.showInput;
this.popoverShowCancel = options.showCancel;
this.popoverInputValue = options.inputValue;
this.popoverConfirmText = options.confirmText;
this.confirmCallback = options.onConfirm;
this.cancelCallback = options.onCancel;
});
}
ngOnInit() {
// Überprüfen des Login-Status beim Start der Anwendung
const isLoggedIn = localStorage.getItem('isLoggedIn') === 'true';
const accessToken = localStorage.getItem('accessToken');
const refreshToken = localStorage.getItem('refreshToken');
this.photoURL = localStorage.getItem('photoURL');
if (isLoggedIn && accessToken && refreshToken) {
this.isLoggedIn = true;
}
}
async loginWithGoogle() {
const provider = new GoogleAuthProvider();
try {
const result: UserCredential = await signInWithPopup(this.auth, provider);
this.isLoggedIn = true;
this.photoURL = result.user.photoURL;
// Speichern des Login-Status und Tokens im Local Storage
localStorage.setItem('isLoggedIn', 'true');
localStorage.setItem('accessToken', await result.user.getIdToken());
localStorage.setItem('refreshToken', result.user.refreshToken);
localStorage.setItem('photoURL', result.user.photoURL);
this.showDropdown = false;
await this.userService.logIn({ name: result.user.displayName, email: result.user.email, sign_in_provider: result.providerId });
console.log('Logged in with Google', result.user);
} catch (error) {
console.error('Google Login failed', error);
}
}
logout() {
this.isLoggedIn = false;
localStorage.removeItem('isLoggedIn');
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
// Optional: Firebase-Logout durchführen
this.auth.signOut();
}
toggleDropdown() {
this.showDropdown = !this.showDropdown;
}
handleConfirm(inputValue?: string) {
this.popoverVisible = false;
if (this.confirmCallback) {
this.confirmCallback(inputValue);
}
}
handleCancel() {
this.popoverVisible = false;
if (this.cancelCallback) {
this.cancelCallback();
}
} }
} }

View File

@ -1,9 +1,25 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { initializeApp, provideFirebaseApp } from '@angular/fire/app';
import { getAuth, provideAuth } from '@angular/fire/auth';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { provideHttpClient } from '@angular/common/http'; import { environment } from './environments/environment';
import { authInterceptor } from './service/auth.interceptor';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes),provideHttpClient()] providers: [
// {
// provide: HTTP_INTERCEPTORS,
// useClass: AuthInterceptor,
// multi: true,
// },
provideFirebaseApp(() => initializeApp(environment.firebase)),
provideAuth(() => getAuth()),
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient(
withInterceptors([authInterceptor]), // Hier wird der Interceptor registriert
),
],
}; };

View File

@ -0,0 +1,56 @@
// popover.component.ts
import { CommonModule } from '@angular/common';
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-popover',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="fixed inset-0 bg-gray-500 bg-opacity-50 flex items-center justify-center z-50" *ngIf="visible">
<div class="bg-white rounded-lg p-6 max-w-xs w-full shadow-lg border border-gray-200">
<h3 class="text-lg font-semibold mb-4">{{ title }}</h3>
<p *ngIf="message" class="mb-4">{{ message }}</p>
<input *ngIf="showInput" type="text" class="w-full p-2 border rounded mb-4 focus:ring-2 focus:ring-blue-500 focus:border-transparent" [(ngModel)]="inputValue" (keyup.enter)="onConfirm()" autofocus />
<div class="flex justify-end space-x-2">
@if(showCancel){
<button (click)="onCancel()" class="px-4 py-2 rounded bg-gray-100 text-gray-700 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-400">Cancel</button>
}
<button (click)="onConfirm()" class="px-4 py-2 rounded bg-blue-500 text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
{{ confirmText }}
</button>
</div>
</div>
</div>
`,
})
export class PopoverComponent {
@Input() title: string = '';
@Input() message: string = '';
@Input() showInput: boolean = false;
@Input() showCancel: boolean = false;
@Input() confirmText: string = 'Confirm';
@Input() inputValue: string = '';
@Input() visible: boolean = false;
@Output() confirmed = new EventEmitter<string>();
@Output() canceled = new EventEmitter<void>();
onConfirm() {
this.confirmed.emit(this.inputValue);
//this.reset();
}
onCancel() {
this.canceled.emit();
//this.reset();
}
private reset() {
this.visible = false;
this.inputValue = '';
}
}

View File

@ -1,4 +1,3 @@
<!-- src/app/create-deck-modal.component.html -->
<div #createDeckModal id="createDeckModal" tabindex="-1" aria-hidden="true" class="fixed top-0 left-0 right-0 z-50 hidden w-full p-4 overflow-x-hidden overflow-y-auto md:inset-0 h-modal md:h-full"> <div #createDeckModal id="createDeckModal" tabindex="-1" aria-hidden="true" class="fixed top-0 left-0 right-0 z-50 hidden w-full p-4 overflow-x-hidden overflow-y-auto md:inset-0 h-modal md:h-full">
<div class="relative w-full h-full max-w-md md:h-auto"> <div class="relative w-full h-full max-w-md md:h-auto">
<div class="relative bg-white rounded-lg shadow"> <div class="relative bg-white rounded-lg shadow">
@ -6,17 +5,17 @@
<svg aria-hidden="true" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <svg aria-hidden="true" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path> <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
</svg> </svg>
<span class="sr-only">Schließen</span> <span class="sr-only">Close</span>
</button> </button>
<div class="p-6"> <div class="p-6">
<h3 class="mb-4 text-xl font-medium text-gray-900">Neues Deck erstellen</h3> <h3 class="mb-4 text-xl font-medium text-gray-900">Create New Deck</h3>
<form (submit)="createDeck($event)"> <form (submit)="createDeck($event)">
<div class="mb-4"> <div class="mb-4">
<label for="deckName" class="block text-sm font-medium text-gray-700">Deck-Name</label> <label for="deckName" class="block text-sm font-medium text-gray-700">Deck Name</label>
<input type="text" id="deckName" [(ngModel)]="deckName" name="deckName" required class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" /> <input type="text" id="deckName" [(ngModel)]="deckName" name="deckName" required class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
</div> </div>
<button type="submit" class="w-full bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600"> <button type="submit" class="w-full bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600">
Erstellen Create
</button> </button>
</form> </form>
</div> </div>

View File

@ -1,15 +1,15 @@
// src/app/create-deck-modal.component.ts
import { Component, Output, EventEmitter, AfterViewInit, ViewChild, ElementRef } from '@angular/core';
import { DeckService } from '../deck.service';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Modal } from 'flowbite'; import { AfterViewInit, Component, ElementRef, EventEmitter, Output, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Modal } from 'flowbite';
import { DeckService } from '../services/deck.service';
import { PopoverService } from '../services/popover.service';
@Component({ @Component({
selector: 'app-create-deck-modal', selector: 'app-create-deck-modal',
templateUrl: './create-deck-modal.component.html', templateUrl: './create-deck-modal.component.html',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule] imports: [CommonModule, FormsModule],
}) })
export class CreateDeckModalComponent implements AfterViewInit { export class CreateDeckModalComponent implements AfterViewInit {
@Output() deckCreated = new EventEmitter<void>(); @Output() deckCreated = new EventEmitter<void>();
@ -17,7 +17,7 @@ export class CreateDeckModalComponent implements AfterViewInit {
deckName: string = ''; deckName: string = '';
modal!: Modal; modal!: Modal;
constructor(private deckService: DeckService) { } constructor(private deckService: DeckService, private popoverService: PopoverService) {}
ngAfterViewInit(): void { ngAfterViewInit(): void {
this.modal = new Modal(this.modalElement.nativeElement); this.modal = new Modal(this.modalElement.nativeElement);
@ -30,7 +30,10 @@ export class CreateDeckModalComponent implements AfterViewInit {
createDeck(event: Event): void { createDeck(event: Event): void {
event.preventDefault(); event.preventDefault();
if (this.deckName.trim() === '') { if (this.deckName.trim() === '') {
alert('Bitte einen Deck-Namen eingeben.'); this.popoverService.show({
title: 'Information',
message: 'Please enter a deck name !',
});
return; return;
} }
@ -40,10 +43,13 @@ export class CreateDeckModalComponent implements AfterViewInit {
this.deckCreated.emit(); this.deckCreated.emit();
this.modal.hide(); this.modal.hide();
}, },
error: (err) => { error: err => {
console.error('Fehler beim Erstellen des Decks', err); console.error('Error creating deck', err);
alert('Fehler beim Erstellen des Decks.'); this.popoverService.show({
} title: 'Error',
message: 'Error creating deck.',
});
},
}); });
} }
@ -51,4 +57,3 @@ export class CreateDeckModalComponent implements AfterViewInit {
this.modal.hide(); this.modal.hide();
} }
} }

View File

@ -1,101 +1,157 @@
<!-- src/app/deck-list.component.html --> <div class="flex flex-col">
<div> <!-- Two-column layout -->
<!-- Button zum Erstellen eines neuen Decks --> <div *ngIf="!trainingsDeck" class="flex flex-col md:flex-row gap-4 mx-auto max-w-6xl">
<div class="flex justify-end mb-4"> <!-- Left column: List of decks -->
<button (click)="openCreateDeckModal()" class="bg-green-500 text-white py-2 px-4 rounded hover:bg-green-600"> <div class="w-auto">
Neues Deck erstellen <div class="bg-white shadow rounded-lg p-4">
</button> <div class="flex">
</div> <h2 class="text-xl font-semibold mb-4">Decks</h2>
<button (click)="openCreateDeckModal()" class="text-gray-500 hover:text-gray-700 focus:outline-none flex items-start ml-2.5 translate-y-0.5">
<!-- Decks anzeigen --> <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="#2563eb">
<div class="flex flex-wrap"> <circle cx="12" cy="12" r="9" stroke-width="2" />
<div *ngFor="let deck of decks" class="bg-white shadow rounded-lg p-6 w-full md:w-1/2 lg:w-1/3 flex flex-col border-dashed border-2 border-indigo-600"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v8M8 12h8" />
<!-- Deck-Header mit Toggle-Button --> </svg>
<div class="flex justify-between items-center mb-4"> </button>
<div class="flex items-center space-x-2"> </div>
<h2 class="text-xl font-semibold">{{ deck.name }}</h2> <div class="space-y-2">
<span class="text-gray-600">({{ deck.images.length }} Bilder)</span> <div *ngFor="let deck of decks" class="flex justify-between items-center p-2 rounded hover:bg-gray-300 cursor-pointer" [class.bg-blue-200]="isDeckActive(deck)" (click)="toggleDeckExpansion(deck)">
<div class="flex flex-col space-y-1">
<div class="flex items-center space-x-2 whitespace-nowrap">
<h3 class="font-medium">{{ deck.name }}</h3>
<span class="text-gray-600">({{ deck.images.length }} Pics)</span>
</div>
<div class="text-sm text-gray-600">
<div [ngClass]="{ 'text-blue-500 font-bold': isToday(getNextTrainingDate(deck)), 'text-rose-500 font-bold': isBeforeToday(getNextTrainingDate(deck)) }">
Next training: {{ getNextTrainingString(deck) }}
</div>
<div>Words to review: {{ getWordsToReview(deck) }}</div>
</div>
</div>
<button class="text-gray-500 hover:text-gray-700 focus:outline-none">
<svg *ngIf="!isDeckActive(deck)" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 transform rotate-0 transition-transform duration-200" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<svg *ngIf="isDeckActive(deck)" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 transform rotate-180 transition-transform duration-200" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
</div>
</div> </div>
<button (click)="toggleDeckExpansion(deck.name)" class="text-gray-500 hover:text-gray-700 focus:outline-none" title="Deck ein-/ausklappen">
<svg *ngIf="!isDeckExpanded(deck.name)" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 transform rotate-0 transition-transform duration-200" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
<svg *ngIf="isDeckExpanded(deck.name)" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 transform rotate-180 transition-transform duration-200" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div> </div>
</div>
<!-- Bildliste und Action-Buttons nur anzeigen, wenn das Deck erweitert ist und kein Training aktiv ist --> <!-- Right column: Active deck details -->
<ng-container *ngIf="isDeckExpanded(deck.name) && !selectedDeck"> <div class="w-auto min-w-96" *ngIf="activeDeck">
<!-- Liste der Bilder mit Anzahl der Boxen und Icons --> <div class="bg-white shadow rounded-lg p-6 border-dashed border-2 border-gray-300">
<!-- Deck header -->
<div class="flex justify-between items-center mb-4">
<div class="flex items-center space-x-2">
<h2 class="text-xl font-semibold">{{ activeDeck.name }}</h2>
<!-- <span class="text-gray-600">({{ activeDeck.images.length }} images)</span> -->
<div class="flex items-center mx-2">
<span class="text-sm mr-2">Shuffle</span>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" class="sr-only peer" [ngModel]="activeDeck.boxOrder === 'position'" (ngModelChange)="changeBoxPosition($event)" />
<div
class="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"
></div>
<span class="ml-2 text-sm font-medium">
<!-- {{ activeDeck.boxOrder === 'shuffle' ? 'Shuffle' : 'Position' }} -->
Position
</span>
</label>
</div>
</div>
<div class="flex space-x-2">
<button (click)="openDeletePopover(activeDeck.name)" class="text-red-500 hover:text-red-700" title="Delete Deck">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<button (click)="openRenamePopover(activeDeck)" class="text-yellow-500 hover:text-yellow-700" title="Rename Deck">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
</div>
</div>
<!-- <span class="text-gray-600">({{ activeDeck.images.length }} images)</span> -->
<!-- Image list -->
<ul class="mb-4"> <ul class="mb-4">
<li *ngFor="let image of deck.images" class="flex justify-between items-center py-2 border-b last:border-b-0"> <li *ngFor="let image of activeDeck.images" class="flex justify-between items-center py-2 border-b last:border-b-0">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div class="relative group"> <div class="relative group">
<!-- Tooltip Inhalt (Bild) -->
<div class="absolute left-0 bottom-full mb-2 w-48 bg-white border border-gray-300 rounded shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-10 pointer-events-none"> <div class="absolute left-0 bottom-full mb-2 w-48 bg-white border border-gray-300 rounded shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-10 pointer-events-none">
<img src="/api/debug_image/{{image.id}}/thumbnail.jpg" alt="{{ image.name }}" class="w-full h-auto object-cover"> <img src="/images/{{ image.bildid }}/thumbnail.webp" alt="{{ image.name }}" class="w-full h-auto object-cover" />
</div> </div>
<!-- Bildname mit Tooltip --> <span class="font-medium cursor-pointer">{{ image.name }}</span>
<span class="font-medium cursor-pointer">
{{ image.name }}
</span>
</div> </div>
<span class="text-gray-600">({{ image.boxes.length }} Boxen)</span> <span class="text-gray-600">({{ image.boxes.length }} boxes)</span>
</div> </div>
<div class="flex space-x-2"> <div class="flex space-x-2">
<!-- Edit Icon --> <button (click)="editImage(activeDeck, image)" class="text-blue-500 hover:text-blue-700" title="Edit Image">
<button (click)="editImage(deck, image)" class="text-blue-500 hover:text-blue-700" title="Bild bearbeiten">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4H4v7m0 0l9-9 9 9M20 13v7h-7m0 0l-9-9-9 9" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4H4v7m0 0l9-9 9 9M20 13v7h-7m0 0l-9-9-9 9" />
</svg> </svg>
</button> </button>
<!-- Delete Icon --> <button (click)="deleteImage(activeDeck, image)" class="text-red-500 hover:text-red-700" title="Delete Image">
<button (click)="deleteImage(deck, image)" class="text-red-500 hover:text-red-700" title="Bild löschen">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
<!-- Move Icon --> <button (click)="openMoveImageModal(activeDeck, image)" class="text-yellow-500 hover:text-yellow-700" title="Move Image">
<button (click)="openMoveImageModal(deck, image)" class="text-yellow-500 hover:text-yellow-700" title="Bild verschieben">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg> </svg>
</button> </button>
<button (click)="openRenameImagePopover(image)" class="text-yellow-500 hover:text-yellow-700" title="Rename Image">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
</div> </div>
</li> </li>
</ul> </ul>
<div class="flex flex-row space-x-2 items-stretch">
<!-- Action-Buttons --> <button (click)="openTraining(activeDeck)" class="flex-1 bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600 flex items-center justify-center whitespace-nowrap">Start Training</button>
<div class="flex space-x-2"> <div class="flex-1">
<button (click)="openTraining(deck)" class="flex-1 bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600"> <div class="relative h-full">
Training starten <label for="imageFile" class="flex justify-center items-center bg-green-500 text-white py-2 px-4 rounded hover:bg-green-600 cursor-pointer h-full">
</button> <div class="flex flex-col items-center">
<button (click)="openUploadImageModal(deck.name)" class="flex-1 bg-green-500 text-white py-2 px-4 rounded hover:bg-green-600"> <div class="whitespace-nowrap">Add Image</div>
Bild hinzufügen <div class="text-xs whitespace-nowrap">(from file)</div>
</button> </div>
</label>
<input #imageFile type="file" id="imageFile" (change)="onFileChange($event)" accept="image/jpeg,image/png,image/gif,image/webp" required class="hidden" />
</div>
</div>
<!-- Neuer Button Paste Image -->
<div class="flex-1">
<button (click)="pasteImage()" class="w-full bg-purple-500 text-white py-2 px-4 rounded hover:bg-purple-600 flex items-center justify-center h-full">
<div class="flex flex-col items-center">
<div class="whitespace-nowrap">Paste Image</div>
<div class="text-xs whitespace-nowrap">(from clipboard)</div>
</div>
</button>
</div>
</div> </div>
</ng-container> </div>
</div>
</div>
<!-- Loading overlay -->
<div *ngIf="loading" class="absolute inset-0 bg-gray-800 bg-opacity-50 flex items-center justify-center z-10">
<div class="bg-white p-4 rounded shadow">
<p class="text-sm text-gray-700">Processing in progress...</p>
</div> </div>
</div> </div>
<!-- CreateDeckModalComponent --> <!-- CreateDeckModalComponent -->
<app-create-deck-modal (deckCreated)="loadDecks()"></app-create-deck-modal> <app-create-deck-modal (deckCreated)="loadDecks()"></app-create-deck-modal>
<!-- UploadImageModalComponent --> <!-- UploadImageModalComponent -->
<!-- <app-upload-image-modal [deckName]="currentUploadDeckName" (imageUploaded)="loadDecks()"></app-upload-image-modal> --> <!-- <app-upload-image-modal (imageUploaded)="onImageUploaded($event)"></app-upload-image-modal> -->
<app-upload-image-modal (imageUploaded)="onImageUploaded($event)"> </app-upload-image-modal> <app-edit-image-modal *ngIf="imageData" [deckName]="activeDeck.name" [imageData]="imageData" (imageSaved)="onImageSaved()" (closed)="onClosed()"></app-edit-image-modal>
<app-edit-image-modal *ngIf="imageData" [deckName]="currentUploadDeckName" [imageData]="imageData" (imageSaved)="onImageSaved()" (closed)="onClosed()"></app-edit-image-modal>
<!-- TrainingComponent --> <!-- TrainingComponent -->
<app-training *ngIf="selectedDeck" [deck]="selectedDeck" (close)="closeTraining()"></app-training> <app-training *ngIf="trainingsDeck" [deck]="trainingsDeck" [boxOrder]="trainingsDeck.boxOrder" (close)="closeTraining()"></app-training>
<!-- MoveImageModalComponent --> <!-- MoveImageModalComponent -->
<app-move-image-modal <app-move-image-modal *ngIf="imageToMove" [image]="imageToMove.image" [sourceDeck]="imageToMove.sourceDeck" [decks]="decks" (moveCompleted)="onImageMoved()" (closed)="imageToMove = null"> </app-move-image-modal>
*ngIf="imageToMove"
[image]="imageToMove.image"
[sourceDeck]="imageToMove.sourceDeck"
[decks]="decks"
(moveCompleted)="onImageMoved()"
(closed)="imageToMove = null">
</app-move-image-modal>
</div> </div>

View File

@ -1,48 +1,61 @@
// src/app/deck-list.component.ts
import { Component, OnInit, ViewChild } from '@angular/core';
import { DeckService, Deck, DeckImage } from './deck.service';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CreateDeckModalComponent } from './create-deck-modal/create-deck-modal.component'; import { HttpClient } from '@angular/common/http';
import { TrainingComponent } from './training/training.component'; import { ChangeDetectorRef, Component, ElementRef, OnInit, ViewChild } from '@angular/core';
import { UploadImageModalComponent } from './upload-image-modal/upload-image-modal.component'; import { FormsModule } from '@angular/forms';
import { EditImageModalComponent } from './edit-image-modal/edit-image-modal.component';
import { firstValueFrom } from 'rxjs'; 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 { 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({ @Component({
selector: 'app-deck-list', selector: 'app-deck-list',
templateUrl: './deck-list.component.html', templateUrl: './deck-list.component.html',
standalone: true, 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: [ imports: [
CommonModule, CommonModule,
CreateDeckModalComponent, CreateDeckModalComponent,
UploadImageModalComponent,
TrainingComponent, TrainingComponent,
EditImageModalComponent, EditImageModalComponent,
MoveImageModalComponent, // Hinzufügen der neuen Komponente MoveImageModalComponent, // Adding the new component
UploadImageModalComponent FormsModule,
] ],
}) })
export class DeckListComponent implements OnInit { export class DeckListComponent implements OnInit {
decks: Deck[] = []; decks: Deck[] = [];
selectedDeck: Deck | null = null; trainingsDeck: Deck | null = null;
activeDeck: Deck | null = null;
loading: boolean = false;
@ViewChild(CreateDeckModalComponent) createDeckModal!: CreateDeckModalComponent; @ViewChild(CreateDeckModalComponent)
@ViewChild(UploadImageModalComponent) uploadImageModal!: UploadImageModalComponent; createDeckModal!: CreateDeckModalComponent;
@ViewChild(EditImageModalComponent) editModal!: EditImageModalComponent; @ViewChild(EditImageModalComponent) editModal!: EditImageModalComponent;
@ViewChild(UploadImageModalComponent) uploadModal!: UploadImageModalComponent; @ViewChild('imageFile') imageFileElement!: ElementRef;
imageData: { imageSrc: string | ArrayBuffer | null, deckImage: DeckImage } | null = null; imageData: {
imageSrc: string | ArrayBuffer | null;
deckImage: DeckImage;
} | null = null;
currentUploadDeckName: string = ''; // Set to track expanded decks
// Set zur Verfolgung erweiterter Decks
expandedDecks: Set<string> = new Set<string>(); expandedDecks: Set<string> = new Set<string>();
// State für das Verschieben von Bildern // State for moving images
imageToMove: { image: DeckImage, sourceDeck: Deck } | null = null; imageToMove: { image: DeckImage; sourceDeck: Deck } | null = null;
constructor(private deckService: DeckService) { } constructor(private deckService: DeckService, private cdr: ChangeDetectorRef, private http: HttpClient, private popoverService: PopoverService) {}
ngOnInit(): void { ngOnInit(): void {
this.loadExpandedDecks(); this.loadExpandedDecks();
@ -51,92 +64,192 @@ export class DeckListComponent implements OnInit {
loadDecks(): void { loadDecks(): void {
this.deckService.getDecks().subscribe({ this.deckService.getDecks().subscribe({
next: (data) => this.decks = data, next: data => {
error: (err) => console.error('Fehler beim Laden der Decks', err) 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),
}); });
} }
deleteDeck(deckName: string): void { // Updated toggle method
if (!confirm(`Bist du sicher, dass du das Deck "${deckName}" löschen möchtest?`)) { toggleDeckExpansion(deck: Deck): void {
return; 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({ this.deckService.deleteDeck(deckName).subscribe({
next: () => this.loadDecks(), next: () => {
error: (err) => console.error('Fehler beim Löschen des Decks', err) this.loadDecks();
this.activeDeck = this.decks.length > 0 ? this.decks[0] : null;
},
error: err => console.error('Error deleting deck', err),
}); });
} }
deleteImage(deck: Deck, image: DeckImage): void { // Method to open the rename popover
if (!confirm(`Bist du sicher, dass du das Bild "${image.name}" löschen möchtest?`)) { openRenamePopover(deck: Deck): void {
return; 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),
});
}
const imageId = image.id; // 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({ this.deckService.deleteImage(imageId).subscribe({
next: () => this.loadDecks(), next: () => {
error: (err) => console.error('Fehler beim Löschen des Bildes', err) 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 { editImage(deck: Deck, image: DeckImage): void {
let imageSrc = null let imageSrc = null;
this.currentUploadDeckName = deck.name; fetch(`/images/${image.bildid}/original.webp`)
fetch(`/api/debug_image/${image.id}/original_compressed.jpg`) .then(response => {
.then(response => { if (!response.ok) {
if (!response.ok) { throw new Error('Network response was not ok');
throw new Error('Netzwerkantwort war nicht ok'); }
} return response.blob();
return response.blob(); })
}) .then(blob => {
.then(blob => { const reader = new FileReader();
const reader = new FileReader(); reader.onloadend = () => {
reader.onloadend = () => { imageSrc = reader.result; // Base64 string
imageSrc = reader.result; // Base64-String this.imageData = { imageSrc, deckImage: image };
this.imageData = { imageSrc, deckImage: image } };
}; reader.readAsDataURL(blob);
reader.readAsDataURL(blob); })
}) .catch(error => {
.catch(error => { console.error('Error loading image:', error);
console.error('Fehler beim Laden des Bildes:', error); });
});
}
openTraining(deck: Deck): void {
this.selectedDeck = deck;
} }
// Method to open the training component
openTraining(deck: Deck): void {
this.trainingsDeck = deck;
}
// Method to close the training component
closeTraining(): void { closeTraining(): void {
this.selectedDeck = null; this.trainingsDeck = null;
this.loadDecks(); this.loadDecks();
} }
async changeBoxPosition(val: boolean) {
this.activeDeck.boxOrder = val ? 'position' : 'shuffle';
this.deckService.updateDeck(this.activeDeck.name, { boxOrder: this.activeDeck.boxOrder }).subscribe({
next: () => {
this.loadDecks();
},
error: err => console.error('Error renaming image', err),
});
}
// Method to open the create deck modal
openCreateDeckModal(): void { openCreateDeckModal(): void {
this.createDeckModal.open(); this.createDeckModal.open();
} }
openUploadImageModal(deckName: string): void { // Method to check if a deck is expanded
this.currentUploadDeckName = deckName;
this.uploadImageModal.deckName = deckName;
this.uploadImageModal.open();
}
// Methode zum Umschalten der Deck-Erweiterung
toggleDeckExpansion(deckName: string): void {
if (this.expandedDecks.has(deckName)) {
this.expandedDecks.delete(deckName);
} else {
this.expandedDecks.add(deckName);
}
this.saveExpandedDecks();
}
// Methode zur Überprüfung, ob ein Deck erweitert ist
isDeckExpanded(deckName: string): boolean { isDeckExpanded(deckName: string): boolean {
return this.expandedDecks.has(deckName); return this.expandedDecks.has(deckName);
} }
// Laden der erweiterten Decks aus dem sessionStorage // Method to load expanded decks from sessionStorage
loadExpandedDecks(): void { loadExpandedDecks(): void {
const stored = sessionStorage.getItem('expandedDecks'); const stored = sessionStorage.getItem('expandedDecks');
if (stored) { if (stored) {
@ -144,48 +257,297 @@ export class DeckListComponent implements OnInit {
const parsed: string[] = JSON.parse(stored); const parsed: string[] = JSON.parse(stored);
this.expandedDecks = new Set<string>(parsed); this.expandedDecks = new Set<string>(parsed);
} catch (e) { } catch (e) {
console.error('Fehler beim Parsen der erweiterten Decks aus sessionStorage', e); console.error('Error parsing expanded decks from sessionStorage', e);
} }
} else { } else {
// Wenn keine Daten gespeichert sind, alle Decks standardmäßig nicht erweitern // If no data is stored, do not expand any decks by default
this.expandedDecks = new Set<string>(); this.expandedDecks = new Set<string>();
} }
} }
// Speichern der erweiterten Decks in das sessionStorage // Method to save expanded decks to sessionStorage
saveExpandedDecks(): void { saveExpandedDecks(): void {
const expandedArray = Array.from(this.expandedDecks); const expandedArray = Array.from(this.expandedDecks);
sessionStorage.setItem('expandedDecks', JSON.stringify(expandedArray)); sessionStorage.setItem('expandedDecks', JSON.stringify(expandedArray));
} }
// Funktion zum Öffnen des Upload Modals (kann durch einen Button ausgelöst werden) // Handler for the imageUploaded event
openUploadModal(): void {
this.uploadImageModal.open();
}
// Handler für das imageUploaded Event
onImageUploaded(imageData: any): void { onImageUploaded(imageData: any): void {
this.imageData = imageData; this.imageData = imageData;
} }
onClosed(){ onClosed() {
this.imageData = null; this.imageData = null;
} }
async onImageSaved() { async onImageSaved() {
// Handle das Speichern der Bilddaten, z.B. aktualisiere die Liste der Bilder // Handle saving the image data, e.g., update the list of images
this.imageData = null; this.imageData = null;
this.decks = await firstValueFrom(this.deckService.getDecks())
// 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;
}
}
} }
// Methode zum Öffnen des MoveImageModal // Method to open the MoveImageModal
openMoveImageModal(deck: Deck, image: DeckImage): void { openMoveImageModal(deck: Deck, image: DeckImage): void {
this.imageToMove = { image, sourceDeck: deck }; this.imageToMove = { image, sourceDeck: deck };
} }
// Handler für das moveCompleted Event // Handler for the moveCompleted event
onImageMoved(): void { onImageMoved(): void {
this.imageToMove = null; this.imageToMove = null;
this.loadDecks(); // 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 < today ? today : nextDate;
}
return today;
}
getNextTrainingString(deck: Deck): string {
return this.daysSinceEpochToLocalDateString(this.getNextTrainingDate(deck));
}
// Methode zur Berechnung der Anzahl der zu bearbeitenden Wörter
getWordsToReview(deck: Deck): number {
const nextTraining = this.getNextTrainingDate(deck);
const today = this.getTodayInDays();
return deck.images.flatMap(image => image.boxes.map(box => (box.due ? box.due : null))).filter(e => e <= nextTraining).length;
}
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);
}
// In deiner Component TypeScript Datei
isToday(epochDays: number): boolean {
return this.getTodayInDays() - epochDays === 0;
}
isBeforeToday(epochDays: number): boolean {
return this.getTodayInDays() - epochDays > 0;
} }
} }

View File

@ -1,34 +1,42 @@
<!-- src/app/edit-image-modal.component.html --> <div
<div #editImageModal id="editImageModal" tabindex="-1" aria-hidden="true" class="fixed top-0 left-0 right-0 z-50 hidden w-full p-4 overflow-x-hidden overflow-y-auto md:inset-0 h-modal md:h-full"> #editImageModal
id="editImageModal"
data-modal-backdrop="static"
tabindex="-1"
aria-hidden="true"
class="fixed top-0 left-0 right-0 z-50 hidden w-full p-4 overflow-x-hidden overflow-y-auto md:inset-0 h-modal md:h-full"
>
<div class="relative h-full contents"> <div class="relative h-full contents">
<div class="relative bg-white rounded-lg shadow"> <div class="relative bg-white rounded-lg shadow p-[20px]">
<button type="button" class="absolute top-3 right-2.5 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center" (click)="closeModal()"> <!-- Header with box count -->
<svg aria-hidden="true" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <div class="flex items-center justify-between px-4 pb-4 border-b rounded-t dark:border-gray-600 border-gray-200">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path> <h3 class="text-xl font-semibold text-gray-900 dark:text-white">
</svg> Edit Image <span *ngIf="boxes.length > 0">({{ boxes.length }} Box{{ boxes.length > 1 ? 'es' : '' }})</span>
<span class="sr-only">Schließen</span>
</button>
<div class="p-6 relative">
<!-- Überschrift mit Boxanzahl -->
<h3 class="mb-4 text-xl font-medium text-gray-900">
Bild bearbeiten <span *ngIf="boxes.length > 0">({{ boxes.length }} Box{{ boxes.length > 1 ? 'en' : '' }})</span>
</h3> </h3>
<button
<!-- Canvas --> type="button"
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
(click)="closeModal()"
>
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" />
</svg>
<span class="sr-only">Close modal</span>
</button>
</div>
<!-- Canvas -->
<div class="p-4 md:p-5 space-y-4">
<div class="mt-4"> <div class="mt-4">
<canvas #canvas class="border border-gray-300 rounded w-full h-auto"></canvas> <canvas #canvas class="border border-gray-300 rounded w-full h-auto"></canvas>
</div> </div>
</div>
<!-- Buttons unter dem Canvas --> <!-- Buttons below the canvas -->
<div class="mt-4 flex justify-between"> <div class="mt-4 flex justify-between">
<button (click)="save()" class="bg-green-500 text-white py-2 px-4 rounded hover:bg-green-600"> <button (click)="save()" class="bg-green-500 text-white py-2 px-4 rounded hover:bg-green-600">Save</button>
Save <button (click)="addNewBox()" class="bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600">New Box</button>
</button>
<button (click)="addNewBox()" class="bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600">
Neue Box
</button>
</div>
</div> </div>
</div> </div>
<!-- </div> -->
</div> </div>
</div> </div>

View File

@ -1,29 +1,30 @@
// src/app/edit-image-modal.component.ts // src/app/edit-image-modal.component.ts
import { Component, Input, Output, EventEmitter, AfterViewInit, ViewChild, ElementRef, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { AfterViewInit, Component, ElementRef, EventEmitter, Input, OnDestroy, Output, ViewChild } from '@angular/core';
import { fabric } from 'fabric'; import { fabric } from 'fabric';
import { Modal } from 'flowbite'; import { Modal } from 'flowbite';
import { DeckImage, DeckService, OcrResult } from '../deck.service'; import { DeckImage, DeckService } from '../services/deck.service';
import { PopoverService } from '../services/popover.service';
@Component({ @Component({
selector: 'app-edit-image-modal', selector: 'app-edit-image-modal',
templateUrl: './edit-image-modal.component.html', templateUrl: './edit-image-modal.component.html',
standalone: true, standalone: true,
imports: [CommonModule] imports: [CommonModule],
}) })
export class EditImageModalComponent implements AfterViewInit, OnDestroy { export class EditImageModalComponent implements AfterViewInit, OnDestroy {
// Konstante für die Boxfarbe // Constant for box color
private readonly BOX_COLOR = 'rgba(255, 0, 0, 0.3)'; // Rot mit Transparenz private readonly BOX_COLOR = 'rgba(255, 0, 0, 0.3)'; // Red with transparency
@Input() deckName: string = ''; @Input() deckName: string = '';
@Input() imageData : {imageSrc:string|ArrayBuffer|null, deckImage:DeckImage|null} = {imageSrc:null,deckImage:null}; @Input() imageData: { imageSrc: string | ArrayBuffer | null; deckImage: DeckImage | null } = { imageSrc: null, deckImage: null };
@Output() imageSaved = new EventEmitter<void>(); @Output() imageSaved = new EventEmitter<void>();
@Output() closed = new EventEmitter<void>(); @Output() closed = new EventEmitter<void>();
@ViewChild('editImageModal') modalElement!: ElementRef; @ViewChild('editImageModal') modalElement!: ElementRef;
@ViewChild('canvas') canvasElement!: ElementRef<HTMLCanvasElement>; @ViewChild('canvas') canvasElement!: ElementRef<HTMLCanvasElement>;
detectedText: string = ''; detectedText: string = '';
boxes: { x1: number; x2: number; y1: number; y2: number }[] = []; boxes: { x1: number; x2: number; y1: number; y2: number; id: number; inserted: string; updated: string }[] = [];
canvas!: fabric.Canvas; canvas!: fabric.Canvas;
maxCanvasWidth: number = 0; maxCanvasWidth: number = 0;
@ -32,14 +33,15 @@ export class EditImageModalComponent implements AfterViewInit, OnDestroy {
private keyDownHandler!: (e: KeyboardEvent) => void; private keyDownHandler!: (e: KeyboardEvent) => void;
modal: any; modal: any;
constructor(private deckService: DeckService) { } constructor(private deckService: DeckService, private popoverService: PopoverService) {}
async ngAfterViewInit() { async ngAfterViewInit() {
this.modal = new Modal(this.modalElement.nativeElement,{ this.modal = new Modal(this.modalElement.nativeElement, {
backdrop: 'static',
onHide: () => { onHide: () => {
this.closed.emit(); this.closed.emit();
}}, },
); });
this.maxCanvasWidth = window.innerWidth * 0.6; this.maxCanvasWidth = window.innerWidth * 0.6;
this.maxCanvasHeight = window.innerHeight * 0.6; this.maxCanvasHeight = window.innerHeight * 0.6;
@ -75,27 +77,27 @@ export class EditImageModalComponent implements AfterViewInit, OnDestroy {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
fabric.Image.fromURL( fabric.Image.fromURL(
url, url,
(img) => { img => {
resolve(img); resolve(img);
}, },
{ {
crossOrigin: 'anonymous', crossOrigin: 'anonymous',
originX: 'left', originX: 'left',
originY: 'top', originY: 'top',
} },
); );
}); });
} }
async processImage(): Promise<void> { async processImage(): Promise<void> {
try { try {
if (!this.imageData){ if (!this.imageData) {
return; return;
} }
this.canvas = new fabric.Canvas(this.canvasElement.nativeElement); this.canvas = new fabric.Canvas(this.canvasElement.nativeElement);
// Hintergrundbild setzen // Set background image
const backgroundImage = await this.loadFabricImage(this.imageData.imageSrc as string); const backgroundImage = await this.loadFabricImage(this.imageData.imageSrc as string);
const originalWidth = backgroundImage.width!; const originalWidth = backgroundImage.width!;
@ -120,19 +122,19 @@ export class EditImageModalComponent implements AfterViewInit, OnDestroy {
this.boxes = []; this.boxes = [];
// Boxen hinzufügen // Add boxes
this.imageData.deckImage?.boxes.forEach(box => { this.imageData.deckImage?.boxes.forEach(box => {
const rect = new fabric.Rect({ const rect = new fabric.Rect({
left: box.x1 * scaleFactor, left: box.x1 * scaleFactor,
top: box.y1 * scaleFactor, top: box.y1 * scaleFactor,
width: (box.x2 - box.x1) * scaleFactor, width: (box.x2 - box.x1) * scaleFactor,
height: (box.y2 - box.y1) * scaleFactor, height: (box.y2 - box.y1) * scaleFactor,
fill: this.BOX_COLOR, // Verwendung der Konstante fill: this.BOX_COLOR, // Use the constant
selectable: true, selectable: true,
hasControls: true, hasControls: true,
hasBorders: true, hasBorders: true,
objectCaching: false, objectCaching: false,
data: { id: box.id, inserted: box.inserted, updated: box.updated },
}); });
rect.on('modified', () => { rect.on('modified', () => {
@ -155,11 +157,9 @@ export class EditImageModalComponent implements AfterViewInit, OnDestroy {
}); });
this.updateBoxCoordinates(); this.updateBoxCoordinates();
// this.detectedText = ocrResults.map(result => result.text).join('\n'); // this.detectedText = ocrResults.map(result => result.text).join('\n');
} catch (error) { } catch (error) {
console.error('Fehler bei der Bildverarbeitung:', error); console.error('Error processing image:', error);
} }
} }
@ -198,7 +198,10 @@ export class EditImageModalComponent implements AfterViewInit, OnDestroy {
x1: Math.round(x1), x1: Math.round(x1),
x2: Math.round(x2), x2: Math.round(x2),
y1: Math.round(y1), y1: Math.round(y1),
y2: Math.round(y2) y2: Math.round(y2),
id: rect.data?.id,
inserted: rect.data?.inserted,
updated: rect.data?.updated,
}); });
}); });
@ -221,13 +224,12 @@ export class EditImageModalComponent implements AfterViewInit, OnDestroy {
top: (canvasHeight - boxHeight) / 2, top: (canvasHeight - boxHeight) / 2,
width: boxWidth, width: boxWidth,
height: boxHeight, height: boxHeight,
fill: this.BOX_COLOR, // Verwendung der Konstante fill: this.BOX_COLOR, // Use the constant
selectable: true, selectable: true,
hasControls: true, hasControls: true,
hasBorders: true, hasBorders: true,
objectCaching: false, objectCaching: false,
}); });
rect.on('modified', () => { rect.on('modified', () => {
this.updateBoxCoordinates(); this.updateBoxCoordinates();
}); });
@ -252,12 +254,12 @@ export class EditImageModalComponent implements AfterViewInit, OnDestroy {
} }
save(): void { save(): void {
// Hier implementierst du die Logik zum Speichern der Bilddaten // Implement the logic to save the image data here
// Zum Beispiel über einen Service oder direkt hier // For example, via a service or directly here
const data = { const data = {
deckname: this.deckName, deckname: this.deckName,
bildname: this.imageData.deckImage?.name, // this.imageFile?.name, bildname: this.imageData.deckImage?.name, // this.imageFile?.name,
bildid: this.imageData.deckImage?.id, bildid: this.imageData.deckImage?.bildid,
boxes: this.boxes, boxes: this.boxes,
}; };
this.deckService.saveImageData(data).subscribe({ this.deckService.saveImageData(data).subscribe({
@ -265,11 +267,14 @@ export class EditImageModalComponent implements AfterViewInit, OnDestroy {
this.imageSaved.emit(); this.imageSaved.emit();
this.closeModal(); this.closeModal();
}, },
error: (err) => { error: err => {
console.error('Fehler beim Speichern des Bildes:', err); console.error('Error saving image:', err);
alert('Fehler beim Speichern des Bildes.'); this.popoverService.show({
title: 'Error',
message: 'Error saving image.',
});
this.closeModal(); this.closeModal();
} },
}); });
} }
} }

View File

@ -0,0 +1,12 @@
export const environment = {
production: true,
firebase: {
apiKey: 'AIzaSyBBH7mGJtwY-6_x0kCmyWCGe6JCesRS49k',
authDomain: 'haiki-452bd.firebaseapp.com',
projectId: 'haiki-452bd',
storageBucket: 'haiki-452bd.firebasestorage.app',
messagingSenderId: '263288723576',
appId: '1:263288723576:web:2bc87146ef52d276f5358d',
measurementId: 'G-C1C3N16KB3',
},
};

View File

@ -0,0 +1,12 @@
export const environment = {
production: false,
firebase: {
apiKey: 'AIzaSyBBH7mGJtwY-6_x0kCmyWCGe6JCesRS49k',
authDomain: 'haiki-452bd.firebaseapp.com',
projectId: 'haiki-452bd',
storageBucket: 'haiki-452bd.firebasestorage.app',
messagingSenderId: '263288723576',
appId: '1:263288723576:web:2bc87146ef52d276f5358d',
measurementId: 'G-C1C3N16KB3',
},
};

View File

@ -1,24 +1,22 @@
<!-- src/app/move-image-modal.component.html -->
<div class="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50"> <div class="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50">
<div class="bg-white rounded-lg shadow-lg w-96 p-6"> <div class="bg-white rounded-lg shadow-lg w-96 p-6">
<h2 class="text-xl font-semibold mb-4">Bild verschieben</h2> <h2 class="text-xl font-semibold mb-4">Move Image</h2>
<p class="mb-4">Wähle das Zieldeck für das Bild <strong>{{ image.name }}</strong> aus.</p> <p class="mb-4">Select the target deck for the image <strong>{{ image.name }}</strong>.</p>
<select [(ngModel)]="selectedDeckId" class="w-full p-2 border border-gray-300 rounded mb-4"> <select [(ngModel)]="selectedDeckId" class="w-full p-2 border border-gray-300 rounded mb-4">
<option *ngFor="let deck of decks" [value]="deck.name" [disabled]="deck.name === sourceDeck.name"> <option *ngFor="let deck of decks" [value]="deck.name" [disabled]="deck.name === sourceDeck.name">
{{ deck.name }} {{ deck.name }}
</option> </option>
</select> </select>
<div class="flex justify-end space-x-2"> <div class="flex justify-end space-x-2">
<button (click)="close()" class="bg-gray-500 text-white py-2 px-4 rounded hover:bg-gray-600"> <button (click)="close()" class="bg-gray-500 text-white py-2 px-4 rounded hover:bg-gray-600">
Abbrechen Cancel
</button> </button>
<button <button
(click)="moveImage()" (click)="moveImage()"
[disabled]="!selectedDeckId" [disabled]="!selectedDeckId"
class="bg-yellow-500 text-white py-2 px-4 rounded hover:bg-yellow-600"> class="bg-yellow-500 text-white py-2 px-4 rounded hover:bg-yellow-600">
Verschieben Move
</button> </button>
</div>
</div> </div>
</div> </div>
</div>

View File

@ -1,15 +1,14 @@
// src/app/move-image-modal.component.ts
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { DeckImage, Deck, DeckService } from '../deck.service'; import { Deck, DeckImage, DeckService } from '../services/deck.service';
import { PopoverService } from '../services/popover.service';
@Component({ @Component({
selector: 'app-move-image-modal', selector: 'app-move-image-modal',
templateUrl: './move-image-modal.component.html', templateUrl: './move-image-modal.component.html',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule] imports: [CommonModule, FormsModule],
}) })
export class MoveImageModalComponent { export class MoveImageModalComponent {
@Input() image!: DeckImage; @Input() image!: DeckImage;
@ -20,22 +19,25 @@ export class MoveImageModalComponent {
selectedDeckId: number | null = null; selectedDeckId: number | null = null;
constructor(private deckService: DeckService) { } constructor(private deckService: DeckService, private popoverService: PopoverService) {}
moveImage(): void { moveImage(): void {
if (this.selectedDeckId === null) { if (this.selectedDeckId === null) {
return; return;
} }
this.deckService.moveImage(this.image.id, this.selectedDeckId).subscribe({ this.deckService.moveImage(this.image.bildid, this.selectedDeckId).subscribe({
next: () => { next: () => {
this.moveCompleted.emit(); this.moveCompleted.emit();
this.close(); this.close();
}, },
error: (err) => { error: err => {
console.error('Fehler beim Verschieben des Bildes:', err); console.error('Error moving image:', err);
alert('Fehler beim Verschieben des Bildes.'); this.popoverService.show({
} title: 'Error',
message: 'Error moving image.',
});
},
}); });
} }

View File

@ -0,0 +1,73 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Auth } from '@angular/fire/auth';
import { from } from 'rxjs';
import { catchError, switchMap } from 'rxjs/operators';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(Auth); // Injizieren Sie den Auth-Dienst
const token = localStorage.getItem('accessToken');
if (token) {
const decodedToken = decodeToken(token);
const expirationTime = decodedToken.exp * 1000; // Umwandeln in Millisekunden
const currentTime = Date.now();
if (currentTime > expirationTime) {
// Token ist abgelaufen, erneuern Sie es
return from(refreshToken(auth)).pipe(
switchMap(newToken => {
const clonedReq = req.clone({
setHeaders: {
Authorization: `Bearer ${newToken}`,
},
});
return next(clonedReq);
}),
catchError(error => {
console.error('Failed to refresh token', error);
return next(req); // Ohne Token fortfahren
}),
);
} else {
// Token ist gültig
const clonedReq = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`,
},
});
return next(clonedReq);
}
} else {
return next(req);
}
};
async function refreshToken(auth: Auth): Promise<string> {
return new Promise((resolve, reject) => {
const unsubscribe = auth.onAuthStateChanged(async user => {
if (user) {
try {
const newToken = await user.getIdToken(true); // Token erneuern
localStorage.setItem('accessToken', newToken);
resolve(newToken);
} catch (error) {
console.error('Failed to refresh token', error);
reject(error);
}
} else {
reject(new Error('No authenticated user found'));
}
unsubscribe(); // Abonnement beenden
});
});
}
function decodeToken(token: string): any {
try {
return JSON.parse(atob(token.split('.')[1]));
} catch (e) {
console.error('Error decoding token', e);
return null;
}
}

View File

@ -0,0 +1,19 @@
import { Directive, ElementRef, EventEmitter, HostListener, Output } from '@angular/core';
@Directive({
selector: '[appClickOutside]',
standalone: true,
})
export class ClickOutsideDirective {
@Output() clickOutside = new EventEmitter<void>();
constructor(private elementRef: ElementRef) {}
@HostListener('document:click', ['$event.target'])
onClick(target: HTMLElement) {
const clickedInside = this.elementRef.nativeElement.contains(target);
if (!clickedInside) {
this.clickOutside.emit();
}
}
}

View File

@ -1,37 +1,38 @@
// src/app/deck.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { map, Observable, switchMap } from 'rxjs'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
export interface Deck { export interface Deck {
name: string; name: string;
boxOrder: 'shuffle' | 'position';
images: DeckImage[]; images: DeckImage[];
} }
export interface DeckImage { export interface DeckImage {
boxes: Box[]; boxes: Box[];
name: string; name: string;
id:string; bildid: string;
} }
export interface Box { export interface Box {
id?:number; id?: number;
x1:number; x1: number;
x2:number; x2: number;
y1:number; y1: number;
y2:number; y2: number;
due?: number; due?: number;
ivl?: number; ivl?: number;
factor?: number; factor?: number;
reps?: number; reps?: number;
lapses?: number; lapses?: number;
isGraduated?:boolean; isGraduated?: boolean;
inserted: string;
updated: string;
} }
export interface BackendBox { export interface BackendBox {
bildname: string; bildname: string;
deckid: number; deckid: number;
iconindex: number;
id: number; id: number;
x1: number; x1: number;
x2: number; x2: number;
@ -39,13 +40,13 @@ export interface BackendBox {
y2: number; y2: number;
} }
// Definiert ein einzelnes Punktpaar [x, y] // Defines a single point pair [x, y]
type OcrPoint = [number, number]; type OcrPoint = [number, number];
// Definiert die Box als Array von vier Punkten // Defines the box as an array of four points
type OcrBox = [OcrPoint, OcrPoint, OcrPoint, OcrPoint]; type OcrBox = [OcrPoint, OcrPoint, OcrPoint, OcrPoint];
// Interface für jedes JSON-Objekt // Interface for each JSON object
export interface OcrResult { export interface OcrResult {
box: OcrBox; box: OcrBox;
confidence: number; confidence: number;
@ -54,52 +55,51 @@ export interface OcrResult {
} }
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root',
}) })
export class DeckService { export class DeckService {
private apiUrl = '/api/decks'; private apiUrl = '/api/decks';
constructor(private http: HttpClient) { } constructor(private http: HttpClient) {}
getDecks(): Observable<Deck[]> { getDecks(): Observable<Deck[]> {
return this.http.get<any[]>(this.apiUrl).pipe( return this.http.get<any[]>(this.apiUrl);
map(decks => decks.map(deck => ({
name: deck.name,
images: this.groupImagesByName(deck.images)
})))
);
} }
private groupImagesByName(images: any[]): DeckImage[] { private groupImagesByName(images: any[]): DeckImage[] {
const imageMap: { [key: string]: DeckImage } = {}; const imageMap: { [key: string]: DeckImage } = {};
images.forEach(image => { images.forEach(image => {
if (!imageMap[image.id]) { if (!imageMap[image.bildid]) {
imageMap[image.id] = { imageMap[image.bildid] = {
name: image.name, name: image.name,
id:image.id, bildid: image.bildid,
boxes: [] boxes: [],
}; };
} }
imageMap[image.id].boxes.push({ imageMap[image.bildid].boxes.push({
id: image.boxid, id: image.id,
x1: image.x1, x1: image.x1,
x2: image.x2, x2: image.x2,
y1: image.y1, y1: image.y1,
y2: image.y2, y2: image.y2,
due: image.due, due: image.due,
ivl:image.ivl, ivl: image.ivl,
factor:image.factor, factor: image.factor,
reps:image.reps, reps: image.reps,
lapses:image.lapses, lapses: image.lapses,
isGraduated:image.isGraduated?true:false isGraduated: image.isGraduated ? true : false,
inserted: image.inserted,
updated: image.updated,
}); });
}); });
return Object.values(imageMap); return Object.values(imageMap);
} }
getDeck(deckname:string): Observable<Deck> {
return this.http.get<Deck>(`${this.apiUrl}/${deckname}/images`); // getDeck(deckname: string): Observable<Deck> {
} // return this.http.get<Deck>(`${this.apiUrl}/${deckname}/images`);
// }
createDeck(deckname: string): Observable<any> { createDeck(deckname: string): Observable<any> {
return this.http.post(this.apiUrl, { deckname }); return this.http.post(this.apiUrl, { deckname });
@ -108,17 +108,25 @@ export class DeckService {
deleteDeck(deckName: string): Observable<any> { deleteDeck(deckName: string): Observable<any> {
return this.http.delete(`${this.apiUrl}/${encodeURIComponent(deckName)}`); return this.http.delete(`${this.apiUrl}/${encodeURIComponent(deckName)}`);
} }
renameDeck(oldDeckName: string, newDeckName: string): Observable<any> {
saveImageData(data:any): Observable<any> { return this.http.put(`${this.apiUrl}/${encodeURIComponent(oldDeckName)}/rename`, { newDeckName });
}
updateDeck(oldDeckName: string, updateData: { newName?: string; boxOrder?: 'shuffle' | 'position' }): Observable<any> {
return this.http.put(`${this.apiUrl}/${encodeURIComponent(oldDeckName)}/update`, updateData);
}
renameImage(bildid: string, newImageName: string): Observable<any> {
return this.http.put(`${this.apiUrl}/image/${encodeURIComponent(bildid)}/rename`, { newImageName });
}
saveImageData(data: any): Observable<any> {
return this.http.post(`${this.apiUrl}/image`, data); return this.http.post(`${this.apiUrl}/image`, data);
} }
// Neue Methode zum Löschen eines Bildes // New method to delete an image
deleteImage(imageName: string): Observable<any> { deleteImage(imageName: string): Observable<any> {
return this.http.delete(`${this.apiUrl}/image/${imageName}`); return this.http.delete(`${this.apiUrl}/image/${imageName}`);
} }
// Neue Methode zum Verschieben eines Bildes // New method to move an image
moveImage(imageId: string, targetDeckId: number): Observable<any> { moveImage(imageId: string, targetDeckId: number): Observable<any> {
return this.http.post(`${this.apiUrl}/images/${imageId}/move`, { targetDeckId }); return this.http.post(`${this.apiUrl}/images/${imageId}/move`, { targetDeckId });
} }

View File

@ -0,0 +1,38 @@
// popover.service.ts
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class PopoverService {
private showPopoverSource = new Subject<{
title: string;
message: string;
showInput: boolean;
showCancel: boolean;
inputValue: string;
confirmText: string;
onConfirm: () => void;
onCancel?: () => void;
}>();
popoverState$ = this.showPopoverSource.asObservable();
show(options: { title: string; message: string; confirmText?: string; showCancel?: boolean; onConfirm?: (inputValue?: string) => void; onCancel?: () => void }) {
this.showPopoverSource.next({
showInput: false,
inputValue: null,
confirmText: 'Ok',
showCancel: false,
onConfirm: (inputValue?: string) => {},
...options,
});
}
showWithInput(options: { title: string; message: string; confirmText: string; inputValue: string; onConfirm: (inputValue?: string) => void; onCancel?: () => void }) {
this.showPopoverSource.next({
showInput: true,
showCancel: true,
...options,
});
}
}

View File

@ -0,0 +1,26 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { lastValueFrom } from 'rxjs';
export interface User {
name: string;
email: string;
sign_in_provider: string;
}
@Injectable({
providedIn: 'root',
})
export class UserService {
// Passe die URL an, je nachdem wie dein Backend-Routing definiert ist.
private apiUrl = '/api/users';
constructor(private http: HttpClient) {}
/**
* Sendet die Benutzerinformationen an das Backend.
*/
logIn(user: User): Promise<any> {
return lastValueFrom(this.http.post<any>(this.apiUrl, user));
}
}

View File

@ -1,63 +1,33 @@
<!-- src/app/training.component.html --> <div class="mt-10 mx-auto max-w-5xl">
<div class="mt-10">
<h2 class="text-2xl font-bold mb-4">Training: {{ deck.name }}</h2> <h2 class="text-2xl font-bold mb-4">Training: {{ deck.name }}</h2>
<div class="bg-white shadow rounded-lg p-6 flex flex-col items-center"> <div class="rounded-lg p-6 flex flex-col items-center">
<canvas #canvas class="mb-4 border max-h-[50vh]"></canvas> <canvas #canvas class="mb-4 border max-h-[70vh]"></canvas>
<div class="flex space-x-4 mb-4"> <div class="flex space-x-4 mb-4">
<!-- Anzeigen Button --> <!-- Show Button -->
<button <button (click)="showText()" class="bg-green-500 disabled:bg-green-200 text-white py-2 px-4 rounded hover:bg-green-600" [disabled]="isShowingBox || currentBoxIndex >= boxesToReview.length">Show</button>
(click)="showText()"
class="bg-green-500 disabled:bg-green-200 text-white py-2 px-4 rounded hover:bg-green-600" <!-- Again Button -->
[disabled]="isShowingBox || currentBoxIndex >= boxesToReview.length" <button (click)="markAgain()" class="bg-orange-500 disabled:bg-orange-200 text-white py-2 px-4 rounded hover:bg-orange-600" [disabled]="!isShowingBox || currentBoxIndex >= boxesToReview.length">
> Again ({{ getNextInterval(currentBox, 'again') }})
Anzeigen
</button>
<!-- Nochmal Button -->
<button
(click)="markAgain()"
class="bg-orange-500 disabled:bg-orange-200 text-white py-2 px-4 rounded hover:bg-orange-600"
[disabled]="!isShowingBox || currentBoxIndex >= boxesToReview.length"
>
Nochmal ({{ getNextInterval(currentBox, 'again') }})
</button>
<!-- Gut Button -->
<button
(click)="markGood()"
class="bg-blue-500 disabled:bg-blue-200 text-white py-2 px-4 rounded hover:bg-blue-600"
[disabled]="!isShowingBox || currentBoxIndex >= boxesToReview.length"
>
Gut ({{ getNextInterval(currentBox, 'good') }})
</button>
<!-- Einfach Button -->
<button
(click)="markEasy()"
class="bg-green-500 disabled:bg-green-200 text-white py-2 px-4 rounded hover:bg-green-600"
[disabled]="!isShowingBox || currentBoxIndex >= boxesToReview.length"
>
Einfach ({{ getNextInterval(currentBox, 'easy') }})
</button> </button>
<!-- Nächstes Bild Button --> <!-- Good Button -->
<button <button (click)="markGood()" class="bg-blue-500 disabled:bg-blue-200 text-white py-2 px-4 rounded hover:bg-blue-600" [disabled]="!isShowingBox || currentBoxIndex >= boxesToReview.length">
(click)="skipToNextImage()" Good ({{ getNextInterval(currentBox, 'good') }})
class="bg-yellow-500 disabled:bg-yellow-200 text-white py-2 px-4 rounded hover:bg-yellow-600"
[disabled]="currentImageIndex >= deck.images.length"
>
Nächstes Bild
</button> </button>
<!-- Easy Button -->
<button (click)="markEasy()" class="bg-green-500 disabled:bg-green-200 text-white py-2 px-4 rounded hover:bg-green-600" [disabled]="!isShowingBox || currentBoxIndex >= boxesToReview.length">
Easy ({{ getNextInterval(currentBox, 'easy') }})
</button>
<!-- Next Image Button -->
<button (click)="skipToNextImage()" class="bg-yellow-500 disabled:bg-yellow-200 text-white py-2 px-4 rounded hover:bg-yellow-600" [disabled]="currentImageIndex >= deck.images.length">Next Image</button>
</div> </div>
<p class="mt-2">{{ progress }}</p> <p class="mt-2">{{ progress }}</p>
<button <button (click)="closeTraining()" class="mt-4 text-gray-500 hover:text-gray-700 underline">End Training</button>
(click)="closeTraining()"
class="mt-4 text-gray-500 hover:text-gray-700 underline"
>
Training beenden
</button>
</div> </div>
</div> </div>

View File

@ -1,33 +1,34 @@
// training.component.ts
import { Component, Input, Output, EventEmitter, OnInit, ViewChild, ElementRef } from '@angular/core';
import { Deck, DeckImage, DeckService, Box } from '../deck.service';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core';
import { lastValueFrom } from 'rxjs'; import { lastValueFrom } from 'rxjs';
import { Box, Deck, DeckImage, DeckService } from '../services/deck.service';
import { PopoverService } from '../services/popover.service';
const LEARNING_STEPS = { const LEARNING_STEPS = {
AGAIN: 1, // 1 minute AGAIN: 1, // 1 minute
GOOD: 10, // 10 minutes GOOD: 10, // 10 minutes
GRADUATION: 1440 // 1 day (in minutes) GRADUATION: 1440, // 1 day (in minutes)
}; };
const FACTOR_CHANGES = { const FACTOR_CHANGES = {
AGAIN: 0.85, // Reduce factor by 15% AGAIN: 0.85, // Reduce factor by 15%
EASY: 1.15, // Increase factor by 15% EASY: 1.15, // Increase factor by 15%
MIN: 1.3, // Minimum factor allowed MIN: 1.3, // Minimum factor allowed
MAX: 2.9 // Maximum factor allowed MAX: 2.9, // Maximum factor allowed
}; };
const EASY_INTERVAL = 4 * 1440; // 4 days in minutes const EASY_INTERVAL = 4 * 1440; // 4 days in minutes
@Component({ @Component({
selector: 'app-training', selector: 'app-training',
templateUrl: './training.component.html', templateUrl: './training.component.html',
standalone: true, standalone: true,
imports: [CommonModule] imports: [CommonModule],
}) })
export class TrainingComponent implements OnInit { export class TrainingComponent implements OnInit {
@Input() deck!: Deck; @Input() deck!: Deck;
@Input() boxOrder: 'shuffle' | 'position' = 'shuffle'; // Standardmäßig shuffle
@Output() close = new EventEmitter<void>(); @Output() close = new EventEmitter<void>();
@ViewChild('canvas',{static : false}) canvasRef!: ElementRef<HTMLCanvasElement>; @ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
currentImageIndex: number = 0; currentImageIndex: number = 0;
currentImageData: DeckImage | null = null; currentImageData: DeckImage | null = null;
@ -38,25 +39,28 @@ export class TrainingComponent implements OnInit {
isShowingBox: boolean = false; isShowingBox: boolean = false;
isTrainingFinished: boolean = false; isTrainingFinished: boolean = false;
constructor(private deckService: DeckService) { } constructor(private deckService: DeckService, private popoverService: PopoverService) {}
ngOnInit(): void { ngOnInit(): void {
// Initialisierung wurde in ngAfterViewInit durchgeführt // Initialization was done in ngAfterViewInit
} }
ngAfterViewInit(){ ngAfterViewInit() {
// Initialisiere das erste Bild und die dazugehörigen Boxen // Initialize the first image and its boxes
if (this.deck && this.deck.images.length > 0) { if (this.deck && this.deck.images.length > 0) {
this.loadImage(this.currentImageIndex); this.loadImage(this.currentImageIndex);
} else { } else {
alert('Kein Deck oder keine Bilder vorhanden.'); this.popoverService.show({
title: 'Information',
message: 'No deck or images available.',
});
this.close.emit(); this.close.emit();
} }
} }
/** /**
* Lädt das Bild basierend auf dem gegebenen Index und initialisiert die zu überprüfenden Boxen. * Loads the image based on the given index and initializes the boxes to review.
* @param imageIndex Index des zu ladenden Bildes im Deck * @param imageIndex Index of the image to load in the deck
*/ */
loadImage(imageIndex: number): void { loadImage(imageIndex: number): void {
if (imageIndex >= this.deck.images.length) { if (imageIndex >= this.deck.images.length) {
@ -65,13 +69,13 @@ export class TrainingComponent implements OnInit {
} }
this.currentImageData = this.deck.images[imageIndex]; this.currentImageData = this.deck.images[imageIndex];
// Initialisiere die Boxen für die aktuelle Runde // Initialize the boxes for the current round
this.initializeBoxesToReview(); this.initializeBoxesToReview();
} }
/** /**
* Ermittelt alle fälligen Boxen für die aktuelle Runde, mischt sie und setzt den aktuellen Box-Index zurück. * Determines all due boxes for the current round, shuffles them, and resets the current box index.
* Wenn keine Boxen mehr zu überprüfen sind, wird zum nächsten Bild gewechselt. * If no boxes are left to review, it moves to the next image.
*/ */
initializeBoxesToReview(): void { initializeBoxesToReview(): void {
if (!this.currentImageData) { if (!this.currentImageData) {
@ -79,42 +83,47 @@ export class TrainingComponent implements OnInit {
return; return;
} }
// Filtere alle Boxen, die fällig sind (due <= heute) // Filter all boxes that are due (due <= today)
const today = this.getTodayInDays(); const today = this.getTodayInDays();
this.boxesToReview = this.currentImageData.boxes.filter(box => box.due === undefined || box.due <= today); this.boxesToReview = this.currentImageData.boxes.filter(box => box.due === undefined || box.due <= today);
if (this.boxesToReview.length === 0) { if (this.boxesToReview.length === 0) {
// Keine Boxen mehr für dieses Bild, wechsle zum nächsten Bild // No more boxes for this image, move to the next image
this.nextImage(); this.nextImage();
return; return;
} }
// Mische die Boxen zufällig // Order the boxes based on the input parameter
this.boxesToReview = this.shuffleArray(this.boxesToReview); if (this.boxOrder === 'position') {
this.boxesToReview = this.sortBoxesByPosition(this.boxesToReview);
} else {
// Default: shuffle
this.boxesToReview = this.shuffleArray(this.boxesToReview);
}
// Initialisiere den Array zur Verfolgung der enthüllten Boxen // Initialize the array to track revealed boxes
this.boxRevealed = new Array(this.boxesToReview.length).fill(false); this.boxRevealed = new Array(this.boxesToReview.length).fill(false);
// Setze den aktuellen Box-Index zurück // Reset the current box index
this.currentBoxIndex = 0; this.currentBoxIndex = 0;
this.isShowingBox = false; this.isShowingBox = false;
// Zeichne das Canvas neu // Redraw the canvas
this.drawCanvas(); this.drawCanvas();
} }
/** /**
* Gibt das heutige Datum in Tagen seit der UNIX-Epoche zurück. * Returns today's date in days since the UNIX epoch.
*/ */
getTodayInDays(): number { getTodayInDays(): number {
const epoch = new Date(1970, 0, 1); // Anki verwendet UNIX-Epoche const epoch = new Date(1970, 0, 1); // Anki uses UNIX epoch
const today = new Date(); const today = new Date();
return Math.floor((today.getTime() - epoch.getTime()) / (1000 * 60 * 60 * 24)); return Math.floor((today.getTime() - epoch.getTime()) / (1000 * 60 * 60 * 24));
} }
/** /**
* Zeichnet das aktuelle Bild und die Boxen auf das Canvas. * Draws the current image and boxes on the canvas.
* Boxen werden rot dargestellt, wenn sie verdeckt sind, und grün, wenn sie enthüllt sind. * Boxes are displayed in red if hidden and green if revealed.
*/ */
drawCanvas(): void { drawCanvas(): void {
const canvas = this.canvasRef.nativeElement; const canvas = this.canvasRef.nativeElement;
@ -122,29 +131,29 @@ export class TrainingComponent implements OnInit {
if (!ctx || !this.currentImageData) return; if (!ctx || !this.currentImageData) return;
const img = new Image(); const img = new Image();
img.src = `/api/debug_image/${this.currentImageData.id}/original_compressed.jpg`; img.src = `/images/${this.currentImageData.bildid}/original.webp`;
img.onload = () => { img.onload = () => {
// Setze die Größe des Canvas auf die Größe des Bildes // Set the canvas size to the image size
canvas.width = img.width; canvas.width = img.width;
canvas.height = img.height; canvas.height = img.height;
// Zeichne das Bild // Draw the image
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// Zeichne die Boxen // Draw the boxes
this.boxesToReview.forEach((box, index) => { this.boxesToReview.forEach((box, index) => {
ctx.beginPath(); ctx.beginPath();
ctx.rect(box.x1, box.y1, box.x2 - box.x1, box.y2 - box.y1); ctx.rect(box.x1, box.y1, box.x2 - box.x1, box.y2 - box.y1);
if (this.currentBoxIndex === index && this.isShowingBox || (box.due && box.due-this.getTodayInDays()>0)) { if ((this.currentBoxIndex === index && this.isShowingBox) || (box.due && box.due - this.getTodayInDays() > 0)) {
// Box ist aktuell enthüllt, keine Überlagerung // Box is currently revealed, no overlay
return; return;
} else if (this.currentBoxIndex === index && !this.isShowingBox) { } else if (this.currentBoxIndex === index && !this.isShowingBox) {
// Box ist enthüllt // Box is revealed
ctx.fillStyle = 'rgba(0, 255, 0, 1)'; // Undurchsichtige grüne Überlagerung ctx.fillStyle = 'rgba(0, 255, 0, 1)'; // Opaque green overlay
} else { } else {
// Box ist verdeckt // Box is hidden
ctx.fillStyle = 'rgba(255, 0, 0, 1)'; // Undurchsichtige rote Überlagerung ctx.fillStyle = 'rgba(255, 0, 0, 1)'; // Opaque red overlay
} }
ctx.fill(); ctx.fill();
ctx.lineWidth = 2; ctx.lineWidth = 2;
@ -154,36 +163,75 @@ export class TrainingComponent implements OnInit {
}; };
img.onerror = () => { img.onerror = () => {
console.error('Fehler beim Laden des Bildes für Canvas.'); console.error('Error loading image for canvas.');
alert('Fehler beim Laden des Bildes für Canvas.'); this.popoverService.show({
title: 'Error',
message: 'Error loading image for canvas.',
});
this.close.emit(); this.close.emit();
}; };
} }
/** /**
* Mischt ein Array zufällig. * Sorts boxes by their position (left to right, top to bottom)
* @param array Das zu mischende Array */
* @returns Das gemischte Array /**
* Sorts boxes by their position (left to right, top to bottom) with better row detection
*/
sortBoxesByPosition(boxes: Box[]): Box[] {
// First create a copy of the array
const boxesCopy = [...boxes];
// Determine the average box height to use for row grouping tolerance
const avgHeight = boxesCopy.reduce((sum, box) => sum + (box.y2 - box.y1), 0) / boxesCopy.length;
const rowTolerance = avgHeight * 0.4; // 40% of average box height as tolerance
// Group boxes into rows
const rows: Box[][] = [];
boxesCopy.forEach(box => {
// Find an existing row within tolerance
const row = rows.find(r => Math.abs(r[0].y1 - box.y1) < rowTolerance || Math.abs(r[0].y2 - box.y2) < rowTolerance);
if (row) {
row.push(box);
} else {
rows.push([box]);
}
});
// Sort each row by x1 (left to right)
rows.forEach(row => row.sort((a, b) => a.x1 - b.x1));
// Sort rows by y1 (top to bottom)
rows.sort((a, b) => a[0].y1 - b[0].y1);
// Flatten the rows into a single array
return rows.flat();
}
/**
* Shuffles an array randomly.
* @param array The array to shuffle
* @returns The shuffled array
*/ */
shuffleArray<T>(array: T[]): T[] { shuffleArray<T>(array: T[]): T[] {
let currentIndex = array.length, randomIndex; let currentIndex = array.length,
randomIndex;
// Solange noch Elemente zum Mischen vorhanden sind // While there are elements to shuffle
while (currentIndex !== 0) { while (currentIndex !== 0) {
// Wähle ein verbleibendes Element // Pick a remaining element
randomIndex = Math.floor(Math.random() * currentIndex); randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex--; currentIndex--;
// Tausche es mit dem aktuellen Element // Swap it with the current element
[array[currentIndex], array[randomIndex]] = [ [array[currentIndex], array[randomIndex]] = [array[randomIndex], array[currentIndex]];
array[randomIndex], array[currentIndex]];
} }
return array; return array;
} }
/** /**
* Gibt die aktuelle Box zurück, die überprüft wird. * Returns the current box being reviewed.
*/ */
get currentBox(): Box | null { get currentBox(): Box | null {
if (this.currentBoxIndex < this.boxesToReview.length) { if (this.currentBoxIndex < this.boxesToReview.length) {
@ -193,7 +241,7 @@ export class TrainingComponent implements OnInit {
} }
/** /**
* Zeigt den Inhalt der aktuellen Box an. * Reveals the content of the current box.
*/ */
showText(): void { showText(): void {
if (this.currentBoxIndex >= this.boxesToReview.length) return; if (this.currentBoxIndex >= this.boxesToReview.length) return;
@ -203,7 +251,7 @@ export class TrainingComponent implements OnInit {
} }
/** /**
* Markiert die aktuelle Box als "Nochmal" und fährt mit der nächsten Box fort. * Marks the current box as "Again" and proceeds to the next box.
*/ */
async markAgain(): Promise<void> { async markAgain(): Promise<void> {
await this.updateCard('again'); await this.updateCard('again');
@ -212,7 +260,7 @@ export class TrainingComponent implements OnInit {
} }
/** /**
* Markiert die aktuelle Box als "Gut" und fährt mit der nächsten Box fort. * Marks the current box as "Good" and proceeds to the next box.
*/ */
async markGood(): Promise<void> { async markGood(): Promise<void> {
await this.updateCard('good'); await this.updateCard('good');
@ -221,7 +269,7 @@ export class TrainingComponent implements OnInit {
} }
/** /**
* Markiert die aktuelle Box als "Einfach" und fährt mit der nächsten Box fort. * Marks the current box as "Easy" and proceeds to the next box.
*/ */
async markEasy(): Promise<void> { async markEasy(): Promise<void> {
await this.updateCard('easy'); await this.updateCard('easy');
@ -230,8 +278,8 @@ export class TrainingComponent implements OnInit {
} }
/** /**
* Aktualisiert die SRS-Daten der aktuellen Box basierend auf der gegebenen Aktion. * Updates the SRS data of the current box based on the given action.
* @param action Die durchgeführte Aktion ('again', 'good', 'easy') * @param action The action performed ('again', 'good', 'easy')
*/ */
async updateCard(action: 'again' | 'good' | 'easy'): Promise<void> { async updateCard(action: 'again' | 'good' | 'easy'): Promise<void> {
if (this.currentBoxIndex >= this.boxesToReview.length) return; if (this.currentBoxIndex >= this.boxesToReview.length) return;
@ -239,100 +287,122 @@ export class TrainingComponent implements OnInit {
const box = this.boxesToReview[this.currentBoxIndex]; const box = this.boxesToReview[this.currentBoxIndex];
const today = this.getTodayInDays(); const today = this.getTodayInDays();
// Berechne das neue Intervall und eventuell den neuen Faktor // Calculate the new interval and possibly the new factor
const { newIvl, newFactor, newReps, newLapses, newIsGraduated } = this.calculateNewInterval(box, action); const { newIvl, newFactor, newReps, newLapses, newIsGraduated } = this.calculateNewInterval(box, action);
// Aktualisiere das Fälligkeitsdatum // Update the due date
const nextDue = today + Math.floor(newIvl/1440); const nextDue = today + Math.floor(newIvl / 1440);
// Aktualisiere das Box-Objekt // Update the box object
box.ivl = newIvl; box.ivl = newIvl;
box.factor = newFactor; box.factor = newFactor;
box.reps = newReps; box.reps = newReps;
box.lapses = newLapses; box.lapses = newLapses;
box.due = nextDue; box.due = nextDue;
box.isGraduated = newIsGraduated box.isGraduated = newIsGraduated;
// Sende das Update an das Backend // Send the update to the backend
try { try {
await lastValueFrom(this.deckService.updateBox(box)); await lastValueFrom(this.deckService.updateBox(box));
} catch (error) { } catch (error) {
console.error('Fehler beim Aktualisieren der Box:', error); console.error('Error updating box:', error);
alert('Fehler beim Aktualisieren der Box.'); this.popoverService.show({
title: 'Error',
message: 'Error updating box.',
});
} }
} }
/** /**
* Berechnet das neue Intervall, den Faktor, die Wiederholungen und die Lapses basierend auf der Aktion. * Calculates the new interval, factor, repetitions, and lapses based on the action.
* @param box Die aktuelle Box * @param box The current box
* @param action Die Aktion ('again', 'good', 'easy') * @param action The action ('again', 'good', 'easy')
* @returns Ein Objekt mit den neuen Werten für ivl, factor, reps und lapses * @returns An object with the new values for ivl, factor, reps, and lapses
*/ */
calculateNewInterval(box: Box, action: 'again' | 'good' | 'easy'): { newIvl: number, newFactor: number, newReps: number, newLapses: number, newIsGraduated: boolean } { calculateNewInterval(
box: Box,
action: 'again' | 'good' | 'easy',
): {
newIvl: number;
newFactor: number;
newReps: number;
newLapses: number;
newIsGraduated: boolean;
} {
const LEARNING_STEPS = {
AGAIN: 1, // 1 minute
GOOD: 10, // 10 minutes
GRADUATION: 1440, // 1 day (1440 minutes)
};
const EASY_BONUS = 1.3; // Zusätzlicher Easy-Multiplikator
/* const FACTOR_CHANGES = {
MIN: 1.3,
MAX: 2.5,
AGAIN: 0.85,
EASY: 1.15
}; */
let newIvl = box.ivl || 0; let newIvl = box.ivl || 0;
let newFactor = box.factor || 2.5; let newFactor = box.factor || 2.5;
let newReps = box.reps || 0; let newReps = box.reps || 0;
let newLapses = box.lapses || 0; let newLapses = box.lapses || 0;
let newIsGraduated = box.isGraduated || false let newIsGraduated = box.isGraduated || false;
if (action === 'again') { if (action === 'again') {
newLapses++; newLapses++;
newReps = 0; newReps = 0;
newIvl = LEARNING_STEPS.AGAIN; newIvl = LEARNING_STEPS.AGAIN;
newIsGraduated = false; newIsGraduated = false;
newFactor = Math.max(FACTOR_CHANGES.MIN, newFactor * FACTOR_CHANGES.AGAIN);
// Reduce factor but not below minimum return { newIvl, newFactor, newReps, newLapses, newIsGraduated };
newFactor = Math.max(
FACTOR_CHANGES.MIN,
newFactor * FACTOR_CHANGES.AGAIN
);
return { newIvl, newFactor, newReps, newLapses, newIsGraduated };
} }
if (action === 'easy') { if (action === 'easy') {
newReps++; newReps++;
// Faktor zuerst aktualisieren
const updatedFactor = Math.min(FACTOR_CHANGES.MAX, newFactor * FACTOR_CHANGES.EASY);
if (!newIsGraduated) {
// Direkte Graduierung mit Easy
newIsGraduated = true; newIsGraduated = true;
newIvl = EASY_INTERVAL; newIvl = LEARNING_STEPS.GRADUATION; // 1 Tag (1440 Minuten)
} else {
// Increase factor but not above maximum // Anki-Formel für Easy in der Review-Phase
newFactor = Math.min( newIvl = Math.round((newIvl + LEARNING_STEPS.GRADUATION / 2) * updatedFactor * EASY_BONUS);
FACTOR_CHANGES.MAX, }
newFactor * FACTOR_CHANGES.EASY
); newFactor = updatedFactor;
return { newIvl, newFactor, newReps, newLapses, newIsGraduated };
return { newIvl, newFactor, newReps, newLapses, newIsGraduated };;
} }
// Handle 'good' action // Handle 'good' action
newReps++; newReps++;
if (!newIsGraduated) { if (!newIsGraduated) {
// Card is in learning phase if (newReps === 1) {
if (newReps === 1) { newIvl = LEARNING_STEPS.GOOD; // 10 Minuten
newIvl = LEARNING_STEPS.GOOD; } else {
} else { newIsGraduated = true;
// Graduate the card newIvl = LEARNING_STEPS.GRADUATION; // 1 Tag nach zweiter Good-Antwort
newIsGraduated = true; }
newIvl = LEARNING_STEPS.GRADUATION;
}
} else { } else {
// Card is in review phase, apply space repetition // Standard-SR-Formel
newIvl = Math.round(newIvl * newFactor); newIvl = Math.round(newIvl * newFactor);
} }
return { newIvl, newFactor, newReps, newLapses, newIsGraduated };; return { newIvl, newFactor, newReps, newLapses, newIsGraduated };
} }
/** /**
* Berechnet das nächste Intervall basierend auf der Aktion und gibt es als String zurück. * Calculates the next interval based on the action and returns it as a string.
* @param box Die aktuelle Box * @param box The current box
* @param action Die Aktion ('again', 'good', 'easy') * @param action The action ('again', 'good', 'easy')
* @returns Das nächste Intervall als String (z.B. "10 min", "2 d") * @returns The next interval as a string (e.g., "10 min", "2 d")
*/ */
getNextInterval(box: Box | null, action: 'again' | 'good' | 'easy'): string { getNextInterval(box: Box | null, action: 'again' | 'good' | 'easy'): string {
if (!box) if (!box) return '';
return '';
const { newIvl } = this.calculateNewInterval(box, action); const { newIvl } = this.calculateNewInterval(box, action);
@ -340,18 +410,18 @@ export class TrainingComponent implements OnInit {
} }
/** /**
* Formatiert das Intervall als String, entweder in Minuten oder Tagen. * Formats the interval as a string, either in minutes or days.
* @param ivl Das Intervall in Tagen * @param ivl The interval in days
* @returns Das formatierte Intervall als String * @returns The formatted interval as a string
*/ */
formatInterval(minutes: number): string { formatInterval(minutes: number): string {
if (minutes < 60) return `${minutes}min`; if (minutes < 60) return `${minutes}min`;
if (minutes < 1440) return `${Math.round(minutes/60)}h`; if (minutes < 1440) return `${Math.round(minutes / 60)}h`;
return `${Math.round(minutes/1440)}d`; return `${Math.round(minutes / 1440)}d`;
} }
/** /**
* Deckt die aktuelle Box wieder auf (verdeckt sie erneut). * Covers the current box again (hides it).
*/ */
coverCurrentBox(): void { coverCurrentBox(): void {
this.boxRevealed[this.currentBoxIndex] = false; this.boxRevealed[this.currentBoxIndex] = false;
@ -359,24 +429,24 @@ export class TrainingComponent implements OnInit {
} }
/** /**
* Geht zur nächsten Box. Wenn alle Boxen in der aktuellen Runde bearbeitet wurden, * Moves to the next box. If all boxes in the current round have been processed,
* wird eine neue Runde gestartet. * a new round is started.
*/ */
nextBox(): void { nextBox(): void {
this.isShowingBox = false; this.isShowingBox = false;
if (this.currentBoxIndex >= this.boxesToReview.length - 1) { if (this.currentBoxIndex >= this.boxesToReview.length - 1) {
// Runde abgeschlossen, starte eine neue Runde // Round completed, start a new round
this.initializeBoxesToReview(); this.initializeBoxesToReview();
} else { } else {
// Gehe zur nächsten Box // Move to the next box
this.currentBoxIndex++; this.currentBoxIndex++;
this.drawCanvas(); this.drawCanvas();
} }
} }
/** /**
* Wechselt zum nächsten Bild im Deck. * Moves to the next image in the deck.
*/ */
nextImage(): void { nextImage(): void {
this.currentImageIndex++; this.currentImageIndex++;
@ -384,39 +454,48 @@ export class TrainingComponent implements OnInit {
} }
/** /**
* Überspringt zum nächsten Bild im Deck. * Skips to the next image in the deck.
*/ */
skipToNextImage(): void { skipToNextImage(): void {
if (this.currentImageIndex < this.deck.images.length - 1) { if (this.currentImageIndex < this.deck.images.length - 1) {
this.currentImageIndex++; this.currentImageIndex++;
this.loadImage(this.currentImageIndex); this.loadImage(this.currentImageIndex);
} else { } else {
alert('Dies ist das letzte Bild im Deck.'); this.popoverService.show({
title: 'Information',
message: 'This is the last image in the deck.',
});
} }
} }
/** /**
* Beendet das Training und gibt eine Abschlussmeldung aus. * Ends the training and displays a completion message.
*/ */
endTraining(): void { endTraining(): void {
this.isTrainingFinished = true; this.isTrainingFinished = true;
alert(`Training beendet!`); this.popoverService.show({
title: 'Information',
message: 'Training completed!',
});
this.close.emit(); this.close.emit();
} }
/** /**
* Fragt den Benutzer, ob das Training beendet werden soll, und schließt es gegebenenfalls. * Asks the user if they want to end the training and closes it if confirmed.
*/ */
closeTraining(): void { closeTraining(): void {
if (confirm('Möchtest du das Training wirklich beenden?')) { this.popoverService.show({
this.close.emit(); title: 'End Training',
} message: 'Do you really want to end the training?',
confirmText: 'End Training',
onConfirm: (inputValue?: string) => this.close.emit(),
});
} }
/** /**
* Gibt den Fortschritt des Trainings an, z.B. "Bild 2 von 5". * Returns the progress of the training, e.g., "Image 2 of 5".
*/ */
get progress(): string { get progress(): string {
return `Bild ${this.currentImageIndex + 1} von ${this.deck.images.length}`; return `Image ${this.currentImageIndex + 1} of ${this.deck.images.length}`;
} }
} }

View File

@ -1,34 +0,0 @@
<!-- src/app/upload-image-modal.component.html -->
<div #uploadImageModal id="uploadImageModal" tabindex="-1" aria-hidden="true" class="fixed top-0 left-0 right-0 z-50 hidden w-full p-4 overflow-x-hidden overflow-y-auto md:inset-0 h-modal md:h-full">
<div class="relative h-full contents">
<div class="relative bg-white rounded-lg shadow">
<button type="button" class="absolute top-3 right-2.5 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center" (click)="closeModal()">
<svg aria-hidden="true" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
</svg>
<span class="sr-only">Schließen</span>
</button>
<div class="p-6 relative">
<h3 class="mb-4 text-xl font-medium text-gray-900">Bild zu Deck hinzufügen</h3>
<!-- Formular zum Hochladen -->
<div class="mb-4">
<label for="imageFile" class="block text-sm font-medium text-gray-700">Bild hochladen</label>
<input #imageFile type="file" id="imageFile" (change)="onFileChange($event)" accept="image/*" required class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
</div>
<!-- Statusanzeige -->
<div *ngIf="processingStatus" class="mt-4">
<p class="text-sm text-gray-700">{{ processingStatus }}</p>
</div>
<!-- Ladeanzeige als Overlay -->
<div *ngIf="loading" class="absolute inset-0 bg-gray-800 bg-opacity-50 flex items-center justify-center z-10">
<div class="bg-white p-4 rounded shadow">
<p class="text-sm text-gray-700">Verarbeitung läuft...</p>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -1,113 +0,0 @@
// src/app/upload-image-modal.component.ts
import { Component, Input, Output, EventEmitter, AfterViewInit, ViewChild, ElementRef, OnDestroy } from '@angular/core';
import { Box, DeckImage, DeckService, OcrResult } from '../deck.service';
import { CommonModule } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Modal } from 'flowbite';
@Component({
selector: 'app-upload-image-modal',
templateUrl: './upload-image-modal.component.html',
standalone: true,
imports: [CommonModule]
})
export class UploadImageModalComponent implements AfterViewInit, OnDestroy {
@Input() deckName: string = '';
@Output() imageUploaded = new EventEmitter<{ imageSrc: string | ArrayBuffer | null | undefined, deckImage:DeckImage }>();
@ViewChild('uploadImageModal') modalElement!: ElementRef;
@ViewChild('imageFile') imageFileElement!: ElementRef;
imageFile: File | null = null;
processingStatus: string = '';
loading: boolean = false;
modal: any;
constructor(private deckService: DeckService, private http: HttpClient) { }
ngAfterViewInit(): void {
this.modal = new Modal(this.modalElement.nativeElement);
}
ngOnDestroy(): void {
// Modal wird automatisch von Flowbite verwaltet
}
open(): void {
this.resetState();
this.modal.show();
}
closeModal(): void {
this.modal.hide();
}
resetState(): void {
this.imageFile = null;
this.processingStatus = '';
this.loading = false;
}
onFileChange(event: any): void {
const file: File = event.target.files[0];
if (!file) return;
this.imageFile = file;
this.processingStatus = 'Verarbeitung läuft...';
this.loading = true;
const reader = new FileReader();
reader.onload = async (e) => {
const imageSrc = e.target?.result;
// Bild als Base64-String ohne Präfix (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.processingStatus = 'Ungültige Antwort vom OCR-Service';
this.loading = false;
return;
}
this.processingStatus = 'Verarbeitung abgeschlossen';
this.loading = false;
// Emit Event mit Bilddaten und OCR-Ergebnissen
const bildname=this.imageFile?.name??'';
const bildid=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})
});
const deckImage:DeckImage={name:bildname,id:bildid,boxes}
this.imageUploaded.emit({ imageSrc, deckImage });
this.resetFileInput();
// Schließe das Upload-Modal
this.closeModal();
} catch (error) {
console.error('Fehler beim OCR-Service:', error);
this.processingStatus = 'Fehler beim OCR-Service';
this.loading = false;
}
};
reader.readAsDataURL(file);
}
/**
* Setzt das Datei-Input-Feld zurück, sodass dieselbe Datei erneut ausgewählt werden kann.
*/
resetFileInput(): void {
if (this.imageFileElement && this.imageFileElement.nativeElement) {
this.imageFileElement.nativeElement.value = '';
}
}
}

19
src/assets/logo.svg Normal file
View File

@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
<!-- Hintergrund -->
<rect width="40" height="40" fill="#ffffff"/>
<!-- Basis H - Rot -->
<path d="M6 4 H14 V16 H26 V4 H34 V36 H26 V24 H14 V36 H6 Z" fill="#FF4F4F"/>
<!-- Grünes Element -->
<path d="M6 4 H14 V16 H26 V4 H34 V20 H6 Z" fill="#2ECC40"/>
<!-- Gelber Bereich -->
<path d="M10 8 H16 V16 H24 V8 H30 V32 H24 V24 H16 V32 H10 Z" fill="#FFBE00"/>
<!-- Jadegrüner Bereich -->
<path d="M12 12 H18 V16 H22 V12 H28 V28 H22 V24 H18 V28 H12 Z" fill="#00C4A7"/>
<!-- Innerstes blaues Element -->
<path d="M14 14 H19 V16 H21 V14 H26 V26 H21 V24 H19 V26 H14 Z" fill="#0077B3"/>
</svg>

After

Width:  |  Height:  |  Size: 682 B

View File

@ -1,13 +1,22 @@
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<title>Vokabeltraining</title> <title>Vokabeltraining</title>
<base href="/"> <base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico"> <link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png" />
</head> <link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png" />
<body> <link rel="icon" type="image/png" sizes="48x48" href="favicon-48x48.png" />
<app-root></app-root> <style>
</body> body {
margin: 0;
height: 100vh;
background: rgba(0, 119, 179, 0.1);
}
</style>
</head>
<body>
<app-root></app-root>
</body>
</html> </html>

View File

@ -1,4 +1,4 @@
/* You can add global styles to this file, and also import other style files */ @use 'tailwindcss' as *;
@tailwind base; button {
@tailwind components; cursor: pointer;
@tailwind utilities; }

View File

@ -1,13 +1,13 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
content: [ content: [
"./src/**/*.{html,ts}", "./src/**/*.{html,ts}",
"./node_modules/flowbite/**/*.js" // add this line "./node_modules/flowbite/**/*.js" // add this line
], ],
theme: { theme: {
extend: {}, extend: {},
}, },
plugins: [ plugins: [
require('flowbite/plugin') require('flowbite/plugin')
], ],
} }

View File

@ -1,8 +1,10 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{ {
"compileOnSave": false, "compileOnSave": false,
"compilerOptions": { "compilerOptions": {
"outDir": "./dist/out-tsc", "outDir": "./dist/out-tsc",
"strict": true, "strict": false,
"noImplicitOverride": true, "noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true, "noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true, "noImplicitReturns": true,
@ -10,17 +12,11 @@
"skipLibCheck": true, "skipLibCheck": true,
"isolatedModules": true, "isolatedModules": true,
"esModuleInterop": true, "esModuleInterop": true,
"sourceMap": true,
"declaration": false,
"experimentalDecorators": true, "experimentalDecorators": true,
"moduleResolution": "bundler", "moduleResolution": "bundler",
"importHelpers": true, "importHelpers": true,
"target": "ES2022", "target": "ES2022",
"module": "ES2022", "module": "ES2022"
"lib": [
"ES2022",
"dom"
]
}, },
"angularCompilerOptions": { "angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false, "enableI18nLegacyMessageIdFormat": false,