This commit is contained in:
David Itehua Xalamihua 2025-05-03 15:47:24 -06:00
parent 8273b48b6b
commit 396df4293f
13 changed files with 753 additions and 271 deletions

View File

@ -8,11 +8,11 @@
-- psql -h 127.0.0.1 -U postgres -a -f conf.sql
DROP DATABASE IF EXISTS forma;
DROP DATABASE IF EXISTS formha;
CREATE DATABASE forma;
CREATE DATABASE formha;
\c forma;
\c formha;
CREATE TABLE contact (
@ -64,3 +64,15 @@ CREATE TABLE posts_visited(
id_post INT,
viewed TIMESTAMP WITH TIME ZONE
);
CREATE TABLE visited_from (
id SERIAL PRIMARY KEY,
post_id INT NOT NULL,
source_name VARCHAR(50),
visit_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
ALTER DATABASE formha SET timezone TO 'America/Mexico_City';

View File

@ -1,6 +1,8 @@
import psycopg2
from psycopg2 import sql
from psycopg2.extras import execute_values
from psycopg2.extras import RealDictCursor
class DBForma:
def __init__(self, db_obj: dict):
@ -91,6 +93,17 @@ class DBForma:
except Exception as e:
raise RuntimeError(f"Error inesperado: {e}")
def get_all_data_dict(self, query):
try:
with self._get_connection() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(query)
return cursor.fetchall()
except psycopg2.DatabaseError as e:
raise RuntimeError(f"Error al ejecutar la consulta: {e}")
except Exception as e:
raise RuntimeError(f"Error inesperado: {e}")
def update_data(self, query: str, data_tuple: tuple) -> bool:
"""
"""

116
main.py
View File

@ -145,102 +145,67 @@ def solutions():
def methodology():
return render_template(v['methodology'], active_page='methodology')
@app.route('/blog')
def blog():
return render_template( v['blog']['all_posts'], active_page='blog' )
# if any(x in search for x in ["'", '"', " OR ", "--", ";", "1=1"]):
# app.logger.warning(f"Intento de SQL injection detectado: {search}")
# 🛑 IMPORTANTE: Este bloque acepta input del usuario y necesita sanitización adecuada.
# TODO: Reemplazar concatenación de strings por parámetros SQL usando psycopg2.sql.SQL y placeholders (%s)
# Ejemplo de ataque detectado: ' OR '1'='1
# Aunque no ejecuta el ataque, sí lanza error → posible vector de DoS
# Parámetros
page = request.args.get("page", 1, type=int)
search = request.args.get("q", "").strip()
per_page = 9
offset = (page - 1) * per_page
# Armado de condiciones SQL para búsqueda
search_filter = ""
if search:
like = f"'%{search}%'"
search_filter = f"""
WHERE
LOWER(p.title) LIKE LOWER({like}) OR
LOWER(p.body_no_img) LIKE LOWER({like}) OR
LOWER(u.nombre || ' ' || u.apellido) LIKE LOWER({like})
"""
# Conteo total
count_query = f"""
SELECT COUNT(*)
FROM posts p
INNER JOIN users u ON u.id = p.id_usr
{search_filter};
"""
total_posts = dbUsers.get_all_data(count_query)[0][0]
total_pages = (total_posts + per_page - 1) // per_page
# Consulta con paginación
q = fr"""
@app.route('/blog/api/posts')
def api_posts():
q = r"""
SELECT
p.id,
u.nombre,
u.apellido,
TO_CHAR(p.created_at, 'DD/MM/YYYY HH24:MI'),
TO_CHAR(p.updated_at, 'DD/MM/YYYY HH24:MI'),
u.nombre || ' ' || u.apellido AS autor,
TO_CHAR(p.created_at AT TIME ZONE 'America/Mexico_City', 'YYYY-MM-DD HH24:MI') AS author_creation,
TO_CHAR(p.updated_at AT TIME ZONE 'America/Mexico_City', 'YYYY-MM-DD HH24:MI') AS author_updated,
p.title,
LEFT(p.body_no_img, 180) AS preview,
p.lista_imagenes->0 AS primera_imagen,
array_length(regexp_split_to_array(TRIM(body_no_img), '\s+'), 1) / 375 as n_words
FROM
posts p
INNER JOIN
users u ON u.id = p.id_usr
{search_filter}
ORDER BY
p.created_at DESC
LIMIT {per_page} OFFSET {offset};
SUBSTRING(p.body_no_img FROM 1 FOR 180) AS preview,
(SELECT value FROM jsonb_array_elements_text(p.lista_imagenes) LIMIT 1) AS first_img,
ROUND(length(regexp_replace(p.body_no_img, '\s+', ' ', 'g')) / 5.0 / 375, 1) AS read_time,
GREATEST(1, CEIL(length(regexp_replace(p.body_no_img, '\s+', ' ', 'g')) / 5.0 / 375)) AS read_time_min
FROM posts p
JOIN users u ON p.id_usr = u.id
ORDER BY p.created_at DESC;
"""
data = dbUsers.get_all_data(q)
data = dbUsers.get_all_data_dict(q)
return jsonify(data)
return render_template(
v['blog']['all_posts'],
active_page='blog',
data=data,
current_page=page,
total_pages=total_pages,
search=search # pasamos el término de búsqueda a la plantilla
)
@app.route('/blog/<int:post_id>')
@app.route('/blog/<int:post_id>/src/<string:source_name>')
# @cache.cached(timeout=43200)
def blog_post(post_id):
# q_visited = "INSERT INTO posts_visited (id_post, viewed ) VALUES ( %s, %s);"
# t_visited = (post_id, cur_date())
# dbUsers.update_data(q_visited, t_visited)
# Obtener el post
def blog_post(post_id, source_name=None):
# source_name = source_name or 'direct'
# source_name = source_name.lower()
# if source_name not in ['facebook', 'linkedin', 'x', 'li', 'fb', 'tw', 'direct']:
# source_name = 'None'
if source_name != None:
q_vf = "INSERT INTO visited_from (post_id, source_name) VALUES (%s, %s);"
t_vf = (post_id, source_name)
dbUsers.update_data(q_vf, t_vf)
q = fr"""
SELECT
u.nombre,
u.apellido,
TO_CHAR(p.created_at, 'DD/MM/YYYY HH24:MI') AS fecha_creada,
TO_CHAR(p.updated_at, 'DD/MM/YYYY HH24:MI') AS fecha_updated,
array_length(regexp_split_to_array(TRIM(p.body_no_img), '\s+'), 1) / 375 as read_time_min,
GREATEST(1, CEIL(length(regexp_replace(p.body_no_img, '\s+', ' ', 'g')) / 5.0 / 375)) AS read_time_min,
p.title,
p.body
p.body,
COUNT(pv.viewed) AS total_views
FROM
posts p
INNER JOIN
users u
ON
p.id_usr = u.id
users u ON p.id_usr = u.id
LEFT JOIN
posts_visited pv ON pv.id_post = p.id
WHERE
p.id = %s;
p.id = %s
GROUP BY
u.nombre, u.apellido, p.created_at, p.updated_at, p.title, p.body, p.body_no_img;
"""
t = (post_id,)
data = dbUsers.get_data(q, t)
@ -248,9 +213,6 @@ def blog_post(post_id):
return render_template(v['blog']['post'], data=data)
@app.route("/contact", methods=['GET', 'POST'])
def contact():
form = ContactForm()
@ -544,8 +506,6 @@ def save_post():
body = data['body']
time = cur_date()
print(time)
soup = BeautifulSoup(body, 'html.parser')
body_no_img = soup.get_text(separator=' ', strip=True)
etiquetas_img = re.findall(r'<img[^>]+src=["\'](.*?)["\']', body)

View File

@ -0,0 +1,62 @@
.share {
position: relative;
display: flex;
align-items: center;
background: #eee;
border-radius: 2rem;
width: 3rem;
height: 3rem;
overflow: hidden;
transition: width 0.3s ease;
}
.share:hover {
width: 18rem;
}
.share__wrapper {
display: flex;
align-items: center;
height: 100%;
position: relative;
}
.share__toggle {
background: #549c67;
color: white;
border-radius: 50%;
width: 2.5rem;
height: 2.5rem;
margin: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.share__button {
background: #555;
color: white;
border-radius: 50%;
width: 2.5rem;
height: 2.5rem;
margin-left: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transform: scale(0);
transition: transform 0.3s ease, opacity 0.3s ease;
position: relative;
}
/* Mostrar botones solo cuando se hace hover */
.share:hover .share__button {
opacity: 1;
transform: scale(1);
}
.fb { background: #1877f2; }
.tw { background: #000000; }
.in { background: #0077b5; }
.copy { background: #444; }

View File

@ -1,9 +1,16 @@
import { simpleNotification } from '../z_comps/notify.js';
const url = window.location.href;
const url_encoded = encodeURIComponent(url);
// Limpia el /src/... si ya viene en la URL
let baseUrl = window.location.href.replace(/\/src\/\w+$/, '');
function encode_url(redoSocial = '') {
let finalUrl = baseUrl;
if (redoSocial !== '') {
finalUrl += `/src/${redoSocial}`;
}
return encodeURIComponent(finalUrl);
}
// Función reutilizable para abrir ventanas de compartir
function openShareWindow(shareUrl) {
window.open(shareUrl, '_blank', 'width=600,height=400,noopener,noreferrer');
}
@ -13,7 +20,7 @@ const btn_copy = document.querySelector("button.copy");
if (btn_copy) {
btn_copy.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(url);
await navigator.clipboard.writeText(baseUrl);
simpleNotification("URL Copiada", "URL copiada", "success");
} catch (err) {
console.error('Error al copiar: ', err);
@ -25,7 +32,7 @@ if (btn_copy) {
const btn_in = document.querySelector("button.in");
if (btn_in) {
btn_in.addEventListener("click", () => {
const linkedInUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${url_encoded}`;
const linkedInUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encode_url('li')}`;
openShareWindow(linkedInUrl);
});
}
@ -34,7 +41,7 @@ if (btn_in) {
const btn_fb = document.querySelector("button.fb");
if (btn_fb) {
btn_fb.addEventListener("click", () => {
const fbShareUrl = `https://www.facebook.com/sharer/sharer.php?u=${url_encoded}`;
const fbShareUrl = `https://www.facebook.com/sharer/sharer.php?u=${encode_url('fb')}`;
openShareWindow(fbShareUrl);
});
}
@ -44,7 +51,7 @@ const btn_x = document.querySelector("button.tw");
if (btn_x) {
btn_x.addEventListener("click", () => {
const tweetText = encodeURIComponent("Mira este post interesante:");
const xShareUrl = `https://twitter.com/intent/tweet?url=${url_encoded}&text=${tweetText}`;
const xShareUrl = `https://twitter.com/intent/tweet?url=${encode_url('x')}&text=${tweetText}`;
openShareWindow(xShareUrl);
});
}

View File

@ -99,5 +99,26 @@
clear: both;
}
/* -------------------------- */
ul, ol {
/* Asegura que el espacio no afecte la alineación de viñetas/números */
padding-left: 1em; /* Ajusta según tu diseño */
}
li {
text-align: justify; /* Justifica el texto */
margin-bottom: 0.5em; /* Espaciado entre elementos (opcional) */
}
li p, li span {
text-align: justify;
display: inline-block; /* Necesario para que funcione en elementos en línea */
width: 100%; /* Ocupa todo el ancho disponible */
}
li {
text-align: justify;
hyphens: auto; /* Permite división de palabras con guiones */
}

View File

@ -0,0 +1,132 @@
{% extends 'template.html' %}
{% block css %}
<link rel="stylesheet" href="{{ url_for('static', filename='e_blog/a_all_posts.css') }}">
{% endblock css %}
{% block navbar %}
{% include 'z_comps/navbar.html' %}
{% endblock navbar %}
{% block body %}
<form method="get" class="mb-4">
<div class="input-group">
<input type="text" class="form-control" name="q" placeholder="Buscar título, autor o contenido..." value="{{ search }}">
<button class="btn btn-outline-primary" type="submit">
<i class="bi bi-search"></i> Buscar
</button>
</div>
</form>
<div class="container">
<!-- {# i pagination #} -->
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center mt-4">
<!-- Anterior -->
<li class="page-item {% if current_page <= 1 %}disabled{% endif %}">
<a class="page-link" href="?page={{ current_page - 1 }}{% if search %}&q={{ search }}{% endif %}">Anterior</a>
</li>
<!-- Páginas -->
{% for page_num in range(1, total_pages + 1) %}
<li class="page-item {% if page_num == current_page %}active{% endif %}">
<a class="page-link" href="?page={{ page_num }}{% if search %}&q={{ search }}{% endif %}">
{{ page_num }}
</a>
</li>
{% endfor %}
<!-- Siguiente -->
<li class="page-item {% if current_page >= total_pages %}disabled{% endif %}">
<a class="page-link" href="?page={{ current_page + 1 }}{% if search %}&q={{ search }}{% endif %}">Siguiente</a>
</li>
</ul>
</nav>
<!-- {# f pagination #} -->
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-4" id="card-container" data-aos="fade" data-aos-delay="0" data-aos-duration="800" data-aos-easing="ease-in-out">
<!-- {# adaptación #} -->
{% for post in data %}
<div class="col card-wrapper">
<div class="card h-100">
<!-- {# img #} -->
<img src="{{ post[7] if post[7] else url_for('static', filename='y_img/other/no_img.png') }}"
class="card-img-top" alt="card image">
<div class="card-body d-flex flex-column">
<div class="mb-3">
<!-- {# título #} -->
<!-- <h5 class="card-title">{{ post[5] }}</h5> -->
<a href="{{ url_for('blog_post', post_id = post[0] ) }}" class="btn btn-info"> <h5>{{post[5]}}</h5> </a> <br>
<small class="text-muted">
<!-- {# autor #} -->
<i class="bi bi-file-person-fill"></i> {{ post[1] }} {{ post[2] }} <br>
<!-- {# fecha creación #} -->
<i class="bi bi-calendar-week"></i> {{ post[3] }}<br>
<!-- {# if fecha actualización #} -->
{% if post[4] is not none %}
<i class="bi bi-arrow-repeat"></i> {{ post[4] }}<br>
{% endif %}
<!-- {# tiempo lectura #} -->
<i class="bi bi-clock"></i> {{ post[8] }} min. <br>
<i class="bi bi-eye"></i>
</small>
<!-- {# breve resumen #} -->
<p class="card-text">{{ post[6] }}...</p>
</div>
<!-- <div class="mt-auto"></div> -->
</div>
</div>
</div>
{% endfor %}
</div>
<!-- {# i pagination #} -->
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center mt-4">
<!-- Anterior -->
<li class="page-item {% if current_page <= 1 %}disabled{% endif %}">
<a class="page-link" href="?page={{ current_page - 1 }}{% if search %}&q={{ search }}{% endif %}">Anterior</a>
</li>
<!-- Páginas -->
{% for page_num in range(1, total_pages + 1) %}
<li class="page-item {% if page_num == current_page %}active{% endif %}">
<a class="page-link" href="?page={{ page_num }}{% if search %}&q={{ search }}{% endif %}">
{{ page_num }}
</a>
</li>
{% endfor %}
<!-- Siguiente -->
<li class="page-item {% if current_page >= total_pages %}disabled{% endif %}">
<a class="page-link" href="?page={{ current_page + 1 }}{% if search %}&q={{ search }}{% endif %}">Siguiente</a>
</li>
</ul>
</nav>
<!-- {# f pagination #} -->
</div>
{% endblock body %}
{% block js %}
<!-- {# aos script #} -->
{% include 'z_comps/aos_script.html' %}
{% endblock js %}

View File

@ -12,113 +12,331 @@
<form method="get" class="mb-4">
<div class="input-group">
<input type="text" class="form-control" name="q" placeholder="Buscar título, autor o contenido..." value="{{ search }}">
<button class="btn btn-outline-primary" type="submit">
<i class="bi bi-search"></i> Buscar
</button>
<style>
.card-img-top {
max-height: 200px;
object-fit: cover;
transition: transform 0.3s ease;
}
.card-img-top:hover {
transform: scale(1.02);
}
.card-wrapper {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.5s ease, transform 0.5s ease;
}
.card-wrapper.visible {
opacity: 1;
transform: translateY(0);
}
#loading-indicator {
display: none;
}
</style>
<div class="container my-3">
<div class="input-group mb-3">
<!-- <input id="searchInput" type="text" autocomplete="off" /> -->
<input id="search-input" type="text" class="form-control" placeholder="Buscar por autor, título o resumen..." autocomplete="off">
<!-- Botón para limpiar -->
<button id="clear-btn" class="btn btn-outline-secondary" type="button">&times;</button>
<span class="input-group-text">
<span id="result-count">0</span> &nbsp;resultado(s)
</span>
</div>
</form>
</div>
<div class="container">
<script>
// limpiar buscador
document.getElementById('clear-btn').addEventListener('click', function () {
const input = document.getElementById('search-input');
input.value = '';
input.dispatchEvent(new Event('input')); // Por si tienes un listener que filtra en tiempo real
});
</script>
<!-- {# i pagination #} -->
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center mt-4">
<!-- Anterior -->
<li class="page-item {% if current_page <= 1 %}disabled{% endif %}">
<a class="page-link" href="?page={{ current_page - 1 }}{% if search %}&q={{ search }}{% endif %}">Anterior</a>
</li>
<div id="loading-indicator" class="text-center my-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Cargando...</span>
</div>
</div>
<!-- Páginas -->
{% for page_num in range(1, total_pages + 1) %}
<li class="page-item {% if page_num == current_page %}active{% endif %}">
<a class="page-link" href="?page={{ page_num }}{% if search %}&q={{ search }}{% endif %}">
{{ page_num }}
</a>
</li>
{% endfor %}
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-4" id="card-container"></div>
<!-- Siguiente -->
<li class="page-item {% if current_page >= total_pages %}disabled{% endif %}">
<a class="page-link" href="?page={{ current_page + 1 }}{% if search %}&q={{ search }}{% endif %}">Siguiente</a>
</li>
<nav aria-label="Page navigation" class="mt-4">
<ul class="pagination justify-content-center" id="pagination"></ul>
</nav>
</ul>
</nav>
<!-- {# f pagination #} -->
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-4" id="card-container" data-aos="fade" data-aos-delay="0" data-aos-duration="800" data-aos-easing="ease-in-out">
<!-- {# adaptación #} -->
{% for post in data %}
<div class="col card-wrapper">
<script>
const DOM = {
container: document.getElementById("card-container"),
pagination: document.getElementById("pagination"),
searchInput: document.getElementById("search-input"),
loading: document.getElementById("loading-indicator"),
resultCount: document.getElementById("result-count")
};
const CONFIG = {
url: window.location.href.replace(/\/$/, "") + "/api/posts",
postsPerPage: 12,
maxVisiblePages: 5,
searchDelay: 300
};
const state = {
allPosts: [],
filteredPosts: [],
currentPage: 1,
totalPages: 1,
searchTerm: ""
};
const utils = {
debounce: (func, delay) => {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), delay);
};
},
normalizePost: post => ({
...post,
_autor: post.autor.toLowerCase(),
_title: post.title.toLowerCase(),
_preview: post.preview.toLowerCase()
}),
saveState: () => {
localStorage.setItem("lastPage", state.currentPage);
localStorage.setItem("lastSearch", state.searchTerm);
},
animateCards: () => {
const cards = document.querySelectorAll('.card-wrapper');
cards.forEach((card, index) => {
setTimeout(() => card.classList.add('visible'), index * 100);
});
}
};
const render = {
showLoading: () => {
DOM.loading.style.display = 'block';
DOM.container.style.opacity = '0.5';
},
hideLoading: () => {
DOM.loading.style.display = 'none';
DOM.container.style.opacity = '1';
},
posts: () => {
DOM.container.innerHTML = "";
const start = (state.currentPage - 1) * CONFIG.postsPerPage;
const end = start + CONFIG.postsPerPage;
const postsToShow = state.filteredPosts.slice(start, end);
if (postsToShow.length === 0) {
DOM.container.innerHTML = `
<div class="col-12 text-center py-5">
<h4>No se encontraron resultados</h4>
<p class="text-muted">Intenta con otros términos de búsqueda</p>
</div>
`;
return;
}
postsToShow.forEach(post => {
const imgSrc = post.first_img || "{{ url_for('static', filename='y_img/other/no_img.png') }}";
const dateUpdate = post.author_updated ? `<i class="bi bi-arrow-repeat"></i> ${post.author_updated}<br>` : "";
const card = document.createElement("div");
card.className = "col card-wrapper";
card.innerHTML = `
<div class="card h-100">
<!-- {# img #} -->
<img src="{{ post[7] if post[7] else url_for('static', filename='y_img/other/no_img.png') }}"
class="card-img-top" alt="card image">
<img src="${imgSrc}" class="card-img-top" alt="${post.title}" loading="lazy">
<div class="card-body d-flex flex-column">
<div class="mb-3">
<!-- {# título #} -->
<!-- <h5 class="card-title">{{ post[5] }}</h5> -->
<a href="{{ url_for('blog_post', post_id = post[0] ) }}" class="btn btn-info"> <h5>{{post[5]}}</h5> </a> <br>
<small class="text-muted">
<!-- {# autor #} -->
<i class="bi bi-file-person-fill"></i> {{ post[1] }} {{ post[2] }} <br>
<!-- {# fecha creación #} -->
<i class="bi bi-calendar-week"></i> {{ post[3] }}<br>
<!-- {# if fecha actualización #} -->
{% if post[4] is not none %}
<i class="bi bi-arrow-repeat"></i> {{ post[4] }}<br>
{% endif %}
<!-- {# tiempo lectura #} -->
<i class="bi bi-clock"></i> {{ post[8] }} min. <br>
<i class="bi bi-eye"></i>
</small>
<!-- {# breve resumen #} -->
<p class="card-text">{{ post[6] }}...</p>
</div>
<!-- <div class="mt-auto"></div> -->
</div>
</div>
</div>
{% endfor %}
</div>
<!-- {# i pagination #} -->
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center mt-4">
<!-- Anterior -->
<li class="page-item {% if current_page <= 1 %}disabled{% endif %}">
<a class="page-link" href="?page={{ current_page - 1 }}{% if search %}&q={{ search }}{% endif %}">Anterior</a>
</li>
<!-- Páginas -->
{% for page_num in range(1, total_pages + 1) %}
<li class="page-item {% if page_num == current_page %}active{% endif %}">
<a class="page-link" href="?page={{ page_num }}{% if search %}&q={{ search }}{% endif %}">
{{ page_num }}
<a href="/blog/${post.id}" class="btn btn-info stretched-link" onclick="utils.saveState()">
<h5 class="card-title">${post.title}</h5>
</a>
</li>
{% endfor %}
<small class="text-muted d-block mt-2">
<i class="bi bi-file-person-fill"></i> ${post.autor}<br>
<i class="bi bi-calendar-week"></i> ${post.author_creation}<br>
${dateUpdate}
<i class="bi bi-clock"></i> ${post.read_time_min} min.
</small>
<p class="card-text mt-2">${post.preview}...</p>
</div>
</div>
</div>
`;
DOM.container.appendChild(card);
});
<!-- Siguiente -->
<li class="page-item {% if current_page >= total_pages %}disabled{% endif %}">
<a class="page-link" href="?page={{ current_page + 1 }}{% if search %}&q={{ search }}{% endif %}">Siguiente</a>
</li>
utils.animateCards();
},
</ul>
</nav>
<!-- {# f pagination #} -->
pagination: () => {
DOM.pagination.innerHTML = "";
state.totalPages = Math.ceil(state.filteredPosts.length / CONFIG.postsPerPage);
DOM.resultCount.textContent = state.filteredPosts.length;
</div>
const pageItem = (page, label = null, disabled = false, active = false) => {
const li = document.createElement("li");
li.className = `page-item ${disabled ? 'disabled' : ''} ${active ? 'active' : ''}`;
const a = document.createElement("a");
a.className = "page-link";
a.href = "#";
a.textContent = label || page;
if (!disabled && !active) {
a.addEventListener("click", (e) => {
e.preventDefault();
state.currentPage = page;
utils.saveState();
render.updateView();
});
}
li.appendChild(a);
return li;
};
const pageDots = () => {
const li = document.createElement("li");
li.className = "page-item disabled";
li.innerHTML = `<span class="page-link">...</span>`;
return li;
};
// Botón anterior
DOM.pagination.appendChild(pageItem(state.currentPage - 1, "«", state.currentPage === 1));
const startPage = Math.max(1, state.currentPage - Math.floor(CONFIG.maxVisiblePages / 2));
const endPage = Math.min(state.totalPages, startPage + CONFIG.maxVisiblePages - 1);
if (startPage > 1) {
DOM.pagination.appendChild(pageItem(1));
if (startPage > 2) DOM.pagination.appendChild(pageDots());
}
for (let i = startPage; i <= endPage; i++) {
DOM.pagination.appendChild(pageItem(i, null, false, i === state.currentPage));
}
if (endPage < state.totalPages) {
if (endPage < state.totalPages - 1) DOM.pagination.appendChild(pageDots());
DOM.pagination.appendChild(pageItem(state.totalPages));
}
// Botón siguiente
DOM.pagination.appendChild(pageItem(state.currentPage + 1, "»", state.currentPage === state.totalPages));
},
updateView: () => {
render.posts();
render.pagination();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
const search = {
filterPosts: term => {
const newSearchTerm = term.toLowerCase(); // convertir el término a minúsculas
const isSameSearch = newSearchTerm === state.searchTerm;
state.searchTerm = newSearchTerm;
if (!state.searchTerm) {
state.filteredPosts = [...state.allPosts];
} else {
state.filteredPosts = state.allPosts.filter(post =>
post._autor.toLowerCase().includes(state.searchTerm) ||
post._title.toLowerCase().includes(state.searchTerm) ||
post._preview.toLowerCase().includes(state.searchTerm)
);
}
if (!isSameSearch) {
state.currentPage = 1;
}
render.updateView();
},
init: () => {
DOM.searchInput.addEventListener("input", utils.debounce(() => {
search.filterPosts(DOM.searchInput.value);
}, CONFIG.searchDelay));
}
};
const init = {
loadPosts: () => {
render.showLoading();
fetch(CONFIG.url)
.then(response => {
if (!response.ok) throw new Error('Error en la red');
return response.json();
})
.then(data => {
state.allPosts = data.map(utils.normalizePost);
const lastSearch = localStorage.getItem("lastSearch") || "";
DOM.searchInput.value = lastSearch;
state.searchTerm = lastSearch.toLowerCase();
if (!state.searchTerm) {
state.filteredPosts = [...state.allPosts];
} else {
state.filteredPosts = state.allPosts.filter(post =>
post._autor.includes(state.searchTerm) ||
post._title.includes(state.searchTerm) ||
post._preview.includes(state.searchTerm)
);
}
const lastPage = parseInt(localStorage.getItem("lastPage")) || 1;
state.totalPages = Math.ceil(state.filteredPosts.length / CONFIG.postsPerPage);
state.currentPage = Math.min(lastPage, state.totalPages || 1);
render.updateView();
})
.catch(error => {
console.error('Error:', error);
DOM.container.innerHTML = `
<div class="col-12 text-center py-5">
<h4>Error al cargar los posts</h4>
<p class="text-muted">Por favor intenta recargar la página</p>
</div>
`;
})
.finally(() => {
render.hideLoading();
});
}
};
document.addEventListener("DOMContentLoaded", () => {
init.loadPosts();
search.init();
});
</script>
@ -126,6 +344,12 @@
{% block js %}
<!-- {# aos script #} -->
{% include 'z_comps/aos_script.html' %}

View File

@ -2,6 +2,7 @@
{% block css %}
<link rel="stylesheet" href="{{ url_for( 'static', filename='h_tmp_user/d_read_post/read_post.css' ) }}">
<link rel="stylesheet" href="{{ url_for( 'static', filename='e_blog/b_share_btn.css' ) }}">
{% endblock css %}
{% block navbar %}
@ -10,87 +11,19 @@
{% block body %}
<style>
.share {
position: relative;
display: flex;
align-items: center;
background: #eee;
border-radius: 2rem;
width: 3rem;
height: 3rem;
overflow: hidden;
transition: width 0.3s ease;
}
.share:hover {
width: 18rem;
}
.share__wrapper {
display: flex;
align-items: center;
height: 100%;
position: relative;
}
.share__toggle {
background: #549c67;
color: white;
border-radius: 50%;
width: 2.5rem;
height: 2.5rem;
margin: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.share__button {
background: #555;
color: white;
border-radius: 50%;
width: 2.5rem;
height: 2.5rem;
margin-left: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transform: scale(0);
transition: transform 0.3s ease, opacity 0.3s ease;
position: relative;
}
/* Mostrar botones solo cuando se hace hover */
.share:hover .share__button {
opacity: 1;
transform: scale(1);
}
.fb { background: #1877f2; }
.tw { background: #000000; }
.in { background: #0077b5; }
.copy { background: #444; }
</style>
{{data}}
<div class="pst-cont">
<h1>{{data[5]}}</h1>
<spam>
<a type="button" class="btn btn-secondary"><i class="bi bi-arrow-left"></i> Publicaciones</a> <br>
<a type="button" class="btn btn-secondary" href="{{ url_for( 'blog' ) }}"><i class="bi bi-arrow-left" ></i> Publicaciones</a> <br>
<i class="bi bi-person-circle"></i> {{data[0]}} {{data[1]}} |
<i class="bi bi-pencil-square"></i> {{data[2]}} |
{% if data[3] is not none %}
<i class="bi bi-arrow-repeat"></i> {{data[3]}} |
{% endif %}
<i class="bi bi-clock-history"></i> {{ data[4] }} Minutos |
<i class="bi bi-eye"></i>
<i class="bi bi-eye"></i> {{ data[7] }}
</spam>
<div class="share">
@ -103,7 +36,8 @@
</div>
</div>
<div >
<div class="bd_post">
{{data[6] | safe}}
</div>
@ -119,8 +53,13 @@
{% endblock body %}
{% block js %}
{% include "z_comps/read_progress.html" %}
<script type="module" src="{{ url_for('static', filename='e_blog/copy_url.js') }}"></script>

View File

@ -7,6 +7,25 @@
{% block body %}
<style>
.video-responsive {
position: relative;
width: 100%;
padding-bottom: 56.25%; /* 16:9 ratio */
height: 0;
overflow: hidden;
}
.video-responsive iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
}
</style>
<div class="pst-cont">
@ -21,13 +40,30 @@
<a type="button" class="btn btn-secondary" href="{{ url_for('edit_post', id_post= post_id ) }}"><i class="bi bi-vector-pen"></i> Editar.</a>
</div>
<div>
<div class="bd-post">
{{data[3] | safe}}
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", () => {
const iframes = document.querySelectorAll("iframe.note-video-clip");
iframes.forEach(iframe => {
const wrapper = document.createElement("div");
wrapper.className = "video-responsive";
iframe.parentNode.insertBefore(wrapper, iframe);
wrapper.appendChild(iframe);
});
});
</script>
{% endblock body %}
@ -36,4 +72,8 @@
{% include 'z_comps/arrow_to_up.html' %}
{% endblock js %}

View File

@ -0,0 +1,72 @@
<!-- Bootstrap Progress Bar -->
<div class="progress reading-progress-bar" id="reading-progress">
<div class="progress-bar" role="progressbar"
style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
<span class="progress-label">0%</span>
</div>
</div>
<!-- CSS personalizado para que flote encima del navbar y tenga color amarillo -->
<style>
.reading-progress-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 20px;
z-index: 9999;
border-radius: 0;
background-color: transparent;
/* fondo del contenedor */
}
.reading-progress-bar .progress-bar {
background-color: #ffc107;
height: 100%;
transition: width 0.25s ease;
display: flex;
align-items: center;
justify-content: center;
color: black;
/* Asegúrate que sea visible */
font-size: 12px;
font-weight: bold;
}
</style>
<!-- JavaScript para actualizar la barra -->
<script>
document.addEventListener("DOMContentLoaded", function () {
const progressBarContainer = document.getElementById("reading-progress");
const progressBar = progressBarContainer.querySelector(".progress-bar");
const postContainer = document.querySelector(".pst-cont");
function updateProgress() {
const scrollTop = window.scrollY;
const offsetTop = postContainer.offsetTop;
const postHeight = postContainer.scrollHeight;
const windowHeight = window.innerHeight;
const totalScrollable = postHeight - offsetTop - windowHeight;
if (totalScrollable <= 0) {
progressBarContainer.style.display = "none";
} else {
const progress = Math.min((scrollTop - offsetTop) / totalScrollable, 1);
const progressPercent = progress >= 0 ? progress * 100 : 0;
progressBar.style.width = `${progressPercent}%`;
progressBar.setAttribute("aria-valuenow", progressPercent.toFixed(1));
progressBarContainer.style.display = "block";
progressBar.style.width = `${progressPercent}%`;
progressBar.setAttribute("aria-valuenow", progressPercent.toFixed(1));
progressBar.querySelector(".progress-label").textContent = `${Math.round(progressPercent)}%`;
}
}
window.addEventListener("scroll", updateProgress);
window.addEventListener("resize", updateProgress);
updateProgress();
});
</script>