diff --git a/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml b/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml
index a725c06..db2d4c3 100644
--- a/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml
+++ b/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml
@@ -61,6 +61,13 @@
+
+
+
+
+
+
+
@@ -75,6 +82,12 @@
+
+
+
+
+
+
@@ -106,9 +119,12 @@
+
+
+
@@ -128,7 +144,10 @@
-
+
+
+
+
diff --git a/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml.cs b/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml.cs
index 0c4097e..7706661 100644
--- a/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml.cs
+++ b/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml.cs
@@ -35,6 +35,7 @@ public partial class MainWindow : Window
private DateTimeOffset? _lastSuccessAt;
private bool _advancedVisible;
private readonly DispatcherTimer _liveTimer = new();
+ private readonly List _logBuffer = new();
public ObservableCollection RecentUploads { get; } = new();
@@ -130,12 +131,15 @@ public partial class MainWindow : Window
_settings.Password = response.Data.Password;
_settings.ResponseFormat = response.Data.ResponseFormat;
_settings.EventName = response.Data.EventName;
+ _settings.ConnectExpiresAt = response.Data.ExpiresAt;
_settingsStore.Save(_settings);
StatusText.Text = "Verbunden. Upload bereit.";
+ AppendLog("Verbunden mit Event.");
PickFolderButton.IsEnabled = true;
TestUploadButton.IsEnabled = true;
ReconnectButton.IsEnabled = true;
+ UpdateAdvancedLockState();
UpdateDiagnostics();
StartUploadPipelineIfReady();
}
@@ -177,9 +181,11 @@ public partial class MainWindow : Window
UploadTempoBox.SelectedIndex = ResolveUploadTempoIndex(_settings.UploadDelayMs);
IncludePatternsBox.Text = _settings.IncludePatterns ?? string.Empty;
ExcludePatternsBox.Text = _settings.ExcludePatterns ?? string.Empty;
+ ResponseFormatBox.SelectedIndex = ResolveResponseFormatIndex(_settings.ResponseFormat);
ManualUploadUrlBox.Text = _settings.UploadUrl ?? string.Empty;
ManualUsernameBox.Text = _settings.Username ?? string.Empty;
ManualPasswordBox.Text = string.Empty;
+ SettingsUnlockToggle.IsChecked = false;
if (!string.IsNullOrWhiteSpace(_settings.UploadUrl))
{
@@ -194,6 +200,8 @@ public partial class MainWindow : Window
UpdateFolderHealth();
UpdateDiagnostics();
UpdateSteps();
+ RefreshProfiles();
+ UpdateAdvancedLockState();
}
private void OnWindowOpened(object? sender, EventArgs e)
@@ -263,6 +271,7 @@ public partial class MainWindow : Window
return;
}
+ RecordLastSeen(e.FullPath);
_uploadService.Enqueue(e.FullPath, OnQueued);
}
@@ -278,6 +287,7 @@ public partial class MainWindow : Window
return;
}
+ RecordLastSeen(e.FullPath);
_uploadService.Enqueue(e.FullPath, OnQueued);
}
@@ -299,6 +309,7 @@ public partial class MainWindow : Window
UpdateUpload(path, UploadStatus.Queued);
AddPendingUpload(path);
UpdateStatusIfAllowed($"Wartet: {Path.GetFileName(path)}", false);
+ AppendLog($"Wartet: {Path.GetFileName(path)}");
UpdateCountersText();
}
@@ -320,6 +331,7 @@ public partial class MainWindow : Window
RemovePendingUpload(path);
MarkUploaded(path);
UpdateStatusIfAllowed($"Hochgeladen: {Path.GetFileName(path)}", false);
+ AppendLog($"Hochgeladen: {Path.GetFileName(path)}");
UpdateCountersText();
UpdateLiveStatus();
}
@@ -333,6 +345,7 @@ public partial class MainWindow : Window
RemovePendingUpload(path);
UpdateStatusIfAllowed($"Upload fehlgeschlagen: {Path.GetFileName(path)}", true);
SetLastError($"{Path.GetFileName(path)} – {message}");
+ AppendLog($"Fehlgeschlagen: {Path.GetFileName(path)} – {message}");
UpdateRetryButton();
UpdateCountersText();
}
@@ -379,6 +392,7 @@ public partial class MainWindow : Window
private void UpdateRetryButton()
{
RetryFailedButton.IsEnabled = _failedPaths.Count > 0;
+ ClearFailedButton.IsEnabled = _failedPaths.Count > 0;
}
private void RetryFailedButton_Click(object? sender, RoutedEventArgs e)
@@ -401,6 +415,21 @@ public partial class MainWindow : Window
UpdateCountersText();
}
+ private void ClearFailedButton_Click(object? sender, RoutedEventArgs e)
+ {
+ if (_failedPaths.Count == 0)
+ {
+ return;
+ }
+
+ var cleared = _failedPaths.Count;
+ _failedPaths.Clear();
+ Interlocked.Add(ref _failedCount, -cleared);
+ UpdateRetryButton();
+ UpdateCountersText();
+ UpdateStatusIfAllowed("Fehlerliste geleert.", false);
+ }
+
private void UpdateSteps()
{
var hasCode = !string.IsNullOrWhiteSpace(_settings.UploadUrl);
@@ -419,6 +448,11 @@ public partial class MainWindow : Window
ReconnectButton.IsEnabled = enabled;
}
+ private void SettingsUnlockToggle_Changed(object? sender, RoutedEventArgs e)
+ {
+ UpdateAdvancedLockState();
+ }
+
private void RestorePendingUploads()
{
EnsureSettingsCollections();
@@ -553,6 +587,7 @@ public partial class MainWindow : Window
_settings.UploadDelayMs = ResolveUploadDelay(UploadTempoBox.SelectedIndex);
_settings.IncludePatterns = NormalizePatternInput(IncludePatternsBox.Text);
_settings.ExcludePatterns = NormalizePatternInput(ExcludePatternsBox.Text);
+ _settings.ResponseFormat = ResolveResponseFormat(ResponseFormatBox.SelectedIndex);
if (!string.IsNullOrWhiteSpace(manualUploadUrl))
{
@@ -577,6 +612,7 @@ public partial class MainWindow : Window
UpdateFolderHealth();
RestartUploadPipeline();
UpdateSteps();
+ UpdateAdvancedLockState();
if (!string.IsNullOrWhiteSpace(_settings.UploadUrl))
{
@@ -678,6 +714,7 @@ public partial class MainWindow : Window
{
FolderHealthText.Text = "Ordner: —";
DiskFreeText.Text = "Freier Speicher: —";
+ LastSeenText.Text = "Letzte Datei: —";
return;
}
@@ -685,13 +722,14 @@ public partial class MainWindow : Window
{
FolderHealthText.Text = "Ordner: fehlt";
DiskFreeText.Text = "Freier Speicher: —";
+ LastSeenText.Text = "Letzte Datei: —";
return;
}
try
{
_ = Directory.EnumerateFileSystemEntries(folder).FirstOrDefault();
- FolderHealthText.Text = "Ordner: OK";
+ FolderHealthText.Text = CanWriteToFolder(folder) ? "Ordner: OK (schreibbar)" : "Ordner: OK (nur lesen)";
}
catch (UnauthorizedAccessException)
{
@@ -702,7 +740,8 @@ public partial class MainWindow : Window
FolderHealthText.Text = "Ordner: Fehler";
}
- DiskFreeText.Text = $"Freier Speicher: {FormatDiskFree(folder)}";
+ DiskFreeText.Text = FormatDiskFree(folder);
+ LastSeenText.Text = FormatLastSeen();
}
private void UpdateLiveStatus()
@@ -712,6 +751,7 @@ public partial class MainWindow : Window
if (_lastSuccessAt is null)
{
LiveStatusText.Text = "Live: —";
+ UpdateDiagnostics();
return;
}
@@ -719,6 +759,7 @@ public partial class MainWindow : Window
var isLive = age <= TimeSpan.FromMinutes(5);
var label = isLive ? "Live: Ja" : "Live: Nein";
LiveStatusText.Text = $"{label} (letzter Upload {FormatRelativeAge(age)})";
+ UpdateDiagnostics();
});
}
@@ -747,17 +788,18 @@ public partial class MainWindow : Window
var root = Path.GetPathRoot(folder);
if (string.IsNullOrWhiteSpace(root))
{
- return "—";
+ return "Freier Speicher: —";
}
var drive = new DriveInfo(root);
if (!drive.IsReady)
{
- return "—";
+ return "Freier Speicher: —";
}
var freeGb = drive.AvailableFreeSpace / (1024d * 1024d * 1024d);
- return $"{freeGb:0.0} GB";
+ var label = freeGb < 5 ? "niedrig" : "ok";
+ return $"Freier Speicher: {freeGb:0.0} GB ({label})";
}
private string? ResolveUploadUrl(string? uploadUrl)
@@ -816,6 +858,7 @@ public partial class MainWindow : Window
EventNameText.Text = $"Event: {_settings.EventName ?? "—"}";
BaseUrlText.Text = $"Basis-URL: {_settings.BaseUrl ?? "—"}";
VersionText.Text = $"App-Version: {GetAppVersion()}";
+ ConnectExpiryText.Text = FormatConnectExpiry();
LastErrorText.Text = $"Letzter Fehler: {FormatLastError()}";
});
}
@@ -866,6 +909,71 @@ public partial class MainWindow : Window
{
_settings.PendingUploads ??= new List();
_settings.UploadedFiles ??= new Dictionary(StringComparer.OrdinalIgnoreCase);
+ _settings.Profiles ??= new List();
+ }
+
+ private void RecordLastSeen(string path)
+ {
+ _settings.LastSeenFile = Path.GetFileName(path);
+ _settings.LastSeenAt = DateTimeOffset.Now.ToString("O");
+ _settingsStore.Save(_settings);
+ UpdateFolderHealth();
+ }
+
+ private bool CanWriteToFolder(string folder)
+ {
+ try
+ {
+ var testFile = Path.Combine(folder, $".fotospiel-write-test-{Guid.NewGuid():N}.tmp");
+ File.WriteAllText(testFile, "ok");
+ File.Delete(testFile);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private string FormatLastSeen()
+ {
+ if (string.IsNullOrWhiteSpace(_settings.LastSeenAt) || string.IsNullOrWhiteSpace(_settings.LastSeenFile))
+ {
+ return "Letzte Datei: —";
+ }
+
+ if (DateTimeOffset.TryParse(_settings.LastSeenAt, out var timestamp))
+ {
+ return $"Letzte Datei: {_settings.LastSeenFile} ({timestamp:HH:mm})";
+ }
+
+ return $"Letzte Datei: {_settings.LastSeenFile}";
+ }
+
+ private string FormatConnectExpiry()
+ {
+ if (string.IsNullOrWhiteSpace(_settings.ConnectExpiresAt))
+ {
+ return "Verbindungscode: —";
+ }
+
+ if (!DateTimeOffset.TryParse(_settings.ConnectExpiresAt, out var expiry))
+ {
+ return "Verbindungscode: —";
+ }
+
+ var remaining = expiry - DateTimeOffset.Now;
+ if (remaining <= TimeSpan.Zero)
+ {
+ return "Verbindungscode: abgelaufen";
+ }
+
+ if (remaining.TotalHours >= 1)
+ {
+ return $"Verbindungscode: {Math.Ceiling(remaining.TotalHours)} h gueltig";
+ }
+
+ return $"Verbindungscode: {Math.Ceiling(remaining.TotalMinutes)} min gueltig";
}
private string? ResolveTestUrl()
@@ -926,6 +1034,31 @@ public partial class MainWindow : Window
return 1;
}
+ private static string? ResolveResponseFormat(int selectedIndex)
+ {
+ return selectedIndex switch
+ {
+ 1 => "json",
+ 2 => "xml",
+ _ => null,
+ };
+ }
+
+ private static int ResolveResponseFormatIndex(string? format)
+ {
+ if (string.Equals(format, "json", StringComparison.OrdinalIgnoreCase))
+ {
+ return 1;
+ }
+
+ if (string.Equals(format, "xml", StringComparison.OrdinalIgnoreCase))
+ {
+ return 2;
+ }
+
+ return 0;
+ }
+
private static string NormalizePatternInput(string? input)
{
if (string.IsNullOrWhiteSpace(input))
@@ -1022,4 +1155,138 @@ public partial class MainWindow : Window
return patternIndex == pattern.Length;
}
+
+ private void UpdateAdvancedLockState()
+ {
+ var connected = !string.IsNullOrWhiteSpace(_settings.UploadUrl);
+ var unlocked = SettingsUnlockToggle?.IsChecked ?? false;
+ var enabled = !connected || unlocked;
+
+ BaseUrlBox.IsEnabled = enabled;
+ MaxUploadsBox.IsEnabled = enabled;
+ UploadTempoBox.IsEnabled = enabled;
+ IncludePatternsBox.IsEnabled = enabled;
+ ExcludePatternsBox.IsEnabled = enabled;
+ ResponseFormatBox.IsEnabled = enabled;
+ ManualUploadUrlBox.IsEnabled = enabled;
+ ManualUsernameBox.IsEnabled = enabled;
+ ManualPasswordBox.IsEnabled = enabled;
+ TestConnectionButton.IsEnabled = enabled;
+ SaveAdvancedButton.IsEnabled = enabled;
+ }
+
+ private void RefreshProfiles()
+ {
+ ProfilesBox.ItemsSource = _settings.Profiles.Select(profile => profile.DisplayName).ToList();
+ }
+
+ private void LoadProfileButton_Click(object? sender, RoutedEventArgs e)
+ {
+ var index = ProfilesBox.SelectedIndex;
+ if (index < 0 || index >= _settings.Profiles.Count)
+ {
+ UpdateStatus("Bitte zuerst ein Profil auswaehlen.");
+ return;
+ }
+
+ var profile = _settings.Profiles[index];
+ _settings.BaseUrl = NormalizeBaseUrl(profile.BaseUrl) ?? _settings.BaseUrl;
+ _settings.UploadUrl = profile.UploadUrl;
+ _settings.Username = profile.Username;
+ _settings.Password = profile.Password;
+ _settings.ResponseFormat = profile.ResponseFormat;
+ _settings.WatchFolder = profile.WatchFolder;
+ _settings.IncludePatterns = profile.IncludePatterns;
+ _settings.ExcludePatterns = profile.ExcludePatterns;
+ _settings.MaxConcurrentUploads = profile.MaxConcurrentUploads > 0 ? profile.MaxConcurrentUploads : _settings.MaxConcurrentUploads;
+ _settings.UploadDelayMs = profile.UploadDelayMs;
+ _settingsStore.Save(_settings);
+
+ ApplySettings();
+ UpdateStatus("Profil geladen.");
+ }
+
+ private void SaveProfileButton_Click(object? sender, RoutedEventArgs e)
+ {
+ var label = _settings.EventName ?? "Neues Profil";
+ var profile = new PhotoboothProfile
+ {
+ Label = label,
+ EventName = _settings.EventName,
+ BaseUrl = _settings.BaseUrl,
+ UploadUrl = _settings.UploadUrl,
+ Username = _settings.Username,
+ Password = _settings.Password,
+ ResponseFormat = _settings.ResponseFormat,
+ WatchFolder = _settings.WatchFolder,
+ IncludePatterns = _settings.IncludePatterns,
+ ExcludePatterns = _settings.ExcludePatterns,
+ MaxConcurrentUploads = _settings.MaxConcurrentUploads,
+ UploadDelayMs = _settings.UploadDelayMs,
+ };
+
+ _settings.Profiles.RemoveAll(existing => string.Equals(existing.DisplayName, profile.DisplayName, StringComparison.OrdinalIgnoreCase));
+ _settings.Profiles.Insert(0, profile);
+ _settingsStore.Save(_settings);
+ RefreshProfiles();
+ ProfilesBox.SelectedIndex = 0;
+ UpdateStatus("Profil gespeichert.");
+ }
+
+ private async void LogCopyButton_Click(object? sender, RoutedEventArgs e)
+ {
+ var content = ReadLogForCopy();
+ if (string.IsNullOrWhiteSpace(content))
+ {
+ UpdateStatus("Log ist leer.");
+ return;
+ }
+
+ var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
+ if (clipboard is null)
+ {
+ UpdateStatus("Zwischenablage nicht verfuegbar.");
+ return;
+ }
+
+ await clipboard.SetTextAsync(content);
+ UpdateStatus("Log kopiert.");
+ }
+
+ private void AppendLog(string message)
+ {
+ var line = $"{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss} {message}";
+ _logBuffer.Add(line);
+ while (_logBuffer.Count > 200)
+ {
+ _logBuffer.RemoveAt(0);
+ }
+
+ try
+ {
+ File.AppendAllLines(_settingsStore.LogPath, new[] { line });
+ }
+ catch
+ {
+ // ignore file errors
+ }
+ }
+
+ private string ReadLogForCopy()
+ {
+ try
+ {
+ if (File.Exists(_settingsStore.LogPath))
+ {
+ var lines = File.ReadAllLines(_settingsStore.LogPath);
+ return string.Join(Environment.NewLine, lines.TakeLast(200));
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ return _logBuffer.Count > 0 ? string.Join(Environment.NewLine, _logBuffer) : string.Empty;
+ }
}
diff --git a/clients/photobooth-uploader/PhotoboothUploader/Models/PhotoboothProfile.cs b/clients/photobooth-uploader/PhotoboothUploader/Models/PhotoboothProfile.cs
new file mode 100644
index 0000000..54429fe
--- /dev/null
+++ b/clients/photobooth-uploader/PhotoboothUploader/Models/PhotoboothProfile.cs
@@ -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";
+}
diff --git a/clients/photobooth-uploader/PhotoboothUploader/Models/PhotoboothSettings.cs b/clients/photobooth-uploader/PhotoboothUploader/Models/PhotoboothSettings.cs
index ab0e370..da83bc4 100644
--- a/clients/photobooth-uploader/PhotoboothUploader/Models/PhotoboothSettings.cs
+++ b/clients/photobooth-uploader/PhotoboothUploader/Models/PhotoboothSettings.cs
@@ -16,6 +16,10 @@ public sealed class PhotoboothSettings
public string? ExcludePatterns { get; set; }
public List PendingUploads { get; set; } = new();
public Dictionary UploadedFiles { get; set; } = new(StringComparer.OrdinalIgnoreCase);
+ public List 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;
diff --git a/clients/photobooth-uploader/PhotoboothUploader/Services/SettingsStore.cs b/clients/photobooth-uploader/PhotoboothUploader/Services/SettingsStore.cs
index a74b5cd..ef3b00f 100644
--- a/clients/photobooth-uploader/PhotoboothUploader/Services/SettingsStore.cs
+++ b/clients/photobooth-uploader/PhotoboothUploader/Services/SettingsStore.cs
@@ -14,6 +14,7 @@ public sealed class SettingsStore
};
public string SettingsPath { get; }
+ public string LogPath { get; }
public SettingsStore()
{
@@ -24,6 +25,7 @@ public sealed class SettingsStore
Directory.CreateDirectory(basePath);
SettingsPath = Path.Combine(basePath, "settings.json");
+ LogPath = Path.Combine(basePath, "uploader.log");
}
public PhotoboothSettings Load()