Files
aj-portfolio/public/admin/project_edit.php
2025-12-23 13:18:58 +02:00

555 lines
21 KiB
PHP

<?php
declare(strict_types=1);
require __DIR__ . '/../../includes/bootstrap.php';
require_admin_login();
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$pageTitle = $id ? 'Edit Project | Admin' : 'New Project | Admin';
// bring optional columns up-to-date (idempotent)
try {
$cols = pdo()->query("SHOW COLUMNS FROM projects")->fetchAll(PDO::FETCH_ASSOC);
$colNames = array_map(static fn($c) => (string)($c['Field'] ?? ''), $cols);
$alter = [];
if (!in_array('tech_json', $colNames, true)) $alter[] = "ADD COLUMN tech_json LONGTEXT NULL";
if (!in_array('links_json', $colNames, true)) $alter[] = "ADD COLUMN links_json LONGTEXT NULL";
if (!in_array('sort_order', $colNames, true)) $alter[] = "ADD COLUMN sort_order INT NOT NULL DEFAULT 0";
if (!in_array('slug', $colNames, true)) $alter[] = "ADD COLUMN slug VARCHAR(120) NULL";
if (!in_array('short_summary', $colNames, true)) $alter[] = "ADD COLUMN short_summary VARCHAR(240) NULL";
foreach ($cols as $c) {
if (($c['Field'] ?? '') !== 'summary') continue;
$type = strtolower((string)($c['Type'] ?? ''));
$null = strtoupper((string)($c['Null'] ?? '')) === 'YES' ? 'NULL' : 'NOT NULL';
if (strpos($type, 'text') === false) $alter[] = "MODIFY summary TEXT {$null}";
break;
}
if ($alter) {
pdo()->exec("ALTER TABLE projects " . implode(", ", $alter));
}
} catch (Throwable $e) {}
$project = [
'slug' => '',
'title' => '',
'tag' => '',
'year' => (int)date('Y'),
'summary' => '',
'short_summary' => '',
'tech_json' => '[]',
'links_json' => '[]',
'sort_order' => 0,
];
$activeTab = (isset($_GET['tab']) && strtolower((string)$_GET['tab']) === 'gallery') ? 'gallery' : 'details';
if ($id) {
$st = pdo()->prepare("SELECT id, slug, title, tag, year, summary, short_summary, tech_json, links_json, sort_order FROM projects WHERE id = ? LIMIT 1");
$st->execute([$id]);
$row = $st->fetch(PDO::FETCH_ASSOC);
if (!$row) {
flash_set('danger', 'Project not found.');
header('Location: /public/admin/projects.php');
exit;
}
$project = $row;
}
$errors = [];
function parse_list(string $raw): array {
$parts = preg_split('/[\r\n,]+/', $raw) ?: [];
$out = [];
foreach ($parts as $p) {
$p = trim((string)$p);
if ($p !== '') $out[] = $p;
}
return array_values(array_unique($out));
}
function parse_links(string $raw): array {
$lines = preg_split('/\R/', $raw) ?: [];
$links = [];
foreach ($lines as $ln) {
$ln = trim((string)$ln);
if ($ln === '') continue;
// Format: Label | https://...
$label = $ln;
$url = '';
if (strpos($ln, '|') !== false) {
[$label, $url] = array_map('trim', explode('|', $ln, 2));
}
if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) continue;
if ($label === '') $label = 'Link';
$links[] = ['label' => $label, 'url' => $url];
}
return $links;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!csrf_check((string)($_POST['csrf'] ?? ''))) {
$errors[] = 'CSRF failed. Refresh and try again.';
}
$slugRaw = trim((string)($_POST['slug'] ?? ''));
$title = trim((string)($_POST['title'] ?? ''));
$tag = trim((string)($_POST['tag'] ?? ''));
$year = (int)($_POST['year'] ?? 0);
$summary = trim((string)($_POST['summary'] ?? ''));
$shortSummary = trim((string)($_POST['short_summary'] ?? ''));
$techRaw = (string)($_POST['tech'] ?? '');
$linksRaw= (string)($_POST['links'] ?? '');
$sort = (int)($_POST['sort_order'] ?? 0);
$slug = project_slugify($slugRaw !== '' ? $slugRaw : $title);
if ($slug === '' && $title !== '') $slug = project_slugify($title);
if ($slug === '' || !preg_match('/^[a-z0-9-]{2,120}$/', $slug)) $errors[] = 'Slug must be letters, numbers, or hyphen (auto-derived from title).';
if ($title === '' || mb_strlen($title) > 140) $errors[] = 'Title must be 1-140 chars.';
if ($tag === '' || mb_strlen($tag) > 50) $errors[] = 'Tag must be 1-50 chars.';
if ($year < 2000 || $year > 2100) $errors[] = 'Year must be 2000-2100.';
if ($summary === '' || mb_strlen($summary) > 5000) $errors[] = 'Summary is required (max 5000 chars).';
if ($shortSummary !== '' && mb_strlen($shortSummary) > 240) $errors[] = 'Card summary must be 240 chars or less.';
$tech = parse_list($techRaw);
$links = parse_links($linksRaw);
$techJson = json_encode($tech, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$linksJson = json_encode($links, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($slug !== '') {
$st = pdo()->prepare("SELECT id FROM projects WHERE slug = ? AND id <> ? LIMIT 1");
$st->execute([$slug, $id]);
if ($st->fetch()) $errors[] = 'Slug already in use.';
}
if (!$errors) {
if ($id) {
$st = pdo()->prepare("UPDATE projects SET slug=?, title=?, tag=?, year=?, summary=?, short_summary=?, tech_json=?, links_json=?, sort_order=? WHERE id=?");
$st->execute([$slug, $title, $tag, $year, $summary, $shortSummary ?: null, $techJson, $linksJson, $sort, $id]);
flash_set('success', 'Project updated.');
} else {
$st = pdo()->prepare("INSERT INTO projects (slug, title, tag, year, summary, short_summary, tech_json, links_json, sort_order) VALUES (?,?,?,?,?,?,?,?,?)");
$st->execute([$slug, $title, $tag, $year, $summary, $shortSummary ?: null, $techJson, $linksJson, $sort]);
flash_set('success', 'Project created.');
}
header('Location: /public/admin/projects.php');
exit;
}
// repopulate if validation fails
$project['slug'] = $slug;
$project['title'] = $title;
$project['tag'] = $tag;
$project['year'] = $year;
$project['summary'] = $summary;
$project['short_summary'] = $shortSummary;
$project['tech_json'] = $techJson;
$project['links_json'] = $linksJson;
$project['sort_order'] = $sort;
}
// Prefill textareas
$techArr = json_decode((string)($project['tech_json'] ?? '[]'), true);
$techTxt = is_array($techArr) ? implode("\n", $techArr) : '';
$linksArr = json_decode((string)($project['links_json'] ?? '[]'), true);
$linksTxt = '';
if (is_array($linksArr)) {
foreach ($linksArr as $l) {
$lbl = (string)($l['label'] ?? '');
$url = (string)($l['url'] ?? '');
if ($url !== '') $linksTxt .= "{$lbl} | {$url}\n";
}
}
$linksTxt = trim($linksTxt);
$mediaEndpoint = url_path('/public/admin/project_media.php');
$projectFolder = $id ? project_media_folder($project) : '';
include __DIR__ . '/_top.php';
?>
<div class="d-flex align-items-center justify-content-between mt-4 flex-wrap gap-2">
<h1 class="h3 m-0 brand"><?= $id ? 'Edit Project' : 'New Project' ?></h1>
<a class="btn btn-outline-light" href="/public/admin/projects.php">Back</a>
</div>
<?php if ($errors): ?>
<div class="alert alert-danger card-glass border-0 mt-3">
<ul class="mb-0">
<?php foreach ($errors as $e): ?><li><?= htmlspecialchars($e) ?></li><?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<div class="card card-glass border-0 mt-3 p-4">
<ul class="nav nav-pills gap-2" id="projectTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link <?= $activeTab === 'details' ? 'active' : '' ?>" id="tabDetailsBtn" data-bs-toggle="tab" data-bs-target="#tabDetails" type="button" role="tab" aria-controls="tabDetails" aria-selected="<?= $activeTab === 'details' ? 'true' : 'false' ?>">
Details
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link <?= $activeTab === 'gallery' ? 'active' : '' ?>" id="tabGalleryBtn" data-bs-toggle="tab" data-bs-target="#tabGallery" type="button" role="tab" aria-controls="tabGallery" aria-selected="<?= $activeTab === 'gallery' ? 'true' : 'false' ?>">
Gallery
</button>
</li>
</ul>
<div class="tab-content mt-3">
<div class="tab-pane fade <?= $activeTab === 'details' ? 'show active' : '' ?>" id="tabDetails" role="tabpanel" aria-labelledby="tabDetailsBtn">
<form method="post">
<input type="hidden" name="csrf" value="<?= htmlspecialchars(csrf_token()) ?>">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label text-white-50">Title</label>
<input class="form-control" name="title" maxlength="140" required
value="<?= htmlspecialchars((string)$project['title']) ?>">
</div>
<div class="col-md-3">
<label class="form-label text-white-50">Year</label>
<input class="form-control" name="year" type="number" min="2000" max="2100" required
value="<?= (int)$project['year'] ?>">
</div>
<div class="col-md-3">
<label class="form-label text-white-50">Tag</label>
<input class="form-control" name="tag" maxlength="50" required
value="<?= htmlspecialchars((string)$project['tag']) ?>">
</div>
<div class="col-md-6">
<label class="form-label text-white-50">Slug (auto)</label>
<input class="form-control" name="slug" maxlength="120"
value="<?= htmlspecialchars((string)($project['slug'] ?? '')) ?>"
placeholder="my-project-name">
<div class="form-text text-white-50">Letters, numbers, and hyphen. Lowercase slug is auto-built from the title.</div>
</div>
<div class="col-md-3">
<label class="form-label text-white-50">Sort order</label>
<input class="form-control" name="sort_order" type="number" value="<?= (int)($project['sort_order'] ?? 0) ?>">
</div>
<div class="col-12">
<label class="form-label text-white-50">Summary</label>
<textarea class="form-control" name="summary" rows="4" required><?= htmlspecialchars((string)$project['summary']) ?></textarea>
</div>
<div class="col-12">
<label class="form-label text-white-50">Card summary (optional, short preview)</label>
<textarea class="form-control" name="short_summary" rows="2" maxlength="240"
placeholder="If blank, the preview will clamp the full summary."><?= htmlspecialchars((string)($project['short_summary'] ?? '')) ?></textarea>
</div>
<div class="col-md-6">
<label class="form-label text-white-50">Tech (one per line OR comma separated)</label>
<textarea class="form-control" name="tech" rows="7" placeholder="PHP&#10;MariaDB&#10;jQuery"><?= htmlspecialchars($techTxt) ?></textarea>
</div>
<div class="col-md-6">
<label class="form-label text-white-50">Links (one per line): <code>Label | https://url</code></label>
<textarea class="form-control" name="links" rows="7" placeholder="Repo | https://github.com/..."><?= htmlspecialchars($linksTxt) ?></textarea>
</div>
</div>
<div class="mt-4 d-flex gap-2">
<button class="btn btn-violet"><?= $id ? 'Save Changes' : 'Create Project' ?></button>
<a class="btn btn-outline-light" href="/public/admin/projects.php">Cancel</a>
</div>
</form>
</div>
<div class="tab-pane fade <?= $activeTab === 'gallery' ? 'show active' : '' ?>" id="tabGallery" role="tabpanel" aria-labelledby="tabGalleryBtn">
<?php if (!$id): ?>
<div class="alert alert-info card-glass border-0 mt-3">
Save the project first to unlock the media gallery.
</div>
<?php else: ?>
<div id="mediaManager"
data-project-id="<?= (int)$id ?>"
data-csrf="<?= htmlspecialchars(csrf_token()) ?>"
data-endpoint="<?= htmlspecialchars($mediaEndpoint) ?>">
<div class="d-flex flex-wrap justify-content-between gap-2 mb-3">
<div>
<div class="text-white-50 small">Files live in</div>
<div class="fw-bold">Project imgs/<?= htmlspecialchars($projectFolder) ?></div>
</div>
<div class="text-white-50 small">JPEG, PNG or WebP up to 8MB; re-encoded via GD.</div>
</div>
<div class="row g-3 align-items-stretch">
<div class="col-lg-5">
<div id="mediaDrop" class="media-drop rounded-3" role="button" tabindex="0">
<div class="text-center">
<div class="fw-semibold">Drag &amp; drop screenshots</div>
<div class="text-white-50 small">or click to pick files</div>
</div>
<input type="file" id="mediaInput" accept="image/*" multiple hidden>
</div>
<div class="small text-white-50 mt-2">Paste an image URL and we'll fetch and sanitize it.</div>
<div class="input-group mt-2">
<input type="url" class="form-control" id="mediaUrlInput" placeholder="https://example.com/screenshot.jpg">
<button class="btn btn-outline-light" type="button" id="mediaUrlBtn">Add URL</button>
</div>
<div class="small mt-2" id="mediaStatus" aria-live="polite"></div>
</div>
<div class="col-lg-7">
<div class="media-grid" id="mediaGrid" aria-live="polite"></div>
</div>
</div>
</div>
<?php endif; ?>
</div>
</div>
</div>
<script>
(function ($) {
$(function () {
const $manager = $("#mediaManager");
if (!$manager.length) return;
const endpoint = $manager.data("endpoint");
const projectId = $manager.data("project-id");
const csrf = $manager.data("csrf");
const $drop = $("#mediaDrop");
const $input = $("#mediaInput");
const $grid = $("#mediaGrid");
const $status = $("#mediaStatus");
const $urlInput = $("#mediaUrlInput");
const $urlBtn = $("#mediaUrlBtn");
const setStatus = (msg, type) => {
if (!$status.length) return;
const cls = type === "error" ? "text-danger" : (type === "success" ? "text-success" : "text-white-50");
$status.attr("class", "small mt-2 " + cls).text(msg || "");
};
const bytesToSize = (bytes) => {
if (!bytes) return "0 KB";
const kb = bytes / 1024;
if (kb < 1024) return kb.toFixed(1) + " KB";
return (kb / 1024).toFixed(1) + " MB";
};
const render = (files) => {
if (!$grid.length) return;
$grid.empty();
if (!files || !files.length) {
$("<div/>", { class: "text-white-50 small" })
.text("No images yet. Drop files or paste a URL to begin.")
.appendTo($grid);
return;
}
files.forEach((f) => {
const sizeLabel = bytesToSize(f.size || 0);
const $card = $("<div/>", { class: "media-card" });
$("<img/>", { src: f.url, alt: f.name }).appendTo($card);
$("<div/>", { class: "media-meta" })
.html(
'<div class="fw-semibold small text-truncate">' + f.name + "</div>" +
'<div class="text-white-50 small">' +
(f.width || "?") + "x" + (f.height || "?") + " - " + sizeLabel + "</div>"
)
.appendTo($card);
$("<div/>", { class: "media-actions" })
.append(
$("<button/>", {
type: "button",
class: "btn btn-sm btn-outline-light",
text: "Delete"
}).on("click", function () {
if (confirm("Remove this image?")) deleteFile(f.name);
})
)
.appendTo($card);
$grid.append($card);
});
};
const fetchList = () => {
setStatus("Loading images...");
return $.ajax({
url: `${endpoint}?action=list&project_id=${encodeURIComponent(projectId)}&csrf=${encodeURIComponent(csrf)}`,
method: "GET",
dataType: "json",
cache: false
})
.done((data) => {
if (!data || data.ok !== true) {
setStatus((data && data.error) ? data.error : "Unable to load images", "error");
return;
}
render(data.files || []);
const files = data.files || [];
setStatus(files.length ? "" : "Drop files or paste a URL to upload.", "muted");
})
.fail((xhr) => {
const msg = (xhr && xhr.responseJSON && xhr.responseJSON.error) || xhr.statusText || "Failed to load images";
setStatus(msg, "error");
});
};
const uploadFiles = (list) => {
const files = Array.from(list || []).filter((f) => (f.type || "").startsWith("image/"));
if (!files.length) {
setStatus("Only image files are accepted.", "error");
return;
}
setStatus("Uploading...", "muted");
const form = new FormData();
form.append("action", "upload");
form.append("project_id", projectId);
form.append("csrf", csrf);
files.slice(0, 10).forEach((f) => form.append("files[]", f));
$.ajax({
url: endpoint,
method: "POST",
data: form,
processData: false,
contentType: false,
cache: false,
dataType: "json"
})
.done((data) => {
if (!data || data.ok !== true) {
setStatus((data && data.error) ? data.error : "Upload failed", "error");
return;
}
setStatus(`Saved ${data.saved || 0} file(s).`, "success");
fetchList();
})
.fail((xhr) => {
const msg = (xhr && xhr.responseJSON && xhr.responseJSON.error) || xhr.statusText || "Upload failed";
setStatus(msg, "error");
});
};
const importUrl = () => {
const url = ($urlInput.val() || "").trim();
if (!url) {
setStatus("Paste an image URL first.", "error");
return;
}
setStatus("Fetching URL...", "muted");
const form = new FormData();
form.append("action", "fetch_url");
form.append("project_id", projectId);
form.append("csrf", csrf);
form.append("image_url", url);
$.ajax({
url: endpoint,
method: "POST",
data: form,
processData: false,
contentType: false,
cache: false,
dataType: "json"
})
.done((data) => {
if (!data || data.ok !== true) {
setStatus((data && data.error) ? data.error : "Import failed", "error");
return;
}
setStatus("Image imported.", "success");
$urlInput.val("");
fetchList();
})
.fail((xhr) => {
const msg = (xhr && xhr.responseJSON && xhr.responseJSON.error) || xhr.statusText || "Import failed";
setStatus(msg, "error");
});
};
const deleteFile = (name) => {
const form = new FormData();
form.append("action", "delete");
form.append("project_id", projectId);
form.append("csrf", csrf);
form.append("name", name);
$.ajax({
url: endpoint,
method: "POST",
data: form,
processData: false,
contentType: false,
cache: false,
dataType: "json"
})
.done((data) => {
if (!data || data.ok !== true) {
setStatus((data && data.error) ? data.error : "Delete failed", "error");
return;
}
setStatus("Image removed.", "success");
fetchList();
})
.fail((xhr) => {
const msg = (xhr && xhr.responseJSON && xhr.responseJSON.error) || xhr.statusText || "Delete failed";
setStatus(msg, "error");
});
};
if ($drop.length && $input.length) {
$drop.on("dragenter dragover", (e) => {
e.preventDefault();
$drop.addClass("is-drag");
});
$drop.on("dragleave drop", (e) => {
e.preventDefault();
$drop.removeClass("is-drag");
});
$drop.on("drop", (e) => {
e.preventDefault();
$drop.removeClass("is-drag");
uploadFiles(e.originalEvent.dataTransfer.files);
});
$drop.on("click", () => $input.trigger("click"));
$drop.on("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
$input.trigger("click");
}
});
$input.on("change", function () {
uploadFiles(this.files);
this.value = "";
});
}
if ($urlBtn.length) $urlBtn.on("click", importUrl);
if ($urlInput.length) {
$urlInput.on("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
importUrl();
}
});
}
fetchList();
});
})(jQuery);
</script>
<link rel="stylesheet" href="/public/css/edit_project.css">
<?php include __DIR__ . '/_bottom.php'; ?>