final home desing
This commit is contained in:
@@ -8,6 +8,63 @@ from django.core.cache import cache
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from library.models import Project
|
||||||
|
|
||||||
|
|
||||||
|
class HomeSectionsTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = get_user_model().objects.create_user(username='Owner', password='pw')
|
||||||
|
|
||||||
|
def _home(self):
|
||||||
|
session = self.client.session
|
||||||
|
session['authorized'] = True
|
||||||
|
session.save()
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
return self.client.get(reverse('landing:home')).content.decode()
|
||||||
|
|
||||||
|
def test_empty_categories_skipped(self):
|
||||||
|
Project.objects.create(slug='only-mod', title='Only Mod', category='mod', owner=self.user)
|
||||||
|
html = self._home()
|
||||||
|
self.assertIn('>Mod</h2>', html)
|
||||||
|
self.assertNotIn('>Skin</h2>', html)
|
||||||
|
self.assertNotIn('>Guide</h2>', html)
|
||||||
|
|
||||||
|
def test_popular_ordered_by_rating(self):
|
||||||
|
Project.objects.create(slug='m0', title='Mod 0', category='mod', owner=self.user, rating_score=0)
|
||||||
|
Project.objects.create(slug='m2', title='Mod 2', category='mod', owner=self.user, rating_score=2)
|
||||||
|
Project.objects.create(slug='m1', title='Mod 1', category='mod', owner=self.user, rating_score=1)
|
||||||
|
html = self._home()
|
||||||
|
sections = html.split('class="home-cat-block"')[1:]
|
||||||
|
mod = next(s for s in sections if '>Mod</h2>' in s)
|
||||||
|
popular = mod.split('home-cat-divider')[0]
|
||||||
|
self.assertLess(popular.index('Mod 2'), popular.index('Mod 1'))
|
||||||
|
self.assertLess(popular.index('Mod 1'), popular.index('Mod 0'))
|
||||||
|
|
||||||
|
def test_columns_capped_at_six(self):
|
||||||
|
for i in range(8):
|
||||||
|
Project.objects.create(slug=f'm{i}', title=f'Mod {i}', category='mod', owner=self.user, rating_score=i)
|
||||||
|
html = self._home()
|
||||||
|
sections = html.split('class="home-cat-block"')[1:]
|
||||||
|
mod = next(s for s in sections if '>Mod</h2>' in s)
|
||||||
|
popular = mod.split('home-cat-divider')[0]
|
||||||
|
recent = mod.split('home-cat-divider')[1]
|
||||||
|
self.assertEqual(popular.count('class="pack-card-row"'), 6)
|
||||||
|
self.assertEqual(recent.count('class="pack-card-row"'), 6)
|
||||||
|
|
||||||
|
def test_recent_column_orders_by_creation(self):
|
||||||
|
Project.objects.create(slug='s0', title='High', category='skin', owner=self.user, rating_score=5)
|
||||||
|
Project.objects.create(slug='s1', title='Mid', category='skin', owner=self.user, rating_score=3)
|
||||||
|
Project.objects.create(slug='s2', title='Low', category='skin', owner=self.user, rating_score=0)
|
||||||
|
html = self._home()
|
||||||
|
sections = html.split('class="home-cat-block"')[1:]
|
||||||
|
skin = next(s for s in sections if '>Skin</h2>' in s)
|
||||||
|
popular = skin.split('home-cat-divider')[0]
|
||||||
|
recent = skin.split('home-cat-divider')[1]
|
||||||
|
# Popular leads with the top-rated pack.
|
||||||
|
self.assertLess(popular.index('High'), popular.index('Low'))
|
||||||
|
# Recent leads with the newest (created last -> 'Low').
|
||||||
|
self.assertLess(recent.index('Low'), recent.index('High'))
|
||||||
|
|
||||||
|
|
||||||
class ServerStatsTests(TestCase):
|
class ServerStatsTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|||||||
@@ -76,16 +76,38 @@ def gate(request):
|
|||||||
|
|
||||||
|
|
||||||
def home(request):
|
def home(request):
|
||||||
latest_projects = (
|
|
||||||
Project.objects.select_related('owner', 'thumbnail')
|
|
||||||
.prefetch_related('tag_links__tag__category', 'versions')
|
|
||||||
.order_by('-created_at')[:4]
|
|
||||||
)
|
|
||||||
total_downloads = Version.objects.aggregate(total=Sum('downloads'))['total'] or 0
|
total_downloads = Version.objects.aggregate(total=Sum('downloads'))['total'] or 0
|
||||||
creators = (
|
creators = (
|
||||||
get_user_model().objects.filter(projects__isnull=False).distinct().count()
|
get_user_model().objects.filter(projects__isnull=False).distinct().count()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Per-category Popular (by rating) + Recent columns, 6 packs each.
|
||||||
|
def _base_query(category):
|
||||||
|
return (
|
||||||
|
Project.objects.filter(category=category)
|
||||||
|
.select_related('owner', 'thumbnail')
|
||||||
|
.prefetch_related('versions')
|
||||||
|
.annotate(downloads_total=Sum('versions__downloads'))
|
||||||
|
)
|
||||||
|
|
||||||
|
home_sections = []
|
||||||
|
for value, label in Project.CATEGORY_CHOICES:
|
||||||
|
base = _base_query(value)
|
||||||
|
popular = list(base.order_by('-rating_score', '-created_at')[:6])
|
||||||
|
recent = list(base.order_by('-created_at')[:6])
|
||||||
|
if popular or recent:
|
||||||
|
home_sections.append({
|
||||||
|
'value': value,
|
||||||
|
'label': label,
|
||||||
|
'popular': popular,
|
||||||
|
'recent': recent,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Two categories share each row (compact cards leave room for it).
|
||||||
|
home_rows = [
|
||||||
|
home_sections[i:i + 2] for i in range(0, len(home_sections), 2)
|
||||||
|
]
|
||||||
|
|
||||||
announcements = (
|
announcements = (
|
||||||
Announcement.objects.filter(published=True)
|
Announcement.objects.filter(published=True)
|
||||||
.annotate(comments_count=Count('comments'))
|
.annotate(comments_count=Count('comments'))
|
||||||
@@ -106,7 +128,7 @@ def home(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'latest_projects': latest_projects,
|
'home_rows': home_rows,
|
||||||
'stats': {
|
'stats': {
|
||||||
'creators': creators,
|
'creators': creators,
|
||||||
'packs': Project.objects.count(),
|
'packs': Project.objects.count(),
|
||||||
|
|||||||
@@ -3890,3 +3890,51 @@ a.deletelink {
|
|||||||
.draft-title { font-weight: 600; color: var(--md-sys-color-on-surface, #cdd6f4); text-decoration: none; }
|
.draft-title { font-weight: 600; color: var(--md-sys-color-on-surface, #cdd6f4); text-decoration: none; }
|
||||||
.draft-meta { color: var(--md-sys-color-on-surface-variant, #a6adc8); font-size: 0.75rem; }
|
.draft-meta { color: var(--md-sys-color-on-surface-variant, #a6adc8); font-size: 0.75rem; }
|
||||||
.draft-actions { display: flex; gap: 0.4rem; align-items: center; }
|
.draft-actions { display: flex; gap: 0.4rem; align-items: center; }
|
||||||
|
|
||||||
|
/* Home per-category Popular/Recent sections */
|
||||||
|
.home-cat-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1px 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: start;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
.home-cat-row-divider { background: var(--md-sys-color-outline-variant, #45475a); align-self: stretch; }
|
||||||
|
.home-cat-block { min-width: 0; }
|
||||||
|
.home-cat-block:only-child { grid-column: 1 / -1; }
|
||||||
|
.home-cat-cols {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1px 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.home-cat-divider { background: var(--md-sys-color-outline-variant, #45475a); align-self: stretch; }
|
||||||
|
.home-cat-head { margin: 0 0 0.6rem; font-size: 0.95rem; }
|
||||||
|
.home-cat-head i { color: var(--md-sys-color-primary); margin-right: 0.3rem; }
|
||||||
|
.pack-row-list { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.home-cat-row { grid-template-columns: 1fr; }
|
||||||
|
.home-cat-row-divider { height: 1px; width: 100%; }
|
||||||
|
.home-cat-cols { grid-template-columns: 1fr; }
|
||||||
|
.home-cat-divider { height: 1px; width: 100%; }
|
||||||
|
}
|
||||||
|
.pack-card-row {
|
||||||
|
display: flex; align-items: center; gap: 0.75rem;
|
||||||
|
background: var(--md-sys-color-surface-variant, #45475a);
|
||||||
|
border: 1px solid var(--md-sys-color-outline-variant, #3a3f4d);
|
||||||
|
border-radius: 10px; padding: 0.5rem 0.7rem;
|
||||||
|
text-decoration: none; transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.pack-card-row:hover { border-color: var(--md-sys-color-primary); }
|
||||||
|
.pack-card-row-thumb {
|
||||||
|
width: 56px; height: 56px; flex-shrink: 0; border-radius: 8px;
|
||||||
|
overflow: hidden; display: flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--md-sys-color-surface, #1e1e2e);
|
||||||
|
}
|
||||||
|
.pack-card-row-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.pack-card-row-thumb .pack-card-thumb-icon { font-size: 1.3rem; color: var(--md-sys-color-on-surface-variant, #a6adc8); }
|
||||||
|
.pack-card-row-body { min-width: 0; flex: 1; }
|
||||||
|
.pack-card-row-body h4 { margin: 0; font-size: 0.9rem; color: var(--md-sys-color-on-surface, #cdd6f4); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.pack-card-row-author { margin: 0.1rem 0 0; color: var(--md-sys-color-on-surface-variant, #a6adc8); font-size: 0.72rem; }
|
||||||
|
.pack-card-row-meta { display: flex; gap: 0.6rem; margin: 0.15rem 0 0; color: var(--md-sys-color-on-surface-variant, #a6adc8); font-size: 0.72rem; flex-wrap: wrap; }
|
||||||
|
.pack-card-row-meta span { white-space: nowrap; }
|
||||||
|
|||||||
@@ -78,26 +78,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="home-section">
|
{% for row in home_rows %}
|
||||||
|
<div class="home-cat-row">
|
||||||
|
{% for section in row %}
|
||||||
|
<div class="home-cat-block">
|
||||||
<div class="home-section-header">
|
<div class="home-section-header">
|
||||||
<h2>Latest packs</h2>
|
<h2>{{ section.label }}</h2>
|
||||||
<a href="{% url 'library:browse' %}" class="home-section-link">View all <i class="fas fa-arrow-right"></i></a>
|
<a href="{% url 'library:browse' %}?category={{ section.value }}" class="home-section-link">View all <i class="fas fa-arrow-right"></i></a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="home-cat-cols">
|
||||||
<div class="pack-grid">
|
<div class="home-cat-col">
|
||||||
{% for project in latest_projects %}
|
<h3 class="home-cat-head"><i class="fas fa-fire"></i> Popular</h3>
|
||||||
{% include 'library/_project_card.html' %}
|
<div class="pack-row-list">
|
||||||
|
{% for project in section.popular %}
|
||||||
|
{% include 'library/_project_card_row.html' %}
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<div class="pack-card pack-card-empty">
|
<p class="empty-hint">None yet.</p>
|
||||||
<div class="pack-card-thumb">
|
|
||||||
<i class="fas fa-box-open"></i>
|
|
||||||
</div>
|
|
||||||
<div class="pack-card-body">
|
|
||||||
<h3>No packs yet</h3>
|
|
||||||
<p class="pack-card-desc">Be the first to share a pack.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="home-cat-divider" role="separator"></div>
|
||||||
|
<div class="home-cat-col">
|
||||||
|
<h3 class="home-cat-head"><i class="fas fa-clock"></i> Recent</h3>
|
||||||
|
<div class="pack-row-list">
|
||||||
|
{% for project in section.recent %}
|
||||||
|
{% include 'library/_project_card_row.html' %}
|
||||||
|
{% empty %}
|
||||||
|
<p class="empty-hint">None yet.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if not forloop.last %}<div class="home-cat-row-divider" role="separator"></div>{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{% load static %}
|
||||||
|
<a href="{% url 'library:project_detail' project.slug %}" class="pack-card-row">
|
||||||
|
<div class="pack-card-row-thumb">
|
||||||
|
{% if project.thumbnail %}
|
||||||
|
<img src="{{ project.thumbnail_url }}" alt="">
|
||||||
|
{% else %}
|
||||||
|
<i class="fas fa-box-open pack-card-thumb-icon"></i>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="pack-card-row-body">
|
||||||
|
<h4>{{ project.title }}</h4>
|
||||||
|
<p class="pack-card-row-author"><i class="fas fa-user"></i> {{ project.owner.username }}</p>
|
||||||
|
{% with latest=project.versions.all|first %}
|
||||||
|
<p class="pack-card-row-meta">
|
||||||
|
<span><i class="fas fa-tag"></i> {{ latest.version_name|default:"no versions" }}</span>
|
||||||
|
<span><i class="fas fa-download"></i> {{ project.downloads_total|default:project.downloads }}</span>
|
||||||
|
<span><i class="fas fa-thumbs-up"></i> {{ project.rating_score }}</span>
|
||||||
|
</p>
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
Reference in New Issue
Block a user