rating and comment system complete
This commit is contained in:
+51
@@ -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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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]}'
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -692,7 +692,7 @@ cursor: default;
|
|||||||
|
|
||||||
/* ========== Forms & Buttons ========== */
|
/* ========== Forms & Buttons ========== */
|
||||||
button,
|
button,
|
||||||
.btn {
|
.btn {
|
||||||
background-color: var(--md-sys-color-primary);
|
background-color: var(--md-sys-color-primary);
|
||||||
color: var(--md-sys-color-on-primary);
|
color: var(--md-sys-color-on-primary);
|
||||||
border: none;
|
border: none;
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user