add gif-Seeder and gif-Templates:
Zusammenfassung der Änderungen
Template-GIF Seeding implementiert:
1. DemoSeeder.cs aktualisiert:
- SeedTemplateGifsAsync() - Öffentliche Methode für externes Seeding
- SeedTemplateGifsInternalAsync() - Kopiert GIFs vom Template-Ordner und erstellt DB-Einträge
- DeleteClubGifsAsync() - Löscht GIF-Dateien und DB-Einträge beim Reset
- ResetDemoClubAsync() - Löscht nun auch GIFs und seedet sie neu
- SeedAsync() - Seedet Template-GIFs beim Demo-Club-Setup
2. IClubGifService.cs erweitert:
- SeedTemplateGifsAsync(Guid clubId) - Neue Interface-Methode
3. ClubGifService.cs erweitert:
- SeedTemplateGifsAsync() implementiert - Liest giftemplates.json, kopiert Dateien und erstellt ClubGif-Einträge
4. ClubService.cs aktualisiert:
- Injiziert IClubGifService
- CreateAsync() seedet automatisch Template-GIFs für neue Clubs
Ablauf:
1. Neuer Club: Template-GIFs werden automatisch kopiert nach wwwroot/club-media/{LoginName}/gifs/
2. Demo-Reset: Alte GIFs werden gelöscht, Templates neu kopiert
3. Demo-Seed: Template-GIFs werden geseedet falls noch keine vorhanden
Template-Struktur:
wwwroot/club-template/gifs/
├── giftemplates.json # Definition der GIFs
├── 44f5bc11-...-d1215.gif # Strike GIF
└── 297970f3-...-a2a93.gif # NoWood GIF
This commit is contained in:
parent
a3a5c288a1
commit
9aee6b50c0
|
|
@ -36,4 +36,11 @@ public interface IClubGifService
|
||||||
|
|
||||||
// Anonymous Submission
|
// Anonymous Submission
|
||||||
Task<ClubGifDto> SubmitAnonymousAsync(string token, IFormFile file, string name, CancellationToken ct = default);
|
Task<ClubGifDto> SubmitAnonymousAsync(string token, IFormFile file, string name, CancellationToken ct = default);
|
||||||
|
|
||||||
|
// Template Seeding
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds template GIFs from club-template folder to a club.
|
||||||
|
/// Should be called after creating a new club.
|
||||||
|
/// </summary>
|
||||||
|
Task<int> SeedTemplateGifsAsync(Guid clubId, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Text.Json;
|
||||||
using Koogle.Application.DTOs;
|
using Koogle.Application.DTOs;
|
||||||
using Koogle.Application.Interfaces;
|
using Koogle.Application.Interfaces;
|
||||||
using Koogle.Domain.Entities;
|
using Koogle.Domain.Entities;
|
||||||
|
|
@ -17,17 +18,25 @@ public class ClubGifService : IClubGifService
|
||||||
private readonly IClubRepository _clubRepository;
|
private readonly IClubRepository _clubRepository;
|
||||||
private readonly IMediaStorageService _mediaStorage;
|
private readonly IMediaStorageService _mediaStorage;
|
||||||
private readonly ICurrentClubContext _clubContext;
|
private readonly ICurrentClubContext _clubContext;
|
||||||
|
private readonly IWebHostEnvironment _env;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for deserializing GIF template entries from giftemplates.json.
|
||||||
|
/// </summary>
|
||||||
|
private record GifTemplateEntry(string Name, string Filename, string ThrowEventType, string? Description);
|
||||||
|
|
||||||
public ClubGifService(
|
public ClubGifService(
|
||||||
IClubGifRepository repository,
|
IClubGifRepository repository,
|
||||||
IClubRepository clubRepository,
|
IClubRepository clubRepository,
|
||||||
IMediaStorageService mediaStorage,
|
IMediaStorageService mediaStorage,
|
||||||
ICurrentClubContext clubContext)
|
ICurrentClubContext clubContext,
|
||||||
|
IWebHostEnvironment env)
|
||||||
{
|
{
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
_clubRepository = clubRepository;
|
_clubRepository = clubRepository;
|
||||||
_mediaStorage = mediaStorage;
|
_mediaStorage = mediaStorage;
|
||||||
_clubContext = clubContext;
|
_clubContext = clubContext;
|
||||||
|
_env = env;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ClubGifDto?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
public async Task<ClubGifDto?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||||
|
|
@ -276,6 +285,92 @@ public class ClubGifService : IClubGifService
|
||||||
return MapToDto(gif, club.LoginName);
|
return MapToDto(gif, club.LoginName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<int> SeedTemplateGifsAsync(Guid clubId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var club = await _clubRepository.GetByIdAsync(clubId, ct)
|
||||||
|
?? throw new ArgumentException("Club not found");
|
||||||
|
|
||||||
|
var templatePath = Path.Combine(_env.WebRootPath, "club-template", "gifs");
|
||||||
|
var templateJsonPath = Path.Combine(templatePath, "giftemplates.json");
|
||||||
|
|
||||||
|
if (!File.Exists(templateJsonPath))
|
||||||
|
{
|
||||||
|
return 0; // No template file, skip seeding
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read template definitions
|
||||||
|
var jsonContent = await File.ReadAllTextAsync(templateJsonPath, ct);
|
||||||
|
var templates = JsonSerializer.Deserialize<List<GifTemplateEntry>>(jsonContent, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (templates == null || templates.Count == 0)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure target directory exists
|
||||||
|
_mediaStorage.EnsureClubDirectoryExists(club.LoginName);
|
||||||
|
|
||||||
|
var seededCount = 0;
|
||||||
|
|
||||||
|
foreach (var template in templates)
|
||||||
|
{
|
||||||
|
var sourceFile = Path.Combine(templatePath, template.Filename);
|
||||||
|
if (!File.Exists(sourceFile))
|
||||||
|
{
|
||||||
|
continue; // Skip if source file doesn't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse event type
|
||||||
|
if (!Enum.TryParse<ThrowEventType>(template.ThrowEventType, out var eventType))
|
||||||
|
{
|
||||||
|
eventType = ThrowEventType.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine content type
|
||||||
|
var extension = Path.GetExtension(template.Filename).ToLowerInvariant();
|
||||||
|
var contentType = extension switch
|
||||||
|
{
|
||||||
|
".gif" => "image/gif",
|
||||||
|
".mp4" => "video/mp4",
|
||||||
|
".webm" => "video/webm",
|
||||||
|
_ => "application/octet-stream"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Copy file via media storage service
|
||||||
|
await using var sourceStream = new FileStream(sourceFile, FileMode.Open, FileAccess.Read);
|
||||||
|
var newFileName = await _mediaStorage.SaveGifAsync(club.LoginName, sourceStream, template.Filename, contentType, ct);
|
||||||
|
|
||||||
|
// Get file size
|
||||||
|
var targetFile = Path.Combine(_env.WebRootPath, "club-media", club.LoginName, "gifs", newFileName);
|
||||||
|
var fileInfo = new FileInfo(targetFile);
|
||||||
|
|
||||||
|
// Create database entry
|
||||||
|
var gif = new ClubGif
|
||||||
|
{
|
||||||
|
ClubId = clubId,
|
||||||
|
Name = template.Name,
|
||||||
|
FileName = newFileName,
|
||||||
|
OriginalFileName = template.Filename,
|
||||||
|
ContentType = contentType,
|
||||||
|
FileSizeBytes = fileInfo.Length,
|
||||||
|
AssignedEvents = eventType,
|
||||||
|
Description = template.Description,
|
||||||
|
IsEnabled = true,
|
||||||
|
IsPendingApproval = false,
|
||||||
|
RatingScore = 0,
|
||||||
|
RatingCount = 0
|
||||||
|
};
|
||||||
|
|
||||||
|
await _repository.AddAsync(gif, ct);
|
||||||
|
seededCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return seededCount;
|
||||||
|
}
|
||||||
|
|
||||||
private static ClubGif? SelectWeightedRandom(List<ClubGif> gifs)
|
private static ClubGif? SelectWeightedRandom(List<ClubGif> gifs)
|
||||||
{
|
{
|
||||||
if (gifs.Count == 0) return null;
|
if (gifs.Count == 0) return null;
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ public class ClubService : IClubService
|
||||||
private readonly IClubRepository _clubRepository;
|
private readonly IClubRepository _clubRepository;
|
||||||
private readonly IMapper _mapper;
|
private readonly IMapper _mapper;
|
||||||
private readonly AppDbContext _appDb;
|
private readonly AppDbContext _appDb;
|
||||||
|
private readonly IClubGifService _gifService;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="ClubService"/> class.
|
/// Initializes a new instance of the <see cref="ClubService"/> class.
|
||||||
|
|
@ -25,11 +26,13 @@ public class ClubService : IClubService
|
||||||
/// <param name="clubRepository">The club repository.</param>
|
/// <param name="clubRepository">The club repository.</param>
|
||||||
/// <param name="mapper">The AutoMapper instance.</param>
|
/// <param name="mapper">The AutoMapper instance.</param>
|
||||||
/// <param name="appDb">The application database context.</param>
|
/// <param name="appDb">The application database context.</param>
|
||||||
public ClubService(IClubRepository clubRepository, IMapper mapper, AppDbContext appDb)
|
/// <param name="gifService">The GIF service for seeding templates.</param>
|
||||||
|
public ClubService(IClubRepository clubRepository, IMapper mapper, AppDbContext appDb, IClubGifService gifService)
|
||||||
{
|
{
|
||||||
_clubRepository = clubRepository;
|
_clubRepository = clubRepository;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
_appDb = appDb;
|
_appDb = appDb;
|
||||||
|
_gifService = gifService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|
@ -72,6 +75,17 @@ public class ClubService : IClubService
|
||||||
};
|
};
|
||||||
|
|
||||||
var created = await _clubRepository.AddAsync(entity, ct);
|
var created = await _clubRepository.AddAsync(entity, ct);
|
||||||
|
|
||||||
|
// Seed template GIFs for new club
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _gifService.SeedTemplateGifsAsync(created.Id, ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Don't fail club creation if GIF seeding fails
|
||||||
|
}
|
||||||
|
|
||||||
return _mapper.Map<ClubDto>(created);
|
return _mapper.Map<ClubDto>(created);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
using System.Text.Json;
|
||||||
using Koogle.Domain.Entities;
|
using Koogle.Domain.Entities;
|
||||||
using Koogle.Domain.Enums;
|
using Koogle.Domain.Enums;
|
||||||
using Koogle.Domain.Interfaces;
|
using Koogle.Domain.Interfaces;
|
||||||
using Koogle.Infrastructure.Identity;
|
using Koogle.Infrastructure.Identity;
|
||||||
using KoogleApp.Data;
|
using KoogleApp.Data;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
@ -24,6 +26,11 @@ public static class DemoSeeder
|
||||||
|
|
||||||
private static readonly string[] GuestNames = { "Stefan Gast", "Thomas Besucher" };
|
private static readonly string[] GuestNames = { "Stefan Gast", "Thomas Besucher" };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for deserializing GIF template entries from giftemplates.json.
|
||||||
|
/// </summary>
|
||||||
|
private record GifTemplateEntry(string Name, string Filename, string ThrowEventType, string? Description);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seeds demo user, club membership, persons, expenses, triggers and sample days.
|
/// Seeds demo user, club membership, persons, expenses, triggers and sample days.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -130,6 +137,14 @@ public static class DemoSeeder
|
||||||
{
|
{
|
||||||
await SeedDemoDataAsync(appDb, triggerRepo, club.Id, demoUser.Id);
|
await SeedDemoDataAsync(appDb, triggerRepo, club.Id, demoUser.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 7) Seed template GIFs if not already present
|
||||||
|
var webEnv = scope.ServiceProvider.GetRequiredService<IWebHostEnvironment>();
|
||||||
|
var hasGifs = await appDb.ClubGifs.AnyAsync(g => g.ClubId == club.Id && !g.IsDeleted);
|
||||||
|
if (!hasGifs)
|
||||||
|
{
|
||||||
|
await SeedTemplateGifsInternalAsync(appDb, webEnv, club.Id, club.LoginName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -140,19 +155,24 @@ public static class DemoSeeder
|
||||||
using var scope = services.CreateScope();
|
using var scope = services.CreateScope();
|
||||||
var appDb = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var appDb = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
var triggerRepo = scope.ServiceProvider.GetRequiredService<ITriggerRepository>();
|
var triggerRepo = scope.ServiceProvider.GetRequiredService<ITriggerRepository>();
|
||||||
|
var env = scope.ServiceProvider.GetRequiredService<IWebHostEnvironment>();
|
||||||
|
|
||||||
// Find creator ID for seeding
|
// Find creator ID and club for seeding
|
||||||
var club = await appDb.Clubs.FindAsync(clubId);
|
var club = await appDb.Clubs.FindAsync(clubId);
|
||||||
if (club == null) return;
|
if (club == null) return;
|
||||||
|
|
||||||
var membership = await appDb.UserProfileClubs.FirstOrDefaultAsync(m => m.ClubId == clubId);
|
var membership = await appDb.UserProfileClubs.FirstOrDefaultAsync(m => m.ClubId == clubId);
|
||||||
var creatorId = membership?.AssignedById ?? Guid.Empty;
|
var creatorId = membership?.AssignedById ?? Guid.Empty;
|
||||||
|
|
||||||
// Hard delete all club data
|
// Hard delete all club data including GIFs
|
||||||
|
await DeleteClubGifsAsync(appDb, env, clubId, club.LoginName);
|
||||||
await HardDeleteClubDataAsync(appDb, clubId);
|
await HardDeleteClubDataAsync(appDb, clubId);
|
||||||
|
|
||||||
// Re-seed demo data
|
// Re-seed demo data
|
||||||
await SeedDemoDataAsync(appDb, triggerRepo, clubId, creatorId);
|
await SeedDemoDataAsync(appDb, triggerRepo, clubId, creatorId);
|
||||||
|
|
||||||
|
// Re-seed template GIFs
|
||||||
|
await SeedTemplateGifsInternalAsync(appDb, env, clubId, club.LoginName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task HardDeleteClubDataAsync(AppDbContext db, Guid clubId)
|
private static async Task HardDeleteClubDataAsync(AppDbContext db, Guid clubId)
|
||||||
|
|
@ -404,4 +424,128 @@ public static class DemoSeeder
|
||||||
}
|
}
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds template GIFs for a club by copying files and creating database entries.
|
||||||
|
/// Called during demo reset and when creating new clubs.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="services">Service provider.</param>
|
||||||
|
/// <param name="clubId">Club ID to seed GIFs for.</param>
|
||||||
|
/// <param name="clubLoginName">Club's login name for folder path.</param>
|
||||||
|
public static async Task SeedTemplateGifsAsync(IServiceProvider services, Guid clubId, string clubLoginName)
|
||||||
|
{
|
||||||
|
using var scope = services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var env = scope.ServiceProvider.GetRequiredService<IWebHostEnvironment>();
|
||||||
|
|
||||||
|
await SeedTemplateGifsInternalAsync(db, env, clubId, clubLoginName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SeedTemplateGifsInternalAsync(
|
||||||
|
AppDbContext db,
|
||||||
|
IWebHostEnvironment env,
|
||||||
|
Guid clubId,
|
||||||
|
string clubLoginName)
|
||||||
|
{
|
||||||
|
var templatePath = Path.Combine(env.WebRootPath, "club-template", "gifs");
|
||||||
|
var templateJsonPath = Path.Combine(templatePath, "giftemplates.json");
|
||||||
|
|
||||||
|
if (!File.Exists(templateJsonPath))
|
||||||
|
{
|
||||||
|
return; // No template file, skip seeding
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read template definitions
|
||||||
|
var jsonContent = await File.ReadAllTextAsync(templateJsonPath);
|
||||||
|
var templates = JsonSerializer.Deserialize<List<GifTemplateEntry>>(jsonContent, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (templates == null || templates.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create target directory
|
||||||
|
var targetPath = Path.Combine(env.WebRootPath, "club-media", clubLoginName, "gifs");
|
||||||
|
Directory.CreateDirectory(targetPath);
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
foreach (var template in templates)
|
||||||
|
{
|
||||||
|
var sourceFile = Path.Combine(templatePath, template.Filename);
|
||||||
|
if (!File.Exists(sourceFile))
|
||||||
|
{
|
||||||
|
continue; // Skip if source file doesn't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new filename for club
|
||||||
|
var extension = Path.GetExtension(template.Filename);
|
||||||
|
var newFileName = $"{Guid.NewGuid()}{extension}";
|
||||||
|
var targetFile = Path.Combine(targetPath, newFileName);
|
||||||
|
|
||||||
|
// Copy file
|
||||||
|
File.Copy(sourceFile, targetFile, overwrite: true);
|
||||||
|
|
||||||
|
// Parse event type
|
||||||
|
if (!Enum.TryParse<ThrowEventType>(template.ThrowEventType, out var eventType))
|
||||||
|
{
|
||||||
|
eventType = ThrowEventType.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file info
|
||||||
|
var fileInfo = new FileInfo(targetFile);
|
||||||
|
|
||||||
|
// Determine content type
|
||||||
|
var contentType = extension.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
".gif" => "image/gif",
|
||||||
|
".mp4" => "video/mp4",
|
||||||
|
".webm" => "video/webm",
|
||||||
|
_ => "application/octet-stream"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create database entry
|
||||||
|
var gif = new ClubGif
|
||||||
|
{
|
||||||
|
ClubId = clubId,
|
||||||
|
Name = template.Name,
|
||||||
|
FileName = newFileName,
|
||||||
|
OriginalFileName = template.Filename,
|
||||||
|
ContentType = contentType,
|
||||||
|
FileSizeBytes = fileInfo.Length,
|
||||||
|
AssignedEvents = eventType,
|
||||||
|
Description = template.Description,
|
||||||
|
IsEnabled = true,
|
||||||
|
IsPendingApproval = false,
|
||||||
|
RatingScore = 0,
|
||||||
|
RatingCount = 0,
|
||||||
|
CreatedAt = now
|
||||||
|
};
|
||||||
|
|
||||||
|
db.ClubGifs.Add(gif);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes all GIF files and database entries for a club.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task DeleteClubGifsAsync(AppDbContext db, IWebHostEnvironment env, Guid clubId, string clubLoginName)
|
||||||
|
{
|
||||||
|
// Delete database entries
|
||||||
|
await db.Database.ExecuteSqlAsync($"DELETE FROM app.ClubGifRatings WHERE ClubGifId IN (SELECT Id FROM app.ClubGifs WHERE ClubId = {clubId})");
|
||||||
|
await db.Database.ExecuteSqlAsync($"DELETE FROM app.ClubGifs WHERE ClubId = {clubId}");
|
||||||
|
await db.Database.ExecuteSqlAsync($"DELETE FROM app.GifSubmissionTokens WHERE ClubId = {clubId}");
|
||||||
|
|
||||||
|
// Delete files
|
||||||
|
var gifPath = Path.Combine(env.WebRootPath, "club-media", clubLoginName, "gifs");
|
||||||
|
if (Directory.Exists(gifPath))
|
||||||
|
{
|
||||||
|
Directory.Delete(gifPath, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1016 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.3 MiB |
|
|
@ -0,0 +1,14 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "alle Neune 1",
|
||||||
|
"filename": "44f5bc11-8471-4ca9-b764-58e3929d1215.gif",
|
||||||
|
"ThrowEventType": "Strike",
|
||||||
|
"description": "Animation, alle Kegel werden getroffen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "kein Holz 1",
|
||||||
|
"filename": "297970f3-1bf1-4090-aa5e-d0f5cea42a93.gif",
|
||||||
|
"ThrowEventType": "NoWood",
|
||||||
|
"description": "Animation, Kegel springen zur Seite"
|
||||||
|
}
|
||||||
|
]
|
||||||
Loading…
Reference in New Issue