Initial production backup

This commit is contained in:
Tesscorp
2026-07-15 19:45:08 -06:00
commit 65b7af866f
433 changed files with 24089 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
<?php
/**
* Admin Authentication Middleware
*/
session_start();
require_once dirname(__DIR__) . '/config.php';
function isLoggedIn(): bool {
return isset($_SESSION['blog_admin_id']) && !empty($_SESSION['blog_admin_id']);
}
function requireLogin(): void {
if (!isLoggedIn()) {
header('Location: ' . BLOG_URL . '/admin/login.php');
exit;
}
}
function getAdminUser(): ?array {
if (!isLoggedIn()) return null;
$db = getDB();
$stmt = $db->prepare("SELECT id, username FROM blog_admins WHERE id = :id LIMIT 1");
$stmt->execute([':id' => $_SESSION['blog_admin_id']]);
return $stmt->fetch() ?: null;
}
+188
View File
@@ -0,0 +1,188 @@
<?php
require_once __DIR__ . '/auth.php';
requireLogin();
$db = getDB();
$error = '';
$success = '';
// Handle delete
if (isset($_GET['delete'])) {
$delId = intval($_GET['delete']);
// Check if category has posts
$stmt = $db->prepare("SELECT COUNT(*) FROM blog_posts WHERE category_id = :id");
$stmt->execute([':id' => $delId]);
$postCount = (int)$stmt->fetchColumn();
if ($postCount > 0) {
$error = "No se puede eliminar: la categoría tiene $postCount artículo(s) asociado(s).";
} else {
$stmt = $db->prepare("DELETE FROM blog_categories WHERE id = :id");
$stmt->execute([':id' => $delId]);
$success = 'Categoría eliminada.';
}
}
// Handle create/update
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$catId = intval($_POST['cat_id'] ?? 0);
$name = trim($_POST['name'] ?? '');
$slug = trim($_POST['slug'] ?? '');
if ($name === '') {
$error = 'El nombre es obligatorio.';
} else {
if ($slug === '') {
$slug = slugify($name);
}
if ($catId > 0) {
// Update
$stmt = $db->prepare("UPDATE blog_categories SET name = :name, slug = :slug WHERE id = :id");
$stmt->execute([':name' => $name, ':slug' => $slug, ':id' => $catId]);
$success = 'Categoría actualizada.';
} else {
// Insert
$stmt = $db->prepare("INSERT INTO blog_categories (name, slug) VALUES (:name, :slug)");
$stmt->execute([':name' => $name, ':slug' => $slug]);
$success = 'Categoría creada.';
}
}
}
// Load categories
$categories = $db->query("SELECT c.*, COUNT(p.id) AS post_count
FROM blog_categories c
LEFT JOIN blog_posts p ON c.id = p.category_id
GROUP BY c.id
ORDER BY c.name")->fetchAll();
// Edit mode
$editing = null;
if (isset($_GET['edit'])) {
$editId = intval($_GET['edit']);
foreach ($categories as $cat) {
if (intval($cat['id']) === $editId) { $editing = $cat; break; }
}
}
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@100..900&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<title>Categorías - Blog Admin</title>
<style>
* { font-family: 'Montserrat', sans-serif; }
body { background: #f4f6fa; }
.admin-header {
background: linear-gradient(135deg, #0a1d3b, #0e55bb);
color: white; padding: 1rem 0;
}
.cat-card {
border: none; border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
}
.form-control {
border-radius: 10px; border: 2px solid #e8eef5; padding: 10px 14px;
}
.form-control:focus {
border-color: #0e55bb; box-shadow: 0 0 0 3px rgba(14,85,187,0.15);
}
.btn-save {
background: linear-gradient(135deg, #0e55bb, #1186cc);
border: none; color: white; font-weight: 700; border-radius: 10px;
}
.btn-save:hover { color: white; }
</style>
</head>
<body>
<div class="admin-header">
<div class="container d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-3">
<a href="<?= BLOG_URL ?>/admin/" class="text-white text-decoration-none">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8"/>
</svg>
</a>
<strong>Categorías</strong>
</div>
</div>
</div>
<div class="container py-4">
<?php if ($error): ?>
<div class="alert alert-danger" style="border-radius: 10px;"><?= e($error) ?></div>
<?php endif; ?>
<?php if ($success): ?>
<div class="alert alert-success" style="border-radius: 10px;"><?= e($success) ?></div>
<?php endif; ?>
<div class="row g-4">
<!-- Form -->
<div class="col-12 col-md-4">
<div class="card cat-card p-4">
<h6 class="fw-bold mb-3" style="color: #0a1d3b;"><?= $editing ? 'Editar categoría' : 'Nueva categoría' ?></h6>
<form method="POST">
<input type="hidden" name="cat_id" value="<?= $editing ? $editing['id'] : 0 ?>">
<div class="mb-3">
<input type="text" name="name" class="form-control" placeholder="Nombre" required
value="<?= e($editing['name'] ?? '') ?>">
</div>
<div class="mb-3">
<input type="text" name="slug" class="form-control" placeholder="slug (auto)"
value="<?= e($editing['slug'] ?? '') ?>">
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-save px-4"><?= $editing ? 'Actualizar' : 'Crear' ?></button>
<?php if ($editing): ?>
<a href="categories.php" class="btn btn-outline-secondary" style="border-radius: 10px;">Cancelar</a>
<?php endif; ?>
</div>
</form>
</div>
</div>
<!-- List -->
<div class="col-12 col-md-8">
<div class="card cat-card">
<?php if (empty($categories)): ?>
<div class="card-body text-center py-5 text-muted">No hay categorías aún.</div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th class="ps-3" style="font-size: 0.75rem; text-transform: uppercase; color: #0a1d3b;">Nombre</th>
<th style="font-size: 0.75rem; text-transform: uppercase; color: #0a1d3b;">Slug</th>
<th style="font-size: 0.75rem; text-transform: uppercase; color: #0a1d3b;">Posts</th>
<th class="text-end pe-3" style="font-size: 0.75rem; text-transform: uppercase; color: #0a1d3b;">Acciones</th>
</tr>
</thead>
<tbody>
<?php foreach ($categories as $cat): ?>
<tr>
<td class="ps-3 fw-bold"><?= e($cat['name']) ?></td>
<td class="text-muted"><?= e($cat['slug']) ?></td>
<td><span class="badge bg-secondary rounded-pill"><?= $cat['post_count'] ?></span></td>
<td class="text-end pe-3">
<a href="?edit=<?= $cat['id'] ?>" class="btn btn-sm btn-outline-primary" style="border-radius: 6px;">Editar</a>
<a href="?delete=<?= $cat['id'] ?>" class="btn btn-sm btn-outline-danger" style="border-radius: 6px;"
onclick="return confirm('¿Eliminar esta categoría?')">Eliminar</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
<?php
require_once __DIR__ . '/auth.php';
requireLogin();
$db = getDB();
$postId = intval($_GET['id'] ?? 0);
if ($postId > 0) {
// Verify post exists
$stmt = $db->prepare("SELECT id, cover_image FROM blog_posts WHERE id = :id LIMIT 1");
$stmt->execute([':id' => $postId]);
$post = $stmt->fetch();
if ($post) {
// Delete post
$stmt = $db->prepare("DELETE FROM blog_posts WHERE id = :id");
$stmt->execute([':id' => $postId]);
$_SESSION['flash'] = 'Artículo eliminado correctamente.';
}
}
header('Location: ' . BLOG_URL . '/admin/');
exit;
+323
View File
@@ -0,0 +1,323 @@
<?php
require_once __DIR__ . '/auth.php';
requireLogin();
$db = getDB();
$error = '';
$success = '';
// Load post if editing
$postId = intval($_GET['id'] ?? 0);
$post = null;
if ($postId > 0) {
$stmt = $db->prepare("SELECT * FROM blog_posts WHERE id = :id LIMIT 1");
$stmt->execute([':id' => $postId]);
$post = $stmt->fetch();
if (!$post) {
header('Location: ' . BLOG_URL . '/admin/');
exit;
}
}
// Load categories
$categories = $db->query("SELECT * FROM blog_categories ORDER BY name")->fetchAll();
// Handle form submission
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title = trim($_POST['title'] ?? '');
$slug = trim($_POST['slug'] ?? '');
$excerpt = trim($_POST['excerpt'] ?? '');
$content = $_POST['content'] ?? '';
$categoryId = intval($_POST['category_id'] ?? 0);
$author = trim($_POST['author'] ?? 'TRIMAPE');
$status = ($_POST['status'] ?? 'draft') === 'published' ? 'published' : 'draft';
$coverImage = trim($_POST['cover_image'] ?? '');
if ($title === '') {
$error = 'El título es obligatorio.';
} else {
if ($slug === '') {
$slug = slugify($title);
}
// Ensure unique slug
$slugCheck = $db->prepare("SELECT id FROM blog_posts WHERE slug = :slug AND id != :id LIMIT 1");
$slugCheck->execute([':slug' => $slug, ':id' => $postId]);
if ($slugCheck->fetch()) {
$slug .= '-' . time();
}
$publishedAt = $status === 'published' ? ($post['published_at'] ?? date('Y-m-d H:i:s')) : null;
if ($post) {
// Update
$stmt = $db->prepare("UPDATE blog_posts SET
title = :title, slug = :slug, excerpt = :excerpt, content = :content,
category_id = :cat, author = :author, status = :status,
cover_image = :cover, published_at = :pub, updated_at = NOW()
WHERE id = :id");
$stmt->execute([
':title' => $title, ':slug' => $slug, ':excerpt' => $excerpt,
':content' => $content, ':cat' => $categoryId ?: null,
':author' => $author, ':status' => $status, ':cover' => $coverImage,
':pub' => $publishedAt, ':id' => $postId
]);
$_SESSION['flash'] = 'Artículo actualizado correctamente.';
} else {
// Insert
$stmt = $db->prepare("INSERT INTO blog_posts
(title, slug, excerpt, content, category_id, author, status, cover_image, published_at)
VALUES (:title, :slug, :excerpt, :content, :cat, :author, :status, :cover, :pub)");
$stmt->execute([
':title' => $title, ':slug' => $slug, ':excerpt' => $excerpt,
':content' => $content, ':cat' => $categoryId ?: null,
':author' => $author, ':status' => $status, ':cover' => $coverImage,
':pub' => $publishedAt
]);
$_SESSION['flash'] = 'Artículo creado correctamente.';
}
header('Location: ' . BLOG_URL . '/admin/');
exit;
}
}
// Prefill form
$formTitle = $post['title'] ?? ($_POST['title'] ?? '');
$formSlug = $post['slug'] ?? ($_POST['slug'] ?? '');
$formExcerpt = $post['excerpt'] ?? ($_POST['excerpt'] ?? '');
$formContent = $post['content'] ?? ($_POST['content'] ?? '');
$formCatId = $post['category_id'] ?? ($_POST['category_id'] ?? 0);
$formAuthor = $post['author'] ?? ($_POST['author'] ?? 'TRIMAPE');
$formStatus = $post['status'] ?? ($_POST['status'] ?? 'draft');
$formCover = $post['cover_image'] ?? ($_POST['cover_image'] ?? '');
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@100..900&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<title><?= $post ? 'Editar' : 'Nuevo' ?> artículo - Blog Admin</title>
<style>
* { font-family: 'Montserrat', sans-serif; }
body { background: #f4f6fa; }
.admin-header {
background: linear-gradient(135deg, #0a1d3b, #0e55bb);
color: white; padding: 1rem 0;
}
.editor-card {
border: none; border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
}
.form-control, .form-select {
border-radius: 10px; border: 2px solid #e8eef5;
padding: 10px 14px;
}
.form-control:focus, .form-select:focus {
border-color: #0e55bb;
box-shadow: 0 0 0 3px rgba(14,85,187,0.15);
}
.form-label { font-weight: 600; font-size: 0.85rem; color: #0a1d3b; }
.btn-save {
background: linear-gradient(135deg, #0e55bb, #1186cc);
border: none; color: white; font-weight: 700;
border-radius: 10px; padding: 10px 30px;
}
.btn-save:hover { color: white; box-shadow: 0 6px 16px rgba(14,85,187,0.3); }
.btn-draft {
background: #f0f4f8; color: #0a1d3b; font-weight: 600;
border: none; border-radius: 10px; padding: 10px 24px;
}
.cover-preview {
width: 100%; max-height: 200px; object-fit: cover;
border-radius: 10px; margin-top: 10px;
}
.tox-tinymce { border-radius: 10px !important; border: 2px solid #e8eef5 !important; }
</style>
</head>
<body>
<!-- Header -->
<div class="admin-header">
<div class="container d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-3">
<a href="<?= BLOG_URL ?>/admin/" class="text-white text-decoration-none">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8"/>
</svg>
</a>
<strong><?= $post ? 'Editar artículo' : 'Nuevo artículo' ?></strong>
</div>
<a href="<?= BLOG_URL ?>/admin/" class="btn btn-sm btn-outline-light">Dashboard</a>
</div>
</div>
<div class="container py-4">
<?php if ($error): ?>
<div class="alert alert-danger" style="border-radius: 10px;"><?= e($error) ?></div>
<?php endif; ?>
<form method="POST" id="postForm">
<div class="row g-4">
<!-- Main content -->
<div class="col-12 col-lg-8">
<div class="card editor-card p-4 mb-4">
<div class="mb-3">
<label class="form-label">Título</label>
<input type="text" name="title" class="form-control" value="<?= e($formTitle) ?>" required
placeholder="Escribe el título del artículo..." id="titleInput">
</div>
<div class="mb-3">
<label class="form-label">Slug (URL)</label>
<div class="input-group">
<span class="input-group-text" style="border-radius: 10px 0 0 10px; border: 2px solid #e8eef5; border-right: none; font-size: 0.8rem; color: #888;">/blog/</span>
<input type="text" name="slug" class="form-control" value="<?= e($formSlug) ?>"
placeholder="se-genera-automaticamente" id="slugInput" style="border-radius: 0 10px 10px 0;">
</div>
</div>
<div class="mb-3">
<label class="form-label">Extracto <small class="text-muted fw-normal">(opcional, se muestra en el listado)</small></label>
<textarea name="excerpt" class="form-control" rows="2" placeholder="Breve descripción del artículo..."><?= e($formExcerpt) ?></textarea>
</div>
<div class="mb-0">
<label class="form-label">Contenido</label>
<textarea name="content" id="editor"><?= e($formContent) ?></textarea>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="col-12 col-lg-4">
<!-- Publish -->
<div class="card editor-card p-4 mb-3">
<h6 class="fw-bold mb-3" style="color: #0a1d3b;">Publicación</h6>
<div class="mb-3">
<label class="form-label">Estado</label>
<select name="status" class="form-select">
<option value="draft" <?= $formStatus === 'draft' ? 'selected' : '' ?>>Borrador</option>
<option value="published" <?= $formStatus === 'published' ? 'selected' : '' ?>>Publicado</option>
</select>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-save"><?= $post ? 'Actualizar' : 'Guardar' ?></button>
</div>
</div>
<!-- Category -->
<div class="card editor-card p-4 mb-3">
<h6 class="fw-bold mb-3" style="color: #0a1d3b;">Categoría</h6>
<select name="category_id" class="form-select">
<option value="0">Sin categoría</option>
<?php foreach ($categories as $cat): ?>
<option value="<?= $cat['id'] ?>" <?= intval($formCatId) === intval($cat['id']) ? 'selected' : '' ?>>
<?= e($cat['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<!-- Author -->
<div class="card editor-card p-4 mb-3">
<h6 class="fw-bold mb-3" style="color: #0a1d3b;">Autor</h6>
<input type="text" name="author" class="form-control" value="<?= e($formAuthor) ?>">
</div>
<!-- Cover Image -->
<div class="card editor-card p-4 mb-3">
<h6 class="fw-bold mb-3" style="color: #0a1d3b;">Imagen de portada</h6>
<input type="text" name="cover_image" class="form-control mb-2" id="coverInput"
value="<?= e($formCover) ?>" placeholder="URL de la imagen...">
<input type="file" id="coverFile" class="form-control" accept="image/*">
<small class="text-muted mt-1 d-block">Sube una imagen o pega una URL</small>
<?php if ($formCover): ?>
<img src="<?= e($formCover) ?>" class="cover-preview" id="coverPreview" alt="Preview">
<?php else: ?>
<img src="" class="cover-preview d-none" id="coverPreview" alt="Preview">
<?php endif; ?>
</div>
</div>
</div>
</form>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.tiny.cloud/1/no-api-key/tinymce/6/tinymce.min.js" referrerpolicy="origin"></script>
<script>
// TinyMCE
tinymce.init({
selector: '#editor',
height: 500,
menubar: 'file edit view insert format tools table',
plugins: 'advlist autolink lists link image charmap preview anchor searchreplace visualblocks code fullscreen insertdatetime media table help wordcount',
toolbar: 'undo redo | blocks | bold italic forecolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link image | removeformat | code',
content_style: "body { font-family: 'Montserrat', sans-serif; font-size: 15px; line-height: 1.7; color: #333; }",
branding: false,
promotion: false,
language: 'es',
images_upload_url: 'upload.php',
automatic_uploads: true,
file_picker_types: 'image',
images_upload_handler: function(blobInfo) {
return new Promise(function(resolve, reject) {
var formData = new FormData();
formData.append('file', blobInfo.blob(), blobInfo.filename());
fetch('upload.php', { method: 'POST', body: formData })
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.location) resolve(data.location);
else reject('Upload failed: ' + (data.error || 'Unknown error'));
})
.catch(function() { reject('Upload error'); });
});
}
});
// Auto-generate slug from title
var titleInput = document.getElementById('titleInput');
var slugInput = document.getElementById('slugInput');
var slugEdited = <?= $post ? 'true' : 'false' ?>;
slugInput.addEventListener('input', function() { slugEdited = true; });
titleInput.addEventListener('input', function() {
if (!slugEdited) {
var text = this.value.toLowerCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9\s-]/g, '')
.trim().replace(/[\s]+/g, '-');
slugInput.value = text;
}
});
// Cover image upload
document.getElementById('coverFile').addEventListener('change', function() {
var file = this.files[0];
if (!file) return;
var formData = new FormData();
formData.append('file', file);
fetch('upload.php', { method: 'POST', body: formData })
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.location) {
document.getElementById('coverInput').value = data.location;
var preview = document.getElementById('coverPreview');
preview.src = data.location;
preview.classList.remove('d-none');
} else {
alert('Error: ' + (data.error || 'No se pudo subir'));
}
});
});
// Preview cover from URL input
document.getElementById('coverInput').addEventListener('change', function() {
var preview = document.getElementById('coverPreview');
if (this.value) {
preview.src = this.value;
preview.classList.remove('d-none');
} else {
preview.classList.add('d-none');
}
});
</script>
</body>
</html>
+190
View File
@@ -0,0 +1,190 @@
<?php
require_once __DIR__ . '/auth.php';
requireLogin();
// Handle logout
if (isset($_GET['logout'])) {
session_destroy();
header('Location: ' . BLOG_URL . '/admin/login.php');
exit;
}
$db = getDB();
$admin = getAdminUser();
// Get all posts
$posts = $db->query("SELECT p.*, c.name AS category_name
FROM blog_posts p
LEFT JOIN blog_categories c ON p.category_id = c.id
ORDER BY p.created_at DESC")->fetchAll();
$totalPublished = 0;
$totalDrafts = 0;
foreach ($posts as $p) {
if ($p['status'] === 'published') $totalPublished++;
else $totalDrafts++;
}
$categories = $db->query("SELECT COUNT(*) FROM blog_categories")->fetchColumn();
// Flash message
$flash = $_SESSION['flash'] ?? '';
unset($_SESSION['flash']);
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@100..900&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<title>Dashboard - Blog Admin</title>
<style>
* { font-family: 'Montserrat', sans-serif; }
body { background: #f4f6fa; }
.admin-header {
background: linear-gradient(135deg, #0a1d3b, #0e55bb);
color: white;
padding: 1rem 0;
}
.stat-card {
border: none; border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
transition: transform 0.2s;
}
.stat-card:hover { transform: translateY(-3px); }
.stat-card .stat-number {
font-size: 2rem; font-weight: 800; color: #0a1d3b;
}
.stat-card .stat-label {
font-size: 0.8rem; color: #888; text-transform: uppercase; letter-spacing: 1px;
}
.table-posts { border-radius: 12px; overflow: hidden; }
.table-posts th {
background: #f8fafd; color: #0a1d3b;
font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.5px;
border: none;
}
.table-posts td { vertical-align: middle; font-size: 0.9rem; }
.badge-pub { background: #d4edda; color: #155724; }
.badge-draft { background: #fff3cd; color: #856404; }
.btn-new {
background: linear-gradient(135deg, #0e55bb, #1186cc);
border: none; color: white; font-weight: 700;
border-radius: 10px; padding: 10px 24px;
}
.btn-new:hover { color: white; box-shadow: 0 6px 16px rgba(14,85,187,0.3); }
</style>
</head>
<body>
<!-- Header -->
<div class="admin-header">
<div class="container d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-3">
<img src="<?= SITE_URL ?>/assets/img/trimape-logo-512-150x150.png" alt="TRIMAPE" style="height: 36px; border-radius: 6px;">
<div>
<strong>Blog Admin</strong>
<small class="d-block" style="opacity: 0.7; font-size: 0.75rem;">Hola, <?= e($admin['username'] ?? 'Admin') ?></small>
</div>
</div>
<div class="d-flex gap-2">
<a href="<?= BLOG_URL ?>" class="btn btn-sm btn-outline-light" target="_blank">Ver blog</a>
<a href="?logout=1" class="btn btn-sm btn-outline-light">Cerrar sesión</a>
</div>
</div>
</div>
<div class="container py-4">
<?php if ($flash): ?>
<div class="alert alert-success alert-dismissible fade show" style="border-radius: 10px;">
<?= e($flash) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<?php endif; ?>
<!-- Stats -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card stat-card p-3 text-center">
<div class="stat-number"><?= $totalPublished ?></div>
<div class="stat-label">Publicados</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card stat-card p-3 text-center">
<div class="stat-number"><?= $totalDrafts ?></div>
<div class="stat-label">Borradores</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card stat-card p-3 text-center">
<div class="stat-number"><?= $categories ?></div>
<div class="stat-label">Categorías</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card stat-card p-3 text-center">
<div class="stat-number"><?= count($posts) ?></div>
<div class="stat-label">Total Posts</div>
</div>
</div>
</div>
<!-- Actions bar -->
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="fw-bold mb-0" style="color: #0a1d3b;">Artículos</h5>
<div class="d-flex gap-2">
<a href="categories.php" class="btn btn-sm btn-outline-primary" style="border-radius: 8px;">Categorías</a>
<a href="editor.php" class="btn btn-sm btn-new">+ Nuevo artículo</a>
</div>
</div>
<!-- Posts table -->
<div class="card border-0 shadow-sm" style="border-radius: 12px;">
<?php if (empty($posts)): ?>
<div class="card-body text-center py-5 text-muted">
<p>No hay artículos aún.</p>
<a href="editor.php" class="btn btn-new">Crear el primero</a>
</div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-posts table-hover mb-0">
<thead>
<tr>
<th class="ps-3">Título</th>
<th>Categoría</th>
<th>Estado</th>
<th>Fecha</th>
<th class="text-end pe-3">Acciones</th>
</tr>
</thead>
<tbody>
<?php foreach ($posts as $p): ?>
<tr>
<td class="ps-3 fw-bold"><?= e($p['title']) ?></td>
<td><span class="text-muted"><?= e($p['category_name'] ?? '—') ?></span></td>
<td>
<span class="badge rounded-pill <?= $p['status'] === 'published' ? 'badge-pub' : 'badge-draft' ?>">
<?= $p['status'] === 'published' ? 'Publicado' : 'Borrador' ?>
</span>
</td>
<td class="text-muted" style="font-size: 0.8rem;">
<?= $p['published_at'] ? date('d/m/Y', strtotime($p['published_at'])) : '—' ?>
</td>
<td class="text-end pe-3">
<a href="editor.php?id=<?= $p['id'] ?>" class="btn btn-sm btn-outline-primary" style="border-radius: 6px;">Editar</a>
<a href="delete.php?id=<?= $p['id'] ?>" class="btn btn-sm btn-outline-danger" style="border-radius: 6px;"
onclick="return confirm('¿Eliminar este artículo?')">Eliminar</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
+113
View File
@@ -0,0 +1,113 @@
<?php
require_once __DIR__ . '/auth.php';
// If already logged in, redirect to dashboard
if (isLoggedIn()) {
header('Location: ' . BLOG_URL . '/admin/');
exit;
}
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
if ($username === '' || $password === '') {
$error = 'Ingresa usuario y contraseña.';
} else {
$db = getDB();
$stmt = $db->prepare("SELECT id, username, password_hash FROM blog_admins WHERE username = :u LIMIT 1");
$stmt->execute([':u' => $username]);
$admin = $stmt->fetch();
if ($admin && password_verify($password, $admin['password_hash'])) {
session_regenerate_id(true);
$_SESSION['blog_admin_id'] = $admin['id'];
header('Location: ' . BLOG_URL . '/admin/');
exit;
} else {
$error = 'Usuario o contraseña incorrectos.';
}
}
}
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@100..900&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<title>Admin Login - Blog TRIMAPE</title>
<style>
* { font-family: 'Montserrat', sans-serif; }
body {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0a1d3b 0%, #0e55bb 50%, #1186cc 100%);
}
.login-card {
background: white;
border-radius: 16px;
padding: 3rem;
width: 100%;
max-width: 420px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.login-card .logo { height: 50px; margin-bottom: 1.5rem; }
.login-card h4 { color: #0a1d3b; font-weight: 700; }
.form-control {
border-radius: 10px;
padding: 12px 16px;
border: 2px solid #e8eef5;
transition: border-color 0.2s;
}
.form-control:focus {
border-color: #0e55bb;
box-shadow: 0 0 0 3px rgba(14,85,187,0.15);
}
.btn-login {
background: linear-gradient(135deg, #0e55bb, #1186cc);
border: none;
color: white;
font-weight: 700;
padding: 12px;
border-radius: 10px;
width: 100%;
transition: all 0.2s;
}
.btn-login:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(14,85,187,0.35);
color: white;
}
</style>
</head>
<body>
<div class="login-card text-center">
<img src="<?= SITE_URL ?>/assets/img/trimape-logo-512-300x300.png" alt="TRIMAPE" class="logo">
<h4 class="mb-1">Blog Admin</h4>
<p class="text-muted mb-4" style="font-size: 0.85rem;">Inicia sesión para administrar el blog</p>
<?php if ($error): ?>
<div class="alert alert-danger py-2" style="font-size: 0.85rem; border-radius: 10px;"><?= e($error) ?></div>
<?php endif; ?>
<form method="POST" autocomplete="off">
<div class="mb-3">
<input type="text" name="username" class="form-control" placeholder="Usuario" required autofocus
value="<?= e($username ?? '') ?>">
</div>
<div class="mb-4">
<input type="password" name="password" class="form-control" placeholder="Contraseña" required>
</div>
<button type="submit" class="btn btn-login">Iniciar sesión</button>
</form>
<a href="<?= BLOG_URL ?>" class="d-block mt-3 text-muted" style="font-size: 0.8rem;">← Volver al blog</a>
</div>
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
<?php
require_once __DIR__ . '/auth.php';
requireLogin();
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['error' => 'Method not allowed']);
exit;
}
if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
echo json_encode(['error' => 'No se recibió ningún archivo.']);
exit;
}
$file = $_FILES['file'];
// Validate size
if ($file['size'] > MAX_UPLOAD_SIZE) {
echo json_encode(['error' => 'El archivo excede el tamaño máximo de 5MB.']);
exit;
}
// Validate type
$allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($file['tmp_name']);
if (!in_array($mimeType, $allowedTypes, true)) {
echo json_encode(['error' => 'Tipo de archivo no permitido. Solo se aceptan imágenes (JPG, PNG, GIF, WebP, SVG).']);
exit;
}
// Validate extension
$allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
$extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($extension, $allowedExtensions, true)) {
echo json_encode(['error' => 'Extensión de archivo no permitida.']);
exit;
}
// Generate unique filename
$filename = date('Y-m') . '-' . bin2hex(random_bytes(8)) . '.' . $extension;
// Create monthly subdirectory
$monthDir = UPLOAD_DIR . date('Y') . '/' . date('m') . '/';
if (!is_dir($monthDir)) {
mkdir($monthDir, 0755, true);
}
$destination = $monthDir . $filename;
if (move_uploaded_file($file['tmp_name'], $destination)) {
$url = UPLOAD_URL . date('Y') . '/' . date('m') . '/' . $filename;
echo json_encode(['location' => $url]);
} else {
echo json_encode(['error' => 'No se pudo guardar el archivo.']);
}