Replace sparkbooth upload with photobooth uploader

This commit is contained in:
soeren
2026-01-29 12:16:27 +01:00
parent e37f533bcb
commit 084c52ba2d
27 changed files with 2163 additions and 50 deletions

2
.gitignore vendored
View File

@@ -3,6 +3,8 @@
/public/build /public/build
/public/hot /public/hot
/public/storage /public/storage
/PhotoboothUploader/bin
/PhotoboothUploader/obj
/storage/*.key /storage/*.key
/storage/framework /storage/framework
/vendor /vendor

View File

@@ -0,0 +1,90 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PhotoboothUploader.App"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<Application.Styles>
<FluentTheme />
<Style>
<Style.Resources>
<Color x:Key="BrandRose">#FFB6C1</Color>
<Color x:Key="BrandRoseStrong">#FF69B4</Color>
<Color x:Key="BrandRoseSoft">#FFE5EC</Color>
<Color x:Key="BrandGold">#FFD700</Color>
<Color x:Key="BrandSky">#87CEEB</Color>
<Color x:Key="BrandSkySoft">#E0F5FF</Color>
<Color x:Key="BrandNavy">#0F4C75</Color>
<Color x:Key="BrandSlate">#1F2937</Color>
<Color x:Key="BrandCream">#FFF8F5</Color>
<SolidColorBrush x:Key="TextPrimaryBrush" Color="{DynamicResource BrandSlate}" />
<SolidColorBrush x:Key="TextMutedBrush" Color="#6B7280" />
<SolidColorBrush x:Key="CardBorderBrush" Color="{DynamicResource BrandRoseSoft}" />
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="AccentBackgroundBrush" Color="{DynamicResource BrandSkySoft}" />
<SolidColorBrush x:Key="InputBorderBrush" Color="{DynamicResource BrandRoseSoft}" />
<SolidColorBrush x:Key="InputBackgroundBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="PrimaryButtonBrush" Color="{DynamicResource BrandRoseStrong}" />
<SolidColorBrush x:Key="PrimaryButtonTextBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="SecondaryButtonBrush" Color="{DynamicResource BrandSky}" />
<SolidColorBrush x:Key="SecondaryButtonTextBrush" Color="{DynamicResource BrandNavy}" />
<LinearGradientBrush x:Key="WindowBackgroundBrush" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="{DynamicResource BrandCream}" Offset="0" />
<GradientStop Color="{DynamicResource BrandRoseSoft}" Offset="0.5" />
<GradientStop Color="{DynamicResource BrandSkySoft}" Offset="1" />
</LinearGradientBrush>
</Style.Resources>
</Style>
<Style Selector="Window">
<Setter Property="Background" Value="{DynamicResource WindowBackgroundBrush}" />
<Setter Property="FontFamily" Value="Inter" />
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
</Style>
<Style Selector="TextBlock.title">
<Setter Property="FontSize" Value="20" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="TextBlock.subtitle">
<Setter Property="FontSize" Value="12" />
<Setter Property="Foreground" Value="{DynamicResource TextMutedBrush}" />
</Style>
<Style Selector="Border.card">
<Setter Property="Background" Value="{DynamicResource CardBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource CardBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="12" />
</Style>
<Style Selector="Border.card.accent">
<Setter Property="Background" Value="{DynamicResource AccentBackgroundBrush}" />
</Style>
<Style Selector="TextBox">
<Setter Property="BorderBrush" Value="{DynamicResource InputBorderBrush}" />
<Setter Property="Background" Value="{DynamicResource InputBackgroundBrush}" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Padding" Value="10,8" />
</Style>
<Style Selector="Button">
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Padding" Value="12,8" />
</Style>
<Style Selector="Button.primary">
<Setter Property="Background" Value="{DynamicResource PrimaryButtonBrush}" />
<Setter Property="Foreground" Value="{DynamicResource PrimaryButtonTextBrush}" />
</Style>
<Style Selector="Button.secondary">
<Setter Property="Background" Value="{DynamicResource SecondaryButtonBrush}" />
<Setter Property="Foreground" Value="{DynamicResource SecondaryButtonTextBrush}" />
</Style>
</Application.Styles>
</Application>

View File

@@ -0,0 +1,23 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
namespace PhotoboothUploader;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow();
}
base.OnFrameworkInitializationCompleted();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 B

View File

@@ -0,0 +1,142 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="520" d:DesignHeight="360"
x:Class="PhotoboothUploader.MainWindow"
Width="560" Height="420"
MinWidth="520" MinHeight="400"
Title="AI Stylegallery - Photobooth Uploader">
<Grid Margin="24,32,24,24" RowDefinitions="Auto,*">
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
<Border Width="40" Height="40" Classes="card accent" VerticalAlignment="Center" HorizontalAlignment="Left">
<Image Source="avares://PhotoboothUploader/Assets/logo.png" Width="28" Height="28" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<StackPanel Spacing="2">
<TextBlock x:Name="TitleText"
Text="AI Stylegallery - Photobooth Uploader"
Classes="title" />
<TextBlock Text="Sicherer Upload der Fotobox-Fotos ins Event." Classes="subtitle" />
</StackPanel>
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,16,*">
<StackPanel Grid.Column="0" Spacing="16" MaxWidth="420">
<Border Padding="14" Classes="card">
<StackPanel Spacing="10">
<TextBlock Text="Zugangsdaten" FontWeight="SemiBold" />
<TextBlock Text="Upload-URL" />
<TextBox x:Name="ManualUploadUrlBox" Watermark="https://stylegallery.fotospiel.app/api/v1/photobooth/upload" />
<TextBlock Text="Benutzername" />
<TextBox x:Name="ManualUsernameBox" />
<TextBlock Text="Passwort" />
<TextBox x:Name="ManualPasswordBox" PasswordChar="•" />
<TextBlock Text="Antwort-Format (optional)" />
<ComboBox x:Name="ResponseFormatBox" SelectedIndex="0">
<ComboBoxItem Content="Auto" />
<ComboBoxItem Content="JSON" />
<ComboBoxItem Content="XML" />
</ComboBox>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button x:Name="TestConnectionButton" Content="Verbindung testen" Click="TestConnectionButton_Click" Classes="secondary" />
<Button x:Name="SaveAdvancedButton" Content="Speichern" Click="SaveAdvancedButton_Click" Classes="primary" />
</StackPanel>
</StackPanel>
</Border>
<Border Padding="14" Classes="card">
<StackPanel Spacing="8">
<TextBlock Text="Upload-Ordner" FontWeight="SemiBold" />
<TextBlock x:Name="FolderText" Text="Noch nicht ausgewählt." TextWrapping="Wrap" Classes="subtitle" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button x:Name="DslrBoothPresetButton" Content="DSLrBooth" Click="DslrBoothPresetButton_Click" Classes="secondary" IsVisible="False" />
<Button x:Name="SparkboothPresetButton" Content="Photobooth (Sparkbooth)" Click="SparkboothPresetButton_Click" Classes="secondary" IsVisible="False" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button x:Name="PickFolderButton" Content="Ordner auswählen" Click="PickFolderButton_Click" Classes="primary" IsEnabled="False" />
<Button x:Name="TestUploadButton" Content="Test-Upload senden" Click="TestUploadButton_Click" Classes="secondary" IsEnabled="False" />
</StackPanel>
</StackPanel>
</Border>
<Border Padding="14" Classes="card">
<StackPanel Spacing="8">
<TextBlock Text="Upload-Optionen" FontWeight="SemiBold" />
<TextBlock Text="Max. parallele Uploads" />
<TextBox x:Name="MaxUploadsBox" Watermark="2" />
<TextBlock Text="Upload-Tempo" />
<ComboBox x:Name="UploadTempoBox" SelectedIndex="1">
<ComboBoxItem Content="Schnell (ohne Pause)" />
<ComboBoxItem Content="Normal" />
<ComboBoxItem Content="Sanft (schont Netzwerk)" />
</ComboBox>
<TextBlock Text="Nur diese Dateien (optional)" />
<TextBox x:Name="IncludePatternsBox" Watermark="*.jpg;*.jpeg;*.png" />
<TextBlock Text="Dateien ausschliessen (optional)" />
<TextBox x:Name="ExcludePatternsBox" Watermark="*_preview*;*.tmp" />
<TextBlock Text="Profile" />
<ComboBox x:Name="ProfilesBox" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button x:Name="LoadProfileButton" Content="Profil laden" Click="LoadProfileButton_Click" Classes="secondary" />
<Button x:Name="SaveProfileButton" Content="Profil speichern" Click="SaveProfileButton_Click" Classes="secondary" />
</StackPanel>
</StackPanel>
</Border>
<ToggleSwitch x:Name="QuietToggle" Content="Ruhiger Modus (nur Fehler anzeigen)" />
</StackPanel>
<StackPanel Grid.Column="2" Spacing="16" MaxWidth="380" Margin="0,6,0,0">
<Border Padding="14" Classes="card accent">
<StackPanel Spacing="6">
<TextBlock Text="Status" FontWeight="SemiBold" />
<TextBlock x:Name="StatusText" Text="Nicht verbunden." TextWrapping="Wrap" />
<TextBlock x:Name="LastUploadText" Text="Letzter Upload: —" />
<TextBlock x:Name="QueueStatusText" Text="Warteschlange: 0 · Läuft: 0 · Fehlgeschlagen: 0" />
<TextBlock x:Name="LiveStatusText" Text="Live: —" />
</StackPanel>
</Border>
<Border Padding="14" Classes="card">
<StackPanel Spacing="6">
<TextBlock Text="Details" FontWeight="SemiBold" />
<TextBlock x:Name="EventNameText" Text="Benutzername: —" TextWrapping="Wrap" />
<TextBlock x:Name="BaseUrlText" Text="Basis-URL: —" TextWrapping="Wrap" />
<TextBlock x:Name="UploadUrlText" Text="Upload-URL: —" TextWrapping="Wrap" />
<TextBlock x:Name="VersionText" Text="App-Version: —" />
<TextBlock x:Name="ConnectExpiryText" Text="Antwort-Format: —" TextWrapping="Wrap" />
<TextBlock x:Name="FolderHealthText" Text="Ordner: —" TextWrapping="Wrap" />
<TextBlock x:Name="DiskFreeText" Text="Freier Speicher: —" TextWrapping="Wrap" />
<TextBlock x:Name="LastSeenText" Text="Letzte Datei: —" TextWrapping="Wrap" />
<TextBlock x:Name="LastErrorText" Text="Letzter Fehler: —" TextWrapping="Wrap" />
<Button x:Name="LogCopyButton" Content="Log kopieren" Click="LogCopyButton_Click" Classes="secondary" />
</StackPanel>
</Border>
<Border Padding="14" Classes="card">
<StackPanel Spacing="8">
<TextBlock Text="Letzte Uploads" FontWeight="SemiBold" />
<ItemsControl x:Name="RecentUploadsList" ItemsSource="{Binding RecentUploads}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="#14FFFFFF" Padding="10" CornerRadius="8" Margin="0,0,0,8">
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto">
<TextBlock Grid.Column="0" Grid.Row="0" Text="{Binding FileName}" />
<TextBlock Grid.Column="1" Grid.Row="0" Text="{Binding StatusLabel}" />
<TextBlock Grid.Column="0" Grid.Row="1" Text="{Binding UpdatedLabel}" Opacity="0.7" FontSize="11" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button x:Name="RetryFailedButton" Content="Fehlgeschlagene erneut senden" Click="RetryFailedButton_Click" IsEnabled="False" Classes="secondary" />
<Button x:Name="ClearFailedButton" Content="Fehlerliste leeren" Click="ClearFailedButton_Click" IsEnabled="False" Classes="secondary" />
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</Grid>
</Grid>
</Window>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
using System.Text.Json.Serialization;
namespace PhotoboothUploader.Models;
public sealed class PhotoboothConnectResponse
{
[JsonPropertyName("data")]
public PhotoboothConnectPayload? Data { get; set; }
[JsonPropertyName("message")]
public string? Message { get; set; }
}
public sealed class PhotoboothConnectPayload
{
[JsonPropertyName("event_name")]
public string? EventName { get; set; }
[JsonPropertyName("upload_url")]
public string? UploadUrl { get; set; }
[JsonPropertyName("username")]
public string? Username { get; set; }
[JsonPropertyName("password")]
public string? Password { get; set; }
[JsonPropertyName("expires_at")]
public string? ExpiresAt { get; set; }
[JsonPropertyName("response_format")]
public string? ResponseFormat { get; set; }
}

View File

@@ -0,0 +1,26 @@
using System;
namespace PhotoboothUploader.Models;
public sealed class PhotoboothProfile
{
public string? Label { get; set; }
public string? EventName { get; set; }
public string? BaseUrl { get; set; }
public string? UploadUrl { get; set; }
public string? Username { get; set; }
public string? Password { get; set; }
public string? ResponseFormat { get; set; }
public string? WatchFolder { get; set; }
public string? IncludePatterns { get; set; }
public string? ExcludePatterns { get; set; }
public int MaxConcurrentUploads { get; set; } = 2;
public int UploadDelayMs { get; set; } = 500;
public string DisplayName
=> !string.IsNullOrWhiteSpace(Label)
? Label
: !string.IsNullOrWhiteSpace(EventName)
? EventName
: UploadUrl ?? BaseUrl ?? "Profil";
}

View File

@@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
namespace PhotoboothUploader.Models;
public sealed class PhotoboothSettings
{
public string? BaseUrl { get; set; }
public string? EventName { get; set; }
public string? UploadUrl { get; set; }
public string? Username { get; set; }
public string? Password { get; set; }
public string? ResponseFormat { get; set; }
public string? WatchFolder { get; set; }
public string? IncludePatterns { get; set; }
public string? ExcludePatterns { get; set; }
public List<string> PendingUploads { get; set; } = new();
public Dictionary<string, string> UploadedFiles { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public List<PhotoboothProfile> Profiles { get; set; } = new();
public string? ConnectExpiresAt { get; set; }
public string? LastSeenFile { get; set; }
public string? LastSeenAt { get; set; }
public string? LastError { get; set; }
public string? LastErrorAt { get; set; }
public int MaxConcurrentUploads { get; set; } = 2;
public int UploadDelayMs { get; set; } = 500;
public double WindowWidth { get; set; }
public double WindowHeight { get; set; }
}

View File

@@ -0,0 +1,74 @@
using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace PhotoboothUploader.Models;
public enum UploadStatus
{
Queued,
Uploading,
Success,
Failed,
}
public sealed class UploadItem : INotifyPropertyChanged
{
private UploadStatus _status;
private DateTimeOffset _updatedAt;
public UploadItem(string path)
{
Path = path;
FileName = System.IO.Path.GetFileName(path);
UpdatedAt = DateTimeOffset.Now;
Status = UploadStatus.Queued;
}
public string Path { get; }
public string FileName { get; }
public UploadStatus Status
{
get => _status;
set
{
if (_status != value)
{
_status = value;
UpdatedAt = DateTimeOffset.Now;
OnPropertyChanged();
OnPropertyChanged(nameof(StatusLabel));
}
}
}
public DateTimeOffset UpdatedAt
{
get => _updatedAt;
private set
{
_updatedAt = value;
OnPropertyChanged();
OnPropertyChanged(nameof(UpdatedLabel));
}
}
public string StatusLabel => Status switch
{
UploadStatus.Uploading => "Upload läuft",
UploadStatus.Success => "Fertig",
UploadStatus.Failed => "Fehlgeschlagen",
_ => "Wartet",
};
public string UpdatedLabel => $"{UpdatedAt:HH:mm}";
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<AssemblyName>AIStylegallery.PhotoboothUploader</AssemblyName>
<Product>AI Stylegallery Photobooth Uploader</Product>
<Company>AI Stylegallery</Company>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.10" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.10" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.10" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.10" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.10">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<None Include="Assets\app.ico" />
<AvaloniaResource Include="Assets\logo.png" />
<AvaloniaResource Include="Assets\sample-upload.png" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,21 @@
using Avalonia;
using System;
namespace PhotoboothUploader;
class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}

View File

@@ -0,0 +1,122 @@
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using PhotoboothUploader.Models;
namespace PhotoboothUploader.Services;
public sealed class PhotoboothConnectClient
{
private const int MaxRetries = 2;
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10);
private readonly HttpClient _httpClient;
private readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
public PhotoboothConnectClient(string baseUrl, string userAgent)
{
_httpClient = new HttpClient
{
BaseAddress = new Uri(baseUrl),
Timeout = DefaultTimeout,
};
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
}
public async Task<PhotoboothConnectResponse> RedeemAsync(string code, CancellationToken cancellationToken = default)
{
var request = new { code };
for (var attempt = 0; attempt <= MaxRetries; attempt++)
{
try
{
using var response = await _httpClient.PostAsJsonAsync("/api/v1/photobooth/connect", request, cancellationToken);
var payload = await ReadPayloadAsync(response, cancellationToken);
if (response.IsSuccessStatusCode)
{
return payload ?? Fail(response.ReasonPhrase ?? "Verbindung fehlgeschlagen.");
}
if (response.StatusCode is HttpStatusCode.UnprocessableEntity or HttpStatusCode.Conflict or HttpStatusCode.Unauthorized)
{
return payload ?? Fail(response.ReasonPhrase ?? "Verbindung fehlgeschlagen.");
}
if (attempt < MaxRetries && IsTransientStatus(response.StatusCode))
{
await Task.Delay(GetRetryDelay(attempt), cancellationToken);
continue;
}
return payload ?? Fail(response.ReasonPhrase ?? "Verbindung fehlgeschlagen.");
}
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
{
if (attempt < MaxRetries)
{
await Task.Delay(GetRetryDelay(attempt), cancellationToken);
continue;
}
return Fail("Zeitüberschreitung bei der Verbindung.");
}
catch (HttpRequestException)
{
if (attempt < MaxRetries)
{
await Task.Delay(GetRetryDelay(attempt), cancellationToken);
continue;
}
return Fail("Netzwerkfehler. Bitte Verbindung prüfen.");
}
catch (JsonException)
{
return Fail("Serverantwort konnte nicht gelesen werden.");
}
}
return Fail("Verbindung fehlgeschlagen.");
}
private async Task<PhotoboothConnectResponse?> ReadPayloadAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (response.Content.Headers.ContentLength == 0)
{
return null;
}
return await response.Content.ReadFromJsonAsync<PhotoboothConnectResponse>(_jsonOptions, cancellationToken);
}
private static bool IsTransientStatus(HttpStatusCode statusCode)
{
return statusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests
or HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout
or HttpStatusCode.InternalServerError;
}
private static TimeSpan GetRetryDelay(int attempt)
{
return TimeSpan.FromMilliseconds(500 * (attempt + 1));
}
private static PhotoboothConnectResponse Fail(string message)
{
return new PhotoboothConnectResponse
{
Message = message,
};
}
}

View File

@@ -0,0 +1,47 @@
using System;
using System.IO;
using System.Text.Json;
using PhotoboothUploader.Models;
namespace PhotoboothUploader.Services;
public sealed class SettingsStore
{
private readonly JsonSerializerOptions _options = new()
{
PropertyNameCaseInsensitive = true,
WriteIndented = true,
};
public string SettingsPath { get; }
public string LogPath { get; }
public SettingsStore()
{
var basePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"AIStylegallery",
"PhotoboothUploader");
Directory.CreateDirectory(basePath);
SettingsPath = Path.Combine(basePath, "settings.json");
LogPath = Path.Combine(basePath, "uploader.log");
}
public PhotoboothSettings Load()
{
if (!File.Exists(SettingsPath))
{
return new PhotoboothSettings();
}
var json = File.ReadAllText(SettingsPath);
return JsonSerializer.Deserialize<PhotoboothSettings>(json, _options) ?? new PhotoboothSettings();
}
public void Save(PhotoboothSettings settings)
{
var json = JsonSerializer.Serialize(settings, _options);
File.WriteAllText(SettingsPath, json);
}
}

View File

@@ -0,0 +1,297 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using PhotoboothUploader.Models;
namespace PhotoboothUploader.Services;
public sealed class UploadService
{
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(20);
private static readonly TimeSpan RetryBaseDelay = TimeSpan.FromSeconds(2);
private const int MaxRetries = 2;
private readonly Channel<string> _queue = Channel.CreateUnbounded<string>();
private readonly ConcurrentDictionary<string, byte> _pending = new(StringComparer.OrdinalIgnoreCase);
private string _userAgent = "AIStylegalleryPhotoboothUploader";
private CancellationTokenSource? _cts;
private readonly List<Task> _workers = new();
public void Configure(string userAgent)
{
if (!string.IsNullOrWhiteSpace(userAgent))
{
_userAgent = userAgent;
}
}
public void Start(
PhotoboothSettings settings,
Action<string> onQueued,
Action<string> onUploading,
Action<string> onSuccess,
Action<string, string> onFailure)
{
Stop();
_cts = new CancellationTokenSource();
var workerCount = GetWorkerCount(settings);
for (var i = 0; i < workerCount; i++)
{
_workers.Add(Task.Run(() => WorkerAsync(settings, onQueued, onUploading, onSuccess, onFailure, _cts.Token)));
}
}
public void Stop()
{
_cts?.Cancel();
_cts = null;
_pending.Clear();
_workers.Clear();
}
public void Enqueue(string path, Action<string> onQueued)
{
if (!_pending.TryAdd(path, 0))
{
return;
}
_queue.Writer.TryWrite(path);
onQueued(path);
}
private async Task WorkerAsync(
PhotoboothSettings settings,
Action<string> onQueued,
Action<string> onUploading,
Action<string> onSuccess,
Action<string, string> onFailure,
CancellationToken token)
{
if (string.IsNullOrWhiteSpace(settings.UploadUrl))
{
return;
}
using var client = new HttpClient();
client.Timeout = DefaultTimeout;
client.DefaultRequestHeaders.UserAgent.ParseAdd(_userAgent);
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
while (await _queue.Reader.WaitToReadAsync(token))
{
while (_queue.Reader.TryRead(out var path))
{
try
{
onUploading(path);
var error = await UploadWithRetryAsync(client, settings, path, token);
if (error is null)
{
onSuccess(path);
}
else
{
onFailure(path, error);
}
}
catch (OperationCanceledException)
{
return;
}
finally
{
_pending.TryRemove(path, out _);
if (settings.UploadDelayMs > 0)
{
await Task.Delay(settings.UploadDelayMs, token);
}
}
}
}
}
private static async Task<string?> UploadWithRetryAsync(
HttpClient client,
PhotoboothSettings settings,
string path,
CancellationToken token)
{
for (var attempt = 0; attempt <= MaxRetries; attempt++)
{
var attemptError = await UploadOnceAsync(client, settings, path, token);
if (attemptError.Success)
{
return null;
}
if (!attemptError.Retryable || attempt >= MaxRetries)
{
return attemptError.Error ?? "Upload fehlgeschlagen.";
}
await Task.Delay(GetRetryDelay(attempt), token);
}
return "Upload fehlgeschlagen.";
}
private static async Task<UploadAttempt> UploadOnceAsync(
HttpClient client,
PhotoboothSettings settings,
string path,
CancellationToken token)
{
var readyError = await WaitForFileReadyAsync(path, token);
if (readyError is not null)
{
return UploadAttempt.Fail(readyError, retryable: false);
}
if (!File.Exists(path))
{
return UploadAttempt.Fail("Datei nicht gefunden.", retryable: false);
}
using var content = new MultipartFormDataContent();
if (!string.IsNullOrWhiteSpace(settings.Username))
{
content.Add(new StringContent(settings.Username), "username");
}
if (!string.IsNullOrWhiteSpace(settings.Password))
{
content.Add(new StringContent(settings.Password), "password");
}
if (!string.IsNullOrWhiteSpace(settings.ResponseFormat))
{
content.Add(new StringContent(settings.ResponseFormat), "response_format");
}
var stream = File.OpenRead(path);
var fileContent = new StreamContent(stream);
fileContent.Headers.ContentType = new MediaTypeHeaderValue(ResolveContentType(path));
content.Add(fileContent, "media", Path.GetFileName(path));
try
{
var response = await client.PostAsync(settings.UploadUrl, content, token);
if (response.IsSuccessStatusCode)
{
return UploadAttempt.Ok();
}
var body = await ReadResponseBodyAsync(response, token);
var status = $"{(int)response.StatusCode} {response.ReasonPhrase}".Trim();
var message = string.IsNullOrWhiteSpace(body) ? status : $"{status} {body}";
return UploadAttempt.Fail(message, IsRetryableStatus(response.StatusCode));
}
catch (TaskCanceledException) when (!token.IsCancellationRequested)
{
return UploadAttempt.Fail("Zeitüberschreitung beim Upload.", retryable: true);
}
catch (HttpRequestException)
{
return UploadAttempt.Fail("Netzwerkfehler beim Upload.", retryable: true);
}
catch (IOException)
{
return UploadAttempt.Fail("Datei konnte nicht gelesen werden.", retryable: false);
}
}
private static async Task<string?> WaitForFileReadyAsync(string path, CancellationToken token)
{
var lastSize = -1L;
for (var attempts = 0; attempts < 10; attempts++)
{
token.ThrowIfCancellationRequested();
if (!File.Exists(path))
{
await Task.Delay(500, token);
continue;
}
var info = new FileInfo(path);
var size = info.Length;
if (size > 0 && size == lastSize)
{
return null;
}
lastSize = size;
await Task.Delay(700, token);
}
return "Datei ist noch in Bearbeitung.";
}
private static string ResolveContentType(string path)
{
return Path.GetExtension(path)?.ToLowerInvariant() switch
{
".png" => "image/png",
".webp" => "image/webp",
_ => "image/jpeg",
};
}
private static bool IsRetryableStatus(System.Net.HttpStatusCode statusCode)
{
var numeric = (int)statusCode;
return numeric >= 500 || statusCode is System.Net.HttpStatusCode.RequestTimeout or System.Net.HttpStatusCode.TooManyRequests;
}
private static TimeSpan GetRetryDelay(int attempt)
{
var jitter = TimeSpan.FromMilliseconds(Random.Shared.Next(100, 350));
return TimeSpan.FromMilliseconds(RetryBaseDelay.TotalMilliseconds * Math.Pow(2, attempt)) + jitter;
}
private static async Task<string?> ReadResponseBodyAsync(HttpResponseMessage response, CancellationToken token)
{
if (response.Content is null)
{
return null;
}
var body = await response.Content.ReadAsStringAsync(token);
if (string.IsNullOrWhiteSpace(body))
{
return null;
}
body = body.Trim();
return body.Length > 200 ? body[..200] + "…" : body;
}
private static int GetWorkerCount(PhotoboothSettings settings)
{
var count = settings.MaxConcurrentUploads;
if (count < 1)
{
return 1;
}
return count > 5 ? 5 : count;
}
private readonly record struct UploadAttempt(bool Success, bool Retryable, string? Error)
{
public static UploadAttempt Ok() => new(true, false, null);
public static UploadAttempt Fail(string error, bool retryable) => new(false, retryable, error);
}
}

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<!-- This manifest is used on Windows only.
Don't remove it as it might cause problems with window transparency and embedded controls.
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="AIStylegallery.PhotoboothUploader"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>

View File

@@ -13,7 +13,7 @@ use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table; use Filament\Tables\Table;
use UnitEnum; use UnitEnum;
class SparkboothConnections extends Page implements HasTable class PhotoboothConnections extends Page implements HasTable
{ {
use InteractsWithTable; use InteractsWithTable;
@@ -23,16 +23,16 @@ class SparkboothConnections extends Page implements HasTable
protected static ?int $navigationSort = 11; protected static ?int $navigationSort = 11;
protected static ?string $title = 'Sparkbooth Verbindungen'; protected static ?string $title = 'Photobooth Verbindungen';
protected ?string $heading = 'Sparkbooth Verbindungen'; protected ?string $heading = 'Photobooth Verbindungen';
protected string $view = 'filament.pages.sparkbooth-connections'; protected string $view = 'filament.pages.photobooth-connections';
public function table(Table $table): Table public function table(Table $table): Table
{ {
return $table return $table
->heading('Vorhandene Sparkbooth-Verbindungen') ->heading('Vorhandene Photobooth-Verbindungen')
->query( ->query(
Gallery::query() Gallery::query()
->whereNotNull('upload_token_hash') ->whereNotNull('upload_token_hash')
@@ -73,7 +73,7 @@ class SparkboothConnections extends Page implements HasTable
$data = [ $data = [
'gallery' => $record->only(['id', 'name', 'slug', 'images_path']), 'gallery' => $record->only(['id', 'name', 'slug', 'images_path']),
'upload_token' => $plainToken, 'upload_token' => $plainToken,
'upload_url' => route('api.sparkbooth.upload'), 'upload_url' => route('api.photobooth.upload'),
'gallery_url' => route('gallery.show', $record), 'gallery_url' => route('gallery.show', $record),
'sparkbooth_username' => $record->sparkbooth_username, 'sparkbooth_username' => $record->sparkbooth_username,
'sparkbooth_password' => $record->sparkbooth_password, 'sparkbooth_password' => $record->sparkbooth_password,
@@ -86,26 +86,26 @@ class SparkboothConnections extends Page implements HasTable
->success() ->success()
->send(); ->send();
return view('filament.pages.partials.sparkbooth-token', $data); return view('filament.pages.partials.photobooth-token', $data);
}), }),
Action::make('deleteConnection') Action::make('deleteConnection')
->label('Verbindung loeschen') ->label('Verbindung loeschen')
->icon('heroicon-o-trash') ->icon('heroicon-o-trash')
->color('danger') ->color('danger')
->requiresConfirmation() ->requiresConfirmation()
->modalHeading('Sparkbooth-Verbindung entfernen') ->modalHeading('Photobooth-Verbindung entfernen')
->modalDescription('Die Galerie bleibt erhalten, aber Upload-Token und Zugangsdaten werden geloescht.') ->modalDescription('Die Galerie bleibt erhalten, aber Upload-Token und Zugangsdaten werden geloescht.')
->action(function (Gallery $record): void { ->action(function (Gallery $record): void {
$record->clearSparkboothConnection(); $record->clearSparkboothConnection();
Notification::make() Notification::make()
->title('Sparkbooth-Verbindung entfernt.') ->title('Photobooth-Verbindung entfernt.')
->body('Der Upload-Token und die Zugangsdaten wurden geloescht.') ->body('Der Upload-Token und die Zugangsdaten wurden geloescht.')
->success() ->success()
->send(); ->send();
}), }),
]) ])
->emptyStateHeading('Keine Sparkbooth-Verbindungen') ->emptyStateHeading('Keine Photobooth-Verbindungen')
->emptyStateDescription('Lege eine neue Verbindung an oder aktiviere Uploads fuer eine Galerie.'); ->emptyStateDescription('Lege eine neue Verbindung an oder aktiviere Uploads fuer eine Galerie.');
} }
} }

View File

@@ -15,7 +15,7 @@ use Filament\Schemas\Schema;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use UnitEnum; use UnitEnum;
class SparkboothSetup extends Page implements HasForms class PhotoboothSetup extends Page implements HasForms
{ {
use InteractsWithForms; use InteractsWithForms;
@@ -25,7 +25,7 @@ class SparkboothSetup extends Page implements HasForms
protected static ?int $navigationSort = 10; protected static ?int $navigationSort = 10;
protected string $view = 'filament.pages.sparkbooth-setup'; protected string $view = 'filament.pages.photobooth-setup';
public ?array $data = []; public ?array $data = [];
@@ -33,7 +33,7 @@ class SparkboothSetup extends Page implements HasForms
public function getTitle(): string public function getTitle(): string
{ {
return 'Sparkbooth Setup'; return 'Photobooth Setup';
} }
public function form(Schema $schema): Schema public function form(Schema $schema): Schema
@@ -64,8 +64,8 @@ class SparkboothSetup extends Page implements HasForms
->label('Uploads aktivieren') ->label('Uploads aktivieren')
->default(true), ->default(true),
TextInput::make('sparkbooth_username') TextInput::make('sparkbooth_username')
->label('Sparkbooth Benutzername') ->label('Photobooth Benutzername')
->helperText('Wird in Sparkbooth unter „Username“ eingetragen. Erlaubt sind Buchstaben, Zahlen sowie ._-') ->helperText('Wird im Photobooth Uploader unter „Benutzername“ eingetragen. Erlaubt sind Buchstaben, Zahlen sowie ._-')
->default(fn (): string => 'spark-'.Str::lower(Str::random(6))) ->default(fn (): string => 'spark-'.Str::lower(Str::random(6)))
->required() ->required()
->maxLength(64) ->maxLength(64)
@@ -73,7 +73,7 @@ class SparkboothSetup extends Page implements HasForms
->unique(table: Gallery::class, column: 'sparkbooth_username'), ->unique(table: Gallery::class, column: 'sparkbooth_username'),
Select::make('sparkbooth_response_format') Select::make('sparkbooth_response_format')
->label('Standard-Antwortformat') ->label('Standard-Antwortformat')
->helperText('Sparkbooth kann JSON oder XML erwarten. JSON ist empfohlen.') ->helperText('Der Photobooth Uploader kann JSON oder XML erwarten. JSON ist empfohlen.')
->options([ ->options([
'json' => 'JSON', 'json' => 'JSON',
'xml' => 'XML', 'xml' => 'XML',
@@ -113,7 +113,7 @@ class SparkboothSetup extends Page implements HasForms
$this->result = [ $this->result = [
'gallery' => $gallery->only(['id', 'name', 'slug', 'images_path']), 'gallery' => $gallery->only(['id', 'name', 'slug', 'images_path']),
'upload_token' => $plainToken, 'upload_token' => $plainToken,
'upload_url' => route('api.sparkbooth.upload'), 'upload_url' => route('api.photobooth.upload'),
'gallery_url' => route('gallery.show', $gallery), 'gallery_url' => route('gallery.show', $gallery),
'sparkbooth_username' => $sparkboothUsername, 'sparkbooth_username' => $sparkboothUsername,
'sparkbooth_password' => $sparkboothPassword, 'sparkbooth_password' => $sparkboothPassword,

View File

@@ -79,14 +79,14 @@ class GalleryForm
'url' => $record ? URL::route('gallery.show', $record) : null, 'url' => $record ? URL::route('gallery.show', $record) : null,
]), ]),
]), ]),
Tab::make('Sparkbooth') Tab::make('Photobooth')
->schema([ ->schema([
Section::make('Sparkbooth Upload') Section::make('Photobooth Upload')
->columns(2) ->columns(2)
->schema([ ->schema([
Toggle::make('upload_enabled') Toggle::make('upload_enabled')
->label('Uploads aktivieren') ->label('Uploads aktivieren')
->helperText('Steuert, ob Sparkbooth-Uploads erlaubt sind.') ->helperText('Steuert, ob Photobooth-Uploads erlaubt sind.')
->default(false), ->default(false),
Select::make('sparkbooth_response_format') Select::make('sparkbooth_response_format')
->label('Standard-Antwortformat') ->label('Standard-Antwortformat')
@@ -96,25 +96,25 @@ class GalleryForm
]) ])
->default('json'), ->default('json'),
TextInput::make('sparkbooth_username') TextInput::make('sparkbooth_username')
->label('Sparkbooth Benutzername') ->label('Photobooth Benutzername')
->helperText('Wird in Sparkbooth unter „Username“ eingetragen. Erlaubt sind Buchstaben, Zahlen sowie ._-') ->helperText('Wird im Photobooth Uploader unter „Benutzername“ eingetragen. Erlaubt sind Buchstaben, Zahlen sowie ._-')
->maxLength(64) ->maxLength(64)
->rule('regex:/^[A-Za-z0-9._-]+$/') ->rule('regex:/^[A-Za-z0-9._-]+$/')
->unique(table: \App\Models\Gallery::class, column: 'sparkbooth_username', ignoreRecord: true) ->unique(table: \App\Models\Gallery::class, column: 'sparkbooth_username', ignoreRecord: true)
->dehydrateStateUsing(fn (?string $state): ?string => $state ? Str::of($state)->lower()->trim()->value() : null), ->dehydrateStateUsing(fn (?string $state): ?string => $state ? Str::of($state)->lower()->trim()->value() : null),
TextInput::make('sparkbooth_password') TextInput::make('sparkbooth_password')
->label('Sparkbooth Passwort (neu)') ->label('Photobooth Passwort (neu)')
->password() ->password()
->revealable() ->revealable()
->dehydrated(false) ->dehydrated(false)
->afterStateHydrated(fn (callable $set) => $set('sparkbooth_password', null)) ->afterStateHydrated(fn (callable $set) => $set('sparkbooth_password', null))
->helperText('Leer lassen, um das bestehende Passwort zu behalten.'), ->helperText('Leer lassen, um das bestehende Passwort zu behalten.'),
]), ]),
View::make('filament.pages.partials.sparkbooth-token') View::make('filament.pages.partials.photobooth-token')
->columnSpanFull() ->columnSpanFull()
->visible(fn (?object $record) => (bool) $record?->id) ->visible(fn (?object $record) => (bool) $record?->id)
->viewData(fn (?object $record) => [ ->viewData(fn (?object $record) => [
'upload_url' => URL::route('api.sparkbooth.upload'), 'upload_url' => URL::route('api.photobooth.upload'),
'gallery_url' => $record ? URL::route('gallery.show', $record) : null, 'gallery_url' => $record ? URL::route('gallery.show', $record) : null,
'sparkbooth_username' => $record?->sparkbooth_username, 'sparkbooth_username' => $record?->sparkbooth_username,
'sparkbooth_password' => $record?->sparkbooth_password, 'sparkbooth_password' => $record?->sparkbooth_password,

View File

@@ -16,14 +16,14 @@
<div class="grid gap-4 md:grid-cols-2"> <div class="grid gap-4 md:grid-cols-2">
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5"> <div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Sparkbooth Benutzername</p> <p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Photobooth Benutzername</p>
<p class="mt-1 break-all font-mono text-sm text-gray-900 dark:text-white">{{ $username }}</p> <p class="mt-1 break-all font-mono text-sm text-gray-900 dark:text-white">{{ $username }}</p>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Eintragen im Sparkbooth Custom Upload Dialog unter Username“.</p> <p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Eintragen im Photobooth Uploader unter Benutzername“.</p>
</div> </div>
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5"> <div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Sparkbooth Passwort</p> <p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Photobooth Passwort</p>
<p class="mt-1 break-all font-mono text-sm text-gray-900 dark:text-white">{{ $password }}</p> <p class="mt-1 break-all font-mono text-sm text-gray-900 dark:text-white">{{ $password }}</p>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Eintragen unter „Password.</p> <p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Eintragen im Photobooth Uploader unter „Passwort.</p>
</div> </div>
</div> </div>
@@ -31,14 +31,14 @@
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5"> <div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Antwortformat</p> <p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Antwortformat</p>
<p class="mt-1 font-semibold text-gray-900 dark:text-white">{{ $format }}</p> <p class="mt-1 font-semibold text-gray-900 dark:text-white">{{ $format }}</p>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">In Sparkbooth „JSON Response“ oder „XML Response“ passend auswählen.</p> <p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Im Photobooth Uploader das passende Antwort-Format auswählen.</p>
</div> </div>
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5"> <div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Sparkbooth Hinweise</p> <p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Photobooth Hinweise</p>
<ul class="mt-2 list-disc space-y-1 pl-4 text-xs text-gray-600 dark:text-gray-300"> <ul class="mt-2 list-disc space-y-1 pl-4 text-xs text-gray-600 dark:text-gray-300">
<li>Uploader „Custom Upload“ wählen.</li> <li>Uploader starten und Zugangsdaten eintragen.</li>
<li>Username &amp; Password wie oben eintragen.</li> <li>Username &amp; Password wie oben eintragen.</li>
<li>Optional: Name/Email/Message Felder in Sparkbooth setzen.</li> <li>Optional: Name/Email/Message Felder im Uploader setzen.</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -15,14 +15,14 @@
<div class="mt-4 grid gap-4 md:grid-cols-2"> <div class="mt-4 grid gap-4 md:grid-cols-2">
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5"> <div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Sparkbooth Benutzername</p> <p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Photobooth Benutzername</p>
<p class="mt-1 break-all font-mono text-sm text-gray-900 dark:text-white">{{ $result['sparkbooth_username'] }}</p> <p class="mt-1 break-all font-mono text-sm text-gray-900 dark:text-white">{{ $result['sparkbooth_username'] }}</p>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Eintragen in Sparkbooth Settings Upload Custom Upload Username.</p> <p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Eintragen im Photobooth Uploader unter „Benutzername.</p>
</div> </div>
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5"> <div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Sparkbooth Passwort</p> <p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Photobooth Passwort</p>
<p class="mt-1 break-all font-mono text-sm text-gray-900 dark:text-white">{{ $result['sparkbooth_password'] }}</p> <p class="mt-1 break-all font-mono text-sm text-gray-900 dark:text-white">{{ $result['sparkbooth_password'] }}</p>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Eintragen in Sparkbooth unter „Password.</p> <p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Eintragen im Photobooth Uploader unter „Passwort.</p>
</div> </div>
</div> </div>
@@ -35,7 +35,7 @@
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5"> <div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Standard-Antwortformat</p> <p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Standard-Antwortformat</p>
<p class="mt-1 font-semibold text-gray-900 dark:text-white">{{ strtoupper($result['response_format']) }}</p> <p class="mt-1 font-semibold text-gray-900 dark:text-white">{{ strtoupper($result['response_format']) }}</p>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Muss mit der Auswahl „JSON Response“ oder „XML Response“ in Sparkbooth übereinstimmen.</p> <p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Muss mit dem Antwort-Format im Photobooth Uploader übereinstimmen.</p>
</div> </div>
</div> </div>
@@ -46,11 +46,11 @@
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Optional: Für bestehende Integrationen nutzbar (Feld „token“).</p> <p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Optional: Für bestehende Integrationen nutzbar (Feld „token“).</p>
</div> </div>
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5"> <div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Sparkbooth Hinweise</p> <p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Photobooth Hinweise</p>
<ul class="mt-2 list-disc space-y-1 pl-4 text-xs text-gray-600 dark:text-gray-300"> <ul class="mt-2 list-disc space-y-1 pl-4 text-xs text-gray-600 dark:text-gray-300">
<li>Uploader: „Custom Upload“ wählen.</li> <li>Uploader starten und Zugangsdaten eintragen.</li>
<li>URL: {{ $result['upload_url'] }}</li> <li>URL: {{ $result['upload_url'] }}</li>
<li>Username/Password eintragen, optional Message.</li> <li>Benutzername/Passwort eintragen.</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -70,7 +70,7 @@
<div class="mt-6 space-y-4"> <div class="mt-6 space-y-4">
<div> <div>
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Sparkbooth Beispiel (Custom Upload)</p> <p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Photobooth Uploader Beispiel</p>
<pre class="mt-2 rounded-xl border border-gray-200 bg-gray-900 p-4 text-xs text-gray-100 dark:border-white/10"> <pre class="mt-2 rounded-xl border border-gray-200 bg-gray-900 p-4 text-xs text-gray-100 dark:border-white/10">
curl -X POST {{ $result['upload_url'] }} \ curl -X POST {{ $result['upload_url'] }} \
-F "media=@your-photo.jpg" \ -F "media=@your-photo.jpg" \

View File

@@ -27,9 +27,9 @@ Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
Route::get('/ai-status', [AiStatusController::class, 'checkStatus']); Route::get('/ai-status', [AiStatusController::class, 'checkStatus']);
Route::post('/ai-status/update', [AiStatusController::class, 'checkAndUpdateStatus']); Route::post('/ai-status/update', [AiStatusController::class, 'checkAndUpdateStatus']);
Route::post('/sparkbooth/upload', [SparkboothUploadController::class, 'store']) Route::post('/v1/photobooth/upload', [SparkboothUploadController::class, 'store'])
->middleware('throttle:30,1') ->middleware('throttle:30,1')
->name('api.sparkbooth.upload'); ->name('api.photobooth.upload');
Route::post('/admin/navigation-state', [NavigationStateController::class, 'store'])->middleware('auth:sanctum'); Route::post('/admin/navigation-state', [NavigationStateController::class, 'store'])->middleware('auth:sanctum');

View File

@@ -2,7 +2,7 @@
namespace Tests\Feature\Filament; namespace Tests\Feature\Filament;
use App\Filament\Pages\SparkboothConnections; use App\Filament\Pages\PhotoboothConnections;
use App\Models\Gallery; use App\Models\Gallery;
use App\Models\Role; use App\Models\Role;
use App\Models\User; use App\Models\User;
@@ -11,7 +11,7 @@ use Illuminate\Foundation\Testing\DatabaseTransactions;
use Livewire\Livewire; use Livewire\Livewire;
use Tests\TestCase; use Tests\TestCase;
class SparkboothConnectionsTest extends TestCase class PhotoboothConnectionsTest extends TestCase
{ {
use DatabaseTransactions; use DatabaseTransactions;
@@ -36,7 +36,7 @@ class SparkboothConnectionsTest extends TestCase
$gallery->setUploadToken('tokentest'); $gallery->setUploadToken('tokentest');
$gallery->save(); $gallery->save();
Livewire::test(SparkboothConnections::class) Livewire::test(PhotoboothConnections::class)
->callTableAction('deleteConnection', $gallery); ->callTableAction('deleteConnection', $gallery);
$updated = $gallery->fresh(); $updated = $gallery->fresh();

View File

@@ -27,7 +27,7 @@ class SparkboothUploadTest extends TestCase
$file = UploadedFile::fake()->image('photo.jpg', 800, 600); $file = UploadedFile::fake()->image('photo.jpg', 800, 600);
$response = $this->postJson(route('api.sparkbooth.upload'), [ $response = $this->postJson(route('api.photobooth.upload'), [
'username' => 'spark-user', 'username' => 'spark-user',
'password' => 'secret-123', 'password' => 'secret-123',
'media' => $file, 'media' => $file,
@@ -66,7 +66,7 @@ class SparkboothUploadTest extends TestCase
$file = UploadedFile::fake()->image('photo.jpg', 800, 600); $file = UploadedFile::fake()->image('photo.jpg', 800, 600);
$response = $this->post(route('api.sparkbooth.upload'), [ $response = $this->post(route('api.photobooth.upload'), [
'username' => 'spark-user', 'username' => 'spark-user',
'password' => 'wrong', 'password' => 'wrong',
'media' => $file, 'media' => $file,
@@ -96,7 +96,7 @@ class SparkboothUploadTest extends TestCase
$binary = base64_encode(file_get_contents($fake->getRealPath())); $binary = base64_encode(file_get_contents($fake->getRealPath()));
$dataUri = 'data:image/png;base64,'.$binary; $dataUri = 'data:image/png;base64,'.$binary;
$response = $this->postJson(route('api.sparkbooth.upload'), [ $response = $this->postJson(route('api.photobooth.upload'), [
'token' => $token, 'token' => $token,
'media' => $dataUri, 'media' => $dataUri,
'filename' => 'custom.png', 'filename' => 'custom.png',