rating and comment system complete

This commit is contained in:
2026-08-05 12:25:59 -05:00
parent 26e9179be4
commit aed6a7c24d
9 changed files with 407 additions and 1 deletions
@@ -0,0 +1,51 @@
# Generated by Django 6.0.3 on 2026-08-05 16:55
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0012_taglist_auto'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='project',
name='rating_count',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='project',
name='rating_score',
field=models.IntegerField(default=0),
),
migrations.CreateModel(
name='Comment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('body', models.TextField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='library.project')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='Rating',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('value', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ratings', to='library.project')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ratings', to=settings.AUTH_USER_MODEL)),
],
options={
'constraints': [models.UniqueConstraint(fields=('project', 'user'), name='uniq_rating_project_user')],
},
),
]
+38
View File
@@ -113,6 +113,8 @@ class Project(models.Model):
) )
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
rating_score = models.IntegerField(default=0)
rating_count = models.PositiveIntegerField(default=0)
def __str__(self): def __str__(self):
return self.title return self.title
@@ -341,3 +343,39 @@ class ProjectDraft(models.Model):
def __str__(self): def __str__(self):
return f'Draft for {self.user.username}' return f'Draft for {self.user.username}'
class Rating(models.Model):
"""One user's up/down vote on a project (Steam-style overall rating).
A user may hold a single vote per project; changing it edits this row.
``rating_score``/``rating_count`` on Project mirror the aggregate."""
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='ratings')
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='ratings',
)
value = models.BooleanField(default=True) # True=positive/up, False=negative/down
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=['project', 'user'], name='uniq_rating_project_user'),
]
def __str__(self):
return f'{"+" if self.value else "-"}{self.user.username} on {self.project.slug}'
class Comment(models.Model):
"""A forum-style comment on a project. Unlimited; body is Markdown."""
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='comments')
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='comments',
)
body = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f'{self.user.username} on {self.project.slug}: {self.body[:40]}'
+4
View File
@@ -20,6 +20,10 @@ urlpatterns = [
path('packs/<slug:slug>/gallery/<int:asset_id>/thumb/', views.asset_thumbnail, name='asset_thumbnail'), path('packs/<slug:slug>/gallery/<int:asset_id>/thumb/', views.asset_thumbnail, name='asset_thumbnail'),
path('packs/<slug:slug>/gallery/<int:asset_id>/delete/', views.asset_delete, name='asset_delete'), path('packs/<slug:slug>/gallery/<int:asset_id>/delete/', views.asset_delete, name='asset_delete'),
path('packs/<slug:slug>/contributors/', views.contributors, name='contributors'), path('packs/<slug:slug>/contributors/', views.contributors, name='contributors'),
path('packs/<slug:slug>/rate/', views.rate_project, name='rate_project'),
path('packs/<slug:slug>/comments/add/', views.add_comment, name='add_comment'),
path('packs/<slug:slug>/comments/<int:comment_id>/edit/', views.edit_comment, name='edit_comment'),
path('packs/<slug:slug>/comments/<int:comment_id>/delete/', views.delete_comment, name='delete_comment'),
path('api/files/<uuid:file_id>/', views.file_request, name='file_request'), path('api/files/<uuid:file_id>/', views.file_request, name='file_request'),
path('api/uploads/', views.api_upload_temp, name='api_upload_temp'), path('api/uploads/', views.api_upload_temp, name='api_upload_temp'),
path('api/uploads/<uuid:upload_uuid>/delete/', views.api_delete_temp, name='api_delete_temp'), path('api/uploads/<uuid:upload_uuid>/delete/', views.api_delete_temp, name='api_delete_temp'),
+132
View File
@@ -19,11 +19,13 @@ from django.utils.text import slugify
from common.markdown import render_markdown from common.markdown import render_markdown
from .forms import ContributorForm, ProjectForm, VersionForm from .forms import ContributorForm, ProjectForm, VersionForm
from .models import ( from .models import (
Comment,
FileIndex, FileIndex,
Project, Project,
ProjectAsset, ProjectAsset,
ProjectContributor, ProjectContributor,
ProjectDraft, ProjectDraft,
Rating,
Tag, Tag,
TagCategory, TagCategory,
TagList, TagList,
@@ -259,6 +261,10 @@ def browse(request):
projects = projects.annotate(_downloads=Sum('versions__downloads')).order_by( projects = projects.annotate(_downloads=Sum('versions__downloads')).order_by(
F('_downloads').desc(nulls_last=True) F('_downloads').desc(nulls_last=True)
) )
elif sort == 'most_liked':
projects = projects.order_by('-rating_score')
elif sort == 'most_voted':
projects = projects.order_by('-rating_count')
elif sort == 'name': elif sort == 'name':
projects = projects.order_by('title') projects = projects.order_by('title')
else: else:
@@ -373,6 +379,21 @@ def project_detail(request, slug):
if loader and loader not in mod_loaders: if loader and loader not in mod_loaders:
mod_loaders.append(loader) mod_loaders.append(loader)
# Ratings + forum.
rating_agg = project.ratings.aggregate(
positive=Count('pk', filter=Q(value=True)),
total=Count('pk'),
)
rating_positive = rating_agg['positive'] or 0
rating_total = rating_agg['total'] or 0
rating_negative = rating_total - rating_positive
rating_percent = round(rating_positive / rating_total * 100) if rating_total else 0
user_rating = None
if request.user.is_authenticated:
user_rating = project.ratings.filter(user=request.user).values_list('value', flat=True).first()
comments = project.comments.select_related('user').order_by('-created_at')[:200]
can_comment = request.user.is_authenticated
autoload_plugins_json = '' autoload_plugins_json = ''
if plugins_manifest: if plugins_manifest:
members = [ members = [
@@ -397,6 +418,15 @@ def project_detail(request, slug):
'mod_manifest': mod_manifest, 'mod_manifest': mod_manifest,
'mod_loaders': mod_loaders, 'mod_loaders': mod_loaders,
'mod_count': mod_count, 'mod_count': mod_count,
'rating_stats': {
'positive': rating_positive,
'negative': rating_negative,
'total': rating_total,
'percent': rating_percent,
},
'user_rating': user_rating,
'comments': comments,
'can_comment': can_comment,
'autoload_plugins_json': autoload_plugins_json, 'autoload_plugins_json': autoload_plugins_json,
'anim_stats': anim_stats, 'anim_stats': anim_stats,
} }
@@ -1311,6 +1341,108 @@ def contributors(request, slug):
}) })
# ---------- Ratings & Forum ----------
def refresh_rating_fields(project):
"""Recompute Project.rating_score (net) + rating_count from its votes."""
agg = project.ratings.aggregate(
up=Count('pk', filter=Q(value=True)),
total=Count('pk'),
)
up = agg['up'] or 0
total = agg['total'] or 0
project.rating_count = total
project.rating_score = 2 * up - total
project.save(update_fields=['rating_score', 'rating_count'])
def _require_rating_user(request, project):
"""Redirect guests to login; return None when allowed."""
if request.user.is_authenticated:
return None
return redirect('{}?next={}'.format(
reverse('profiles:login'),
request.path,
))
def rate_project(request, slug):
"""Set / change / clear the current user's vote on a project."""
project = get_object_or_404(Project, slug=slug)
if (login_redirect := _require_rating_user(request, project)) is not None:
return login_redirect
if request.method != 'POST':
return redirect('library:project_detail', slug=slug)
value_raw = request.POST.get('value', '').strip()
if value_raw in ('1', 'up'):
value = True
elif value_raw in ('0', 'down'):
value = False
else:
value = None # clear the vote
rating, created = Rating.objects.get_or_create(
project=project, user=request.user, defaults={'value': value if value is not None else True},
)
if value is None:
rating.delete()
messages.success(request, 'Your rating was removed.')
elif created or rating.value != value:
rating.value = value
rating.save(update_fields=['value', 'updated_at'])
messages.success(request, 'Your rating was updated.')
else:
messages.info(request, 'Your rating is unchanged.')
refresh_rating_fields(project)
return redirect(reverse('library:project_detail', args=[slug]) + '#rating')
def add_comment(request, slug):
project = get_object_or_404(Project, slug=slug)
if (login_redirect := _require_rating_user(request, project)) is not None:
return login_redirect
if request.method == 'POST':
body = request.POST.get('body', '').strip()
if body:
Comment.objects.create(project=project, user=request.user, body=body)
messages.success(request, 'Comment added.')
else:
messages.error(request, 'Comment cannot be empty.')
return redirect(reverse('library:project_detail', args=[slug]) + '#forum')
def edit_comment(request, slug, comment_id):
project = get_object_or_404(Project, slug=slug)
if (login_redirect := _require_rating_user(request, project)) is not None:
return login_redirect
comment = get_object_or_404(Comment, pk=comment_id, project=project)
if comment.user_id != request.user.pk and not request.user.is_staff:
return HttpResponseForbidden('You can only edit your own comments.')
if request.method == 'POST':
body = request.POST.get('body', '').strip()
if body:
comment.body = body
comment.save(update_fields=['body', 'updated_at'])
messages.success(request, 'Comment updated.')
else:
messages.error(request, 'Comment cannot be empty.')
return redirect(reverse('library:project_detail', args=[slug]) + '#forum')
def delete_comment(request, slug, comment_id):
project = get_object_or_404(Project, slug=slug)
if (login_redirect := _require_rating_user(request, project)) is not None:
return login_redirect
comment = get_object_or_404(Comment, pk=comment_id, project=project)
if comment.user_id != request.user.pk and not request.user.is_staff:
return HttpResponseForbidden('You can only delete your own comments.')
if request.method == 'POST':
comment.delete()
messages.success(request, 'Comment deleted.')
return redirect(reverse('library:project_detail', args=[slug]) + '#forum')
# ---------- API (autocomplete) ---------- # ---------- API (autocomplete) ----------
def api_tags_autocomplete(request): def api_tags_autocomplete(request):
+45
View File
@@ -701,6 +701,7 @@ button,
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s; transition: background-color 0.2s;
text-decoration: none;
} }
button:hover, button:hover,
.btn:hover { .btn:hover {
@@ -3639,3 +3640,47 @@ a.deletelink {
cursor: pointer; cursor: pointer;
} }
.gate-error { color: #f38ba8 !important; font-weight: 600; margin-top: 0.5rem; } .gate-error { color: #f38ba8 !important; font-weight: 600; margin-top: 0.5rem; }
/* Rating tab */
.rating-summary { margin-bottom: 1rem; }
.rating-percent { font-size: 1.6rem; font-weight: 700; color: var(--md-sys-color-on-surface, #cdd6f4); }
.rating-count { color: var(--md-sys-color-on-surface-variant, #a6adc8); font-size: 0.8rem; margin-left: 0.5rem; }
.rating-bar {
display: flex; height: 10px; border-radius: 999px; overflow: hidden;
background: var(--md-sys-color-surface, #1e1e2e); margin-top: 0.5rem; max-width: 420px;
}
.rating-bar-up { background: #40a02b; }
.rating-bar-down { background: #d20f39; }
.rating-vote { display: flex; gap: 0.5rem; margin-top: 1rem; align-items: center; }
.rating-btn {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.45rem 0.9rem; border-radius: 8px;
border: 1px solid var(--md-sys-color-outline-variant, #45475a);
background: var(--md-sys-color-surface, #1e1e2e);
color: var(--md-sys-color-on-surface-variant, #a6adc8);
cursor: pointer; font-size: 0.85rem;
}
.rating-btn.active { background: var(--md-sys-color-primary, #89b4fa); color: #111; border-color: var(--md-sys-color-primary, #89b4fa); }
/* Forum tab */
.comment-form { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1.25rem; }
.comment-form textarea, .comment-edit-form textarea {
width: 100%; padding: 0.55rem 0.7rem; border-radius: 8px;
border: 1px solid var(--md-sys-color-outline-variant, #45475a);
background: var(--md-sys-color-surface, #1e1e2e);
color: var(--md-sys-color-on-surface, #cdd6f4);
font-family: inherit;
}
.comment-form button { align-self: flex-start; }
.comment-list { display: flex; flex-direction: column; gap: 0.9rem; }
.comment { padding: 0.8rem; border-radius: 10px; background: var(--md-sys-color-surface-variant, #45475a); }
.comment-head { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
.comment-time, .comment-edited { color: var(--md-sys-color-on-surface-variant, #a6adc8); font-size: 0.75rem; }
.comment-actions { margin-left: auto; display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.78rem; }
.comment-body { margin-top: 0.4rem; }
.comment-edit-form { margin-top: 0.5rem; display: flex; flex-direction: column; gap: 0.4rem; }
.comment-edit-form[hidden] { display: none; }
.comment-edit-actions { display: flex; gap: 0.4rem; }
.link-btn { background: none; border: none; color: inherit; cursor: pointer; padding: 0; }
.inline { display: inline; }
.pack-card-rating { color: var(--md-sys-color-on-surface-variant, #a6adc8); font-size: 0.75rem; }
+4
View File
@@ -35,6 +35,7 @@
<i class="{{ OS_ICON }}" style="margin-left: 4px;"></i> <i class="{{ OS_ICON }}" style="margin-left: 4px;"></i>
</span> </span>
{% if request.path != '/' %}
<a href="{% url 'landing:home' %}"> <a href="{% url 'landing:home' %}">
<i class="fas fa-home"></i><span class="nav-text"> Home</span> <i class="fas fa-home"></i><span class="nav-text"> Home</span>
</a> </a>
@@ -44,6 +45,7 @@
<a href="{% url 'profiles:user_list' %}"> <a href="{% url 'profiles:user_list' %}">
<i class="fas fa-users"></i><span class="nav-text"> Users</span> <i class="fas fa-users"></i><span class="nav-text"> Users</span>
</a> </a>
{% endif %}
</nav> </nav>
</div> </div>
<main class="main-content"> <main class="main-content">
@@ -131,11 +133,13 @@
} }
</script> </script>
{% else %} {% else %}
{% if request.path != '/' %}
<div class="auth-bar"> <div class="auth-bar">
<a href="{% url 'profiles:login' %}"><i class="fas fa-sign-in-alt"></i> Login</a> | <a href="{% url 'profiles:login' %}"><i class="fas fa-sign-in-alt"></i> Login</a> |
<a href="{% url 'profiles:register' %}"><i class="fas fa-user-plus"></i> Register</a> <a href="{% url 'profiles:register' %}"><i class="fas fa-user-plus"></i> Register</a>
</div> </div>
{% endif %} {% endif %}
{% endif %}
{% block extra_js %}{% endblock %} {% block extra_js %}{% endblock %}
</body> </body>
</html> </html>
@@ -21,6 +21,7 @@
<span class="pack-card-version"><i class="fas fa-tag"></i> {{ latest.version_name|default:"no versions" }}</span> <span class="pack-card-version"><i class="fas fa-tag"></i> {{ latest.version_name|default:"no versions" }}</span>
{% endwith %} {% endwith %}
<span class="pack-card-downloads"><i class="fas fa-download"></i> {{ project.downloads }}</span> <span class="pack-card-downloads"><i class="fas fa-download"></i> {{ project.downloads }}</span>
<span class="pack-card-rating"><i class="fas fa-thumbs-up"></i> {{ project.rating_score }}</span>
</div> </div>
</div> </div>
</a> </a>
+2
View File
@@ -57,6 +57,8 @@
<select name="sort" onchange="this.form.submit()"> <select name="sort" onchange="this.form.submit()">
<option value="recent" {% if sort == 'recent' %}selected{% endif %}>Newest</option> <option value="recent" {% if sort == 'recent' %}selected{% endif %}>Newest</option>
<option value="downloads" {% if sort == 'downloads' %}selected{% endif %}>Most downloaded</option> <option value="downloads" {% if sort == 'downloads' %}selected{% endif %}>Most downloaded</option>
<option value="most_liked" {% if sort == 'most_liked' %}selected{% endif %}>Most liked</option>
<option value="most_voted" {% if sort == 'most_voted' %}selected{% endif %}>Most voted</option>
<option value="name" {% if sort == 'name' %}selected{% endif %}>Name A-Z</option> <option value="name" {% if sort == 'name' %}selected{% endif %}>Name A-Z</option>
</select> </select>
</form> </form>
@@ -81,6 +81,8 @@
{% if skins_manifest %}<button class="tab-btn" data-tab="skins" role="tab">Skins</button>{% endif %} {% if skins_manifest %}<button class="tab-btn" data-tab="skins" role="tab">Skins</button>{% endif %}
{% if mod_manifest %}<button class="tab-btn" data-tab="modinfo" role="tab">Mod Info</button>{% endif %} {% if mod_manifest %}<button class="tab-btn" data-tab="modinfo" role="tab">Mod Info</button>{% endif %}
<button class="tab-btn" data-tab="gallery" role="tab">Gallery</button> <button class="tab-btn" data-tab="gallery" role="tab">Gallery</button>
<button class="tab-btn" data-tab="rating" role="tab">Rating</button>
<button class="tab-btn" data-tab="forum" role="tab">Forum</button>
</div> </div>
<section class="tab-panel active" id="tab-{% if project.category == 'guide' %}guide{% else %}description{% endif %}"> <section class="tab-panel active" id="tab-{% if project.category == 'guide' %}guide{% else %}description{% endif %}">
@@ -517,6 +519,105 @@
</div> </div>
</section> </section>
<section class="tab-panel" id="tab-rating">
<div class="card">
<h2><i class="fas fa-thumbs-up"></i> Rating</h2>
{% if rating_stats.total %}
<div class="rating-summary">
<span class="rating-percent">{{ rating_stats.percent }}% positive</span>
<span class="rating-count">{{ rating_stats.total }} vote{{ rating_stats.total|pluralize }} · {{ rating_stats.positive }} up · {{ rating_stats.negative }} down</span>
<div class="rating-bar">
<div class="rating-bar-up" style="width: {{ rating_stats.percent }}%"></div>
<div class="rating-bar-down" style="width: {{ rating_stats.negative }}%"></div>
</div>
</div>
{% else %}
<p class="empty-hint">No ratings yet — be the first to vote.</p>
{% endif %}
{% if can_comment %}
<div class="rating-vote">
<form method="post" action="{% url 'library:rate_project' project.slug %}">
{% csrf_token %}
<input type="hidden" name="value" value="1">
<button type="submit" class="rating-btn {% if user_rating %}active{% endif %}" title="Positive"><i class="fas fa-thumbs-up"></i> Good</button>
</form>
<form method="post" action="{% url 'library:rate_project' project.slug %}">
{% csrf_token %}
<input type="hidden" name="value" value="0">
<button type="submit" class="rating-btn {% if user_rating is False %}active{% endif %}" title="Negative"><i class="fas fa-thumbs-down"></i> Bad</button>
</form>
{% if user_rating is not None %}
<form method="post" action="{% url 'library:rate_project' project.slug %}">
{% csrf_token %}
<input type="hidden" name="value" value="">
<button type="submit" class="rating-btn" title="Clear your vote"><i class="fas fa-undo"></i></button>
</form>
{% endif %}
</div>
{% else %}
<p class="empty-hint"><a href="{% url 'profiles:login' %}?next={{ request.path }}" class="btn btn-secondary btn-sm">Log in</a> to rate this pack.</p>
{% endif %}
</div>
</section>
<section class="tab-panel" id="tab-forum">
<div class="card">
<h2><i class="fas fa-comments"></i> Forum</h2>
{% if can_comment %}
<form method="post" action="{% url 'library:add_comment' project.slug %}" class="comment-form">
{% csrf_token %}
<textarea name="body" rows="4" placeholder="Write a comment… Markdown supported." required></textarea>
<button type="submit" class="btn btn-primary"><i class="fas fa-paper-plane"></i> Post</button>
</form>
{% else %}
<p class="empty-hint"><a href="{% url 'profiles:login' %}?next={{ request.path }}" class="btn btn-secondary btn-sm">Log in</a> to join the discussion.</p>
{% endif %}
<div class="comment-list">
{% for comment in comments %}
<div class="comment" id="comment-{{ comment.pk }}">
<div class="comment-head">
<strong>{{ comment.user.username }}</strong>
<span class="comment-time">{{ comment.created_at|timesince }} ago</span>
{% if comment.updated_at != comment.created_at %}<span class="comment-edited">· edited</span>{% endif %}
{% if can_comment and comment.user == request.user %}
<span class="comment-actions">
<a href="#" class="comment-edit btn btn-secondary btn-sm" data-comment="{{ comment.pk }}"><i class="fas fa-edit"></i> Edit</a>
<form method="post" action="{% url 'library:delete_comment' project.slug comment.pk %}" class="inline">
{% csrf_token %}
<button type="submit" class="link-btn" title="Delete"><i class="fas fa-trash"></i></button>
</form>
</span>
{% elif can_comment and request.user.is_staff %}
<span class="comment-actions">
<form method="post" action="{% url 'library:delete_comment' project.slug comment.pk %}" class="inline" title="Delete this comment">
{% csrf_token %}
<button type="submit" class="btn btn-secondary btn-sm"><i class="fas fa-trash"></i> Delete</button>
</form>
</span>
{% endif %}
</div>
<div class="comment-body markdown-body">{{ comment.body|markdown }}</div>
{% if can_comment and comment.user == request.user %}
<form method="post" action="{% url 'library:edit_comment' project.slug comment.pk %}" class="comment-edit-form" data-comment="{{ comment.pk }}" hidden>
{% csrf_token %}
<textarea name="body" rows="3" required>{{ comment.body }}</textarea>
<div class="comment-edit-actions">
<button type="submit" class="btn btn-primary btn-sm">Save</button>
<button type="button" class="btn btn-secondary btn-sm comment-edit-cancel">Cancel</button>
</div>
</form>
{% endif %}
</div>
{% empty %}
<p class="empty-hint">No comments yet.</p>
{% endfor %}
</div>
</div>
</section>
<div class="modal-overlay" id="gallery-modal" hidden> <div class="modal-overlay" id="gallery-modal" hidden>
<div class="modal-content media-modal-content"> <div class="modal-content media-modal-content">
<button type="button" class="modal-close" data-close-modal aria-label="Close"><i class="fas fa-times"></i></button> <button type="button" class="modal-close" data-close-modal aria-label="Close"><i class="fas fa-times"></i></button>
@@ -542,6 +643,34 @@
}); });
}); });
// Land on the #rating / #forum tab when the URL points there.
const hashTab = location.hash.replace('#', '');
if (hashTab === 'rating' || hashTab === 'forum') {
const btn = document.querySelector(`.tab-btn[data-tab="${hashTab}"]`);
if (btn) btn.click();
}
// YouTube-style inline comment editing.
document.querySelectorAll('.comment-edit').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const id = link.dataset.comment;
const body = document.querySelector(`#comment-${id} .comment-body`);
const form = document.querySelector(`#comment-${id} .comment-edit-form`);
if (body) body.hidden = true;
if (form) form.hidden = false;
});
});
document.querySelectorAll('.comment-edit-cancel').forEach(btn => {
btn.addEventListener('click', () => {
const form = btn.closest('.comment-edit-form');
const id = form.dataset.comment;
const body = document.querySelector(`#comment-${id} .comment-body`);
if (body) body.hidden = false;
form.hidden = true;
});
});
// Gallery deletion without a page reload, behind a confirmation dialog. // Gallery deletion without a page reload, behind a confirmation dialog.
document.querySelectorAll('.gallery-delete').forEach(link => { document.querySelectorAll('.gallery-delete').forEach(link => {
link.addEventListener('click', (e) => { link.addEventListener('click', (e) => {