Merge branch 'movie-importer'

This commit is contained in:
2025-11-07 02:09:59 +01:00
25 changed files with 339 additions and 47 deletions

View File

@@ -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(),

View File

@@ -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}, //?
],
},

View File

@@ -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<Vorstellung[]> {
getPerformacesByFilter(filter: string[]): Observable<Vorstellung[]> {
return this.http.post<Vorstellung[]>(`${this.baseUrl}vorstellung/filter`, filter);
}
@@ -79,11 +79,31 @@ export class HttpService {
return this.http.get<Film[]>(`${this.baseUrl}film`);
}
/* POST /api/vorstellung/filter */
getMoviesByFilter(filter: string[]): Observable<Film[]> {
return this.http.post<Film[]>(`${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<OmdbSearch> {
return this.http.get<OmdbSearch>(`${this.baseUrl}importer/search`, {
params: { title: query }
});
}
/* POST /api/importer/import */
importMovie(imdbId: string): Observable<Film> {
return this.http.post<Film>(`${this.baseUrl}importer/import?id=${imdbId}`, {})
}
}

View File

@@ -0,0 +1,14 @@
<div class="schedule-header px-4 py-4">
<div class="flex items-center">
@if ( icon() ) {
<mat-icon style="font-size: 35px; width: 35px; height: 35px; opacity: 50%;">{{ icon() }}</mat-icon>
}
<p class="text-2xl font-medium pl-2 bg-gradient-to-r from-indigo-500 to-pink-600 bg-clip-text text-transparent">
{{ title() }}
</p>
</div>
@if ( searchBar() ) {
<app-movie-search (movieSearchResult)="movieSearchResult.emit($event)"></app-movie-search>
}
</div>
<mat-divider></mat-divider>

View File

@@ -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<string>();
icon = input<string>();
searchBar = input<boolean>(false);
movieSearchResult = output<string>();
}

View File

@@ -0,0 +1,5 @@
<div class="flex flex-col items-center justify-center py-12 text-gray-500 h-100 m-auto">
<mat-icon class="text-6xl mb-4 opacity-50" style="font-size: 50px; width: 50px; height: 50px;">search_off</mat-icon>
<p class="text-lg">Kein Film gefunden</p>
<p class="text-sm">Für '{{ search() }}' konnten über IMDb keine Filme gefunden werden.</p>
</div>

View File

@@ -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<string>();
}

View File

@@ -0,0 +1,4 @@
:host {
display: block;
margin: 60px 0;
}

View File

@@ -0,0 +1,22 @@
<div class="flex flex-col md:flex-row gap-1">
<div class="w-1/5">
<img
[src]="movie().poster && movie().poster !== 'N/A' ? movie().poster :'assets/poster_placeholder.png'"
alt="Movie Poster"
class="w-40 h-auto shadow-md rounded-md"
(error)="onPosterError($event)"
>
</div>
<div class="mx-8 my-auto w-4/5">
<h1 class="text-4xl font-bold mb-2">{{ movie().title }}</h1>
<h2 class="text-xl mb-8">
Erscheinungsjahr: {{ movie().year }}
</h2>
<button matFab extended class="mb-3" (click)="importMovie(movie().imdbID, movie().title)" [disabled]="this.buttonDisabled">
<mat-icon>{{ buttonIcon }}</mat-icon>
{{ buttonText }}
</button>
</div>
</div>

View File

@@ -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<OmdbMovie>();
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;
}
}
}

View File

@@ -0,0 +1,3 @@
:host {
min-height: 100%;
}

View File

@@ -0,0 +1,52 @@
<app-menu-header title="Film aus IMDb importieren" icon="cloud_download"></app-menu-header>
<div class="w-6/10 m-auto my-20">
<form class="movie-search-form w-full" (ngSubmit)="DoSubmit()">
<div class="flex items-center space-x-4">
<mat-form-field class="w-full" subscriptSizing="dynamic">
<mat-label>Film online suchen</mat-label>
<input class="w-full" type="text" matInput [formControl]="formControl">
@if (formControl.hasError('noResults')) {
<mat-error>Keine Suchergebnisse gefunden</mat-error>
}
</mat-form-field>
<button matFab [disabled]="!formControl.value?.trim()?.length || (loadingService.loading$ | async)" type="submit">
<mat-icon>arrow_forward</mat-icon>
</button>
</div>
</form>
@if (search_query.length > 0 && !isSearching) {
<div class="search-results my-20">
@if (movies.length > 0) {
<!-- 1. Ergebnis -->
<app-movie-import-search-info [movie]="movies[0]" class="py-3"></app-movie-import-search-info>
@if (movies.length > 1 && !showAll) {
<div class="text-blue-500 cursor-pointer mt-2 w-fit" (click)="toggleShowAll()">
{{ movies.length - 1 }} weitere Suchergebnisse anzeigen
</div>
}
<!-- Weitere Ergebnisse -->
@if (showAll) {
@for (movie of movies.slice(1); track movie.imdbID) {
<app-movie-import-search-info [movie]="movie" class="py-3"></app-movie-import-search-info>
}
<div class="text-blue-500 cursor-pointer mt-20 mb-30 w-fit" (click)="toggleShowAll()">
Weitere Suchergebnisse ausblenden
</div>
}
}
<!-- Keine Ergebnisse -->
@else {
<app-movie-import-no-search-result [search]="search_query"></app-movie-import-no-search-result>
}
</div>
}
</div>

View File

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

View File

@@ -1,5 +1,10 @@
<div class="w-64 mx-auto my-2">
<img [src]="movie().image" alt="Movie Poster" class="w-full h-auto shadow-md">
<img
[src]="movie().image && movie().image !== 'N/A' ? movie().image : 'assets/poster_placeholder.png'"
alt="Movie Poster"
class="w-full h-auto shadow-md rounded-md"
(error)="onPosterError($event)"
>
</div>
<div class="flex gap-1 justify-between">
<app-movie-rating [rating]="movie().rating"></app-movie-rating>

View File

@@ -9,4 +9,13 @@ import { Film } from '@infinimotion/model-frontend';
})
export class MoviePosterComponent {
movie = input.required<Film>();
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;
}
}
}

View File

@@ -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'},
]
}

View File

@@ -1,10 +0,0 @@
<div class="schedule-header px-4 py-4 duration-1000">
<div class="flex items-center">
<mat-icon style="font-size: 35px; width: 35px; height: 35px; opacity: 50%;">event</mat-icon>
<p class="text-2xl font-medium pl-2 bg-gradient-to-r from-indigo-500 to-pink-600 bg-clip-text text-transparent">
Programmübersicht
</p>
</div>
<app-movie-search (movieSearchResult)="movieSearchResult.emit($event)"></app-movie-search>
</div>
<mat-divider></mat-divider>

View File

@@ -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<string>();
}

View File

@@ -1,4 +1,4 @@
<app-schedule-header (movieSearchResult)="movieSearchResult = $event"></app-schedule-header>
<app-menu-header title="Programmübersicht" icon="event" [searchBar]="true" (movieSearchResult)="movieSearchResult = $event"></app-menu-header>
<mat-tab-group mat-stretch-tabs>
@for (dateInfo of dates; track dateInfo.date; let i = $index) {

View File

@@ -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();