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
+22
View File
@@ -0,0 +1,22 @@
RewriteEngine On
RewriteBase /blog/
# Don't rewrite existing files or directories
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
# /blog/admin/* - let admin handle its own routing
RewriteRule ^admin/ - [L]
# /blog/categoria/slug → index.php?cat=slug
RewriteRule ^categoria/([a-z0-9-]+)/?$ index.php?cat=$1 [L,QSA]
# /blog/pagina/2 → index.php?page=2
RewriteRule ^pagina/([0-9]+)/?$ index.php?page=$1 [L,QSA]
# /blog/categoria/slug/pagina/2 → index.php?cat=slug&page=2
RewriteRule ^categoria/([a-z0-9-]+)/pagina/([0-9]+)/?$ index.php?cat=$1&page=$2 [L,QSA]
# /blog/slug-del-post → post.php?slug=slug-del-post
RewriteRule ^([a-z0-9-]+)/?$ post.php?slug=$1 [L,QSA]
+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.']);
}
+73
View File
@@ -0,0 +1,73 @@
<?php
/**
* Blog Configuration - TRIMAPE
* Conexión PDO a la base de datos trimape_blog (separada de producción)
*/
// --- Database ---
define('DB_HOST', 'localhost');
define('DB_NAME', 'trimape_blog');
define('DB_USER', 'trimape_blog_usr');
define('DB_PASS', 'Tr1m4p3Bl0g2026!xK');
define('DB_CHARSET', 'utf8mb4');
// --- Blog Settings ---
define('BLOG_TITLE', 'Blog - TRIMAPE');
define('BLOG_DESCRIPTION', 'Artículos sobre mantenimiento industrial, eficiencia energética y más.');
define('BLOG_URL', 'https://trimape.mx/blog');
define('SITE_URL', 'https://trimape.mx');
define('POSTS_PER_PAGE', 6);
define('UPLOAD_DIR', __DIR__ . '/uploads/');
define('UPLOAD_URL', BLOG_URL . '/uploads/');
define('MAX_UPLOAD_SIZE', 5 * 1024 * 1024); // 5MB
// --- PDO Connection ---
function getDB(): PDO {
static $pdo = null;
if ($pdo === null) {
$dsn = 'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=' . DB_CHARSET;
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
$pdo = new PDO($dsn, DB_USER, DB_PASS, $options);
}
return $pdo;
}
// --- Helper Functions ---
function e(string $str): string {
return htmlspecialchars($str, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
function slugify(string $text): string {
$text = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $text);
$text = preg_replace('/[^a-zA-Z0-9\s-]/', '', $text);
$text = strtolower(trim($text));
$text = preg_replace('/[\s-]+/', '-', $text);
return $text;
}
function truncate(string $text, int $length = 150): string {
$text = strip_tags($text);
if (mb_strlen($text) <= $length) return $text;
return mb_substr($text, 0, $length) . '...';
}
function timeAgo(string $datetime): string {
$now = new DateTime();
$ago = new DateTime($datetime);
$diff = $now->diff($ago);
if ($diff->y > 0) return $diff->y . ' año' . ($diff->y > 1 ? 's' : '');
if ($diff->m > 0) return $diff->m . ' mes' . ($diff->m > 1 ? 'es' : '');
if ($diff->d > 0) return $diff->d . ' día' . ($diff->d > 1 ? 's' : '');
if ($diff->h > 0) return $diff->h . ' hora' . ($diff->h > 1 ? 's' : '');
return 'hace un momento';
}
// Create uploads directory if not exists
if (!is_dir(UPLOAD_DIR)) {
mkdir(UPLOAD_DIR, 0755, true);
}
+359
View File
@@ -0,0 +1,359 @@
<?php
require_once __DIR__ . '/config.php';
$db = getDB();
// Get category filter
$catSlug = isset($_GET['cat']) ? trim($_GET['cat']) : '';
$page = max(1, intval($_GET['page'] ?? 1));
$offset = ($page - 1) * POSTS_PER_PAGE;
// Build query
$where = "WHERE p.status = 'published'";
$params = [];
if ($catSlug !== '') {
$where .= " AND c.slug = :cat";
$params[':cat'] = $catSlug;
}
// Count total
$countSql = "SELECT COUNT(*) FROM blog_posts p LEFT JOIN blog_categories c ON p.category_id = c.id $where";
$stmt = $db->prepare($countSql);
$stmt->execute($params);
$totalPosts = (int)$stmt->fetchColumn();
$totalPages = max(1, ceil($totalPosts / POSTS_PER_PAGE));
// Fetch posts
$sql = "SELECT p.*, c.name AS category_name, c.slug AS category_slug
FROM blog_posts p
LEFT JOIN blog_categories c ON p.category_id = c.id
$where
ORDER BY p.published_at DESC
LIMIT :limit OFFSET :offset";
$stmt = $db->prepare($sql);
foreach ($params as $k => $v) {
$stmt->bindValue($k, $v, PDO::PARAM_STR);
}
$stmt->bindValue(':limit', POSTS_PER_PAGE, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
$posts = $stmt->fetchAll();
// Fetch categories for sidebar
$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 AND p.status = 'published'
GROUP BY c.id
ORDER BY c.name")->fetchAll();
// Current category name
$currentCatName = '';
if ($catSlug !== '') {
foreach ($categories as $cat) {
if ($cat['slug'] === $catSlug) {
$currentCatName = $cat['name'];
break;
}
}
}
$pageTitle = $currentCatName ? e($currentCatName) . ' - Blog TRIMAPE' : 'Blog - TRIMAPE | Excelencia en Mantenimientos';
$pageDesc = $currentCatName
? 'Artículos sobre ' . e($currentCatName) . ' en el blog de TRIMAPE.'
: 'Artículos sobre mantenimiento industrial, eficiencia energética y más. Blog de TRIMAPE.';
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<title><?= $pageTitle ?></title>
<meta name="description" content="<?= e($pageDesc) ?>">
<link rel="icon" href="<?= SITE_URL ?>/assets/img/favico.png">
<link rel="icon" href="<?= SITE_URL ?>/assets/img/trimape-logo-512-150x150.png" sizes="32x32">
<link rel="icon" href="<?= SITE_URL ?>/assets/img/trimape-logo-512-300x300.png" sizes="192x192">
<link rel="apple-touch-icon" href="<?= SITE_URL ?>/assets/img/trimape-logo-512-300x300.png">
<meta property="og:title" content="<?= e($pageTitle) ?>">
<meta property="og:description" content="<?= e($pageDesc) ?>">
<meta property="og:type" content="website">
<meta property="og:url" content="<?= BLOG_URL ?>">
<meta property="og:image" content="<?= SITE_URL ?>/assets/img/trimape-1200.webp">
<?php include dirname(__DIR__) . '/css-columna.htm'; ?>
<style>
.hero { position: relative; width: 100%; height: 50vh; overflow: hidden; }
.hero .imgback { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; z-index: -1; }
.blog-card {
border: none;
border-radius: 12px;
overflow: hidden;
transition: all 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94);
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}
.blog-card:hover {
transform: translateY(-8px);
box-shadow: 0 16px 40px rgba(10, 29, 59, 0.18);
}
.blog-card .card-img-wrap {
overflow: hidden;
height: 220px;
}
.blog-card .card-img-top {
height: 100%;
object-fit: cover;
transition: transform 0.5s ease;
}
.blog-card:hover .card-img-top {
transform: scale(1.08);
}
.blog-card .card-body { padding: 1.5rem; }
.blog-card .badge-cat {
background: linear-gradient(135deg, #0e55bb, #1186cc);
font-size: 0.7rem;
letter-spacing: 0.5px;
text-transform: uppercase;
padding: 5px 12px;
border-radius: 20px;
}
.blog-card .card-title {
font-size: 1.05rem;
font-weight: 700;
color: #0a1d3b;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.blog-card .card-text {
font-size: 0.875rem;
color: #555;
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.blog-card .card-meta {
font-size: 0.75rem;
color: #888;
}
.blog-card .read-more {
color: #1186cc;
font-weight: 600;
font-size: 0.85rem;
text-decoration: none;
transition: color 0.2s;
}
.blog-card .read-more:hover { color: #0e55bb; }
.cat-sidebar .list-group-item {
border: none;
padding: 10px 16px;
font-size: 0.9rem;
border-radius: 8px !important;
margin-bottom: 4px;
transition: all 0.2s;
}
.cat-sidebar .list-group-item:hover,
.cat-sidebar .list-group-item.active {
background: linear-gradient(135deg, #0e55bb, #1186cc);
color: #fff;
}
.cat-sidebar .list-group-item.active .badge {
background: rgba(255,255,255,0.25) !important;
}
.pagination .page-link {
border: none;
color: #0a1d3b;
font-weight: 600;
border-radius: 8px !important;
margin: 0 3px;
}
.pagination .page-item.active .page-link {
background: linear-gradient(135deg, #0e55bb, #1186cc);
color: #fff;
}
.empty-state {
text-align: center;
padding: 80px 20px;
}
.empty-state svg {
width: 80px;
height: 80px;
color: #ccc;
margin-bottom: 20px;
}
a.blog-link { text-decoration: none; color: inherit; }
a.blog-link:hover { color: inherit; }
</style>
</head>
<body>
<?php include dirname(__DIR__) . '/menu-general.htm'; ?>
<!-- Hero -->
<div class="hero" id="hero">
<video autoplay loop muted playsinline class="w-100 imgback">
<source src="<?= SITE_URL ?>/assets/videos/obra-civil-1.webm" type="video/webm">
</video>
<div class="container position-relative d-flex flex-column justify-content-center align-items-center h-100">
<div class="text-center text-white">
<h1 class="mb-1 pt-5 texto-sombra fw-bold z-2">BLOG</h1>
<p class="texto-sombra fw-bold z-2">TRIMAPE | EXCELENCIA EN MANTENIMIENTOS</p>
</div>
</div>
</div>
<!-- Breadcrumbs -->
<div class="container-fluid text-uppercase">
<div class="container py-4">
<nav style="--bs-breadcrumb-divider: url(&#34;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath d='M2.5 0L1 1.5 3.5 4 1 6.5 2.5 8l4-4-4-4z' fill='currentColor'/%3E%3C/svg%3E&#34;);" aria-label="breadcrumb">
<ol class="breadcrumb texto-bread">
<li class="breadcrumb-item"><a href="<?= SITE_URL ?>">Portada</a></li>
<?php if ($currentCatName): ?>
<li class="breadcrumb-item"><a href="<?= BLOG_URL ?>">Blog</a></li>
<li class="breadcrumb-item active" aria-current="page"><?= e($currentCatName) ?></li>
<?php else: ?>
<li class="breadcrumb-item active" aria-current="page">Blog</li>
<?php endif; ?>
</ol>
</nav>
</div>
</div>
<!-- Blog Content -->
<div class="container pb-5">
<div class="row">
<!-- Posts -->
<div class="col-12 col-lg-8">
<?php if ($currentCatName): ?>
<h2 class="fw-bold mb-4" style="color: #0a1d3b;"><?= e($currentCatName) ?></h2>
<?php endif; ?>
<?php if (empty($posts)): ?>
<div class="empty-state">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 01-2.25 2.25M16.5 7.5V18a2.25 2.25 0 002.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 002.25 2.25h13.5M6 7.5h3v3H6v-3z" />
</svg>
<h4 class="fw-bold" style="color: #0a1d3b;">Próximamente</h4>
<p class="text-muted">Estamos preparando contenido increíble para ti. ¡Vuelve pronto!</p>
</div>
<?php else: ?>
<div class="row g-4">
<?php foreach ($posts as $post): ?>
<div class="col-12 col-md-6">
<a href="<?= BLOG_URL ?>/<?= e($post['slug']) ?>" class="blog-link">
<div class="card blog-card h-100">
<div class="card-img-wrap">
<?php if ($post['cover_image']): ?>
<img src="<?= e($post['cover_image']) ?>" class="card-img-top" alt="<?= e($post['title']) ?>">
<?php else: ?>
<div class="card-img-top d-flex align-items-center justify-content-center" style="background: linear-gradient(135deg, #0a1d3b, #1186cc); height: 100%;">
<img src="<?= SITE_URL ?>/assets/img/trimape-logo-512-300x300.png" alt="TRIMAPE" style="width: 80px; opacity: 0.5;">
</div>
<?php endif; ?>
</div>
<div class="card-body d-flex flex-column">
<?php if ($post['category_name']): ?>
<span class="badge badge-cat align-self-start mb-2"><?= e($post['category_name']) ?></span>
<?php endif; ?>
<h5 class="card-title"><?= e($post['title']) ?></h5>
<p class="card-text flex-grow-1"><?= e($post['excerpt'] ?: truncate($post['content'])) ?></p>
<div class="d-flex justify-content-between align-items-center mt-3">
<span class="card-meta">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-calendar3 me-1" viewBox="0 0 16 16">
<path d="M14 0H2a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2M1 3.857C1 3.384 1.448 3 2 3h12c.552 0 1 .384 1 .857v10.286c0 .473-.448.857-1 .857H2c-.552 0-1-.384-1-.857z"/>
<path d="M6.5 7a1 1 0 1 0 0-2 1 1 0 0 0 0 2m3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2m3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2m-9 3a1 1 0 1 0 0-2 1 1 0 0 0 0 2m3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2m3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2m3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2m-9 3a1 1 0 1 0 0-2 1 1 0 0 0 0 2m3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2m3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2"/>
</svg>
<?= date('d M Y', strtotime($post['published_at'])) ?>
</span>
<span class="read-more">Leer más →</span>
</div>
</div>
</div>
</a>
</div>
<?php endforeach; ?>
</div>
<!-- Pagination -->
<?php if ($totalPages > 1): ?>
<nav class="mt-5 d-flex justify-content-center">
<ul class="pagination">
<?php
$baseUrl = $catSlug ? BLOG_URL . "/categoria/$catSlug" : BLOG_URL;
?>
<?php if ($page > 1): ?>
<li class="page-item">
<a class="page-link" href="<?= $baseUrl ?>/pagina/<?= $page - 1 ?>">← Anterior</a>
</li>
<?php endif; ?>
<?php for ($i = 1; $i <= $totalPages; $i++): ?>
<li class="page-item <?= $i === $page ? 'active' : '' ?>">
<a class="page-link" href="<?= $i === 1 ? $baseUrl : $baseUrl . '/pagina/' . $i ?>"><?= $i ?></a>
</li>
<?php endfor; ?>
<?php if ($page < $totalPages): ?>
<li class="page-item">
<a class="page-link" href="<?= $baseUrl ?>/pagina/<?= $page + 1 ?>">Siguiente →</a>
</li>
<?php endif; ?>
</ul>
</nav>
<?php endif; ?>
<?php endif; ?>
</div>
<!-- Sidebar -->
<div class="col-12 col-lg-4 mt-4 mt-lg-0">
<div class="sticky-top" style="top: 100px;">
<!-- Categories -->
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
<div class="card-body cat-sidebar">
<h6 class="fw-bold text-uppercase mb-3" style="color: #0a1d3b; letter-spacing: 1px; font-size: 0.8rem;">Categorías</h6>
<div class="list-group list-group-flush">
<a href="<?= BLOG_URL ?>" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center <?= $catSlug === '' ? 'active' : '' ?>">
Todas
<span class="badge bg-secondary rounded-pill"><?= $totalPosts ?></span>
</a>
<?php foreach ($categories as $cat): ?>
<a href="<?= BLOG_URL ?>/categoria/<?= e($cat['slug']) ?>"
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center <?= $catSlug === $cat['slug'] ? 'active' : '' ?>">
<?= e($cat['name']) ?>
<span class="badge bg-secondary rounded-pill"><?= $cat['post_count'] ?></span>
</a>
<?php endforeach; ?>
</div>
</div>
</div>
<!-- CTA -->
<div class="card border-0 text-white text-center p-4" style="border-radius: 12px; background: linear-gradient(135deg, #0a1d3b 0%, #0e55bb 50%, #1186cc 100%);">
<div class="card-body">
<h5 class="fw-bold mb-3">¿Necesitas mantenimiento industrial?</h5>
<p class="mb-3" style="font-size: 0.9rem; opacity: 0.9;">Contáctanos y recibe una cotización sin compromiso.</p>
<a href="<?= SITE_URL ?>/contacto" class="btn px-4 py-2 fw-bold" style="background-color: rgb(255, 102, 0); color: white; border-radius: 8px;">CONTACTO</a>
</div>
</div>
</div>
</div>
</div>
</div>
<?php include dirname(__DIR__) . '/contacto-div.htm'; ?>
<?php include dirname(__DIR__) . '/footer.htm'; ?>
<?php include dirname(__DIR__) . '/footer-scripts.htm'; ?>
</body>
</html>
+343
View File
@@ -0,0 +1,343 @@
<?php
require_once __DIR__ . '/config.php';
$slug = isset($_GET['slug']) ? trim($_GET['slug']) : '';
if ($slug === '') {
header('Location: ' . BLOG_URL);
exit;
}
$db = getDB();
// Fetch post
$stmt = $db->prepare("SELECT p.*, c.name AS category_name, c.slug AS category_slug
FROM blog_posts p
LEFT JOIN blog_categories c ON p.category_id = c.id
WHERE p.slug = :slug AND p.status = 'published'
LIMIT 1");
$stmt->execute([':slug' => $slug]);
$post = $stmt->fetch();
if (!$post) {
http_response_code(404);
$pageTitle = 'Artículo no encontrado - Blog TRIMAPE';
$notFound = true;
} else {
$notFound = false;
$pageTitle = e($post['title']) . ' - Blog TRIMAPE';
$pageDesc = $post['excerpt'] ? e($post['excerpt']) : e(truncate($post['content'], 160));
$coverImage = $post['cover_image'] ?: SITE_URL . '/assets/img/trimape-1200.webp';
// Related posts (same category, excluding current)
$relatedStmt = $db->prepare("SELECT p.*, c.name AS category_name, c.slug AS category_slug
FROM blog_posts p
LEFT JOIN blog_categories c ON p.category_id = c.id
WHERE p.status = 'published' AND p.id != :id AND p.category_id = :catid
ORDER BY p.published_at DESC LIMIT 3");
$relatedStmt->execute([':id' => $post['id'], ':catid' => $post['category_id']]);
$related = $relatedStmt->fetchAll();
// If not enough related from same category, fill with recent
if (count($related) < 3) {
$excludeIds = array_merge([$post['id']], array_column($related, 'id'));
$placeholders = implode(',', array_fill(0, count($excludeIds), '?'));
$fillStmt = $db->prepare("SELECT p.*, c.name AS category_name, c.slug AS category_slug
FROM blog_posts p
LEFT JOIN blog_categories c ON p.category_id = c.id
WHERE p.status = 'published' AND p.id NOT IN ($placeholders)
ORDER BY p.published_at DESC LIMIT " . (3 - count($related)));
$fillStmt->execute($excludeIds);
$related = array_merge($related, $fillStmt->fetchAll());
}
}
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<title><?= $pageTitle ?></title>
<?php if (!$notFound): ?>
<meta name="description" content="<?= $pageDesc ?>">
<meta property="og:title" content="<?= e($post['title']) ?>">
<meta property="og:description" content="<?= $pageDesc ?>">
<meta property="og:type" content="article">
<meta property="og:url" content="<?= BLOG_URL ?>/<?= e($post['slug']) ?>">
<meta property="og:image" content="<?= e($coverImage) ?>">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="<?= e($post['title']) ?>">
<meta name="twitter:description" content="<?= $pageDesc ?>">
<meta name="twitter:image" content="<?= e($coverImage) ?>">
<?php endif; ?>
<link rel="icon" href="<?= SITE_URL ?>/assets/img/favico.png">
<link rel="icon" href="<?= SITE_URL ?>/assets/img/trimape-logo-512-150x150.png" sizes="32x32">
<link rel="icon" href="<?= SITE_URL ?>/assets/img/trimape-logo-512-300x300.png" sizes="192x192">
<link rel="apple-touch-icon" href="<?= SITE_URL ?>/assets/img/trimape-logo-512-300x300.png">
<?php include dirname(__DIR__) . '/css-columna.htm'; ?>
<style>
.post-hero {
position: relative;
width: 100%;
height: 45vh;
min-height: 350px;
overflow: hidden;
}
.post-hero img {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
object-fit: cover;
}
.post-hero .overlay {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
background: linear-gradient(to top, rgba(10,29,59,0.85) 0%, rgba(10,29,59,0.3) 60%, transparent 100%);
z-index: 1;
}
.post-hero .hero-content {
position: relative;
z-index: 2;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding-bottom: 3rem;
}
.post-content {
font-size: 1.05rem;
line-height: 1.85;
color: #333;
}
.post-content h2, .post-content h3, .post-content h4 {
color: #0a1d3b;
font-weight: 700;
margin-top: 2rem;
margin-bottom: 1rem;
}
.post-content p { margin-bottom: 1.25rem; }
.post-content img {
max-width: 100%;
height: auto;
border-radius: 10px;
margin: 1.5rem 0;
}
.post-content blockquote {
border-left: 4px solid #0e55bb;
padding: 1rem 1.5rem;
margin: 1.5rem 0;
background: #f0f7ff;
border-radius: 0 8px 8px 0;
font-style: italic;
color: #444;
}
.post-content ul, .post-content ol {
padding-left: 1.5rem;
margin-bottom: 1.25rem;
}
.post-content li { margin-bottom: 0.5rem; }
.post-meta {
font-size: 0.85rem;
color: rgba(255,255,255,0.8);
}
.post-meta .badge-cat {
background: linear-gradient(135deg, #0e55bb, #1186cc);
font-size: 0.7rem;
letter-spacing: 0.5px;
text-transform: uppercase;
padding: 5px 14px;
border-radius: 20px;
}
.author-box {
display: flex;
align-items: center;
gap: 12px;
padding: 1.25rem;
background: #f8fafd;
border-radius: 12px;
border: 1px solid #e8eef5;
}
.author-avatar {
width: 48px; height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, #0e55bb, #1186cc);
display: flex; align-items: center; justify-content: center;
color: white; font-weight: 700; font-size: 1.2rem;
}
.share-buttons a {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px; height: 40px;
border-radius: 10px;
background: #f0f4f8;
color: #555;
transition: all 0.2s;
text-decoration: none;
}
.share-buttons a:hover {
background: #0e55bb;
color: white;
transform: translateY(-2px);
}
.related-card {
border: none;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
transition: all 0.3s;
}
.related-card:hover {
transform: translateY(-5px);
box-shadow: 0 12px 30px rgba(10,29,59,0.15);
}
.related-card img { height: 180px; object-fit: cover; }
.related-card .card-title {
font-size: 0.95rem; font-weight: 700; color: #0a1d3b;
display: -webkit-box; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; overflow: hidden;
}
a.related-link { text-decoration: none; color: inherit; }
</style>
</head>
<body>
<?php include dirname(__DIR__) . '/menu-general.htm'; ?>
<?php if ($notFound): ?>
<!-- 404 -->
<div class="container py-5 text-center" style="min-height: 60vh; display: flex; flex-direction: column; justify-content: center;">
<h1 class="display-1 fw-bold" style="color: #0a1d3b;">404</h1>
<p class="lead">El artículo que buscas no existe o fue eliminado.</p>
<a href="<?= BLOG_URL ?>" class="btn px-4 py-2 mt-3" style="background: linear-gradient(135deg, #0e55bb, #1186cc); color: white; border-radius: 8px;">Volver al blog</a>
</div>
<?php else: ?>
<!-- Post Hero -->
<div class="post-hero">
<?php if ($post['cover_image']): ?>
<img src="<?= e($post['cover_image']) ?>" alt="<?= e($post['title']) ?>">
<?php else: ?>
<div style="width:100%;height:100%;background: linear-gradient(135deg, #0a1d3b 0%, #0e55bb 50%, #1186cc 100%);"></div>
<?php endif; ?>
<div class="overlay"></div>
<div class="container hero-content">
<div class="post-meta mb-2">
<?php if ($post['category_name']): ?>
<a href="<?= BLOG_URL ?>/categoria/<?= e($post['category_slug']) ?>" class="badge badge-cat text-decoration-none text-white"><?= e($post['category_name']) ?></a>
<?php endif; ?>
</div>
<h1 class="text-white fw-bold mb-2" style="font-size: 2.2rem; line-height: 1.3; max-width: 800px;"><?= e($post['title']) ?></h1>
<div class="post-meta d-flex align-items-center gap-3 flex-wrap">
<span>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-person me-1" viewBox="0 0 16 16">
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6m2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0m4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4m-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10s-3.516.68-4.168 1.332c-.678.678-.83 1.418-.832 1.664z"/>
</svg>
<?= e($post['author'] ?: 'TRIMAPE') ?>
</span>
<span>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-calendar3 me-1" viewBox="0 0 16 16">
<path d="M14 0H2a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2M1 3.857C1 3.384 1.448 3 2 3h12c.552 0 1 .384 1 .857v10.286c0 .473-.448.857-1 .857H2c-.552 0-1-.384-1-.857z"/>
<path d="M6.5 7a1 1 0 1 0 0-2 1 1 0 0 0 0 2m3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2m3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2m-9 3a1 1 0 1 0 0-2 1 1 0 0 0 0 2m3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2m3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2m3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2m-9 3a1 1 0 1 0 0-2 1 1 0 0 0 0 2m3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2m3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2"/>
</svg>
<?= date('d \d\e F, Y', strtotime($post['published_at'])) ?>
</span>
</div>
</div>
</div>
<!-- Post Content -->
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-12 col-lg-8">
<!-- Share buttons -->
<div class="share-buttons d-flex gap-2 mb-4">
<a href="https://www.facebook.com/sharer/sharer.php?u=<?= urlencode(BLOG_URL . '/' . $post['slug']) ?>" target="_blank" rel="noopener" title="Compartir en Facebook">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8.049c0-4.446-3.582-8.05-8-8.05C3.58 0-.002 3.603-.002 8.05c0 4.017 2.926 7.347 6.75 7.951v-5.625h-2.03V8.05H6.75V6.275c0-2.017 1.195-3.131 3.022-3.131.876 0 1.791.157 1.791.157v1.98h-1.009c-.993 0-1.303.621-1.303 1.258v1.51h2.218l-.354 2.326H9.25V16c3.824-.604 6.75-3.934 6.75-7.951"/>
</svg>
</a>
<a href="https://twitter.com/intent/tweet?url=<?= urlencode(BLOG_URL . '/' . $post['slug']) ?>&text=<?= urlencode($post['title']) ?>" target="_blank" rel="noopener" title="Compartir en X">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M12.6.75h2.454l-5.36 6.142L16 15.25h-4.937l-3.867-5.07-4.425 5.07H.316l5.733-6.57L0 .75h5.063l3.495 4.633L12.601.75Zm-.86 13.028h1.36L4.323 2.145H2.865z"/>
</svg>
</a>
<a href="https://www.linkedin.com/shareArticle?mini=true&url=<?= urlencode(BLOG_URL . '/' . $post['slug']) ?>&title=<?= urlencode($post['title']) ?>" target="_blank" rel="noopener" title="Compartir en LinkedIn">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
<path d="M0 1.146C0 .513.526 0 1.175 0h13.65C15.474 0 16 .513 16 1.146v13.708c0 .633-.526 1.146-1.175 1.146H1.175C.526 16 0 15.487 0 14.854zm4.943 12.248V6.169H2.542v7.225zm-1.2-8.212c.837 0 1.358-.554 1.358-1.248-.015-.709-.52-1.248-1.342-1.248S2.4 3.226 2.4 3.934c0 .694.521 1.248 1.327 1.248zm4.908 8.212V9.359c0-.216.016-.432.08-.586.173-.431.568-.878 1.232-.878.869 0 1.216.662 1.216 1.634v3.865h2.401V9.25c0-2.22-1.184-3.252-2.764-3.252-1.274 0-1.845.7-2.165 1.193v.025h-.016l.016-.025V6.169h-2.4c.03.678 0 7.225 0 7.225z"/>
</svg>
</a>
<a href="https://wa.me/?text=<?= urlencode($post['title'] . ' ' . BLOG_URL . '/' . $post['slug']) ?>" target="_blank" rel="noopener" title="Compartir en WhatsApp">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
<path d="M13.601 2.326A7.85 7.85 0 0 0 7.994 0C3.627 0 .068 3.558.064 7.926c0 1.399.366 2.76 1.057 3.965L0 16l4.204-1.102a7.9 7.9 0 0 0 3.79.965h.004c4.368 0 7.926-3.558 7.93-7.93A7.9 7.9 0 0 0 13.6 2.326zM7.994 14.521a6.6 6.6 0 0 1-3.356-.92l-.24-.144-2.494.654.666-2.433-.156-.251a6.56 6.56 0 0 1-1.007-3.505c0-3.626 2.957-6.584 6.591-6.584a6.56 6.56 0 0 1 4.66 1.931 6.56 6.56 0 0 1 1.928 4.66c-.004 3.639-2.961 6.592-6.592 6.592m3.615-4.934c-.197-.099-1.17-.578-1.353-.646-.182-.065-.315-.099-.445.099-.133.197-.513.646-.627.775-.114.133-.232.148-.43.05-.197-.1-.836-.308-1.592-.985-.59-.525-.985-1.175-1.103-1.372-.114-.198-.011-.304.088-.403.087-.088.197-.232.296-.346.1-.114.133-.198.198-.33.065-.134.034-.248-.015-.347-.05-.099-.445-1.076-.612-1.47-.16-.389-.323-.335-.445-.34-.114-.007-.247-.007-.38-.007a.73.73 0 0 0-.529.247c-.182.198-.691.677-.691 1.654s.71 1.916.81 2.049c.098.133 1.394 2.132 3.383 2.992.47.205.84.326 1.129.418.475.152.904.129 1.246.08.38-.058 1.171-.48 1.338-.943.164-.464.164-.86.114-.943-.049-.084-.182-.133-.38-.232"/>
</svg>
</a>
</div>
<!-- Article body -->
<article class="post-content">
<?= $post['content'] ?>
</article>
<!-- Author -->
<div class="author-box mt-5">
<div class="author-avatar"><?= strtoupper(mb_substr($post['author'] ?: 'T', 0, 1)) ?></div>
<div>
<div class="fw-bold" style="color: #0a1d3b;"><?= e($post['author'] ?: 'TRIMAPE') ?></div>
<div style="font-size: 0.8rem; color: #888;">Publicado el <?= date('d \d\e F, Y', strtotime($post['published_at'])) ?></div>
</div>
</div>
<!-- Back to blog -->
<div class="text-center mt-5">
<a href="<?= BLOG_URL ?>" class="btn px-4 py-2 fw-bold" style="background: linear-gradient(135deg, #0e55bb, #1186cc); color: white; border-radius: 8px;">
← Volver al blog
</a>
</div>
</div>
</div>
<!-- Related Posts -->
<?php if (!empty($related)): ?>
<div class="mt-5 pt-4 border-top">
<h4 class="fw-bold mb-4 text-center" style="color: #0a1d3b;">Artículos relacionados</h4>
<div class="row g-4">
<?php foreach ($related as $rel): ?>
<div class="col-12 col-md-4">
<a href="<?= BLOG_URL ?>/<?= e($rel['slug']) ?>" class="related-link">
<div class="card related-card h-100">
<?php if ($rel['cover_image']): ?>
<img src="<?= e($rel['cover_image']) ?>" class="card-img-top" alt="<?= e($rel['title']) ?>">
<?php else: ?>
<div class="card-img-top d-flex align-items-center justify-content-center" style="background: linear-gradient(135deg, #0a1d3b, #1186cc); height: 180px;">
<img src="<?= SITE_URL ?>/assets/img/trimape-logo-512-300x300.png" alt="TRIMAPE" style="width: 60px; opacity: 0.4;">
</div>
<?php endif; ?>
<div class="card-body">
<h6 class="card-title"><?= e($rel['title']) ?></h6>
<small class="text-muted"><?= date('d M Y', strtotime($rel['published_at'])) ?></small>
</div>
</div>
</a>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php include dirname(__DIR__) . '/contacto-div.htm'; ?>
<?php include dirname(__DIR__) . '/footer.htm'; ?>
<?php include dirname(__DIR__) . '/footer-scripts.htm'; ?>
</body>
</html>