diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml new file mode 100644 index 0000000..9bb2ee4 --- /dev/null +++ b/.github/workflows/deploy-web.yml @@ -0,0 +1,49 @@ +name: Deploy Flutter Web to GitHub Pages + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: Flutter version + run: flutter --version + + - name: Enable web and get packages + run: | + flutter config --enable-web + flutter pub get + + - name: Build Flutter web (release) + env: + REPO_NAME: ${{ github.event.repository.name }} + run: | + # Use canvaskit for better desktop rendering; adjust if needed + flutter build web \ + --release \ + --web-renderer canvaskit \ + --base-href "/${REPO_NAME}/" + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./build/web + # Keep history small + force_orphan: true + diff --git a/lib/features/movies/presentation/movie_detail_screen.dart b/lib/features/movies/presentation/movie_detail_screen.dart index c95cc96..f929ec6 100644 --- a/lib/features/movies/presentation/movie_detail_screen.dart +++ b/lib/features/movies/presentation/movie_detail_screen.dart @@ -127,7 +127,34 @@ class _MovieDetailScreenState extends ConsumerState { label: const Text('Speichern'), ) : null, - body: SizedBox.expand( + body: MenuAnchor( + menuChildren: [ + MenuItemButton( + child: const Text('update'), + onPressed: () async { + final messenger = ScaffoldMessenger.of(context); + try { + final tmdb = ref.read(tmdbApiProvider); + final json = await tmdb.getMovie(m.tmdbId); + await ref.read(backendApiProvider).upsertMovie(json); + if (!mounted) return; + setState(() => _tmdb = json); + ref.invalidate(moviesStreamProvider); + ref.invalidate(moviesProvider); + messenger.showSnackBar(const SnackBar(content: Text('TMDB Daten aktualisiert'))); + } catch (e) { + messenger.showSnackBar(SnackBar(content: Text('TMDB Update fehlgeschlagen: $e'))); + } + }, + ), + ], + builder: (ctx, controller, child) => GestureDetector( + onSecondaryTapDown: (_) => controller.open(), + onSecondaryTapUp: (_) => controller.open(), + onLongPress: () => controller.open(), + child: child!, + ), + child: SizedBox.expand( child: Container( decoration: (backdrop != null || poster != null) ? BoxDecoration( @@ -248,7 +275,7 @@ class _MovieDetailScreenState extends ConsumerState { ), ), ), - ); + )); } Widget _statusSelector(ItemStatus current) { diff --git a/lib/features/movies/presentation/movie_list_screen.dart b/lib/features/movies/presentation/movie_list_screen.dart index a9592a5..7b5b45d 100644 --- a/lib/features/movies/presentation/movie_list_screen.dart +++ b/lib/features/movies/presentation/movie_list_screen.dart @@ -215,6 +215,7 @@ class MovieListScreen extends ConsumerWidget { } Future deleteMovie() async { + final messenger = ScaffoldMessenger.of(context); final confirm = await showDialog( context: context, builder: (ctx) => AlertDialog( @@ -226,8 +227,8 @@ class MovieListScreen extends ConsumerWidget { ], ), ); + if (!context.mounted) return; if (confirm != true) return; - final messenger = ScaffoldMessenger.of(context); try { await ref.read(backendApiProvider).deleteMovie(m.id); ref.invalidate(moviesStreamProvider); diff --git a/lib/features/series/presentation/series_list_screen.dart b/lib/features/series/presentation/series_list_screen.dart index 113bdab..695b3de 100644 --- a/lib/features/series/presentation/series_list_screen.dart +++ b/lib/features/series/presentation/series_list_screen.dart @@ -2,7 +2,6 @@ import 'dart:convert'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart' as widgets; import '../../../core/status.dart'; import '../../movies/presentation/widgets/status_chip.dart'; @@ -504,6 +503,7 @@ class SeriesListScreen extends ConsumerWidget { onPressed: () async { final sid = int.tryParse(showKey.split('##').last); if (sid == null) return; + final messenger = ScaffoldMessenger.of(context); final confirm = await showDialog( context: context, builder: (ctx) => AlertDialog( @@ -515,8 +515,8 @@ class SeriesListScreen extends ConsumerWidget { ], ), ); + if (!context.mounted) return; if (confirm != true) return; - final messenger = ScaffoldMessenger.of(context); try { final backend = ref.read(backendApiProvider); await backend.deleteShow(sid); @@ -650,6 +650,7 @@ class SeriesListScreen extends ConsumerWidget { onPressed: () async { final sid = int.tryParse(showKey.split('##').last); if (sid == null) return; + final messenger = ScaffoldMessenger.of(context); final confirm = await showDialog( context: context, builder: (ctx) => AlertDialog( @@ -661,8 +662,8 @@ class SeriesListScreen extends ConsumerWidget { ], ), ); - if (confirm != true) return; - final messenger = ScaffoldMessenger.of(context); + if (!context.mounted) return; + if (confirm != true) return; try { final backend = ref.read(backendApiProvider); await backend.deleteShow(sid); ref.invalidate(seriesGroupedProvider); ref.invalidate(seriesSummaryProvider); messenger.showSnackBar(const SnackBar(content: Text('Serie gelöscht')));} catch (e) { messenger.showSnackBar(SnackBar(content: Text('Löschen fehlgeschlagen: $e')));} }, ), @@ -724,7 +725,7 @@ class SeriesListScreen extends ConsumerWidget { MenuItemButton( child: const Text('delete'), onPressed: () async { - final sid = int.tryParse(showKey.split('##').last); if (sid == null) return; final confirm = await showDialog(context: context, builder: (ctx) => AlertDialog(title: const Text('Serie löschen?'), content: Text(displayName), actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Abbrechen')), FilledButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Löschen'))])); if (confirm != true) return; final messenger = ScaffoldMessenger.of(context); try { final backend = ref.read(backendApiProvider); await backend.deleteShow(sid); ref.invalidate(seriesGroupedProvider); ref.invalidate(seriesSummaryProvider); messenger.showSnackBar(const SnackBar(content: Text('Serie gelöscht')));} catch (e) { messenger.showSnackBar(SnackBar(content: Text('Löschen fehlgeschlagen: $e')));} + final sid = int.tryParse(showKey.split('##').last); if (sid == null) return; final confirm = await showDialog(context: context, builder: (ctx) => AlertDialog(title: const Text('Serie löschen?'), content: Text(displayName), actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Abbrechen')), FilledButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Löschen'))])); if (!context.mounted) return; if (confirm != true) return; final messenger = ScaffoldMessenger.of(context); try { final backend = ref.read(backendApiProvider); await backend.deleteShow(sid); ref.invalidate(seriesGroupedProvider); ref.invalidate(seriesSummaryProvider); messenger.showSnackBar(const SnackBar(content: Text('Serie gelöscht')));} catch (e) { messenger.showSnackBar(SnackBar(content: Text('Löschen fehlgeschlagen: $e')));} }, ), MenuItemButton( @@ -1410,10 +1411,11 @@ extension _SeriesContextMenu on SeriesListScreen { ], ), ); - if (confirm != true) return; - final messenger = ScaffoldMessenger.of(context); - try { - final backend = ref.read(backendApiProvider); + if (!context.mounted) return; + if (confirm != true) return; + final messenger = ScaffoldMessenger.of(context); + try { + final backend = ref.read(backendApiProvider); await backend.deleteShow(sid); // ignore: unused_result ref.invalidate(seriesGroupedProvider); @@ -1439,12 +1441,17 @@ extension _SeriesContextMenu on SeriesListScreen { } return [ - MenuItemButton(child: const Text('set all progress to done'), onPressed: setAllProgressToDone), - MenuItemButton(child: const Text('update'), onPressed: performUpdate), - MenuItemButton(child: const Text('delete'), onPressed: performDelete), - MenuItemButton(child: const Text('open download path'), onPressed: openDownload), + MenuItemButton(onPressed: setAllProgressToDone, child: const Text('set all progress to done')), + MenuItemButton(onPressed: performUpdate, child: const Text('update')), + MenuItemButton(onPressed: performDelete, child: const Text('delete')), + MenuItemButton(onPressed: openDownload, child: const Text('open download path')), ]; } } + + + + + diff --git a/web/.htaccess b/web/.htaccess new file mode 100644 index 0000000..7941175 --- /dev/null +++ b/web/.htaccess @@ -0,0 +1,53 @@ +# Flutter Web on Apache: SPA routing, caching, compression, and correct MIME types + + + Options -MultiViews + RewriteEngine On + + # Serve existing files/directories directly + RewriteCond %{REQUEST_FILENAME} -f [OR] + RewriteCond %{REQUEST_FILENAME} -d + RewriteRule ^ - [L] + + # Fallback all other requests to index.html (client-side routing) + RewriteRule ^ index.html [L] + + +# Correct MIME types (important for CanvasKit/WebAssembly and fonts) +AddType application/wasm wasm +AddType font/woff2 woff2 +AddType application/javascript js +AddType text/css css + +# Caching policy + + # Long cache for static assets (Flutter uses hashed filenames) + + Header set Cache-Control "public, max-age=31536000, immutable" + + + # Service configs/JSON can be cached briefly + + Header set Cache-Control "public, max-age=3600" + + + # Never cache the HTML entry point + + Header set Cache-Control "no-cache, no-store, must-revalidate, max-age=0" + + + # Minimal hardening + Header always set X-Content-Type-Options "nosniff" + Header always set Referrer-Policy "strict-origin-when-cross-origin" + + +# Compression (enable if modules are available) + + AddOutputFilterByType BROTLI_COMPRESS \ + text/html text/plain text/css application/javascript application/json application/wasm image/svg+xml + + + AddOutputFilterByType DEFLATE \ + text/html text/plain text/css application/javascript application/json application/wasm image/svg+xml + +