test
This commit is contained in:
parent
8273b48b6b
commit
396df4293f
@ -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';
|
Binary file not shown.
@ -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
116
main.py
@ -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)
|
||||
|
62
static/e_blog/b_share_btn.css
Normal file
62
static/e_blog/b_share_btn.css
Normal 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; }
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
0
static/h_tmp_user/d_read_post/iframe_fullscreen.js
Normal file
0
static/h_tmp_user/d_read_post/iframe_fullscreen.js
Normal 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 */
|
||||
}
|
||||
|
||||
|
||||
|
132
templates/e_blog/a_all_posts copy.html
Normal file
132
templates/e_blog/a_all_posts copy.html
Normal 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 %}
|
@ -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">×</button>
|
||||
|
||||
<span class="input-group-text">
|
||||
<span id="result-count">0</span> resultado(s)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<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>
|
||||
|
||||
</ul>
|
||||
<nav aria-label="Page navigation" class="mt-4">
|
||||
<ul class="pagination justify-content-center" id="pagination"></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 %}
|
||||
|
||||
<!-- 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 #} -->
|
||||
|
||||
<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);
|
||||
});
|
||||
|
||||
utils.animateCards();
|
||||
},
|
||||
|
||||
pagination: () => {
|
||||
DOM.pagination.innerHTML = "";
|
||||
state.totalPages = Math.ceil(state.filteredPosts.length / CONFIG.postsPerPage);
|
||||
DOM.resultCount.textContent = state.filteredPosts.length;
|
||||
|
||||
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' %}
|
||||
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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 %}
|
72
templates/z_comps/read_progress.html
Normal file
72
templates/z_comps/read_progress.html
Normal 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>
|
Loading…
x
Reference in New Issue
Block a user