Añade foro de comunidad integrado (app forum) con el diseño de NovaWoW

- app 'forum': portada (categorías/foros), ver foro y tema (paginado), crear tema,
  responder, editar/borrar posts y moderación (fijar, bloquear, mover, borrar/restaurar)
- lee/escribe la BD acore_web (5ª conexión, DB_NAME_WEB) por SQL directo, tablas
  forum_categories/forums/forum_topics/forum_posts/forum_reads (portado de NovaWeb-main)
- identidad = cuenta de juego en sesión; permisos simplificados por gmlevel
  (FORUM_MOD_GMLEVEL) en vez de la matriz group_level×permission del original
- saneado del HTML de posts con nh3 (evita el XSS del original); POST+CSRF en formularios
- plantillas con los partials del sitio; enlace 'FOROS' del menú apunta al foro interno
- sql/forum_schema.sql y docs/FORO.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 17:10:55 +00:00
parent 2162fe34d0
commit 2c30844bf7
21 changed files with 1385 additions and 1 deletions
+42
View File
@@ -0,0 +1,42 @@
<style>
.forum-wrap { max-width: 1000px; margin: 0 auto; }
.forum-cat { margin-bottom: 24px; }
.forum-cat-title { font-size: 1.2rem; color: #d79602; border-bottom: 1px solid rgba(215,150,2,.4); padding: 6px 4px; margin-bottom: 8px; }
.forum-row, .topic-row { display: flex; align-items: center; gap: 12px; padding: 12px 10px; border-bottom: 1px solid rgba(255,255,255,.08); }
.forum-row:hover, .topic-row:hover { background: rgba(255,255,255,.04); }
.forum-row .main, .topic-row .main { flex: 1; min-width: 0; }
.forum-row .name, .topic-row .name { color: #e0c07a; font-weight: bold; text-decoration: none; }
.forum-row .desc { color: #b1997f; font-size: .85rem; }
.forum-row .stats, .topic-row .meta { text-align: right; font-size: .8rem; color: #b1997f; white-space: nowrap; }
.forum-badge { display: inline-block; font-size: .7rem; padding: 1px 6px; border-radius: 3px; background: #7a5c1e; color: #fff; margin-right: 4px; }
.forum-post { display: flex; gap: 0; border: 1px solid rgba(255,255,255,.1); margin-bottom: 16px; background: rgba(0,0,0,.15); }
.forum-post .left { width: 180px; padding: 14px; background: rgba(0,0,0,.25); text-align: center; }
.forum-post .left .pname { color: #e0c07a; font-weight: bold; }
.forum-post .left .prole { font-size: .8rem; color: #d79602; }
.forum-post .left .pmeta { font-size: .75rem; color: #b1997f; margin-top: 6px; }
.forum-post .right { flex: 1; padding: 14px; min-width: 0; }
.forum-post .pbody { white-space: pre-wrap; word-wrap: break-word; color: #e8e0d0; }
.forum-post .pbody img { max-width: 100%; height: auto; }
.forum-post .pfoot { margin-top: 12px; font-size: .75rem; color: #8a7a63; display: flex; justify-content: space-between; }
.forum-actions { margin: 14px 0; display: flex; gap: 8px; flex-wrap: wrap; }
.forum-btn { display: inline-block; padding: 6px 14px; background: #7a5c1e; color: #fff !important; border: none; border-radius: 3px; cursor: pointer; text-decoration: none; font-size: .85rem; }
.forum-btn:hover { background: #d79602; }
.forum-btn.danger { background: #7a2020; }
.forum-btn.danger:hover { background: #c41e3a; }
.forum-pagination { margin: 16px 0; text-align: center; }
.forum-pagination a, .forum-pagination span { display: inline-block; padding: 4px 9px; margin: 0 2px; border: 1px solid rgba(215,150,2,.4); color: #d79602; text-decoration: none; border-radius: 3px; }
.forum-pagination .current { background: #7a5c1e; color: #fff; }
.forum-form textarea { width: 100%; min-height: 180px; background: rgba(0,0,0,.3); color: #e8e0d0; border: 1px solid rgba(215,150,2,.4); padding: 10px; border-radius: 3px; }
.forum-form input[type=text] { width: 100%; background: rgba(0,0,0,.3); color: #e8e0d0; border: 1px solid rgba(215,150,2,.4); padding: 8px; border-radius: 3px; margin-bottom: 10px; }
.forum-msg { padding: 8px 12px; border-radius: 3px; margin-bottom: 10px; }
.forum-msg.error { background: rgba(196,30,58,.2); color: #ff9a9a; }
.forum-msg.success { background: rgba(46,160,67,.2); color: #9affb0; }
.forum-deleted { opacity: .55; }
</style>
{% if messages %}
<div class="forum-messages forum-wrap">
{% for message in messages %}
<div class="forum-msg {{ message.tags }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
+34
View File
@@ -0,0 +1,34 @@
<!DOCTYPE html>
{% include 'partials/head.html' %}
{% include 'partials/header.html' %}
{% include 'partials/video.html' %}
<div class="main-page">
<div class="middle-content">
<div class="body-content">
<div class="title-content"><h1>Nuevo tema</h1></div>
<div class="box-content">
<div class="body-box-content">
{% include 'forum/_style.html' %}
<div class="forum-wrap forum-form">
<p style="color:#b1997f">Foro: <strong>{{ forum.name }}</strong></p>
<form action="{% url 'forum_create_topic_submit' forum_id=forum.id %}" method="POST">
{% csrf_token %}
<input type="text" name="name" maxlength="45" placeholder="Título del tema" required>
<textarea name="text" placeholder="Contenido del tema..." required></textarea>
<div class="forum-actions">
<button type="submit" class="forum-btn">Publicar tema</button>
<a class="forum-btn" href="{% url 'forum_view_p1' forum_id=forum.id %}">Cancelar</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
{% include 'partials/social.html' %}
{% include 'partials/footer.html' %}
{% include 'partials/final.html' %}
+32
View File
@@ -0,0 +1,32 @@
<!DOCTYPE html>
{% include 'partials/head.html' %}
{% include 'partials/header.html' %}
{% include 'partials/video.html' %}
<div class="main-page">
<div class="middle-content">
<div class="body-content">
<div class="title-content"><h1>Editar mensaje</h1></div>
<div class="box-content">
<div class="body-box-content">
{% include 'forum/_style.html' %}
<div class="forum-wrap forum-form">
<form action="{% url 'forum_update_post' post_id=post.id %}" method="POST">
{% csrf_token %}
<textarea name="text" required>{{ post.text|safe }}</textarea>
<div class="forum-actions">
<button type="submit" class="forum-btn">Guardar cambios</button>
<a class="forum-btn" href="{% url 'forum_topic_p1' topic_id=post.topic_id %}">Cancelar</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
{% include 'partials/social.html' %}
{% include 'partials/footer.html' %}
{% include 'partials/final.html' %}
+63
View File
@@ -0,0 +1,63 @@
<!DOCTYPE html>
{% include 'partials/head.html' %}
{% include 'partials/header.html' %}
{% include 'partials/video.html' %}
<div class="main-page">
<div class="middle-content">
<div class="body-content">
<div class="title-content"><h1>{{ forum.name }}</h1></div>
<div class="box-content">
<div class="body-box-content">
{% include 'forum/_style.html' %}
<div class="forum-wrap">
<p class="desc" style="color:#b1997f">{{ forum.description }}</p>
<div class="forum-actions">
<a class="forum-btn" href="{% url 'app_forum' %}">← Volver al foro</a>
{% if forum_is_authenticated %}
<a class="forum-btn" href="{% url 'forum_create_topic' forum_id=forum.id %}">Crear nuevo tema</a>
{% endif %}
</div>
{% for topic in topics %}
<div class="topic-row">
<div class="main">
<a class="name" href="{% url 'forum_topic_p1' topic_id=topic.id %}">
{% if topic.sticky %}<span class="forum-badge">Fijado</span>{% endif %}
{% if topic.locked %}<span class="forum-badge">Bloqueado</span>{% endif %}
{% if topic.moved %}<span class="forum-badge">Movido</span>{% endif %}
{{ topic.name }}
</a>
<div class="desc">por {{ topic.poster }} · {{ topic.created }}</div>
</div>
<div class="meta">
<div>{{ topic.post_count }} respuestas</div>
{% if topic.last_poster %}<div>último: {{ topic.last_poster }}</div>{% endif %}
{% if topic.last_time %}<div>{{ topic.last_time }}</div>{% endif %}
</div>
</div>
{% empty %}
<p>No hay temas todavía. ¡Sé el primero en publicar!</p>
{% endfor %}
{% if pagination.total_pages > 1 %}
<div class="forum-pagination">
{% if pagination.has_prev %}<a href="{% url 'forum_view' forum_id=forum.id page=pagination.prev_page %}">« Anterior</a>{% endif %}
{% for p in pagination.page_range %}
{% if p == pagination.page %}<span class="current">{{ p }}</span>
{% else %}<a href="{% url 'forum_view' forum_id=forum.id page=p %}">{{ p }}</a>{% endif %}
{% endfor %}
{% if pagination.has_next %}<a href="{% url 'forum_view' forum_id=forum.id page=pagination.next_page %}">Siguiente »</a>{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
{% include 'partials/social.html' %}
{% include 'partials/footer.html' %}
{% include 'partials/final.html' %}
+50
View File
@@ -0,0 +1,50 @@
<!DOCTYPE html>
{% include 'partials/head.html' %}
{% include 'partials/header.html' %}
{% include 'partials/video.html' %}
<div class="main-page">
<div class="middle-content">
<div class="body-content">
<div class="title-content"><h1>Foro de la comunidad</h1></div>
<div class="box-content">
<div class="body-box-content">
{% include 'forum/_style.html' %}
<div class="forum-wrap">
{% for category in categories %}
<div class="forum-cat">
<div class="forum-cat-title">{{ category.name }}</div>
{% for forum in category.forums %}
<div class="forum-row {% if forum.deleted %}forum-deleted{% endif %}">
<div class="main">
<a class="name" href="{% url 'forum_view_p1' forum_id=forum.id %}"
{% if forum.colortitle and forum.colortitle != '0' %}style="color:{{ forum.colortitle }}"{% endif %}>
{{ forum.name }}
</a>
{% if not forum.visibility %}<span class="forum-badge">Oculto</span>{% endif %}
<div class="desc">{{ forum.description }}</div>
</div>
<div class="stats">
<div>{{ forum.topic_count }} temas</div>
<div>{{ forum.post_count }} mensajes</div>
{% if forum.last_topic_name %}
<div>Último: <a href="{% url 'forum_topic_p1' topic_id=forum.last_topic_id %}">{{ forum.last_topic_name|truncatechars:30 }}</a></div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% empty %}
<p>Todavía no hay foros disponibles.</p>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
</div>
{% include 'partials/social.html' %}
{% include 'partials/footer.html' %}
{% include 'partials/final.html' %}
+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
{% include 'partials/head.html' %}
{% include 'partials/header.html' %}
{% include 'partials/video.html' %}
<div class="main-page">
<div class="middle-content">
<div class="body-content">
<div class="title-content"><h1>Perfil de {{ profile.username }}</h1></div>
<div class="box-content">
<div class="body-box-content">
{% include 'forum/_style.html' %}
<div class="forum-wrap">
<p>Temas creados: <strong>{{ profile.topic_count }}</strong></p>
<p>Mensajes publicados: <strong>{{ profile.post_count }}</strong></p>
{% if profile.joindate %}<p>Registro: <strong>{{ profile.joindate|date:"d/m/Y" }}</strong></p>{% endif %}
<div class="forum-actions">
<a class="forum-btn" href="{% url 'app_forum' %}">← Volver al foro</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% include 'partials/social.html' %}
{% include 'partials/footer.html' %}
{% include 'partials/final.html' %}
+120
View File
@@ -0,0 +1,120 @@
<!DOCTYPE html>
{% include 'partials/head.html' %}
{% include 'partials/header.html' %}
{% include 'partials/video.html' %}
<div class="main-page">
<div class="middle-content">
<div class="body-content">
<div class="title-content"><h1>{{ topic.name }}</h1></div>
<div class="box-content">
<div class="body-box-content">
{% include 'forum/_style.html' %}
<div class="forum-wrap">
<div class="forum-actions">
{% if forum %}<a class="forum-btn" href="{% url 'forum_view_p1' forum_id=forum.id %}">← {{ forum.name }}</a>{% endif %}
{% if topic.locked %}<span class="forum-badge">Tema bloqueado</span>{% endif %}
</div>
{% for post in posts %}
<div class="forum-post {% if post.deleted %}forum-deleted{% endif %}" id="post-{{ post.id }}">
<div class="left">
<div class="pname">
<a href="{% url 'forum_profile' username=post.poster %}" style="color:#e0c07a">{{ post.poster }}</a>
</div>
<div class="prole">{{ post.author.status }}</div>
<div class="pmeta">
{% if post.author.joindate %}Registro: {{ post.author.joindate|date:"d/m/Y" }}<br>{% endif %}
Mensajes: {{ post.author.post_count }}
</div>
</div>
<div class="right">
{% if post.deleted %}
<div class="pbody"><em>[Mensaje eliminado]</em></div>
{% else %}
<div class="pbody">{{ post.text|safe }}</div>
{% endif %}
<div class="pfoot">
<span>{{ post.time }}{% if post.updated_at and post.updated_at != post.time %} · editado {{ post.updated_at }}{% endif %}</span>
<span>
{% if post.can_edit and not post.deleted %}
<a href="{% url 'forum_edit_post' post_id=post.id %}">Editar</a>
· <a href="{% url 'forum_delete_post' post_id=post.id %}" onclick="return confirm('¿Borrar este mensaje?')">Borrar</a>
{% endif %}
{% if post.deleted and forum_is_moderator %}
<a href="{% url 'forum_restore_post' post_id=post.id %}">Restaurar</a>
{% endif %}
</span>
</div>
</div>
</div>
{% endfor %}
{% if pagination.total_pages > 1 %}
<div class="forum-pagination">
{% if pagination.has_prev %}<a href="{% url 'forum_topic' topic_id=topic.id page=pagination.prev_page %}">« Anterior</a>{% endif %}
{% for p in pagination.page_range %}
{% if p == pagination.page %}<span class="current">{{ p }}</span>
{% else %}<a href="{% url 'forum_topic' topic_id=topic.id page=p %}">{{ p }}</a>{% endif %}
{% endfor %}
{% if pagination.has_next %}<a href="{% url 'forum_topic' topic_id=topic.id page=pagination.next_page %}">Siguiente »</a>{% endif %}
</div>
{% endif %}
<span id="post-bottom"></span>
{% if can_reply %}
<div class="forum-form" style="margin-top:20px">
<h3 style="color:#d79602">Responder</h3>
<form action="{% url 'forum_post_reply' topic_id=topic.id %}" method="POST">
{% csrf_token %}
<textarea name="text" placeholder="Escribe tu respuesta..." required></textarea>
<div class="forum-actions">
<button type="submit" class="forum-btn">Publicar respuesta</button>
</div>
</form>
</div>
{% elif topic.locked %}
<p style="color:#b1997f">Este tema está bloqueado. No se admiten nuevas respuestas.</p>
{% elif not forum_is_authenticated %}
<p><a href="{% url 'login' %}">Inicia sesión</a> para responder.</p>
{% endif %}
{% if forum_is_moderator %}
<div class="forum-form" style="margin-top:24px; border-top:1px solid rgba(255,255,255,.1); padding-top:14px">
<h3 style="color:#d79602">Moderación</h3>
<div class="forum-actions">
{% if topic.locked %}
<a class="forum-btn" href="{% url 'forum_unlock_topic' topic_id=topic.id %}">Desbloquear</a>
{% else %}
<a class="forum-btn" href="{% url 'forum_lock_topic' topic_id=topic.id %}">Bloquear</a>
{% endif %}
<a class="forum-btn" href="{% url 'forum_toggle_sticky' topic_id=topic.id %}">{% if topic.sticky %}Quitar fijado{% else %}Fijar{% endif %}</a>
{% if topic.deleted %}
<a class="forum-btn" href="{% url 'forum_restore_topic' topic_id=topic.id %}">Restaurar tema</a>
{% else %}
<a class="forum-btn danger" href="{% url 'forum_delete_topic' topic_id=topic.id %}" onclick="return confirm('¿Borrar el tema completo?')">Borrar tema</a>
{% endif %}
</div>
{% if move_forums %}
<form action="{% url 'forum_move_topic' topic_id=topic.id %}" method="POST" style="margin-top:8px">
{% csrf_token %}
<select name="forum_id" style="padding:6px;background:rgba(0,0,0,.3);color:#e8e0d0;border:1px solid rgba(215,150,2,.4)">
{% for f in move_forums %}<option value="{{ f.id }}">{{ f.name }}</option>{% endfor %}
</select>
<button type="submit" class="forum-btn">Mover tema</button>
</form>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
{% include 'partials/social.html' %}
{% include 'partials/footer.html' %}
{% include 'partials/final.html' %}