diff --git a/.gitignore b/.gitignore index 5613df6..44e34a2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ /CSharpGhostImporter/bin/* /CSharpGhostImporter/obj/* /0 +/GhostImageManager/.vs/* +/GhostImageManager/bin/* +/GhostImageManager/obj/* +/GhostImageManager/Properties/* diff --git a/CSharpGhostImporter/Converter.cs b/CSharpGhostImporter/Converter.cs index 5ccafc9..7c70bc2 100644 --- a/CSharpGhostImporter/Converter.cs +++ b/CSharpGhostImporter/Converter.cs @@ -816,8 +816,37 @@ class Program // 4. Tags erstellen/zuordnen (Kategorien + Tags) var tags = new List(); var allTagSources = new List(); - if (postFile.Categories.Count > 0) + + // Bei mehreren Kategorien: primäre Kategorie abfragen + if (postFile.Categories.Count > 1) + { + Console.WriteLine(); + Console.WriteLine(" [Kategorien] Mehrere Kategorien gefunden:"); + for (int i = 0; i < postFile.Categories.Count; i++) + { + Console.WriteLine($" [{i + 1}] {postFile.Categories[i].Name}"); + } + Console.Write(" Relevante Kategorie (Nummer, Enter = erste): "); + string? catChoice = Console.ReadLine()?.Trim(); + if (int.TryParse(catChoice, out int catIdx) && catIdx >= 1 && catIdx <= postFile.Categories.Count) + { + // Gewählte Kategorie an erste Stelle setzen + var primary = postFile.Categories[catIdx - 1]; + allTagSources.Add(primary); + allTagSources.AddRange(postFile.Categories.Where((_, idx) => idx != catIdx - 1)); + Console.WriteLine($" -> Primäre Kategorie: {primary.Name}"); + } + else + { + allTagSources.AddRange(postFile.Categories); + Console.WriteLine($" -> Erste Kategorie: {postFile.Categories[0].Name}"); + } + } + else if (postFile.Categories.Count == 1) + { allTagSources.AddRange(postFile.Categories); + } + if (postFile.Tags.Count > 0) allTagSources.AddRange(postFile.Tags); diff --git a/GhostImageManager/Components/App.razor b/GhostImageManager/Components/App.razor new file mode 100644 index 0000000..e16703c --- /dev/null +++ b/GhostImageManager/Components/App.razor @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GhostImageManager/Components/Layout/EmptyLayout.razor b/GhostImageManager/Components/Layout/EmptyLayout.razor new file mode 100644 index 0000000..f3da3cb --- /dev/null +++ b/GhostImageManager/Components/Layout/EmptyLayout.razor @@ -0,0 +1,9 @@ +@inherits LayoutComponentBase + +@Body + +
+ An unhandled error has occurred. + Reload + 🗙 +
diff --git a/GhostImageManager/Components/Layout/MainLayout.razor b/GhostImageManager/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..a74dc63 --- /dev/null +++ b/GhostImageManager/Components/Layout/MainLayout.razor @@ -0,0 +1,23 @@ +@inherits LayoutComponentBase + +
+ + +
+
+

Ghost Image Manager

+
+ +
+ @Body +
+
+
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
diff --git a/GhostImageManager/Components/Layout/MainLayout.razor.css b/GhostImageManager/Components/Layout/MainLayout.razor.css new file mode 100644 index 0000000..60cec92 --- /dev/null +++ b/GhostImageManager/Components/Layout/MainLayout.razor.css @@ -0,0 +1,20 @@ +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } diff --git a/GhostImageManager/Components/Layout/NavMenu.razor b/GhostImageManager/Components/Layout/NavMenu.razor new file mode 100644 index 0000000..f09090c --- /dev/null +++ b/GhostImageManager/Components/Layout/NavMenu.razor @@ -0,0 +1,45 @@ + + + + + diff --git a/GhostImageManager/Components/Layout/ReconnectModal.razor b/GhostImageManager/Components/Layout/ReconnectModal.razor new file mode 100644 index 0000000..e740b0c --- /dev/null +++ b/GhostImageManager/Components/Layout/ReconnectModal.razor @@ -0,0 +1,31 @@ + + + +
+ +

+ Rejoining the server... +

+

+ Rejoin failed... trying again in seconds. +

+

+ Failed to rejoin.
Please retry or reload the page. +

+ +

+ The session has been paused by the server. +

+

+ Failed to resume the session.
Please retry or reload the page. +

+ +
+
diff --git a/GhostImageManager/Components/Layout/ReconnectModal.razor.css b/GhostImageManager/Components/Layout/ReconnectModal.razor.css new file mode 100644 index 0000000..3ad3773 --- /dev/null +++ b/GhostImageManager/Components/Layout/ReconnectModal.razor.css @@ -0,0 +1,157 @@ +.components-reconnect-first-attempt-visible, +.components-reconnect-repeated-attempt-visible, +.components-reconnect-failed-visible, +.components-pause-visible, +.components-resume-failed-visible, +.components-rejoining-animation { + display: none; +} + +#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible, +#components-reconnect-modal.components-reconnect-show .components-rejoining-animation, +#components-reconnect-modal.components-reconnect-paused .components-pause-visible, +#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible, +#components-reconnect-modal.components-reconnect-retrying, +#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible, +#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation, +#components-reconnect-modal.components-reconnect-failed, +#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible { + display: block; +} + + +#components-reconnect-modal { + background-color: white; + width: 20rem; + margin: 20vh auto; + padding: 2rem; + border: 0; + border-radius: 0.5rem; + box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3); + opacity: 0; + transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete; + animation: components-reconnect-modal-fadeOutOpacity 0.5s both; + &[open] + +{ + animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s; + animation-fill-mode: both; +} + +} + +#components-reconnect-modal::backdrop { + background-color: rgba(0, 0, 0, 0.4); + animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out; + opacity: 1; +} + +@keyframes components-reconnect-modal-slideUp { + 0% { + transform: translateY(30px) scale(0.95); + } + + 100% { + transform: translateY(0); + } +} + +@keyframes components-reconnect-modal-fadeInOpacity { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes components-reconnect-modal-fadeOutOpacity { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} + +.components-reconnect-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +#components-reconnect-modal p { + margin: 0; + text-align: center; +} + +#components-reconnect-modal button { + border: 0; + background-color: #6b9ed2; + color: white; + padding: 4px 24px; + border-radius: 4px; +} + + #components-reconnect-modal button:hover { + background-color: #3b6ea2; + } + + #components-reconnect-modal button:active { + background-color: #6b9ed2; + } + +.components-rejoining-animation { + position: relative; + width: 80px; + height: 80px; +} + + .components-rejoining-animation div { + position: absolute; + border: 3px solid #0087ff; + opacity: 1; + border-radius: 50%; + animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite; + } + + .components-rejoining-animation div:nth-child(2) { + animation-delay: -0.5s; + } + +@keyframes components-rejoining-animation { + 0% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 0; + } + + 4.9% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 0; + } + + 5% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 1; + } + + 100% { + top: 0px; + left: 0px; + width: 80px; + height: 80px; + opacity: 0; + } +} diff --git a/GhostImageManager/Components/Layout/ReconnectModal.razor.js b/GhostImageManager/Components/Layout/ReconnectModal.razor.js new file mode 100644 index 0000000..a44de78 --- /dev/null +++ b/GhostImageManager/Components/Layout/ReconnectModal.razor.js @@ -0,0 +1,63 @@ +// Set up event handlers +const reconnectModal = document.getElementById("components-reconnect-modal"); +reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged); + +const retryButton = document.getElementById("components-reconnect-button"); +retryButton.addEventListener("click", retry); + +const resumeButton = document.getElementById("components-resume-button"); +resumeButton.addEventListener("click", resume); + +function handleReconnectStateChanged(event) { + if (event.detail.state === "show") { + reconnectModal.showModal(); + } else if (event.detail.state === "hide") { + reconnectModal.close(); + } else if (event.detail.state === "failed") { + document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + } else if (event.detail.state === "rejected") { + location.reload(); + } +} + +async function retry() { + document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + + try { + // Reconnect will asynchronously return: + // - true to mean success + // - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID) + // - exception to mean we didn't reach the server (this can be sync or async) + const successful = await Blazor.reconnect(); + if (!successful) { + // We have been able to reach the server, but the circuit is no longer available. + // We'll reload the page so the user can continue using the app as quickly as possible. + const resumeSuccessful = await Blazor.resumeCircuit(); + if (!resumeSuccessful) { + location.reload(); + } else { + reconnectModal.close(); + } + } + } catch (err) { + // We got an exception, server is currently unavailable + document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + } +} + +async function resume() { + try { + const successful = await Blazor.resumeCircuit(); + if (!successful) { + location.reload(); + } + } catch { + reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed"); + } +} + +async function retryWhenDocumentBecomesVisible() { + if (document.visibilityState === "visible") { + await retry(); + } +} diff --git a/GhostImageManager/Components/Pages/AccessDenied.razor b/GhostImageManager/Components/Pages/AccessDenied.razor new file mode 100644 index 0000000..1b0e5f8 --- /dev/null +++ b/GhostImageManager/Components/Pages/AccessDenied.razor @@ -0,0 +1,9 @@ +@page "/access-denied" + +Zugriff verweigert + +
+

Zugriff verweigert

+

Du hast nicht die Berechtigung, auf diese Seite zuzugreifen.

+ Zur Anmeldung +
diff --git a/GhostImageManager/Components/Pages/Error.razor b/GhostImageManager/Components/Pages/Error.razor new file mode 100644 index 0000000..576cc2d --- /dev/null +++ b/GhostImageManager/Components/Pages/Error.razor @@ -0,0 +1,36 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/GhostImageManager/Components/Pages/Home.razor b/GhostImageManager/Components/Pages/Home.razor new file mode 100644 index 0000000..7918249 --- /dev/null +++ b/GhostImageManager/Components/Pages/Home.razor @@ -0,0 +1,55 @@ +@page "/" +@attribute [Authorize] +@inject GhostConfigService ConfigService + +Startseite + +
+

Ghost Image Manager

+

+ Verwalte Bilder deiner Ghost-Instanzen direkt vom Server aus. +

+
+

+ Funktionen: Bilder auflisten, nach Posts suchen, verwaiste Bilder finden und Bilder austauschen. +

+ Zu den Bildern + Einstellungen +
+ +@if (_config.Instances.Count == 0) +{ +
+ Hinweis: Es ist noch keine Ghost-Instanz konfiguriert. + Bitte hinterlege unter Einstellungen deine Ghost-URL, den Admin API Key und den lokalen Image-Ordner. +
+} +else +{ +
+ @foreach (var instance in _config.Instances) + { +
+
+
+
@instance.Name
+

+ URL: @instance.Url
+ Image-Ordner: @instance.ImageFolderPath +

+ Bilder anzeigen +
+
+
+ } +
+} + +@code { + private GhostConfig _config = new(); + + protected override void OnInitialized() + { + _config = ConfigService.Load(); + } +} diff --git a/GhostImageManager/Components/Pages/Images.razor b/GhostImageManager/Components/Pages/Images.razor new file mode 100644 index 0000000..63462b3 --- /dev/null +++ b/GhostImageManager/Components/Pages/Images.razor @@ -0,0 +1,625 @@ +@page "/images" +@attribute [Authorize] +@rendermode InteractiveServer +@inject GhostConfigService ConfigService +@inject ImageAnalyzerService ImageAnalyzer +@inject ImageReplacementService ReplacementService +@inject ImageDeletionService DeletionService +@inject ImageExportService ExportService +@inject IJSRuntime JsRuntime +@inject ILogger Logger + +Ghost Bilder + +

Ghost Bilderverwaltung

+ +@if (_instances.Count == 0) +{ +
+ Keine Ghost-Instanzen konfiguriert. Bitte zuerst unter Einstellungen eine Instanz anlegen. +
+} +else +{ +
+
+ + +
+
+ +
+ + +
+
+
+ + + @if (FilteredImages.Any()) + { +
+ + +
+ + @if (SelectedOrphanedImages.Any()) + { + + } + } +
+
+ + @if (!string.IsNullOrEmpty(_errorMessage)) + { +
@_errorMessage
+ } + + @if (!string.IsNullOrEmpty(_successMessage)) + { +
@_successMessage
+ } + + @if (_allImages.Count > 0) + { +
+
+
+ + + + +
+
+
+
+ + +
+
+
+ +
+ @foreach (var image in FilteredImages) + { +
+
+ @if (image.IsOrphaned) + { +
+
+ + +
+
+ } +
+ @image.FileName +
+
+
@image.FileName
+

+ Pfad: @image.RelativePath + Größe: @image.FormattedFileSize
+ Dimensionen: @image.Dimensions
+ Datum: @image.LastModified.ToString("dd.MM.yyyy HH:mm") +

+ + @if (image.IsOrphaned) + { +
+ ⚠️ Verwaist (nicht in Posts verwendet) +
+ } + else + { +
+ Verwendet in: +
    + @foreach (var post in image.UsedInPosts.Take(3)) + { +
  • + + @(post.IsFeatureImage ? "Feature" : "Content") + + @post.Title +
  • + } + @if (image.UsedInPosts.Count > 3) + { +
  • ... und @(image.UsedInPosts.Count - 3) weitere
  • + } +
+
+ } + +
+ + @if (image.IsOrphaned) + { + + } +
+
+
+
+ } +
+ } + else if (!_isLoading && _hasLoaded) + { +
Keine Bilder gefunden.
+ } +} + +@if (_showReplaceDialog && _selectedImage != null) +{ + +} + +@if (_showDeleteDialog && _selectedImage != null) +{ + +} + +@if (_showBulkDeleteDialog) +{ + +} + +@code { + private List _instances = new(); + private string _selectedInstanceId = ""; + private string _searchTerm = ""; + private List _allImages = new(); + private bool _isLoading; + private bool _hasLoaded; + private string? _errorMessage; + private string? _successMessage; + + private ImageFilter _currentFilter = ImageFilter.All; + private int _orphanedCount; + private int _featureCount; + private int _contentCount; + + private GhostImage? _selectedImage; + private bool _showReplaceDialog; + private bool _showDeleteDialog; + private bool _showBulkDeleteDialog; + private IBrowserFile? _newImageFile; + private string? _newImagePreview; + private string? _replaceError; + private bool _isReplacing; + private bool _isDeleting; + + private HashSet _selectedImageUrls = new(); + private bool _selectAllOrphaned; + + protected override void OnInitialized() + { + var config = ConfigService.Load(); + _instances = config.Instances; + _selectedInstanceId = _instances.FirstOrDefault()?.Id ?? ""; + } + + private async Task OnInstanceChanged() + { + _allImages.Clear(); + _hasLoaded = false; + _selectedImageUrls.Clear(); + await LoadImages(); + } + + private async Task LoadImages() + { + if (string.IsNullOrEmpty(_selectedInstanceId)) + return; + + _isLoading = true; + _errorMessage = null; + _successMessage = null; + StateHasChanged(); + + try + { + var instance = _instances.First(i => i.Id == _selectedInstanceId); + var filter = string.IsNullOrWhiteSpace(_searchTerm) ? "" : $"title:~'{EscapeFilter(_searchTerm)}'"; + _allImages = await ImageAnalyzer.AnalyzeImagesAsync(instance, filter); + CalculateCounts(); + _hasLoaded = true; + } + catch (Exception ex) + { + Logger.LogError(ex, "Fehler beim Laden der Bilder"); + _errorMessage = $"Fehler: {ex.Message}"; + } + finally + { + _isLoading = false; + StateHasChanged(); + } + } + + private void CalculateCounts() + { + _orphanedCount = _allImages.Count(i => i.IsOrphaned); + _featureCount = _allImages.Count(i => i.UsedInPosts.Any(r => r.IsFeatureImage)); + _contentCount = _allImages.Count(i => i.UsedInPosts.Any(r => r.IsInContent)); + } + + private List FilteredImages => _currentFilter switch + { + ImageFilter.Orphaned => _allImages.Where(i => i.IsOrphaned).ToList(), + ImageFilter.Feature => _allImages.Where(i => i.UsedInPosts.Any(r => r.IsFeatureImage)).ToList(), + ImageFilter.Content => _allImages.Where(i => i.UsedInPosts.Any(r => r.IsInContent)).ToList(), + _ => _allImages + }; + + private List SelectedOrphanedImages => _allImages.Where(i => _selectedImageUrls.Contains(i.Url) && i.IsOrphaned).ToList(); + + private void SetFilter(ImageFilter filter) + { + _currentFilter = filter; + } + + private string GetFilterClass(ImageFilter filter) => _currentFilter == filter ? "active" : ""; + + private string GetCardClass(GhostImage image) => image.IsOrphaned ? "border-danger" : ""; + + private void ToggleSelection(GhostImage image, ChangeEventArgs e) + { + var isSelected = e.Value is bool b && b; + if (isSelected) + _selectedImageUrls.Add(image.Url); + else + _selectedImageUrls.Remove(image.Url); + + _selectAllOrphaned = false; + StateHasChanged(); + } + + private void ToggleSelectAllOrphaned(ChangeEventArgs e) + { + _selectAllOrphaned = e.Value is bool b && b; + if (_selectAllOrphaned) + { + foreach (var image in _allImages.Where(i => i.IsOrphaned)) + _selectedImageUrls.Add(image.Url); + } + else + { + foreach (var image in _allImages.Where(i => i.IsOrphaned)) + _selectedImageUrls.Remove(image.Url); + } + StateHasChanged(); + } + + private void ShowReplaceDialog(GhostImage image) + { + _selectedImage = image; + _showReplaceDialog = true; + _newImageFile = null; + _newImagePreview = null; + _replaceError = null; + } + + private void CloseReplaceDialog() + { + _showReplaceDialog = false; + _selectedImage = null; + _newImageFile = null; + _newImagePreview = null; + _replaceError = null; + } + + private void ShowDeleteDialog(GhostImage image) + { + _selectedImage = image; + _showDeleteDialog = true; + } + + private void CloseDeleteDialog() + { + _showDeleteDialog = false; + _selectedImage = null; + } + + private void ShowBulkDeleteDialog() + { + _showBulkDeleteDialog = true; + } + + private void CloseBulkDeleteDialog() + { + _showBulkDeleteDialog = false; + } + + private void DeleteSelectedOrphaned() + { + if (SelectedOrphanedImages.Any()) + ShowBulkDeleteDialog(); + } + + private async Task DeleteSelectedOrphanedConfirmed() + { + _isDeleting = true; + StateHasChanged(); + + try + { + var result = await DeletionService.DeleteImagesAsync(SelectedOrphanedImages); + _selectedImageUrls.Clear(); + _selectAllOrphaned = false; + _successMessage = $"{result.Success} Bilder gelöscht, {result.Failed} fehlgeschlagen."; + await LoadImages(); + } + catch (Exception ex) + { + _errorMessage = $"Fehler beim Löschen: {ex.Message}"; + } + finally + { + _isDeleting = false; + _showBulkDeleteDialog = false; + StateHasChanged(); + } + } + + private async Task DeleteImage() + { + if (_selectedImage == null) return; + + try + { + var success = await DeletionService.DeleteImageAsync(_selectedImage); + if (success) + { + _selectedImageUrls.Remove(_selectedImage.Url); + _successMessage = $"{_selectedImage.FileName} gelöscht."; + CloseDeleteDialog(); + await LoadImages(); + } + else + { + _errorMessage = "Bild konnte nicht gelöscht werden."; + } + } + catch (Exception ex) + { + _errorMessage = $"Fehler beim Löschen: {ex.Message}"; + } + } + + private async Task LoadNewImage(InputFileChangeEventArgs e) + { + _newImageFile = e.File; + _replaceError = null; + + try + { + using var stream = _newImageFile.OpenReadStream(maxAllowedSize: 104857600); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + var bytes = ms.ToArray(); + _newImagePreview = $"data:{_newImageFile.ContentType};base64,{Convert.ToBase64String(bytes)}"; + } + catch (Exception ex) + { + _replaceError = $"Fehler beim Lesen der Datei: {ex.Message}"; + } + + StateHasChanged(); + } + + private async Task ReplaceImage() + { + if (_selectedImage == null || _newImageFile == null) + return; + + _isReplacing = true; + _replaceError = null; + StateHasChanged(); + + try + { + using var stream = _newImageFile.OpenReadStream(maxAllowedSize: 104857600); + var success = await ReplacementService.ReplaceImageAsync(_selectedImage, stream, _newImageFile.Name); + + if (success) + { + await LoadImages(); + CloseReplaceDialog(); + } + else + { + _replaceError = "Bild konnte nicht ersetzt werden."; + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Fehler beim Tauschen des Bildes"); + _replaceError = $"Fehler: {ex.Message}"; + } + finally + { + _isReplacing = false; + StateHasChanged(); + } + } + + private async Task ExportCsv() + { + var data = ExportService.ExportToCsv(FilteredImages); + await DownloadFileAsync($"ghost-images-{DateTime.Now:yyyyMMdd-HHmm}.csv", data, "text/csv"); + } + + private async Task ExportTxt() + { + var data = ExportService.ExportToTxt(FilteredImages); + await DownloadFileAsync($"ghost-images-{DateTime.Now:yyyyMMdd-HHmm}.txt", data, "text/plain"); + } + + private async Task ExportSelectedZip() + { + var selectedImages = _allImages.Where(i => _selectedImageUrls.Contains(i.Url)).ToList(); + if (!selectedImages.Any()) + { + _errorMessage = "Keine Bilder ausgewählt."; + return; + } + + var data = await ExportService.ExportToZipAsync(selectedImages); + await DownloadFileAsync($"ghost-images-{DateTime.Now:yyyyMMdd-HHmm}.zip", data, "application/zip"); + } + + private async Task DownloadFileAsync(string fileName, byte[] data, string contentType) + { + var base64 = Convert.ToBase64String(data); + await JsRuntime.InvokeVoidAsync("downloadFileFromBase64", fileName, base64, contentType); + } + + private static string EscapeFilter(string term) + { + return term.Replace("'", "\\'"); + } + + private enum ImageFilter + { + All, + Feature, + Content, + Orphaned + } +} diff --git a/GhostImageManager/Components/Pages/Login.razor b/GhostImageManager/Components/Pages/Login.razor new file mode 100644 index 0000000..02277ea --- /dev/null +++ b/GhostImageManager/Components/Pages/Login.razor @@ -0,0 +1,59 @@ +@page "/login" +@layout Layout.EmptyLayout +@inject IWebHostEnvironment Env + +Anmelden + + + +@code { + [SupplyParameterFromQuery] + public string? ReturnUrl { get; set; } + + [SupplyParameterFromQuery] + public string? ErrorMessage { get; set; } + + private bool _imageExists; + + protected override void OnInitialized() + { + var imagePath = Path.Combine(Env.ContentRootPath, "start.jpg"); + _imageExists = File.Exists(imagePath); + } +} diff --git a/GhostImageManager/Components/Pages/Logout.razor b/GhostImageManager/Components/Pages/Logout.razor new file mode 100644 index 0000000..dec5b5e --- /dev/null +++ b/GhostImageManager/Components/Pages/Logout.razor @@ -0,0 +1,10 @@ +@page "/logout" + +Abmelden + +
+

Abmelden

+
+ +
+
diff --git a/GhostImageManager/Components/Pages/NotFound.razor b/GhostImageManager/Components/Pages/NotFound.razor new file mode 100644 index 0000000..917ada1 --- /dev/null +++ b/GhostImageManager/Components/Pages/NotFound.razor @@ -0,0 +1,5 @@ +@page "/not-found" +@layout MainLayout + +

Not Found

+

Sorry, the content you are looking for does not exist.

\ No newline at end of file diff --git a/GhostImageManager/Components/Pages/Settings.razor b/GhostImageManager/Components/Pages/Settings.razor new file mode 100644 index 0000000..daf51b6 --- /dev/null +++ b/GhostImageManager/Components/Pages/Settings.razor @@ -0,0 +1,192 @@ +@page "/settings" +@attribute [Authorize] +@rendermode InteractiveServer +@inject GhostConfigService ConfigService + +Einstellungen + +

Ghost-Instanzen verwalten

+ +
+
+
+
+
Konfigurierte Instanzen
+
+
+ @if (_config.Instances.Count == 0) + { +
Keine Instanzen konfiguriert.
+ } + else + { + + + + + + + + + + + @foreach (var instance in _config.Instances) + { + + + + + + + } + +
NameURLBilderverzeichnisAktionen
@instance.Name@instance.Url@instance.ImageFolderPath + + +
+ } +
+
+
+ +
+
+
+
@(_editingInstance.Id == null || _isNew ? "Neue Instanz" : "Instanz bearbeiten")
+
+
+ + + + +
+ + +
+ +
+ + +
+ +
+ + +
Format: {id}:{secret} aus den Ghost-Integrationen
+
+ +
+ + +
+ +
+ + +
Vollständiger Pfad zum Ghost content/images Ordner
+
+ +
+ + +
+
+ + @if (!string.IsNullOrEmpty(_message)) + { +
@_message
+ } +
+
+
+
+ +@code { + private GhostConfig _config = new(); + private GhostInstanceForm _editingInstance = new(); + private bool _isNew = true; + private string? _message; + + protected override void OnInitialized() + { + LoadConfig(); + } + + private void LoadConfig() + { + _config = ConfigService.Load(); + } + + private void EditInstance(GhostInstance instance) + { + _editingInstance = new GhostInstanceForm + { + Id = instance.Id, + Name = instance.Name, + Url = instance.Url, + AdminApiKey = instance.AdminApiKey, + ContentApiKey = instance.ContentApiKey, + ImageFolderPath = instance.ImageFolderPath + }; + _isNew = false; + _message = null; + } + + private void DeleteInstance(GhostInstance instance) + { + _config.Instances.Remove(instance); + ConfigService.Save(_config); + LoadConfig(); + ResetForm(); + } + + private void SaveInstance() + { + if (_isNew) + { + _config.Instances.Add(new GhostInstance + { + Id = Guid.NewGuid().ToString("N")[..8], + Name = _editingInstance.Name, + Url = _editingInstance.Url.TrimEnd('/'), + AdminApiKey = _editingInstance.AdminApiKey, + ContentApiKey = _editingInstance.ContentApiKey, + ImageFolderPath = _editingInstance.ImageFolderPath + }); + } + else + { + var existing = _config.Instances.FirstOrDefault(i => i.Id == _editingInstance.Id); + if (existing != null) + { + existing.Name = _editingInstance.Name; + existing.Url = _editingInstance.Url.TrimEnd('/'); + existing.AdminApiKey = _editingInstance.AdminApiKey; + existing.ContentApiKey = _editingInstance.ContentApiKey; + existing.ImageFolderPath = _editingInstance.ImageFolderPath; + } + } + + ConfigService.Save(_config); + LoadConfig(); + ResetForm(); + _message = "Instanz gespeichert."; + } + + private void ResetForm() + { + _editingInstance = new GhostInstanceForm(); + _isNew = true; + _message = null; + } + + private class GhostInstanceForm + { + public string? Id { get; set; } + public string Name { get; set; } = ""; + public string Url { get; set; } = ""; + public string AdminApiKey { get; set; } = ""; + public string ContentApiKey { get; set; } = ""; + public string ImageFolderPath { get; set; } = ""; + } +} diff --git a/GhostImageManager/Components/Routes.razor b/GhostImageManager/Components/Routes.razor new file mode 100644 index 0000000..105855d --- /dev/null +++ b/GhostImageManager/Components/Routes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/GhostImageManager/Components/_Imports.razor b/GhostImageManager/Components/_Imports.razor new file mode 100644 index 0000000..8e0e467 --- /dev/null +++ b/GhostImageManager/Components/_Imports.razor @@ -0,0 +1,15 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using GhostImageManager +@using GhostImageManager.Components +@using GhostImageManager.Components.Layout +@using GhostImageManager.Models +@using GhostImageManager.Services diff --git a/GhostImageManager/GhostImageManager.csproj b/GhostImageManager/GhostImageManager.csproj new file mode 100644 index 0000000..5ce8a54 --- /dev/null +++ b/GhostImageManager/GhostImageManager.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + PreserveNewest + + + + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/GhostImageManager/GhostImageManager.csproj.user b/GhostImageManager/GhostImageManager.csproj.user new file mode 100644 index 0000000..f6c73da --- /dev/null +++ b/GhostImageManager/GhostImageManager.csproj.user @@ -0,0 +1,7 @@ + + + + https + E:\Wp2Ghost\GhostImageManager\Properties\PublishProfiles\FolderProfile.pubxml + + \ No newline at end of file diff --git a/GhostImageManager/Models/AppSettings.cs b/GhostImageManager/Models/AppSettings.cs new file mode 100644 index 0000000..d722096 --- /dev/null +++ b/GhostImageManager/Models/AppSettings.cs @@ -0,0 +1,7 @@ +namespace GhostImageManager.Models; + +public class AppSettings +{ + public string Password { get; set; } = "change-me-in-production"; + public int SessionTimeoutMinutes { get; set; } = 480; +} diff --git a/GhostImageManager/Models/GhostConfig.cs b/GhostImageManager/Models/GhostConfig.cs new file mode 100644 index 0000000..1cadb32 --- /dev/null +++ b/GhostImageManager/Models/GhostConfig.cs @@ -0,0 +1,6 @@ +namespace GhostImageManager.Models; + +public class GhostConfig +{ + public List Instances { get; set; } = new(); +} diff --git a/GhostImageManager/Models/GhostImage.cs b/GhostImageManager/Models/GhostImage.cs new file mode 100644 index 0000000..1ffe21e --- /dev/null +++ b/GhostImageManager/Models/GhostImage.cs @@ -0,0 +1,33 @@ +namespace GhostImageManager.Models; + +public class GhostImage +{ + public string Url { get; set; } = ""; + public string FileName { get; set; } = ""; + public string RelativePath { get; set; } = ""; + public string FilePath { get; set; } = ""; + public long FileSize { get; set; } + public DateTime LastModified { get; set; } + public int Width { get; set; } + public int Height { get; set; } + public bool IsOrphaned { get; set; } = true; + public List UsedInPosts { get; set; } = new(); + + public string FormattedFileSize => FileSize switch + { + >= 1024 * 1024 => $"{FileSize / (1024.0 * 1024.0):F2} MB", + >= 1024 => $"{FileSize / 1024.0:F2} KB", + _ => $"{FileSize} B" + }; + + public string Dimensions => Width > 0 && Height > 0 ? $"{Width}x{Height}" : "n/a"; +} + +public class PostReference +{ + public string PostId { get; set; } = ""; + public string Title { get; set; } = ""; + public string Slug { get; set; } = ""; + public bool IsFeatureImage { get; set; } + public bool IsInContent { get; set; } +} diff --git a/GhostImageManager/Models/GhostInstance.cs b/GhostImageManager/Models/GhostInstance.cs new file mode 100644 index 0000000..201ce00 --- /dev/null +++ b/GhostImageManager/Models/GhostInstance.cs @@ -0,0 +1,11 @@ +namespace GhostImageManager.Models; + +public class GhostInstance +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8]; + public string Name { get; set; } = ""; + public string Url { get; set; } = ""; + public string AdminApiKey { get; set; } = ""; + public string ContentApiKey { get; set; } = ""; + public string ImageFolderPath { get; set; } = ""; +} diff --git a/GhostImageManager/Models/GhostPost.cs b/GhostImageManager/Models/GhostPost.cs new file mode 100644 index 0000000..fcc704f --- /dev/null +++ b/GhostImageManager/Models/GhostPost.cs @@ -0,0 +1,13 @@ +namespace GhostImageManager.Models; + +public class GhostPost +{ + public string Id { get; set; } = ""; + public string Title { get; set; } = ""; + public string Slug { get; set; } = ""; + public string Html { get; set; } = ""; + public string? FeatureImage { get; set; } + public DateTime? CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + public string Status { get; set; } = ""; +} diff --git a/GhostImageManager/Program.cs b/GhostImageManager/Program.cs new file mode 100644 index 0000000..78e1550 --- /dev/null +++ b/GhostImageManager/Program.cs @@ -0,0 +1,102 @@ +using GhostImageManager.Components; +using GhostImageManager.Models; +using GhostImageManager.Services; +using Microsoft.AspNetCore.Authentication.Cookies; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + +builder.Services.Configure(builder.Configuration.GetSection("AppSettings")); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddHttpClient(); + +// Authentication +builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(options => + { + options.LoginPath = "/login"; + options.LogoutPath = "/logout"; + options.AccessDeniedPath = "/access-denied"; + options.ExpireTimeSpan = TimeSpan.FromMinutes(480); + options.SlidingExpiration = true; + }); + +builder.Services.AddAuthorization(); +builder.Services.AddCascadingAuthenticationState(); + +// Upload-Limit erhöhen (für Bildtausch) +builder.WebHost.ConfigureKestrel(options => +{ + options.Limits.MaxRequestBodySize = 104857600; // 100 MB +}); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); +} + +app.UseHttpsRedirection(); + +// Standard wwwroot +app.UseStaticFiles(); + +// Zusätzlich: start.jpg aus dem App-Root ausliefern +var rootImagePath = Path.Combine(app.Environment.ContentRootPath, "start.jpg"); +if (File.Exists(rootImagePath)) +{ + app.UseStaticFiles(new StaticFileOptions + { + FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(app.Environment.ContentRootPath), + RequestPath = "/start-image" + }); +} + +app.UseRouting(); +app.UseAuthentication(); +app.UseAuthorization(); +app.UseAntiforgery(); + +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +// Authentication API endpoints +app.MapPost("/auth/login", async (HttpContext context, AuthService authService) => +{ + var form = await context.Request.ReadFormAsync(); + var password = form["password"].ToString(); + var returnUrl = form["returnUrl"].ToString(); + + if (string.IsNullOrEmpty(returnUrl)) + returnUrl = "/"; + + try + { + await authService.SignInAsync(context, password); + return Results.Redirect(returnUrl); + } + catch (UnauthorizedAccessException) + { + return Results.Redirect($"/login?errorMessage={Uri.EscapeDataString("Ungültiges Passwort.")}&returnUrl={Uri.EscapeDataString(returnUrl)}"); + } +}); + +app.MapPost("/auth/logout", async (HttpContext context, AuthService authService) => +{ + await authService.SignOutAsync(context); + return Results.Redirect("/login"); +}); + +app.Run(); diff --git a/GhostImageManager/Services/AuthService.cs b/GhostImageManager/Services/AuthService.cs new file mode 100644 index 0000000..37af5d8 --- /dev/null +++ b/GhostImageManager/Services/AuthService.cs @@ -0,0 +1,51 @@ +using System.Security.Claims; +using GhostImageManager.Models; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; + +namespace GhostImageManager.Services; + +public class AuthService +{ + private readonly AppSettings _appSettings; + + public AuthService(IConfiguration configuration) + { + _appSettings = configuration.GetSection("AppSettings").Get() ?? new AppSettings(); + } + + public bool ValidatePassword(string password) + { + return !string.IsNullOrEmpty(password) && + password == _appSettings.Password; + } + + public async Task SignInAsync(HttpContext context, string password) + { + if (!ValidatePassword(password)) + throw new UnauthorizedAccessException("Ungültiges Passwort."); + + var claims = new List + { + new(ClaimTypes.Name, "Admin"), + new(ClaimTypes.Role, "Administrator") + }; + + var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + var principal = new ClaimsPrincipal(identity); + + await context.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + principal, + new AuthenticationProperties + { + IsPersistent = true, + ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(_appSettings.SessionTimeoutMinutes) + }); + } + + public async Task SignOutAsync(HttpContext context) + { + await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + } +} diff --git a/GhostImageManager/Services/GhostApiService.cs b/GhostImageManager/Services/GhostApiService.cs new file mode 100644 index 0000000..4089fa4 --- /dev/null +++ b/GhostImageManager/Services/GhostApiService.cs @@ -0,0 +1,169 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using GhostImageManager.Models; +using HtmlAgilityPack; + +namespace GhostImageManager.Services; + +public class GhostApiService +{ + private readonly HttpClient _httpClient; + + public GhostApiService(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task> GetPostsAsync(GhostInstance instance, string filter = "", int limit = 100) + { + var request = CreateRequest(HttpMethod.Get, instance, $"posts/?limit={limit}&filter={Uri.EscapeDataString(filter)}&fields=id,title,slug,feature_image,html,created_at,updated_at,status"); + var response = await _httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json); + var posts = new List(); + + if (doc.RootElement.TryGetProperty("posts", out var postsElement)) + { + foreach (var post in postsElement.EnumerateArray()) + { + posts.Add(new GhostPost + { + Id = GetString(post, "id"), + Title = GetString(post, "title"), + Slug = GetString(post, "slug"), + Html = GetString(post, "html"), + FeatureImage = GetString(post, "feature_image"), + CreatedAt = GetDateTime(post, "created_at"), + UpdatedAt = GetDateTime(post, "updated_at"), + Status = GetString(post, "status") + }); + } + } + + return posts; + } + + public async Task GetPostAsync(GhostInstance instance, string postId) + { + var request = CreateRequest(HttpMethod.Get, instance, $"posts/{postId}/?fields=id,title,slug,feature_image,html,created_at,updated_at,status"); + var response = await _httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) return null; + + var json = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json); + + if (doc.RootElement.TryGetProperty("posts", out var posts) && posts.GetArrayLength() > 0) + { + var post = posts[0]; + return new GhostPost + { + Id = GetString(post, "id"), + Title = GetString(post, "title"), + Slug = GetString(post, "slug"), + Html = GetString(post, "html"), + FeatureImage = GetString(post, "feature_image"), + CreatedAt = GetDateTime(post, "created_at"), + UpdatedAt = GetDateTime(post, "updated_at"), + Status = GetString(post, "status") + }; + } + + return null; + } + + public async Task UpdatePostAsync(GhostInstance instance, GhostPost post) + { + var request = CreateRequest(HttpMethod.Put, instance, $"posts/{post.Id}/?source=html"); + var body = new + { + posts = new[] + { + new + { + id = post.Id, + title = post.Title, + html = post.Html, + status = post.Status, + feature_image = post.FeatureImage, + updated_at = post.UpdatedAt?.ToString("o") + } + } + }; + + request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); + var response = await _httpClient.SendAsync(request); + return response.IsSuccessStatusCode; + } + + public async Task UploadImageAsync(GhostInstance instance, Stream fileStream, string fileName, string? reference = null) + { + var token = JwtTokenService.GenerateToken(instance.AdminApiKey); + var request = new HttpRequestMessage(HttpMethod.Post, $"{instance.Url.TrimEnd('/')}/ghost/api/admin/images/upload/"); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Ghost", token); + request.Headers.Add("Accept-Version", "v5.0"); + + var content = new MultipartFormDataContent(); + var streamContent = new StreamContent(fileStream); + var ext = Path.GetExtension(fileName).ToLowerInvariant(); + var mimeType = ext switch + { + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".svg" => "image/svg+xml", + _ => "application/octet-stream" + }; + streamContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(mimeType); + content.Add(streamContent, "file", fileName); + + if (!string.IsNullOrEmpty(reference)) + content.Add(new StringContent(reference), "ref"); + + request.Content = content; + var response = await _httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) return null; + + var json = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("images", out var images) && images.GetArrayLength() > 0) + { + return GetString(images[0], "url"); + } + + return null; + } + + private HttpRequestMessage CreateRequest(HttpMethod method, GhostInstance instance, string endpoint) + { + var token = JwtTokenService.GenerateToken(instance.AdminApiKey); + var request = new HttpRequestMessage(method, $"{instance.Url.TrimEnd('/')}/ghost/api/admin/{endpoint}"); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Ghost", token); + request.Headers.Add("Accept-Version", "v5.0"); + return request; + } + + private static string GetString(JsonElement element, string property) + { + if (element.TryGetProperty(property, out var prop) && prop.ValueKind != JsonValueKind.Null) + return prop.GetString() ?? ""; + return ""; + } + + private static DateTime? GetDateTime(JsonElement element, string property) + { + if (element.TryGetProperty(property, out var prop) && prop.ValueKind != JsonValueKind.Null) + if (DateTime.TryParse(prop.GetString(), out var dt)) + return dt; + return null; + } +} + +public class GhostImageReference +{ + [JsonPropertyName("url")] public string Url { get; set; } = ""; + [JsonPropertyName("ref")] public string? Ref { get; set; } +} diff --git a/GhostImageManager/Services/GhostConfigService.cs b/GhostImageManager/Services/GhostConfigService.cs new file mode 100644 index 0000000..6fe458a --- /dev/null +++ b/GhostImageManager/Services/GhostConfigService.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using GhostImageManager.Models; + +namespace GhostImageManager.Services; + +public class GhostConfigService +{ + private readonly string _configPath; + private readonly object _lock = new(); + + public GhostConfigService(IWebHostEnvironment env) + { + _configPath = Path.Combine(env.ContentRootPath, "ghost-instances.json"); + } + + public GhostConfig Load() + { + if (!File.Exists(_configPath)) + { + var empty = new GhostConfig(); + Save(empty); + return empty; + } + + var json = File.ReadAllText(_configPath); + return JsonSerializer.Deserialize(json, GetOptions()) ?? new GhostConfig(); + } + + public void Save(GhostConfig config) + { + lock (_lock) + { + var json = JsonSerializer.Serialize(config, GetOptions()); + File.WriteAllText(_configPath, json); + } + } + + private static JsonSerializerOptions GetOptions() => new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; +} diff --git a/GhostImageManager/Services/ImageAnalyzerService.cs b/GhostImageManager/Services/ImageAnalyzerService.cs new file mode 100644 index 0000000..df73147 --- /dev/null +++ b/GhostImageManager/Services/ImageAnalyzerService.cs @@ -0,0 +1,215 @@ +using System.Text.RegularExpressions; +using GhostImageManager.Models; +using HtmlAgilityPack; + +namespace GhostImageManager.Services; + +public class ImageAnalyzerService +{ + private readonly GhostApiService _ghostApiService; + private readonly ILogger _logger; + + public ImageAnalyzerService(GhostApiService ghostApiService, ILogger logger) + { + _ghostApiService = ghostApiService; + _logger = logger; + } + + public async Task> AnalyzeImagesAsync(GhostInstance instance, string searchTerm = "") + { + var images = new List(); + if (!Directory.Exists(instance.ImageFolderPath)) + { + _logger.LogWarning("Image folder not found: {Path}", instance.ImageFolderPath); + return images; + } + + // 1. Alle Posts abrufen + var posts = await _ghostApiService.GetPostsAsync(instance, searchTerm); + + // 2. Bild-Referenzen aus Posts sammeln + var imageReferences = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var post in posts) + { + // Feature Image + if (!string.IsNullOrEmpty(post.FeatureImage)) + { + if (!imageReferences.ContainsKey(post.FeatureImage)) + imageReferences[post.FeatureImage] = new List(); + + imageReferences[post.FeatureImage].Add(new PostReference + { + PostId = post.Id, + Title = post.Title, + Slug = post.Slug, + IsFeatureImage = true, + IsInContent = false + }); + } + + // Bilder im HTML + if (!string.IsNullOrEmpty(post.Html)) + { + var htmlImages = ExtractImageUrls(post.Html); + foreach (var imgUrl in htmlImages) + { + if (!imageReferences.ContainsKey(imgUrl)) + imageReferences[imgUrl] = new List(); + + // Prüfen ob bereits als Feature Image vorhanden + var existing = imageReferences[imgUrl].FirstOrDefault(r => r.PostId == post.Id); + if (existing != null) + { + existing.IsInContent = true; + } + else + { + imageReferences[imgUrl].Add(new PostReference + { + PostId = post.Id, + Title = post.Title, + Slug = post.Slug, + IsFeatureImage = false, + IsInContent = true + }); + } + } + } + } + + // 3. Lokale Dateien scannen + var localFiles = ScanLocalImageFiles(instance.ImageFolderPath); + + // 4. GhostImage-Objekte erstellen + foreach (var file in localFiles) + { + var absoluteUrl = ConvertLocalPathToUrl(instance, file.FullName); + var relativePath = GetRelativePath(instance.ImageFolderPath, file.FullName).Replace("\\", "/"); + + var image = new GhostImage + { + FilePath = file.FullName, + FileName = file.Name, + RelativePath = relativePath, + Url = absoluteUrl, + FileSize = file.Length, + LastModified = file.LastWriteTime, + UsedInPosts = imageReferences.TryGetValue(absoluteUrl, out var refs) ? refs : new List(), + IsOrphaned = !imageReferences.ContainsKey(absoluteUrl) + }; + + // Dimensionen ermitteln + GetImageDimensions(image); + + images.Add(image); + } + + // Auch URLs aus Posts, die lokal nicht existieren, hinzufügen (z.B. externe Bilder) + foreach (var url in imageReferences.Keys) + { + var localPath = ConvertUrlToLocalPath(instance, url); + if (!File.Exists(localPath) && !images.Any(i => i.Url.Equals(url, StringComparison.OrdinalIgnoreCase))) + { + images.Add(new GhostImage + { + Url = url, + FileName = Path.GetFileName(url), + RelativePath = GetRelativePath(instance.ImageFolderPath, localPath).Replace("\\", "/"), + FilePath = localPath, + UsedInPosts = imageReferences[url], + IsOrphaned = false + }); + } + } + + return images; + } + + private List ScanLocalImageFiles(string folderPath) + { + var files = new List(); + var extensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg" }; + + if (!Directory.Exists(folderPath)) + return files; + + foreach (var ext in extensions) + { + files.AddRange(new DirectoryInfo(folderPath) + .EnumerateFiles($"*{ext}", SearchOption.AllDirectories)); + } + + return files.OrderBy(f => f.FullName).ToList(); + } + + private List ExtractImageUrls(string html) + { + var urls = new List(); + try + { + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + var imgTags = doc.DocumentNode.SelectNodes("//img[@src]"); + if (imgTags != null) + { + foreach (var img in imgTags) + { + var src = img.GetAttributeValue("src", "").Trim(); + if (!string.IsNullOrEmpty(src)) + urls.Add(src); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Fehler beim Parsen von HTML"); + } + + return urls.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + } + + private string ConvertLocalPathToUrl(GhostInstance instance, string localPath) + { + var relativePath = GetRelativePath(instance.ImageFolderPath, localPath).Replace("\\", "/"); + return $"{instance.Url.TrimEnd('/')}/content/images/{relativePath.TrimStart('/')}"; + } + + private string ConvertUrlToLocalPath(GhostInstance instance, string url) + { + var contentImagesUrl = $"{instance.Url.TrimEnd('/')}/content/images/"; + if (url.StartsWith(contentImagesUrl, StringComparison.OrdinalIgnoreCase)) + { + var relativePath = url[contentImagesUrl.Length..]; + return Path.Combine(instance.ImageFolderPath, relativePath.Replace("/", "\\")); + } + return Path.Combine(instance.ImageFolderPath, Path.GetFileName(url)); + } + + private string GetRelativePath(string basePath, string targetPath) + { + var baseUri = new Uri(basePath.EndsWith("\\") || basePath.EndsWith("/") ? basePath : basePath + "\\"); + var targetUri = new Uri(targetPath); + var relativeUri = baseUri.MakeRelativeUri(targetUri); + return Uri.UnescapeDataString(relativeUri.ToString()); + } + + private void GetImageDimensions(GhostImage image) + { + if (image.FilePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)) + return; + + try + { + using var fs = File.OpenRead(image.FilePath); + var (width, height) = ImageDimensionReader.Read(fs); + image.Width = width; + image.Height = height; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Konnte Bilddimensionen nicht ermitteln für {File}", image.FilePath); + } + } +} diff --git a/GhostImageManager/Services/ImageDeletionService.cs b/GhostImageManager/Services/ImageDeletionService.cs new file mode 100644 index 0000000..e705869 --- /dev/null +++ b/GhostImageManager/Services/ImageDeletionService.cs @@ -0,0 +1,61 @@ +using GhostImageManager.Models; + +namespace GhostImageManager.Services; + +public class ImageDeletionService +{ + private readonly ILogger _logger; + + public ImageDeletionService(ILogger logger) + { + _logger = logger; + } + + public Task DeleteImageAsync(GhostImage image) + { + return Task.FromResult(DeleteFile(image.FilePath)); + } + + public Task<(int Success, int Failed)> DeleteImagesAsync(IEnumerable images) + { + int success = 0; + int failed = 0; + + foreach (var image in images) + { + if (DeleteFile(image.FilePath)) + success++; + else + failed++; + } + + return Task.FromResult((success, failed)); + } + + private bool DeleteFile(string filePath) + { + try + { + if (!File.Exists(filePath)) + { + _logger.LogWarning("Datei nicht gefunden: {Path}", filePath); + return false; + } + + File.Delete(filePath); + + // Auch Backup löschen, falls vorhanden + var backupPath = filePath + ".backup"; + if (File.Exists(backupPath)) + File.Delete(backupPath); + + _logger.LogInformation("Datei gelöscht: {Path}", filePath); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Fehler beim Löschen von {Path}", filePath); + return false; + } + } +} diff --git a/GhostImageManager/Services/ImageDimensionReader.cs b/GhostImageManager/Services/ImageDimensionReader.cs new file mode 100644 index 0000000..f31ac34 --- /dev/null +++ b/GhostImageManager/Services/ImageDimensionReader.cs @@ -0,0 +1,123 @@ +namespace GhostImageManager.Services; + +public static class ImageDimensionReader +{ + public static (int Width, int Height) Read(Stream stream) + { + using var reader = new BinaryReader(stream); + var header = reader.ReadBytes(8); + + if (IsJpeg(header)) + return ReadJpeg(stream, reader); + + if (IsPng(header)) + return ReadPng(reader); + + if (IsGif(header)) + return ReadGif(reader); + + if (IsWebP(header)) + return ReadWebP(stream, reader); + + return (0, 0); + } + + private static bool IsJpeg(byte[] header) => header[0] == 0xFF && header[1] == 0xD8; + private static bool IsPng(byte[] header) => header[0] == 0x89 && header[1] == 0x50 && header[2] == 0x4E && header[3] == 0x47; + private static bool IsGif(byte[] header) => header[0] == 0x47 && header[1] == 0x49 && header[2] == 0x46; + private static bool IsWebP(byte[] header) => header[0] == 0x52 && header[1] == 0x49 && header[2] == 0x46 && header[3] == 0x46; + + private static (int, int) ReadPng(BinaryReader reader) + { + reader.BaseStream.Seek(16, SeekOrigin.Begin); + var width = ReadBigEndianInt(reader); + var height = ReadBigEndianInt(reader); + return (width, height); + } + + private static (int, int) ReadGif(BinaryReader reader) + { + reader.BaseStream.Seek(6, SeekOrigin.Begin); + var width = reader.ReadUInt16(); + var height = reader.ReadUInt16(); + return (width, height); + } + + private static (int, int) ReadJpeg(Stream stream, BinaryReader reader) + { + stream.Seek(2, SeekOrigin.Begin); + while (stream.Position < stream.Length) + { + int marker = reader.ReadByte(); + if (marker != 0xFF) continue; + + int type = reader.ReadByte(); + if (type == 0xD9 || type == 0xDA) break; // EOI or SOS + + // Skip padding + while (type == 0xFF) type = reader.ReadByte(); + if (type == 0xD9 || type == 0xDA) break; + + if (type == 0xD8 || type == 0x01) continue; + + int length = (reader.ReadByte() << 8) | reader.ReadByte(); + if (length < 2) break; + + if (type == 0xC0 || type == 0xC1 || type == 0xC2 || type == 0xC3 || + type == 0xC5 || type == 0xC6 || type == 0xC7 || + type == 0xC9 || type == 0xCA || type == 0xCB || + type == 0xCD || type == 0xCE || type == 0xCF) + { + reader.ReadByte(); // precision + int height = (reader.ReadByte() << 8) | reader.ReadByte(); + int width = (reader.ReadByte() << 8) | reader.ReadByte(); + return (width, height); + } + + stream.Seek(length - 2, SeekOrigin.Current); + } + + return (0, 0); + } + + private static (int, int) ReadWebP(Stream stream, BinaryReader reader) + { + stream.Seek(12, SeekOrigin.Begin); // Skip RIFF....WEBP + var chunk = reader.ReadChars(4); + var chunkType = new string(chunk); + + if (chunkType == "VP8 ") + { + stream.Seek(26, SeekOrigin.Begin); + int width = reader.ReadUInt16() & 0x3FFF; + int height = reader.ReadUInt16() & 0x3FFF; + return (width, height); + } + + if (chunkType == "VP8L") + { + stream.Seek(21, SeekOrigin.Begin); + int data = reader.ReadInt32(); + int width = (data & 0x3FFF) + 1; + int height = ((data >> 14) & 0x3FFF) + 1; + return (width, height); + } + + if (chunkType == "VP8X") + { + stream.Seek(24, SeekOrigin.Begin); + var sizeBytes = reader.ReadBytes(6); + int width = (sizeBytes[0] | (sizeBytes[1] << 8) | (sizeBytes[2] << 16)) + 1; + int height = (sizeBytes[3] | (sizeBytes[4] << 8) | (sizeBytes[5] << 16)) + 1; + return (width, height); + } + + return (0, 0); + } + + private static int ReadBigEndianInt(BinaryReader reader) + { + var bytes = reader.ReadBytes(4); + return (bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]; + } +} diff --git a/GhostImageManager/Services/ImageExportService.cs b/GhostImageManager/Services/ImageExportService.cs new file mode 100644 index 0000000..02f60f2 --- /dev/null +++ b/GhostImageManager/Services/ImageExportService.cs @@ -0,0 +1,86 @@ +using System.IO.Compression; +using System.Text; +using GhostImageManager.Models; + +namespace GhostImageManager.Services; + +public class ImageExportService +{ + public byte[] ExportToCsv(List images) + { + var sb = new StringBuilder(); + sb.AppendLine("Dateiname;Relativer Pfad;URL;Größe Bytes;Größe;Dimensionen;Letzte Änderung;Verwaist;Verwendet in Posts"); + + foreach (var image in images) + { + var posts = string.Join(" | ", image.UsedInPosts.Select(p => $"{p.Title} ({(p.IsFeatureImage ? "Feature" : "Content")})")); + sb.AppendLine($"{EscapeCsv(image.FileName)};{EscapeCsv(image.RelativePath)};{EscapeCsv(image.Url)};{image.FileSize};{image.FormattedFileSize};{image.Dimensions};{image.LastModified:yyyy-MM-dd HH:mm};{image.IsOrphaned};{EscapeCsv(posts)}"); + } + + return Encoding.UTF8.GetBytes(sb.ToString()); + } + + public byte[] ExportToTxt(List images) + { + var sb = new StringBuilder(); + sb.AppendLine("Ghost Image Export"); + sb.AppendLine($"Erstellt am: {DateTime.Now:yyyy-MM-dd HH:mm}"); + sb.AppendLine($"Anzahl Bilder: {images.Count}"); + sb.AppendLine($"Verwaiste Bilder: {images.Count(i => i.IsOrphaned)}"); + sb.AppendLine(new string('-', 80)); + + foreach (var image in images) + { + sb.AppendLine($"Datei: {image.FileName}"); + sb.AppendLine($"URL: {image.Url}"); + sb.AppendLine($"Pfad: {image.RelativePath}"); + sb.AppendLine($"Verwaist: {(image.IsOrphaned ? "Ja" : "Nein")}"); + if (image.UsedInPosts.Any()) + { + sb.AppendLine("Verwendet in:"); + foreach (var post in image.UsedInPosts) + { + sb.AppendLine($" - {post.Title} ({(post.IsFeatureImage ? "Feature" : "Content")})"); + } + } + sb.AppendLine(); + } + + return Encoding.UTF8.GetBytes(sb.ToString()); + } + + public async Task ExportToZipAsync(List images) + { + using var ms = new MemoryStream(); + using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, true)) + { + foreach (var image in images) + { + if (!File.Exists(image.FilePath)) + continue; + + var entry = archive.CreateEntry(image.RelativePath.Replace("/", "\\")); + await using (var entryStream = entry.Open()) + { + await using var fileStream = File.OpenRead(image.FilePath); + await fileStream.CopyToAsync(entryStream); + } + } + } + + return ms.ToArray(); + } + + private static string EscapeCsv(string value) + { + if (string.IsNullOrEmpty(value)) + return ""; + + if (value.Contains(';') || value.Contains('"') || value.Contains('\n')) + { + return $"\"{value.Replace("\"", "\"\"")}\""; + } + + return value; + } +} diff --git a/GhostImageManager/Services/ImageReplacementService.cs b/GhostImageManager/Services/ImageReplacementService.cs new file mode 100644 index 0000000..78c2ec2 --- /dev/null +++ b/GhostImageManager/Services/ImageReplacementService.cs @@ -0,0 +1,61 @@ +using GhostImageManager.Models; + +namespace GhostImageManager.Services; + +public class ImageReplacementService +{ + private readonly ILogger _logger; + + public ImageReplacementService(ILogger logger) + { + _logger = logger; + } + + public async Task ReplaceImageAsync(GhostImage oldImage, Stream newImageStream, string newFileName) + { + try + { + if (!File.Exists(oldImage.FilePath)) + { + _logger.LogError("Alte Bilddatei nicht gefunden: {Path}", oldImage.FilePath); + return false; + } + + // Backup der alten Datei erstellen + var backupPath = oldImage.FilePath + ".backup"; + File.Copy(oldImage.FilePath, backupPath, overwrite: true); + + // Neue Datei schreiben + using (var fileStream = File.Create(oldImage.FilePath)) + { + await newImageStream.CopyToAsync(fileStream); + } + + _logger.LogInformation("Bild ersetzt: {OldFile} durch {NewFile}", oldImage.FileName, newFileName); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Fehler beim Ersetzen des Bildes {File}", oldImage.FileName); + return false; + } + } + + public bool RestoreBackup(GhostImage image) + { + try + { + var backupPath = image.FilePath + ".backup"; + if (!File.Exists(backupPath)) + return false; + + File.Copy(backupPath, image.FilePath, overwrite: true); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Fehler beim Wiederherstellen des Backups für {File}", image.FileName); + return false; + } + } +} diff --git a/GhostImageManager/Services/JwtTokenService.cs b/GhostImageManager/Services/JwtTokenService.cs new file mode 100644 index 0000000..0418973 --- /dev/null +++ b/GhostImageManager/Services/JwtTokenService.cs @@ -0,0 +1,34 @@ +using System.Security.Cryptography; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; + +namespace GhostImageManager.Services; + +public static class JwtTokenService +{ + public static string GenerateToken(string adminApiKey) + { + var parts = adminApiKey.Split(':', 2); + if (parts.Length != 2) + throw new ArgumentException("Admin API Key muss im Format {id}:{secret} sein."); + + string keyId = parts[0]; + string secret = parts[1]; + + var now = DateTimeOffset.UtcNow; + var securityKey = new SymmetricSecurityKey(Convert.FromHexString(secret)); + var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + header: new JwtHeader(credentials) { { "kid", keyId } }, + payload: new JwtPayload + { + { "iat", now.ToUnixTimeSeconds() }, + { "exp", now.AddMinutes(5).ToUnixTimeSeconds() }, + { "aud", "/admin/" } + } + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} diff --git a/GhostImageManager/appsettings.Development.json b/GhostImageManager/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/GhostImageManager/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/GhostImageManager/appsettings.json b/GhostImageManager/appsettings.json new file mode 100644 index 0000000..37f2aa2 --- /dev/null +++ b/GhostImageManager/appsettings.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "AppSettings": { + "Password": "change-me-in-production", + "SessionTimeoutMinutes": 480 + } +} diff --git a/GhostImageManager/dotnet-tools.json b/GhostImageManager/dotnet-tools.json new file mode 100644 index 0000000..5c1165b --- /dev/null +++ b/GhostImageManager/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.12", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/GhostImageManager/ghost-instances.json b/GhostImageManager/ghost-instances.json new file mode 100644 index 0000000..6e25f47 --- /dev/null +++ b/GhostImageManager/ghost-instances.json @@ -0,0 +1,12 @@ +{ + "instances": [ + { + "id": "prod", + "name": "Produktion", + "url": "https://www.brigitteundrolandunterwegs.com", + "adminApiKey": "HIER_ADMIN_API_KEY_EINGEBEN", + "contentApiKey": "", + "imageFolderPath": "C:\\Ghost\\content\\images" + } + ] +} diff --git a/GhostImageManager/logo.jpg b/GhostImageManager/logo.jpg new file mode 100644 index 0000000..fa73ceb Binary files /dev/null and b/GhostImageManager/logo.jpg differ diff --git a/GhostImageManager/start.jpg b/GhostImageManager/start.jpg new file mode 100644 index 0000000..d652806 Binary files /dev/null and b/GhostImageManager/start.jpg differ diff --git a/GhostImageManager/wwwroot/app.css b/GhostImageManager/wwwroot/app.css new file mode 100644 index 0000000..9c0aea4 --- /dev/null +++ b/GhostImageManager/wwwroot/app.css @@ -0,0 +1,179 @@ +h1:focus { + outline: none; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid #e50000; +} + +.validation-message { + color: #e50000; +} + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.darker-border-checkbox.form-check-input { + border-color: #929292; +} + +.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { + color: var(--bs-secondary-color); + text-align: end; +} + +.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { + text-align: start; +} + +/* Login Page Styles */ +.login-container { + position: relative; + width: 100vw; + height: 100vh; + overflow: hidden; + display: flex; + align-items: flex-end; + justify-content: center; +} + +.login-background { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; + z-index: 0; +} + +.login-background-placeholder { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + align-items: center; + justify-content: center; +} + +.login-form-wrapper { + position: relative; + z-index: 1; + width: 100%; + display: flex; + justify-content: center; + padding-bottom: 3rem; +} + +.login-form-card { + background-color: rgba(33, 37, 41, 0.75); + backdrop-filter: blur(10px); + border-radius: 12px; + padding: 2rem; + width: 100%; + max-width: 420px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.login-form-card .form-control { + background-color: rgba(255, 255, 255, 0.9); + border: none; +} + +.login-form-card .form-control:focus { + background-color: #fff; + box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.5); +} + +/* Custom Ghost Image Manager Styles */ +.page { + display: flex; + flex-direction: row; +} + +.sidebar { + width: 250px; + min-height: 100vh; + background-color: #212529; + color: white; +} + +main { + flex: 1; + min-width: 0; +} + +.top-row { + background-color: #f8f9fa; + border-bottom: 1px solid #dee2e6; +} + +.content { + padding-top: 1.5rem; +} + +.card-img-top { + overflow: hidden; +} + +.card-img-top img { + transition: transform 0.2s; +} + +.card-img-top:hover img { + transform: scale(1.05); +} + +.modal.show { + display: block; +} + +.nav-scrollable { + display: block; +} + +.navbar-toggler { + appearance: none; + cursor: pointer; + width: 3.5rem; + height: 2.5rem; + color: white; + position: absolute; + top: 0.5rem; + right: 1rem; + border: 1px solid rgba(255, 255, 255, 0.1); + background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem; +} + +.navbar-toggler:checked { + background-color: rgba(255, 255, 255, 0.1); +} + +@media (max-width: 768px) { + .page { + flex-direction: column; + } + + .sidebar { + width: 100%; + min-height: auto; + } + + .nav-scrollable { + display: none; + } + + .navbar-toggler:checked ~ .nav-scrollable { + display: block; + } +} \ No newline at end of file diff --git a/GhostImageManager/wwwroot/favicon.png b/GhostImageManager/wwwroot/favicon.png new file mode 100644 index 0000000..62bf214 Binary files /dev/null and b/GhostImageManager/wwwroot/favicon.png differ