- ghost admin
This commit is contained in:
parent
b598b0d5af
commit
6e558d90d0
45 changed files with 2705 additions and 1 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -2,3 +2,7 @@
|
||||||
/CSharpGhostImporter/bin/*
|
/CSharpGhostImporter/bin/*
|
||||||
/CSharpGhostImporter/obj/*
|
/CSharpGhostImporter/obj/*
|
||||||
/0
|
/0
|
||||||
|
/GhostImageManager/.vs/*
|
||||||
|
/GhostImageManager/bin/*
|
||||||
|
/GhostImageManager/obj/*
|
||||||
|
/GhostImageManager/Properties/*
|
||||||
|
|
|
||||||
|
|
@ -816,8 +816,37 @@ class Program
|
||||||
// 4. Tags erstellen/zuordnen (Kategorien + Tags)
|
// 4. Tags erstellen/zuordnen (Kategorien + Tags)
|
||||||
var tags = new List<GhostAdminTag>();
|
var tags = new List<GhostAdminTag>();
|
||||||
var allTagSources = new List<WpCategory>();
|
var allTagSources = new List<WpCategory>();
|
||||||
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);
|
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)
|
if (postFile.Tags.Count > 0)
|
||||||
allTagSources.AddRange(postFile.Tags);
|
allTagSources.AddRange(postFile.Tags);
|
||||||
|
|
||||||
|
|
|
||||||
34
GhostImageManager/Components/App.razor
Normal file
34
GhostImageManager/Components/App.razor
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<base href="/" />
|
||||||
|
<ResourcePreloader />
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
|
||||||
|
<link rel="stylesheet" href="@Assets["app.css"]" />
|
||||||
|
<link rel="stylesheet" href="@Assets["GhostImageManager.styles.css"]" />
|
||||||
|
<ImportMap />
|
||||||
|
<HeadOutlet />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<Routes />
|
||||||
|
<ReconnectModal />
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
|
||||||
|
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||||
|
<script>
|
||||||
|
window.downloadFileFromBase64 = (fileName, base64, contentType) => {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = 'data:' + contentType + ';base64,' + base64;
|
||||||
|
link.download = fileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
9
GhostImageManager/Components/Layout/EmptyLayout.razor
Normal file
9
GhostImageManager/Components/Layout/EmptyLayout.razor
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
|
@Body
|
||||||
|
|
||||||
|
<div id="blazor-error-ui" data-nosnippet>
|
||||||
|
An unhandled error has occurred.
|
||||||
|
<a href="." class="reload">Reload</a>
|
||||||
|
<span class="dismiss">🗙</span>
|
||||||
|
</div>
|
||||||
23
GhostImageManager/Components/Layout/MainLayout.razor
Normal file
23
GhostImageManager/Components/Layout/MainLayout.razor
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<div class="sidebar">
|
||||||
|
<NavMenu />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="top-row px-4">
|
||||||
|
<h3>Ghost Image Manager</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article class="content px-4">
|
||||||
|
@Body
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="blazor-error-ui" data-nosnippet>
|
||||||
|
An unhandled error has occurred.
|
||||||
|
<a href="." class="reload">Reload</a>
|
||||||
|
<span class="dismiss">🗙</span>
|
||||||
|
</div>
|
||||||
20
GhostImageManager/Components/Layout/MainLayout.razor.css
Normal file
20
GhostImageManager/Components/Layout/MainLayout.razor.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
45
GhostImageManager/Components/Layout/NavMenu.razor
Normal file
45
GhostImageManager/Components/Layout/NavMenu.razor
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
<div class="top-row ps-3 navbar navbar-dark">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="">Ghost Image Manager</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
|
||||||
|
|
||||||
|
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
|
||||||
|
<nav class="flex-column">
|
||||||
|
<div class="nav-item px-3">
|
||||||
|
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
||||||
|
<span class="bi bi-house-door-fill" aria-hidden="true"></span> Startseite
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
<AuthorizeView>
|
||||||
|
<Authorized>
|
||||||
|
<div class="nav-item px-3">
|
||||||
|
<NavLink class="nav-link" href="images">
|
||||||
|
<span class="bi bi-images" aria-hidden="true"></span> Bilder
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item px-3">
|
||||||
|
<NavLink class="nav-link" href="settings">
|
||||||
|
<span class="bi bi-gear-fill" aria-hidden="true"></span> Einstellungen
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item px-3 mt-3">
|
||||||
|
<form method="post" action="/auth/logout" class="d-inline">
|
||||||
|
<button type="submit" class="nav-link btn btn-link text-start w-100">
|
||||||
|
<span class="bi bi-box-arrow-right" aria-hidden="true"></span> Abmelden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Authorized>
|
||||||
|
<NotAuthorized>
|
||||||
|
<div class="nav-item px-3">
|
||||||
|
<NavLink class="nav-link" href="login">
|
||||||
|
<span class="bi bi-box-arrow-in-right" aria-hidden="true"></span> Anmelden
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
</NotAuthorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
31
GhostImageManager/Components/Layout/ReconnectModal.razor
Normal file
31
GhostImageManager/Components/Layout/ReconnectModal.razor
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
|
||||||
|
|
||||||
|
<dialog id="components-reconnect-modal" data-nosnippet>
|
||||||
|
<div class="components-reconnect-container">
|
||||||
|
<div class="components-rejoining-animation" aria-hidden="true">
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
<p class="components-reconnect-first-attempt-visible">
|
||||||
|
Rejoining the server...
|
||||||
|
</p>
|
||||||
|
<p class="components-reconnect-repeated-attempt-visible">
|
||||||
|
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
|
||||||
|
</p>
|
||||||
|
<p class="components-reconnect-failed-visible">
|
||||||
|
Failed to rejoin.<br />Please retry or reload the page.
|
||||||
|
</p>
|
||||||
|
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
<p class="components-pause-visible">
|
||||||
|
The session has been paused by the server.
|
||||||
|
</p>
|
||||||
|
<p class="components-resume-failed-visible">
|
||||||
|
Failed to resume the session.<br />Please retry or reload the page.
|
||||||
|
</p>
|
||||||
|
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
|
||||||
|
Resume
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
157
GhostImageManager/Components/Layout/ReconnectModal.razor.css
Normal file
157
GhostImageManager/Components/Layout/ReconnectModal.razor.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
63
GhostImageManager/Components/Layout/ReconnectModal.razor.js
Normal file
63
GhostImageManager/Components/Layout/ReconnectModal.razor.js
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
GhostImageManager/Components/Pages/AccessDenied.razor
Normal file
9
GhostImageManager/Components/Pages/AccessDenied.razor
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
@page "/access-denied"
|
||||||
|
|
||||||
|
<PageTitle>Zugriff verweigert</PageTitle>
|
||||||
|
|
||||||
|
<div class="alert alert-danger mt-5">
|
||||||
|
<h4>Zugriff verweigert</h4>
|
||||||
|
<p>Du hast nicht die Berechtigung, auf diese Seite zuzugreifen.</p>
|
||||||
|
<a href="/login" class="btn btn-primary">Zur Anmeldung</a>
|
||||||
|
</div>
|
||||||
36
GhostImageManager/Components/Pages/Error.razor
Normal file
36
GhostImageManager/Components/Pages/Error.razor
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
@page "/Error"
|
||||||
|
@using System.Diagnostics
|
||||||
|
|
||||||
|
<PageTitle>Error</PageTitle>
|
||||||
|
|
||||||
|
<h1 class="text-danger">Error.</h1>
|
||||||
|
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||||
|
|
||||||
|
@if (ShowRequestId)
|
||||||
|
{
|
||||||
|
<p>
|
||||||
|
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<h3>Development Mode</h3>
|
||||||
|
<p>
|
||||||
|
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||||
|
It can result in displaying sensitive information from exceptions to end users.
|
||||||
|
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||||
|
and restarting the app.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
55
GhostImageManager/Components/Pages/Home.razor
Normal file
55
GhostImageManager/Components/Pages/Home.razor
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
@page "/"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject GhostConfigService ConfigService
|
||||||
|
|
||||||
|
<PageTitle>Startseite</PageTitle>
|
||||||
|
|
||||||
|
<div class="jumbotron bg-light p-5 rounded">
|
||||||
|
<h1 class="display-4">Ghost Image Manager</h1>
|
||||||
|
<p class="lead">
|
||||||
|
Verwalte Bilder deiner Ghost-Instanzen direkt vom Server aus.
|
||||||
|
</p>
|
||||||
|
<hr class="my-4" />
|
||||||
|
<p>
|
||||||
|
Funktionen: Bilder auflisten, nach Posts suchen, verwaiste Bilder finden und Bilder austauschen.
|
||||||
|
</p>
|
||||||
|
<a class="btn btn-primary btn-lg" href="images" role="button">Zu den Bildern</a>
|
||||||
|
<a class="btn btn-outline-secondary btn-lg" href="settings" role="button">Einstellungen</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_config.Instances.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning mt-4">
|
||||||
|
<strong>Hinweis:</strong> Es ist noch keine Ghost-Instanz konfiguriert.
|
||||||
|
Bitte hinterlege unter <a href="settings">Einstellungen</a> deine Ghost-URL, den Admin API Key und den lokalen Image-Ordner.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="row mt-4">
|
||||||
|
@foreach (var instance in _config.Instances)
|
||||||
|
{
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">@instance.Name</h5>
|
||||||
|
<p class="card-text">
|
||||||
|
<strong>URL:</strong> @instance.Url<br />
|
||||||
|
<strong>Image-Ordner:</strong> <span class="text-truncate d-block" title="@instance.ImageFolderPath">@instance.ImageFolderPath</span>
|
||||||
|
</p>
|
||||||
|
<a href="images" class="btn btn-primary">Bilder anzeigen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private GhostConfig _config = new();
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
_config = ConfigService.Load();
|
||||||
|
}
|
||||||
|
}
|
||||||
625
GhostImageManager/Components/Pages/Images.razor
Normal file
625
GhostImageManager/Components/Pages/Images.razor
Normal file
|
|
@ -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<Images> Logger
|
||||||
|
|
||||||
|
<PageTitle>Ghost Bilder</PageTitle>
|
||||||
|
|
||||||
|
<h1>Ghost Bilderverwaltung</h1>
|
||||||
|
|
||||||
|
@if (_instances.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
Keine Ghost-Instanzen konfiguriert. Bitte zuerst unter <a href="settings">Einstellungen</a> eine Instanz anlegen.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Ghost-Instanz</label>
|
||||||
|
<select class="form-select" @bind="_selectedInstanceId" @bind:after="OnInstanceChanged">
|
||||||
|
@foreach (var instance in _instances)
|
||||||
|
{
|
||||||
|
<option value="@instance.Id">@instance.Name</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Suche nach Post</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" @bind="_searchTerm" @bind:event="oninput" placeholder="Post-Titel..." />
|
||||||
|
<button class="btn btn-outline-secondary" @onclick="LoadImages" disabled="@_isLoading">Suchen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 d-flex align-items-end">
|
||||||
|
<button class="btn btn-primary me-2" @onclick="LoadImages" disabled="@_isLoading">
|
||||||
|
@if (_isLoading)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||||
|
<span>Lade...</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>Alle Bilder laden</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (FilteredImages.Any())
|
||||||
|
{
|
||||||
|
<div class="btn-group me-2">
|
||||||
|
<button class="btn btn-outline-success dropdown-toggle" data-bs-toggle="dropdown">
|
||||||
|
Exportieren
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><button class="dropdown-item" @onclick="ExportCsv">CSV exportieren</button></li>
|
||||||
|
<li><button class="dropdown-item" @onclick="ExportTxt">TXT exportieren</button></li>
|
||||||
|
<li><button class="dropdown-item" @onclick="ExportSelectedZip">Ausgewählte als ZIP</button></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (SelectedOrphanedImages.Any())
|
||||||
|
{
|
||||||
|
<button class="btn btn-danger me-2" @onclick="DeleteSelectedOrphaned">
|
||||||
|
Auswahl löschen (@SelectedOrphanedImages.Count)
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger">@_errorMessage</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_successMessage))
|
||||||
|
{
|
||||||
|
<div class="alert alert-success">@_successMessage</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_allImages.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="row mb-3 align-items-center">
|
||||||
|
<div class="col">
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<button class="btn btn-outline-secondary @GetFilterClass(ImageFilter.All)" @onclick="() => SetFilter(ImageFilter.All)">Alle (@_allImages.Count)</button>
|
||||||
|
<button class="btn btn-outline-secondary @GetFilterClass(ImageFilter.Feature)" @onclick="() => SetFilter(ImageFilter.Feature)">Feature (@_featureCount)</button>
|
||||||
|
<button class="btn btn-outline-secondary @GetFilterClass(ImageFilter.Content)" @onclick="() => SetFilter(ImageFilter.Content)">Content (@_contentCount)</button>
|
||||||
|
<button class="btn btn-outline-danger @GetFilterClass(ImageFilter.Orphaned)" @onclick="() => SetFilter(ImageFilter.Orphaned)">Verwaist (@_orphanedCount)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="selectAllOrphaned" @onchange="ToggleSelectAllOrphaned" checked="@_selectAllOrphaned" />
|
||||||
|
<label class="form-check-label" for="selectAllOrphaned">Alle verwaisten auswählen</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
|
||||||
|
@foreach (var image in FilteredImages)
|
||||||
|
{
|
||||||
|
<div class="col">
|
||||||
|
<div class="card h-100 @GetCardClass(image)">
|
||||||
|
@if (image.IsOrphaned)
|
||||||
|
{
|
||||||
|
<div class="card-header py-1">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" checked="@_selectedImageUrls.Contains(image.Url)" @onchange="e => ToggleSelection(image, e)" id="chk-@image.GetHashCode()" />
|
||||||
|
<label class="form-check-label small" for="chk-@image.GetHashCode()">Auswählen</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="card-img-top bg-light d-flex align-items-center justify-content-center" style="height: 200px;">
|
||||||
|
<img src="@image.Url" alt="@image.FileName" class="img-fluid" style="max-height: 100%; max-width: 100%; object-fit: contain;" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title text-truncate" title="@image.FileName">@image.FileName</h5>
|
||||||
|
<p class="card-text small text-muted">
|
||||||
|
<strong>Pfad:</strong> <span class="text-truncate d-block" title="@image.RelativePath">@image.RelativePath</span>
|
||||||
|
<strong>Größe:</strong> @image.FormattedFileSize<br />
|
||||||
|
<strong>Dimensionen:</strong> @image.Dimensions<br />
|
||||||
|
<strong>Datum:</strong> @image.LastModified.ToString("dd.MM.yyyy HH:mm")
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (image.IsOrphaned)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning py-1 px-2 mb-2 small">
|
||||||
|
⚠️ Verwaist (nicht in Posts verwendet)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="mb-2">
|
||||||
|
<small class="text-muted">Verwendet in:</small>
|
||||||
|
<ul class="list-unstyled small mb-0">
|
||||||
|
@foreach (var post in image.UsedInPosts.Take(3))
|
||||||
|
{
|
||||||
|
<li>
|
||||||
|
<span class="badge @(post.IsFeatureImage ? "bg-primary" : "bg-secondary") me-1">
|
||||||
|
@(post.IsFeatureImage ? "Feature" : "Content")
|
||||||
|
</span>
|
||||||
|
<span title="@post.Title">@post.Title</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
@if (image.UsedInPosts.Count > 3)
|
||||||
|
{
|
||||||
|
<li class="text-muted">... und @(image.UsedInPosts.Count - 3) weitere</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" @onclick="() => ShowReplaceDialog(image)">
|
||||||
|
Tauschen
|
||||||
|
</button>
|
||||||
|
@if (image.IsOrphaned)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => ShowDeleteDialog(image)">
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (!_isLoading && _hasLoaded)
|
||||||
|
{
|
||||||
|
<div class="alert alert-info">Keine Bilder gefunden.</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_showReplaceDialog && _selectedImage != null)
|
||||||
|
{
|
||||||
|
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Bild tauschen: @_selectedImage.FileName</h5>
|
||||||
|
<button type="button" class="btn-close" @onclick="CloseReplaceDialog"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6>Aktuelles Bild</h6>
|
||||||
|
<img src="@_selectedImage.Url" class="img-fluid border" alt="Aktuell" />
|
||||||
|
<p class="small text-muted mt-2">
|
||||||
|
@_selectedImage.FormattedFileSize | @_selectedImage.Dimensions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6>Neues Bild</h6>
|
||||||
|
<InputFile OnChange="LoadNewImage" class="form-control" accept="image/*" />
|
||||||
|
@if (_newImagePreview != null)
|
||||||
|
{
|
||||||
|
<img src="@_newImagePreview" class="img-fluid border mt-2" alt="Vorschau" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_replaceError))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger mt-3">@_replaceError</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" @onclick="CloseReplaceDialog">Abbrechen</button>
|
||||||
|
<button type="button" class="btn btn-primary" @onclick="ReplaceImage" disabled="@(_newImageFile == null || _isReplacing)">
|
||||||
|
@if (_isReplacing)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||||
|
<span>Tausche...</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>Hochladen & Ersetzen</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_showDeleteDialog && _selectedImage != null)
|
||||||
|
{
|
||||||
|
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Bild löschen</h5>
|
||||||
|
<button type="button" class="btn-close" @onclick="CloseDeleteDialog"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Möchtest du wirklich <strong>@_selectedImage.FileName</strong> löschen?</p>
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
⚠️ Dieses Bild ist verwaist und wird in keinem Post verwendet.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" @onclick="CloseDeleteDialog">Abbrechen</button>
|
||||||
|
<button type="button" class="btn btn-danger" @onclick="DeleteImage">
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_showBulkDeleteDialog)
|
||||||
|
{
|
||||||
|
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Ausgewählte Bilder löschen</h5>
|
||||||
|
<button type="button" class="btn-close" @onclick="CloseBulkDeleteDialog"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Möchtest du wirklich <strong>@SelectedOrphanedImages.Count</strong> verwaiste Bilder löschen?</p>
|
||||||
|
<ul class="list-group small">
|
||||||
|
@foreach (var img in SelectedOrphanedImages.Take(10))
|
||||||
|
{
|
||||||
|
<li class="list-group-item">@img.FileName</li>
|
||||||
|
}
|
||||||
|
@if (SelectedOrphanedImages.Count > 10)
|
||||||
|
{
|
||||||
|
<li class="list-group-item text-muted">... und @(SelectedOrphanedImages.Count - 10) weitere</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" @onclick="CloseBulkDeleteDialog">Abbrechen</button>
|
||||||
|
<button type="button" class="btn btn-danger" @onclick="DeleteSelectedOrphanedConfirmed" disabled="@_isDeleting">
|
||||||
|
@if (_isDeleting)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||||
|
<span>Lösche...</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>Alle löschen</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<GhostInstance> _instances = new();
|
||||||
|
private string _selectedInstanceId = "";
|
||||||
|
private string _searchTerm = "";
|
||||||
|
private List<GhostImage> _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<string> _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<GhostImage> 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<GhostImage> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
59
GhostImageManager/Components/Pages/Login.razor
Normal file
59
GhostImageManager/Components/Pages/Login.razor
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
@page "/login"
|
||||||
|
@layout Layout.EmptyLayout
|
||||||
|
@inject IWebHostEnvironment Env
|
||||||
|
|
||||||
|
<PageTitle>Anmelden</PageTitle>
|
||||||
|
|
||||||
|
<div class="login-container">
|
||||||
|
<!-- Hintergrundbild -->
|
||||||
|
@if (_imageExists)
|
||||||
|
{
|
||||||
|
<img src="/start-image/start.jpg" alt="Startbild" class="login-background" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="login-background login-background-placeholder">
|
||||||
|
<div class="text-center text-white">
|
||||||
|
<i class="bi bi-image display-1"></i>
|
||||||
|
<p class="mt-3">Bitte lege eine Datei namens <code>start.jpg</code> im Root-Verzeichnis der App ab.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Login-Formular unten mittig -->
|
||||||
|
<div class="login-form-wrapper">
|
||||||
|
<div class="login-form-card">
|
||||||
|
<h4 class="mb-3 text-white">Ghost Image Manager</h4>
|
||||||
|
<form method="post" action="/auth/login">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label text-white">Passwort</label>
|
||||||
|
<input type="password" name="password" class="form-control" placeholder="Passwort eingeben" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(ErrorMessage))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger">@ErrorMessage</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<input type="hidden" name="returnUrl" value="@ReturnUrl" />
|
||||||
|
<button type="submit" class="btn btn-primary w-100">Anmelden</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
GhostImageManager/Components/Pages/Logout.razor
Normal file
10
GhostImageManager/Components/Pages/Logout.razor
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
@page "/logout"
|
||||||
|
|
||||||
|
<PageTitle>Abmelden</PageTitle>
|
||||||
|
|
||||||
|
<div class="text-center mt-5">
|
||||||
|
<h3>Abmelden</h3>
|
||||||
|
<form method="post" action="/auth/logout">
|
||||||
|
<button type="submit" class="btn btn-primary">Jetzt abmelden</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
5
GhostImageManager/Components/Pages/NotFound.razor
Normal file
5
GhostImageManager/Components/Pages/NotFound.razor
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
@page "/not-found"
|
||||||
|
@layout MainLayout
|
||||||
|
|
||||||
|
<h3>Not Found</h3>
|
||||||
|
<p>Sorry, the content you are looking for does not exist.</p>
|
||||||
192
GhostImageManager/Components/Pages/Settings.razor
Normal file
192
GhostImageManager/Components/Pages/Settings.razor
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
@page "/settings"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
@inject GhostConfigService ConfigService
|
||||||
|
|
||||||
|
<PageTitle>Einstellungen</PageTitle>
|
||||||
|
|
||||||
|
<h1>Ghost-Instanzen verwalten</h1>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Konfigurierte Instanzen</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
@if (_config.Instances.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-info">Keine Instanzen konfiguriert.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>URL</th>
|
||||||
|
<th>Bilderverzeichnis</th>
|
||||||
|
<th class="text-end">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var instance in _config.Instances)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@instance.Name</td>
|
||||||
|
<td>@instance.Url</td>
|
||||||
|
<td class="text-truncate" style="max-width: 300px;" title="@instance.ImageFolderPath">@instance.ImageFolderPath</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button class="btn btn-sm btn-outline-primary me-1" @onclick="() => EditInstance(instance)">Bearbeiten</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteInstance(instance)">Löschen</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">@(_editingInstance.Id == null || _isNew ? "Neue Instanz" : "Instanz bearbeiten")</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<EditForm Model="_editingInstance" OnValidSubmit="SaveInstance">
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
<ValidationSummary />
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
<InputText @bind-Value="_editingInstance.Name" class="form-control" placeholder="z.B. Produktion" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Ghost URL</label>
|
||||||
|
<InputText @bind-Value="_editingInstance.Url" class="form-control" placeholder="https://mein-blog.de" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Admin API Key</label>
|
||||||
|
<InputText @bind-Value="_editingInstance.AdminApiKey" class="form-control" type="password" placeholder="id:secret" />
|
||||||
|
<div class="form-text">Format: {id}:{secret} aus den Ghost-Integrationen</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Content API Key (optional)</label>
|
||||||
|
<InputText @bind-Value="_editingInstance.ContentApiKey" class="form-control" placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Lokaler Image-Ordner</label>
|
||||||
|
<InputText @bind-Value="_editingInstance.ImageFolderPath" class="form-control" placeholder="C:\\Ghost\\content\\images" />
|
||||||
|
<div class="form-text">Vollständiger Pfad zum Ghost content/images Ordner</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<button type="button" class="btn btn-secondary" @onclick="ResetForm">Zurücksetzen</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_message))
|
||||||
|
{
|
||||||
|
<div class="alert alert-success mt-3">@_message</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@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; } = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
6
GhostImageManager/Components/Routes.razor
Normal file
6
GhostImageManager/Components/Routes.razor
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
|
||||||
|
<Found Context="routeData">
|
||||||
|
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
|
||||||
|
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||||
|
</Found>
|
||||||
|
</Router>
|
||||||
15
GhostImageManager/Components/_Imports.razor
Normal file
15
GhostImageManager/Components/_Imports.razor
Normal file
|
|
@ -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
|
||||||
30
GhostImageManager/GhostImageManager.csproj
Normal file
30
GhostImageManager/GhostImageManager.csproj
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="HtmlAgilityPack" Version="1.11.66" />
|
||||||
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Update="wwwroot\favicon.png">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="logo.jpg">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
<None Update="start.jpg">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
7
GhostImageManager/GhostImageManager.csproj.user
Normal file
7
GhostImageManager/GhostImageManager.csproj.user
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||||
|
<PropertyGroup>
|
||||||
|
<ActiveDebugProfile>https</ActiveDebugProfile>
|
||||||
|
<NameOfLastUsedPublishProfile>E:\Wp2Ghost\GhostImageManager\Properties\PublishProfiles\FolderProfile.pubxml</NameOfLastUsedPublishProfile>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
7
GhostImageManager/Models/AppSettings.cs
Normal file
7
GhostImageManager/Models/AppSettings.cs
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
6
GhostImageManager/Models/GhostConfig.cs
Normal file
6
GhostImageManager/Models/GhostConfig.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
namespace GhostImageManager.Models;
|
||||||
|
|
||||||
|
public class GhostConfig
|
||||||
|
{
|
||||||
|
public List<GhostInstance> Instances { get; set; } = new();
|
||||||
|
}
|
||||||
33
GhostImageManager/Models/GhostImage.cs
Normal file
33
GhostImageManager/Models/GhostImage.cs
Normal file
|
|
@ -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<PostReference> 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; }
|
||||||
|
}
|
||||||
11
GhostImageManager/Models/GhostInstance.cs
Normal file
11
GhostImageManager/Models/GhostInstance.cs
Normal file
|
|
@ -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; } = "";
|
||||||
|
}
|
||||||
13
GhostImageManager/Models/GhostPost.cs
Normal file
13
GhostImageManager/Models/GhostPost.cs
Normal file
|
|
@ -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; } = "";
|
||||||
|
}
|
||||||
102
GhostImageManager/Program.cs
Normal file
102
GhostImageManager/Program.cs
Normal file
|
|
@ -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<AppSettings>(builder.Configuration.GetSection("AppSettings"));
|
||||||
|
builder.Services.AddSingleton<GhostConfigService>();
|
||||||
|
builder.Services.AddScoped<GhostApiService>();
|
||||||
|
builder.Services.AddScoped<ImageAnalyzerService>();
|
||||||
|
builder.Services.AddScoped<ImageReplacementService>();
|
||||||
|
builder.Services.AddScoped<ImageDeletionService>();
|
||||||
|
builder.Services.AddScoped<ImageExportService>();
|
||||||
|
builder.Services.AddScoped<AuthService>();
|
||||||
|
builder.Services.AddHttpClient<GhostApiService>();
|
||||||
|
|
||||||
|
// 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<App>()
|
||||||
|
.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();
|
||||||
51
GhostImageManager/Services/AuthService.cs
Normal file
51
GhostImageManager/Services/AuthService.cs
Normal file
|
|
@ -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<AppSettings>() ?? 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<Claim>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
169
GhostImageManager/Services/GhostApiService.cs
Normal file
169
GhostImageManager/Services/GhostApiService.cs
Normal file
|
|
@ -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<List<GhostPost>> 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<GhostPost>();
|
||||||
|
|
||||||
|
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<GhostPost?> 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<bool> 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<string?> 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; }
|
||||||
|
}
|
||||||
43
GhostImageManager/Services/GhostConfigService.cs
Normal file
43
GhostImageManager/Services/GhostConfigService.cs
Normal file
|
|
@ -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<GhostConfig>(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
|
||||||
|
};
|
||||||
|
}
|
||||||
215
GhostImageManager/Services/ImageAnalyzerService.cs
Normal file
215
GhostImageManager/Services/ImageAnalyzerService.cs
Normal file
|
|
@ -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<ImageAnalyzerService> _logger;
|
||||||
|
|
||||||
|
public ImageAnalyzerService(GhostApiService ghostApiService, ILogger<ImageAnalyzerService> logger)
|
||||||
|
{
|
||||||
|
_ghostApiService = ghostApiService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<GhostImage>> AnalyzeImagesAsync(GhostInstance instance, string searchTerm = "")
|
||||||
|
{
|
||||||
|
var images = new List<GhostImage>();
|
||||||
|
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<string, List<PostReference>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var post in posts)
|
||||||
|
{
|
||||||
|
// Feature Image
|
||||||
|
if (!string.IsNullOrEmpty(post.FeatureImage))
|
||||||
|
{
|
||||||
|
if (!imageReferences.ContainsKey(post.FeatureImage))
|
||||||
|
imageReferences[post.FeatureImage] = new List<PostReference>();
|
||||||
|
|
||||||
|
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<PostReference>();
|
||||||
|
|
||||||
|
// 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<PostReference>(),
|
||||||
|
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<FileInfo> ScanLocalImageFiles(string folderPath)
|
||||||
|
{
|
||||||
|
var files = new List<FileInfo>();
|
||||||
|
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<string> ExtractImageUrls(string html)
|
||||||
|
{
|
||||||
|
var urls = new List<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
GhostImageManager/Services/ImageDeletionService.cs
Normal file
61
GhostImageManager/Services/ImageDeletionService.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
using GhostImageManager.Models;
|
||||||
|
|
||||||
|
namespace GhostImageManager.Services;
|
||||||
|
|
||||||
|
public class ImageDeletionService
|
||||||
|
{
|
||||||
|
private readonly ILogger<ImageDeletionService> _logger;
|
||||||
|
|
||||||
|
public ImageDeletionService(ILogger<ImageDeletionService> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> DeleteImageAsync(GhostImage image)
|
||||||
|
{
|
||||||
|
return Task.FromResult(DeleteFile(image.FilePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<(int Success, int Failed)> DeleteImagesAsync(IEnumerable<GhostImage> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
123
GhostImageManager/Services/ImageDimensionReader.cs
Normal file
123
GhostImageManager/Services/ImageDimensionReader.cs
Normal file
|
|
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
86
GhostImageManager/Services/ImageExportService.cs
Normal file
86
GhostImageManager/Services/ImageExportService.cs
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Text;
|
||||||
|
using GhostImageManager.Models;
|
||||||
|
|
||||||
|
namespace GhostImageManager.Services;
|
||||||
|
|
||||||
|
public class ImageExportService
|
||||||
|
{
|
||||||
|
public byte[] ExportToCsv(List<GhostImage> 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<GhostImage> 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<byte[]> ExportToZipAsync(List<GhostImage> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
GhostImageManager/Services/ImageReplacementService.cs
Normal file
61
GhostImageManager/Services/ImageReplacementService.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
using GhostImageManager.Models;
|
||||||
|
|
||||||
|
namespace GhostImageManager.Services;
|
||||||
|
|
||||||
|
public class ImageReplacementService
|
||||||
|
{
|
||||||
|
private readonly ILogger<ImageReplacementService> _logger;
|
||||||
|
|
||||||
|
public ImageReplacementService(ILogger<ImageReplacementService> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
GhostImageManager/Services/JwtTokenService.cs
Normal file
34
GhostImageManager/Services/JwtTokenService.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
GhostImageManager/appsettings.Development.json
Normal file
8
GhostImageManager/appsettings.Development.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
GhostImageManager/appsettings.json
Normal file
13
GhostImageManager/appsettings.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"AppSettings": {
|
||||||
|
"Password": "change-me-in-production",
|
||||||
|
"SessionTimeoutMinutes": 480
|
||||||
|
}
|
||||||
|
}
|
||||||
13
GhostImageManager/dotnet-tools.json
Normal file
13
GhostImageManager/dotnet-tools.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"isRoot": true,
|
||||||
|
"tools": {
|
||||||
|
"dotnet-ef": {
|
||||||
|
"version": "10.0.12",
|
||||||
|
"commands": [
|
||||||
|
"dotnet-ef"
|
||||||
|
],
|
||||||
|
"rollForward": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
GhostImageManager/ghost-instances.json
Normal file
12
GhostImageManager/ghost-instances.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
GhostImageManager/logo.jpg
Normal file
BIN
GhostImageManager/logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
BIN
GhostImageManager/start.jpg
Normal file
BIN
GhostImageManager/start.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 MiB |
179
GhostImageManager/wwwroot/app.css
Normal file
179
GhostImageManager/wwwroot/app.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
GhostImageManager/wwwroot/favicon.png
Normal file
BIN
GhostImageManager/wwwroot/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 720 KiB |
Loading…
Reference in a new issue