diff --git a/package-lock.json b/package-lock.json index 6eb2e17..ea5c339 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@angular/material": "^20.2.9", "@angular/platform-browser": "^20.3.0", "@angular/router": "^20.3.0", - "@infinimotion/model-frontend": "^0.0.84", + "@infinimotion/model-frontend": "^0.0.85", "@tailwindcss/postcss": "^4.1.14", "postcss": "^8.5.6", "rxjs": "~7.8.0", @@ -400,6 +400,7 @@ "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-20.2.9.tgz", "integrity": "sha512-rbY1AMz9389WJI29iAjWp4o0QKRQHCrQQUuP0ctNQzh1tgWpwiRLx8N4yabdVdsCA846vPsyKJtBlSNwKMsjJA==", "license": "MIT", + "peer": true, "dependencies": { "parse5": "^8.0.0", "tslib": "^2.3.0" @@ -446,6 +447,7 @@ "node_modules/@angular/common": { "version": "20.3.5", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -460,6 +462,7 @@ "node_modules/@angular/compiler": { "version": "20.3.5", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -471,6 +474,7 @@ "version": "20.3.5", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "7.28.3", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -501,6 +505,7 @@ "node_modules/@angular/core": { "version": "20.3.5", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -524,6 +529,7 @@ "node_modules/@angular/forms": { "version": "20.3.5", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -557,6 +563,7 @@ "node_modules/@angular/platform-browser": { "version": "20.3.5", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -615,6 +622,7 @@ "version": "7.28.3", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -1290,9 +1298,9 @@ } }, "node_modules/@infinimotion/model-frontend": { - "version": "0.0.84", - "resolved": "https://git.infinimotion.de/api/packages/infinimotion/npm/%40infinimotion%2Fmodel-frontend/-/0.0.84/model-frontend-0.0.84.tgz", - "integrity": "sha512-+SMFsobPpfh6H9cU54DfVl9sF9Mp1vj6HuB135Y+grWvk/nIN4wzzZLvYPIk3BDURTT1DHgg8O3m66FaBB22sQ==", + "version": "0.0.85", + "resolved": "https://git.infinimotion.de/api/packages/infinimotion/npm/%40infinimotion%2Fmodel-frontend/-/0.0.85/model-frontend-0.0.85.tgz", + "integrity": "sha512-QPiZNl//Y1JdxtXk+VScc67h1K664z68PUCXRff9fRf4IHlYXtqutc+ainK8vxOVSqqL6EEmDAtbLsRwrG6kRg==", "license": "ISC" }, "node_modules/@inquirer/ansi": { @@ -1507,6 +1515,7 @@ "version": "7.8.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@inquirer/checkbox": "^4.2.1", "@inquirer/confirm": "^5.1.14", @@ -3593,6 +3602,7 @@ "version": "24.8.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.14.0" } @@ -3878,6 +3888,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -4766,6 +4777,7 @@ "version": "5.1.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -5571,7 +5583,8 @@ "node_modules/jasmine-core": { "version": "5.9.0", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/jiti": { "version": "2.6.1", @@ -5645,6 +5658,7 @@ "version": "6.4.4", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@colors/colors": "1.5.0", "body-parser": "^1.19.0", @@ -6270,6 +6284,7 @@ "version": "9.0.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cli-truncate": "^4.0.0", "colorette": "^2.0.20", @@ -7728,6 +7743,7 @@ "node_modules/rxjs": { "version": "7.8.2", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -7776,6 +7792,7 @@ "version": "1.90.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -8432,7 +8449,8 @@ }, "node_modules/tslib": { "version": "2.8.1", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tuf-js": { "version": "3.1.0", @@ -8464,6 +8482,7 @@ "version": "5.9.3", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8622,6 +8641,7 @@ "version": "7.1.5", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -8971,6 +8991,7 @@ "version": "3.25.76", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -8985,7 +9006,8 @@ }, "node_modules/zone.js": { "version": "0.15.1", - "license": "MIT" + "license": "MIT", + "peer": true } } } diff --git a/package.json b/package.json index e456362..5027aa3 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@angular/material": "^20.2.9", "@angular/platform-browser": "^20.3.0", "@angular/router": "^20.3.0", - "@infinimotion/model-frontend": "^0.0.84", + "@infinimotion/model-frontend": "^0.0.85", "@tailwindcss/postcss": "^4.1.14", "postcss": "^8.5.6", "rxjs": "~7.8.0", diff --git a/src/app/app-module.ts b/src/app/app-module.ts index 970fd36..751011c 100644 --- a/src/app/app-module.ts +++ b/src/app/app-module.ts @@ -16,7 +16,7 @@ import { MatSnackBarModule } from '@angular/material/snack-bar'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatInputModule } from '@angular/material/input'; import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconButton } from '@angular/material/button'; +import { MatButtonModule, MatIconButton } from '@angular/material/button'; import { MatDividerModule } from '@angular/material/divider'; import { HeaderComponent } from './header/header.component'; @@ -41,8 +41,11 @@ import { SeatComponent } from './seat/seat.component'; import { SeatRowComponent } from './seat-row/seat-row.component'; import { TheaterLayoutComponent } from './theater-layout/theater-layout.component'; import { MovieSearchComponent } from './movie-search/movie-search.component'; -import { ScheduleHeaderComponent } from './schedule-header/schedule-header.component'; +import { MenuHeaderComponent } from './menu-header/menu-header.component'; import { MovieScheduleNoSearchResultComponent } from './movie-schedule-no-search-result/movie-schedule-no-search-result.component'; +import { MovieImporterComponent } from './movie-importer/movie-importer.component'; +import { MovieImportNoSearchResultComponent } from './movie-import-no-search-result/movie-import-no-search-result.component'; +import { MovieImportSearchInfoComponent } from './movie-import-search-info/movie-import-search-info.component'; @NgModule({ @@ -69,8 +72,11 @@ import { MovieScheduleNoSearchResultComponent } from './movie-schedule-no-search SeatRowComponent, TheaterLayoutComponent, MovieSearchComponent, - ScheduleHeaderComponent, - MovieScheduleNoSearchResultComponent + MenuHeaderComponent, + MovieScheduleNoSearchResultComponent, + MovieImporterComponent, + MovieImportNoSearchResultComponent, + MovieImportSearchInfoComponent, ], imports: [ AppRoutingModule, @@ -87,7 +93,8 @@ import { MovieScheduleNoSearchResultComponent } from './movie-schedule-no-search MatInputModule, MatFormFieldModule, MatIconButton, - MatDividerModule + MatDividerModule, + MatButtonModule ], providers: [ provideBrowserGlobalErrorListeners(), diff --git a/src/app/app-routing-module.ts b/src/app/app-routing-module.ts index 81ccf1b..4aaacf6 100644 --- a/src/app/app-routing-module.ts +++ b/src/app/app-routing-module.ts @@ -6,6 +6,7 @@ import { MainLayoutComponent } from './layouts/main-layout/main-layout.component import { MainComponent } from './main/main.component'; import { ScheduleComponent } from './schedule/schedule.component'; import { TheaterOverlayComponent} from './theater-overlay/theater-overlay.component'; +import { MovieImporterComponent } from './movie-importer/movie-importer.component'; const routes: Routes = [ // Seiten ohne Layout @@ -19,6 +20,7 @@ const routes: Routes = [ children: [ { path: '', component: MainComponent }, { path: 'schedule', component: ScheduleComponent }, + { path: 'admin/movie-importer', component: MovieImporterComponent }, { path: 'selection/performance/:id', component: TheaterOverlayComponent}, //? ], }, diff --git a/src/app/http.service.ts b/src/app/http.service.ts index 2429917..5e7b342 100644 --- a/src/app/http.service.ts +++ b/src/app/http.service.ts @@ -1,4 +1,4 @@ -import { Kinosaal, Sitzplatz, Vorstellung, Film } from '@infinimotion/model-frontend'; +import { Kinosaal, Sitzplatz, Vorstellung, Film, OmdbSearch } from '@infinimotion/model-frontend'; import { HttpClient } from "@angular/common/http"; import { inject, Injectable } from "@angular/core"; import { Observable } from "rxjs"; @@ -52,7 +52,7 @@ export class HttpService { } /* POST /api/vorstellung/filter */ - getPerformaceByFilter(filter: string[]): Observable { + getPerformacesByFilter(filter: string[]): Observable { return this.http.post(`${this.baseUrl}vorstellung/filter`, filter); } @@ -79,11 +79,31 @@ export class HttpService { return this.http.get(`${this.baseUrl}film`); } + /* POST /api/vorstellung/filter */ + getMoviesByFilter(filter: string[]): Observable { + return this.http.post(`${this.baseUrl}film/filter`, filter); + } - /* Show-Seats APIs*/ + + /* Show-Seats APIs */ /* GET /api/show-seats/{show} */ getSeatsByShowId(show: number): Observable<{seats:Sitzplatz[], reserved:Sitzplatz[], booked:Sitzplatz[]}> { return this.http.get<{seats:Sitzplatz[], reserved:Sitzplatz[], booked:Sitzplatz[]}>(`${this.baseUrl}show-seats/${show}`); } + + + /* Movie Importer APIs */ + + /* GET /api/importer/search */ + searchMovie(query: string): Observable { + return this.http.get(`${this.baseUrl}importer/search`, { + params: { title: query } + }); + } + + /* POST /api/importer/import */ + importMovie(imdbId: string): Observable { + return this.http.post(`${this.baseUrl}importer/import?id=${imdbId}`, {}) + } } diff --git a/src/app/schedule-header/schedule-header.component.css b/src/app/menu-header/menu-header.component.css similarity index 100% rename from src/app/schedule-header/schedule-header.component.css rename to src/app/menu-header/menu-header.component.css diff --git a/src/app/menu-header/menu-header.component.html b/src/app/menu-header/menu-header.component.html new file mode 100644 index 0000000..1941a29 --- /dev/null +++ b/src/app/menu-header/menu-header.component.html @@ -0,0 +1,14 @@ +
+
+ @if ( icon() ) { + {{ icon() }} + } +

+ {{ title() }} +

+
+ @if ( searchBar() ) { + + } +
+ diff --git a/src/app/menu-header/menu-header.component.ts b/src/app/menu-header/menu-header.component.ts new file mode 100644 index 0000000..18155ff --- /dev/null +++ b/src/app/menu-header/menu-header.component.ts @@ -0,0 +1,15 @@ +import { Component, input, output } from '@angular/core'; + +@Component({ + selector: 'app-menu-header', + standalone: false, + templateUrl: './menu-header.component.html', + styleUrl: './menu-header.component.css' +}) +export class MenuHeaderComponent { + title = input.required(); + icon = input(); + + searchBar = input(false); + movieSearchResult = output(); +} diff --git a/src/app/movie-import-no-search-result/movie-import-no-search-result.component.css b/src/app/movie-import-no-search-result/movie-import-no-search-result.component.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/movie-import-no-search-result/movie-import-no-search-result.component.html b/src/app/movie-import-no-search-result/movie-import-no-search-result.component.html new file mode 100644 index 0000000..89dc33a --- /dev/null +++ b/src/app/movie-import-no-search-result/movie-import-no-search-result.component.html @@ -0,0 +1,5 @@ +
+ search_off +

Kein Film gefunden

+

Für '{{ search() }}' konnten über IMDb keine Filme gefunden werden.

+
diff --git a/src/app/movie-import-no-search-result/movie-import-no-search-result.component.ts b/src/app/movie-import-no-search-result/movie-import-no-search-result.component.ts new file mode 100644 index 0000000..ec1ab4b --- /dev/null +++ b/src/app/movie-import-no-search-result/movie-import-no-search-result.component.ts @@ -0,0 +1,11 @@ +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'app-movie-import-no-search-result', + standalone: false, + templateUrl: './movie-import-no-search-result.component.html', + styleUrl: './movie-import-no-search-result.component.css' +}) +export class MovieImportNoSearchResultComponent { + search = input.required(); +} diff --git a/src/app/movie-import-search-info/movie-import-search-info.component.css b/src/app/movie-import-search-info/movie-import-search-info.component.css new file mode 100644 index 0000000..0426c40 --- /dev/null +++ b/src/app/movie-import-search-info/movie-import-search-info.component.css @@ -0,0 +1,4 @@ +:host { + display: block; + margin: 60px 0; +} diff --git a/src/app/movie-import-search-info/movie-import-search-info.component.html b/src/app/movie-import-search-info/movie-import-search-info.component.html new file mode 100644 index 0000000..fa3bab7 --- /dev/null +++ b/src/app/movie-import-search-info/movie-import-search-info.component.html @@ -0,0 +1,22 @@ +
+
+ Movie Poster +
+
+

{{ movie().title }}

+

+ Erscheinungsjahr: {{ movie().year }} +

+ + + +
+
diff --git a/src/app/movie-import-search-info/movie-import-search-info.component.ts b/src/app/movie-import-search-info/movie-import-search-info.component.ts new file mode 100644 index 0000000..8763783 --- /dev/null +++ b/src/app/movie-import-search-info/movie-import-search-info.component.ts @@ -0,0 +1,68 @@ +import { Component, inject, input } from '@angular/core'; +import { Film, OmdbMovie } from '@infinimotion/model-frontend'; +import { HttpService } from '../http.service'; +import { LoadingService } from '../loading.service'; +import { catchError, EMPTY, of, switchMap, tap } from 'rxjs'; + +@Component({ + selector: 'app-movie-import-search-info', + standalone: false, + templateUrl: './movie-import-search-info.component.html', + styleUrl: './movie-import-search-info.component.css' +}) +export class MovieImportSearchInfoComponent { + readonly movie = input.required(); + + importedMovie: Film | undefined; + buttonDisabled = false; + buttonText: string = "Film von IMDb importieren"; + buttonIcon: string = "cloud_download" + + private http = inject(HttpService) + private loading = inject(LoadingService) + + importMovie(imdbId: string, title: string) { + this.buttonDisabled = true; + this.loading.show(); + + const filter = this.generateTitleFilter(title); + + this.http.getMoviesByFilter(filter).pipe( + switchMap(movies => { + if (movies.length !== 0) { + this.loading.showError(`Dublette erkannt! Film '${title}' existiert bereits.`); + console.warn('Dublette erkannt. Import abgebrochen.'); + this.buttonText = 'Film existiert bereits' + this.buttonIcon = 'file_present'; + return EMPTY; + } + return this.http.importMovie(imdbId); + }), + tap(importedMovie => { + this.importedMovie = importedMovie; + this.buttonText = `Film erfolgreich importiert (Id: ${importedMovie.id})`; + this.buttonIcon = 'cloud_done'; + this.loading.hide(); + }), + catchError(err => { + this.buttonDisabled = false; + this.loading.showError(err); + console.error('Fehler beim Import oder bei der Dublettenprüfung', err); + return of([]); + }) + ).subscribe(); + } + + private generateTitleFilter(title: string): string[] { + return [`eq;title;string;${title}`] + } + + onPosterError(event: Event) { + const img = event.target as HTMLImageElement; + const placeholder = 'assets/poster_placeholder.png'; + + if (img.src !== window.location.origin + placeholder) { + img.src = placeholder; + } + } +} diff --git a/src/app/movie-importer/movie-importer.component.css b/src/app/movie-importer/movie-importer.component.css new file mode 100644 index 0000000..dd48b70 --- /dev/null +++ b/src/app/movie-importer/movie-importer.component.css @@ -0,0 +1,3 @@ +:host { + min-height: 100%; +} diff --git a/src/app/movie-importer/movie-importer.component.html b/src/app/movie-importer/movie-importer.component.html new file mode 100644 index 0000000..42b2ea2 --- /dev/null +++ b/src/app/movie-importer/movie-importer.component.html @@ -0,0 +1,52 @@ + + +
+
+
+ + Film online suchen + + @if (formControl.hasError('noResults')) { + Keine Suchergebnisse gefunden + } + + +
+
+ + @if (search_query.length > 0 && !isSearching) { +
+ + @if (movies.length > 0) { + + + + + @if (movies.length > 1 && !showAll) { +
+ {{ movies.length - 1 }} weitere Suchergebnisse anzeigen +
+ } + + + @if (showAll) { + @for (movie of movies.slice(1); track movie.imdbID) { + + } +
+ Weitere Suchergebnisse ausblenden +
+ } + } + + + @else { + + } + +
+ } +
+ diff --git a/src/app/movie-importer/movie-importer.component.ts b/src/app/movie-importer/movie-importer.component.ts new file mode 100644 index 0000000..c68289b --- /dev/null +++ b/src/app/movie-importer/movie-importer.component.ts @@ -0,0 +1,58 @@ +import { LoadingService } from './../loading.service'; +import { Component, inject } from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { catchError, finalize, of, tap } from 'rxjs'; +import { HttpService } from '../http.service'; +import { OmdbMovie } from '@infinimotion/model-frontend'; + +@Component({ + selector: 'app-movie-importer', + standalone: false, + templateUrl: './movie-importer.component.html', + styleUrl: './movie-importer.component.css' +}) +export class MovieImporterComponent { + formControl = new FormControl('') + + movies: OmdbMovie[] = []; + search_query: string = ''; + showAll = false; + isSearching = false; + + private httpService = inject(HttpService) + public loadingService = inject(LoadingService) + + DoSubmit() { + this.showAll = false; + this.searchForMovies(); + } + + searchForMovies() { + this.search_query = this.formControl.value?.trim() || ''; + if (this.search_query?.length == 0) return; + + this.isSearching = true; + this.formControl.disable(); + this.loadingService.show(); + + this.httpService.searchMovie(this.search_query).pipe( + tap(movies => { + this.movies = movies.search || [] ; + this.loadingService.hide(); + }), + catchError(err => { + this.loadingService.showError(err); + console.error('Fehler bei der Suchen', err); + return of([]); + }), + finalize(() => { + this.isSearching = false; + this.formControl.enable(); + }) + ).subscribe(); + } + + toggleShowAll() { + this.showAll = !this.showAll; + } +} diff --git a/src/app/movie-poster/movie-poster.component.html b/src/app/movie-poster/movie-poster.component.html index f7e3ea2..1378eab 100644 --- a/src/app/movie-poster/movie-poster.component.html +++ b/src/app/movie-poster/movie-poster.component.html @@ -1,5 +1,10 @@
- Movie Poster + Movie Poster
diff --git a/src/app/movie-poster/movie-poster.component.ts b/src/app/movie-poster/movie-poster.component.ts index 33b1ccf..b9900de 100644 --- a/src/app/movie-poster/movie-poster.component.ts +++ b/src/app/movie-poster/movie-poster.component.ts @@ -9,4 +9,13 @@ import { Film } from '@infinimotion/model-frontend'; }) export class MoviePosterComponent { movie = input.required(); + + onPosterError(event: Event) { + const img = event.target as HTMLImageElement; + const placeholder = 'assets/poster_placeholder.png'; + + if (img.src !== window.location.origin + placeholder) { + img.src = placeholder; + } + } } diff --git a/src/app/navbar/navbar.component.ts b/src/app/navbar/navbar.component.ts index fa984ce..321347c 100644 --- a/src/app/navbar/navbar.component.ts +++ b/src/app/navbar/navbar.component.ts @@ -7,11 +7,8 @@ import { Component } from '@angular/core'; styleUrl: './navbar.component.css' }) export class NavbarComponent { - /* - die routen abfragen und die routen namen verwenden? - */ - - navItems:{label:string, path:string}[] = [ + navItems: { label:string, path:string }[] = [ {label: 'Programm', path: '/schedule'}, + {label: 'Film importieren', path: '/admin/movie-importer'}, ] } diff --git a/src/app/schedule-header/schedule-header.component.html b/src/app/schedule-header/schedule-header.component.html deleted file mode 100644 index cfb017f..0000000 --- a/src/app/schedule-header/schedule-header.component.html +++ /dev/null @@ -1,10 +0,0 @@ -
-
- event -

- Programmübersicht -

-
- -
- diff --git a/src/app/schedule-header/schedule-header.component.ts b/src/app/schedule-header/schedule-header.component.ts deleted file mode 100644 index 8ac6849..0000000 --- a/src/app/schedule-header/schedule-header.component.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Component, output } from '@angular/core'; - -@Component({ - selector: 'app-schedule-header', - standalone: false, - templateUrl: './schedule-header.component.html', - styleUrl: './schedule-header.component.css' -}) -export class ScheduleHeaderComponent { - movieSearchResult = output(); -} diff --git a/src/app/schedule/schedule.component.html b/src/app/schedule/schedule.component.html index f5924df..902dab4 100644 --- a/src/app/schedule/schedule.component.html +++ b/src/app/schedule/schedule.component.html @@ -1,4 +1,4 @@ - + @for (dateInfo of dates; track dateInfo.date; let i = $index) { diff --git a/src/app/schedule/schedule.component.ts b/src/app/schedule/schedule.component.ts index aae539d..5d88ada 100644 --- a/src/app/schedule/schedule.component.ts +++ b/src/app/schedule/schedule.component.ts @@ -4,7 +4,7 @@ import { Vorstellung } from '@infinimotion/model-frontend'; import { Performance } from '../model/performance.model'; import { MovieGroup } from '../model/movie-group.model'; import { LoadingService } from '../loading.service'; -import { catchError, map, of, tap } from 'rxjs'; +import { catchError, of, tap } from 'rxjs'; @Component({ selector: 'app-schedule', @@ -61,8 +61,7 @@ export class ScheduleComponent implements OnInit { loadPerformances(bookableDays: number) { this.loading.show(); const filter = this.generateDateFilter(bookableDays); - this.http.getPerformaceByFilter(filter).pipe( - map(data => Array.isArray(data) ? data : [data]), + this.http.getPerformacesByFilter(filter).pipe( tap(performaces => { this.performaces = performaces; this.assignPerformancesToDates(); diff --git a/src/assets/poster_placeholder.png b/src/assets/poster_placeholder.png new file mode 100644 index 0000000..2d94819 Binary files /dev/null and b/src/assets/poster_placeholder.png differ