Browse Source

- Misago tracks threads reads
- Forums layouts are back
- Subforums are listed in thread view

Ralfp 12 years ago
parent
commit
0fcbbb02e6

+ 12 - 0
misago/forums/forms.py

@@ -11,6 +11,11 @@ class CategoryForm(Form):
     description = forms.CharField(widget=forms.Textarea,required=False)
     closed = forms.BooleanField(widget=YesNoSwitch,required=False)
     style = forms.CharField(max_length=255,required=False)
+    template = forms.ChoiceField(choices=(
+                                          ('row', _('One forum per row')),
+                                          ('half', _('Two forums per row')),
+                                          ('quarter', _('Four forums per row')),
+                                          ))
     
     layout = (
               (
@@ -26,6 +31,7 @@ class CategoryForm(Form):
               (
                _("Display Options"),
                (
+                ('template', {'label': _("Category Layout"), 'help_text': _('Controls how this category is displayed on forums lists.')}),
                 ('style', {'label': _("Category Style"), 'help_text': _('You can add custom CSS classess to this category, to change way it looks on board index.')}),
                 ),
               ),
@@ -45,6 +51,11 @@ class ForumForm(Form):
     style = forms.CharField(max_length=255,required=False)
     prune_start = forms.IntegerField(min_value=0,initial=0)
     prune_last = forms.IntegerField(min_value=0,initial=0)
+    template = forms.ChoiceField(choices=(
+                                          ('row', _('One forum per row')),
+                                          ('half', _('Two forums per row')),
+                                          ('quarter', _('Four forums per row')),
+                                          ))
     
     layout = (
               (
@@ -67,6 +78,7 @@ class ForumForm(Form):
               (
                _("Display Options"),
                (
+                ('template', {'label': _("Subforums Layout"), 'help_text': _('Controls how this forum displays subforums list.')}),
                 ('style', {'label': _("Forum Style"), 'help_text': _('You can add custom CSS classess to this forum to change way it looks on forums lists.')}),
                 ),
               ),

+ 19 - 10
misago/forums/models.py

@@ -5,7 +5,7 @@ from mptt.models import MPTTModel,TreeForeignKey
 from misago.roles.models import Role
 
 class ForumManager(models.Manager):
-    def treelist(self, acl, parent=None):
+    def treelist(self, acl, parent=None, tracker=None):
         complete_list = []
         forums_list = []
         parents = {}
@@ -17,6 +17,9 @@ class ForumManager(models.Manager):
             
         for forum in queryset:
             forum.subforums = []
+            forum.is_read = False
+            if tracker:
+                forum.is_read = tracker.is_read(forum)
             parents[forum.pk] = forum
             complete_list.append(forum)
             if forum.parent_id in parents:
@@ -31,15 +34,20 @@ class ForumManager(models.Manager):
                 parents[forum.parent_id].threads_delta += forum.threads_delta
                 parents[forum.parent_id].posts += forum.posts
                 parents[forum.parent_id].posts_delta += forum.posts_delta
-                if acl.can_browse(forum.pk) and forum.last_thread_date and (not parents[forum.parent_id].last_thread_date or forum.last_thread_date > parents[forum.parent_id].last_thread_date):
-                    parents[forum.parent_id].last_thread = forum.last_thread
-                    parents[forum.parent_id].last_thread_name = forum.last_thread_name
-                    parents[forum.parent_id].last_thread_slug = forum.last_thread_slug
-                    parents[forum.parent_id].last_thread_date = forum.last_thread_date
-                    parents[forum.parent_id].last_poster = forum.last_poster
-                    parents[forum.parent_id].last_poster_name = forum.last_poster_name
-                    parents[forum.parent_id].last_poster_slug = forum.last_poster_slug
-                    parents[forum.parent_id].last_poster_style = forum.last_poster_style
+                if acl.can_browse(forum.pk):
+                    # If forum is unread, make parent unread too
+                    if not forum.is_read:
+                        parents[forum.parent_id].is_read = False
+                    # Sum stats
+                    if forum.last_thread_date and (not parents[forum.parent_id].last_thread_date or forum.last_thread_date > parents[forum.parent_id].last_thread_date):
+                        parents[forum.parent_id].last_thread = forum.last_thread
+                        parents[forum.parent_id].last_thread_name = forum.last_thread_name
+                        parents[forum.parent_id].last_thread_slug = forum.last_thread_slug
+                        parents[forum.parent_id].last_thread_date = forum.last_thread_date
+                        parents[forum.parent_id].last_poster = forum.last_poster
+                        parents[forum.parent_id].last_poster_name = forum.last_poster_name
+                        parents[forum.parent_id].last_poster_slug = forum.last_poster_slug
+                        parents[forum.parent_id].last_poster_style = forum.last_poster_style
         return forums_list
 
 
@@ -68,6 +76,7 @@ class Forum(MPTTModel):
     prune_start = models.PositiveIntegerField(default=0)
     prune_last = models.PositiveIntegerField(default=0)
     redirect = models.CharField(max_length=255,null=True,blank=True)
+    template = models.CharField(max_length=255,null=True,blank=True)
     style = models.CharField(max_length=255,null=True,blank=True)
     closed = models.BooleanField(default=False)
     

+ 11 - 4
misago/forums/views.py

@@ -1,3 +1,4 @@
+import copy
 from django.core.urlresolvers import reverse as django_reverse
 from django.db.models import Q
 from django.utils.translation import ugettext as _
@@ -82,6 +83,7 @@ class NewCategory(FormWidget):
     def submit_form(self, form, target):
         new_forum = Forum(
                      name=form.cleaned_data['name'],
+                     template=form.cleaned_data['template'],
                      slug=slugify(form.cleaned_data['name']),
                      type='category',
                      style=form.cleaned_data['style'],
@@ -115,6 +117,7 @@ class NewForum(FormWidget):
                      name=form.cleaned_data['name'],
                      slug=slugify(form.cleaned_data['name']),
                      type='forum',
+                     template=form.cleaned_data['template'],
                      style=form.cleaned_data['style'],
                      closed=form.cleaned_data['closed'],
                      prune_start=form.cleaned_data['prune_start'],
@@ -219,15 +222,16 @@ class Edit(FormWidget):
     
     def get_form(self, target):
         if target.type == 'category':
-            self.name= _("Edit Category")
+            self.name = _("Edit Category")
             self.form = CategoryForm
         if target.type == 'redirect':
-            self.name= _("Edit Redirect")
+            self.name = _("Edit Redirect")
             self.form = RedirectForm
         
         # Remove invalid targets from parent select
+        self.form = copy.deepcopy(self.form)
         valid_targets = Forum.tree.get(token='root').get_descendants(include_self=target.type == 'category').exclude(Q(lft__gte=target.lft) & Q(rght__lte=target.rght))
-        self.form.fields['parent'] = TreeNodeChoiceField(queryset=valid_targets,level_indicator=u'- - ')
+        self.form.base_fields['parent'] = TreeNodeChoiceField(queryset=valid_targets,level_indicator=u'- - ')
         
         return self.form
     
@@ -241,6 +245,7 @@ class Edit(FormWidget):
         if model.type == 'redirect':
             initial['redirect'] = model.redirect
         else:
+            initial['template'] = model.template
             initial['style'] = model.style
             initial['closed'] = model.closed
             
@@ -256,6 +261,7 @@ class Edit(FormWidget):
         if target.type == 'redirect':
             target.redirect = form.cleaned_data['redirect']
         else:
+            target.template = form.cleaned_data['template']
             target.style = form.cleaned_data['style']
             target.closed = form.cleaned_data['closed']
             
@@ -299,8 +305,9 @@ class Delete(FormWidget):
             self.name= _("Delete Redirect")
         
         # Remove invalid targets from parent select
+        self.form = copy.deepcopy(self.form)
         valid_targets = Forum.tree.get(token='root').get_descendants(include_self=target.type == 'category').exclude(Q(lft__gte=target.lft) & Q(rght__lte=target.rght))
-        self.form.fields['parent'] = TreeNodeChoiceField(queryset=valid_targets,required=False,empty_label=_("Remove with forum"),level_indicator=u'- - ')
+        self.form.base_fields['parent'] = TreeNodeChoiceField(queryset=valid_targets,required=False,empty_label=_("Remove with forum"),level_indicator=u'- - ')
         
         return self.form
         

+ 0 - 0
misago/readstracker/__init__.py


+ 25 - 0
misago/readstracker/models.py

@@ -0,0 +1,25 @@
+from django.db import models
+import base64
+try:
+    import cPickle as pickle
+except ImportError:
+    import pickle
+
+class Record(models.Model):
+    user = models.ForeignKey('users.User')
+    forum = models.ForeignKey('forums.Forum')
+    threads = models.TextField(null=True,blank=True)
+    updated = models.DateTimeField()
+    cleared = models.DateTimeField()
+    
+    def get_threads(self):
+        try:
+            return pickle.loads(base64.decodestring(self.threads))
+        except Exception:
+            # ValueError, SuspiciousOperation, unpickling exceptions. If any of
+            # these happen, just return an empty dictionary (an empty permissions list).
+            return {}
+    
+    def set_threads(self, threads):
+        self.threads = base64.encodestring(pickle.dumps(threads, pickle.HIGHEST_PROTOCOL))
+    

+ 73 - 0
misago/readstracker/trackers.py

@@ -0,0 +1,73 @@
+from datetime import timedelta
+from django.conf import settings
+from django.utils import timezone
+from misago.readstracker.models import Record
+from misago.threads.models import Thread
+
+class ForumsTracker(object):
+    def __init__(self, user):
+        self.user = user
+        self.cutoff = timezone.now() - timedelta(days=settings.READS_TRACKER_LENGTH)
+        self.forums = {}
+        if self.user.is_authenticated() and settings.READS_TRACKER_LENGTH > 0:
+            for forum in Record.objects.filter(user=user).filter(updated__gte=self.cutoff).values('id', 'forum_id', 'updated', 'cleared'):
+                 self.forums[forum['forum_id']] = forum
+        print self.forums
+                 
+    def is_read(self, forum):
+        if not self.user.is_authenticated() or not forum.last_thread_date:
+            return True
+        try:
+            return forum.last_thread_date <= self.cutoff or forum.last_thread_date <= self.forums[forum.pk]['cleared']
+        except KeyError:
+            return False
+
+
+class ThreadsTracker(object):
+    def __init__(self, user, forum):
+        self.need_sync = False
+        self.need_update = False
+        self.user = user
+        self.forum = forum
+        self.cutoff = timezone.now() - timedelta(days=settings.READS_TRACKER_LENGTH)
+        try:
+            self.record = Record.objects.get(user=user,forum=forum)
+        except Record.DoesNotExist:
+            self.record = Record(user=user,forum=forum,cleared=self.cutoff)
+        self.threads = self.record.get_threads()
+    
+    def is_read(self, thread):
+        if not self.user.is_authenticated():
+            return True
+        try:
+            if thread.last <= self.cutoff and thread.pk in self.threads:
+                del self.threads[thread.pk]
+                self.need_update = True
+            return thread.last <= self.cutoff or thread.last <= self.threads[thread.pk]
+        except KeyError:
+            return False
+        
+    def set_read(self, thread, post):
+        if self.user.is_authenticated():
+            try:
+                if self.threads[thread.pk] < post.date:
+                    self.threads[thread.pk] = post.date
+                    self.need_sync = True
+            except KeyError:
+                self.threads[thread.pk] = post.date
+                self.need_sync = True
+                        
+    def sync(self):
+        now = timezone.now()
+        if self.need_sync:
+            unread_threads = 0
+            for thread in Thread.objects.filter(last__gte=self.record.cleared).all():
+                if not self.is_read(thread):
+                    unread_threads += 1
+            if not unread_threads:
+                self.record.cleared = now
+                
+        if self.need_sync or self.need_update:
+            self.record.updated = now
+            self.record.set_threads(self.threads)
+            self.record.save(force_update=self.record.pk)

+ 7 - 0
misago/settings_base.py

@@ -31,6 +31,12 @@ LOCALE_PATHS = (
 # If DEBUG_MODE is on, all emails will be sent to this address instead of real recipient.
 CATCH_ALL_EMAIL_ADDRESS = ''
 
+# Forums and threads read tracker length (days
+# Enter 0 to turn tracking off
+# The bigger the number, then longer tracker keeps threads reads
+# information and the more costful it is to track reads
+READS_TRACKER_LENGTH = 7
+
 # List of finder classes that know how to find static files in
 # various locations.
 STATICFILES_FINDERS = (
@@ -150,6 +156,7 @@ INSTALLED_APPS = (
     'misago.activation', # Activate inactive User or resend activation e-mail
     'misago.resetpswd', # Reset User Password
     'misago.threads', # Threads and Posts
+    'misago.readstracker', # Forums and Threads reads tracker
 )
 
 # IP's that can see debug toolbar

+ 6 - 1
misago/threads/views/list.py

@@ -4,6 +4,7 @@ from django.template import RequestContext
 from django.utils.translation import ugettext as _
 from misago.acl.utils import ACLError403, ACLError404
 from misago.forums.models import Forum
+from misago.readstracker.trackers import ForumsTracker, ThreadsTracker
 from misago.threads.models import Thread, Post
 from misago.threads.views.base import BaseView
 from misago.views import error403, error404
@@ -14,13 +15,17 @@ class ThreadsView(BaseView):
         self.forum = Forum.objects.get(pk=forum, type='forum')
         self.request.acl.forums.allow_forum_view(self.forum)
         self.parents = self.forum.get_ancestors().filter(level__gt=1)
-        
+        self.forum.subforums = Forum.objects.treelist(self.request.acl.forums, self.forum, tracker=ForumsTracker(self.request.user))
+        self.tracker = ThreadsTracker(self.request.user, self.forum)
+                
     def fetch_threads(self, page):
         self.count = Thread.objects.filter(forum=self.forum).count()
         self.threads = Thread.objects.filter(forum=self.forum).order_by('-weight', '-last').all()
         self.pagination = make_pagination(page, self.count, self.request.settings.threads_per_page)
         if self.request.settings.threads_per_page < self.count:
             self.threads = self.threads[self.pagination['start']:self.pagination['stop']]
+        for thread in self.threads:
+            thread.is_read = self.tracker.is_read(thread)
     
     def __call__(self, request, slug=None, forum=None, page=0):
         self.request = request

+ 5 - 4
misago/threads/views/posting.py

@@ -63,14 +63,15 @@ class PostingView(BaseView):
         if request.method == 'POST':
             form = self.get_form(True)
             if form.is_valid():
+                now = timezone.now()
                 # Get or create new thread
                 if self.mode in ['new_thread', 'edit_thread']:
                     thread = Thread.objects.create(
                                                    forum=self.forum,
                                                    name=form.cleaned_data['thread_name'],
                                                    slug=slugify(form.cleaned_data['thread_name']),
-                                                   start=timezone.now(),
-                                                   last=timezone.now(),
+                                                   start=now,
+                                                   last=now,
                                                    )
                 else:
                     thread = self.thread
@@ -85,7 +86,7 @@ class PostingView(BaseView):
                                            agent=request.META.get('HTTP_USER_AGENT'),
                                            post=form.cleaned_data['post'],
                                            post_preparsed=post_markdown(request, form.cleaned_data['post']),
-                                           date=timezone.now()
+                                           date=now
                                            )
                 
                 if self.mode == 'new_thread':
@@ -96,7 +97,7 @@ class PostingView(BaseView):
                     if request.user.rank and request.user.rank.style:
                         thread.start_poster_style = request.user.rank.style
                     
-                thread.last = timezone.now()
+                thread.last = now
                 thread.last_post = post
                 thread.last_poster = request.user
                 thread.last_poster_name = request.user.username

+ 7 - 2
misago/threads/views/thread.py

@@ -5,6 +5,7 @@ from django.utils.translation import ugettext as _
 from misago.acl.utils import ACLError403, ACLError404
 from misago.forms import FormFields
 from misago.forums.models import Forum
+from misago.readstracker.trackers import ThreadsTracker
 from misago.threads.forms import QuickReplyForm
 from misago.threads.models import Thread, Post
 from misago.threads.views.base import BaseView
@@ -18,14 +19,18 @@ class ThreadView(BaseView):
         self.request.acl.forums.allow_forum_view(self.forum)
         self.request.acl.threads.allow_thread_view(self.thread)
         self.parents = self.forum.get_ancestors(include_self=True).filter(level__gt=1)
+        self.tracker = ThreadsTracker(self.request.user, self.forum)
     
     def fetch_posts(self, page):
         self.count = Post.objects.filter(thread=self.thread).count()
-        self.posts = Post.objects.filter(thread=self.thread).order_by('pk').all()
+        self.posts = Post.objects.filter(thread=self.thread).order_by('pk').all().prefetch_related('user', 'user__rank')
         self.pagination = make_pagination(page, self.count, self.request.settings.posts_per_page)
         if self.request.settings.threads_per_page < self.count:
             self.posts = self.posts[self.pagination['start']:self.pagination['stop']]
-        self.posts.prefetch_related('user', 'user__rank')
+        last_post = self.posts[len(self.posts) - 1]
+        if not self.tracker.is_read(self.thread):
+            self.tracker.set_read(self.thread, last_post)
+            self.tracker.sync()
         
     def __call__(self, request, slug=None, thread=None, page=0):
         self.request = request

+ 1 - 1
misago/users/models.py

@@ -146,7 +146,7 @@ class User(models.Model):
     following = models.PositiveIntegerField(default=0)
     followers = models.PositiveIntegerField(default=0)
     score = models.IntegerField(default=0,db_index=True)
-    rank = models.ForeignKey('ranks.Rank',null=True,blank=True,db_index=True,on_delete=models.SET_NULL)
+    rank = models.ForeignKey('ranks.Rank',null=True,blank=True,on_delete=models.SET_NULL)
     last_sync = models.DateTimeField(null=True,blank=True)
     follows = models.ManyToManyField('self',related_name='follows_set',symmetrical=False)
     ignores = models.ManyToManyField('self',related_name='ignores_set',symmetrical=False)

+ 5 - 3
misago/views.py

@@ -3,6 +3,7 @@ from django.shortcuts import redirect
 from django.template import RequestContext
 from django.utils.translation import ugettext as _
 from misago.forums.models import Forum
+from misago.readstracker.trackers import ForumsTracker
 from misago.sessions.models import Session
 
 def home(request):
@@ -12,10 +13,10 @@ def home(request):
         if session.user.pk not in team_pks:
             team_pks.append(session.user.pk)
             team_online.append(session.user)
-            
+    reads_tracker = ForumsTracker(request.user)
     return request.theme.render_to_response('index.html',
                                             {
-                                             'forums_list': Forum.objects.treelist(request.acl.forums),
+                                             'forums_list': Forum.objects.treelist(request.acl.forums, tracker=reads_tracker),
                                              'team_online': team_online,
                                              },
                                             context_instance=RequestContext(request));
@@ -30,11 +31,12 @@ def category(request, forum, slug):
             return error403(request, _("You don't have permission to browse this category."))
     except Forum.DoesNotExist:
         return error404(request)
+
+    forum.subforums = Forum.objects.treelist(request.acl.forums, forum, tracker=ForumsTracker(request.user))
     return request.theme.render_to_response('category.html',
                                             {
                                              'category': forum,
                                              'parents': forum.get_ancestors().filter(level__gt=1),
-                                             'forums_list': Forum.objects.treelist(request.acl.forums, forum),
                                              },
                                             context_instance=RequestContext(request));
 

+ 8 - 3
static/sora/css/sora.css

@@ -943,19 +943,24 @@ td.lead-cell{color:#555555;font-weight:bold;}
 .nav-tabs .tab-search form{marging:0px;margin-bottom:-4px;}
 .nav-tabs .tab-search.tab-search-no-tabs{position:relative;bottom:12px;}
 .nav-tabs button{padding-left:7px;padding-right:7px;}
-.forums-list{padding-top:4px;}.forums-list .category{margin-bottom:12px;}.forums-list .category h2{color:#666666;font-size:110%;margin-bottom:0px;}.forums-list .category h2 small{color:#a6a6a6;font-size:100%;}
+.forums-list{padding-top:4px;}.forums-list .category h2{color:#666666;font-size:110%;margin-bottom:0px;}.forums-list .category h2 small{color:#a6a6a6;font-size:100%;}
 .forums-list .category-important .well-forum{border:1px solid #0099e6;-webkit-box-shadow:0px 0px 0px 3px #66ccff;-moz-box-shadow:0px 0px 0px 3px #66ccff;box-shadow:0px 0px 0px 3px #66ccff;}
 .forums-list .well-forum{margin:0px -8px;margin-bottom:14px;overflow:auto;padding:12px 8px;padding-bottom:8px;}.forums-list .well-forum .row .span3{margin-left:0px;padding-left:16px;}
-.forums-list .well-forum .forum-icon{background-color:#0088cc;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;float:left;padding:3px 6px;position:relative;bottom:4px;margin-bottom:-4px;}
+.forums-list .well-forum .forum-icon{background-color:#eeeeee;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;float:left;padding:3px 6px;position:relative;bottom:4px;margin-bottom:-4px;}
+.forums-list .well-forum .forum-new{background-color:#0088cc !important;}
 .forums-list .well-forum .redirect-icon{background-color:#46a546;}
 .forums-list .well-forum .forum-details{margin-left:36px;overflow:auto;}
 .forums-list .well-forum .forum-stat{margin-left:16px;margin-right:4px;padding:8px 12px;font-size:140%;}.forums-list .well-forum .forum-stat .muted{color:#999999;}
 .forums-list .well-forum .forum-stat .stag,.forums-list .well-forum .forum-stat .positive{-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;margin:-2px 0px;padding:2px 5px;text-shadow:0px 1px 0px #ffffff;}
 .forums-list .well-forum .forum-stat .stag{background-color:#eeeeee;color:#8c8c8c;}
 .forums-list .well-forum .forum-stat .positive{background-color:#cdeacd;color:#3e933e;}
-.forums-list .well-forum h3{margin:0px;padding:0px;font-size:110%;line-height:100%;}.forums-list .well-forum h3 a:link,.forums-list .well-forum h3 a:active,.forums-list .well-forum h3 a:visited{color:#333333;}
+.forums-list .well-forum h3{margin:0px;padding:0px;font-size:110%;line-height:100%;}.forums-list .well-forum h3 .read-forum{color:#75a5bd;}
 .forums-list .well-forum .muted{margin-top:4px;color:#737373;}
 .forum-list-side{padding-top:10px;}
+.subforums-list{margin:0px;position:relative;bottom:32px;}.subforums-list .category{margin-bottom:-24px;}
+.threads-list .thread-icon{background-color:#999999;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;padding:5px 6px;}
+.threads-list .thread-closed{background-color:#9d261d;}
+.threads-list .thread-new{background-color:#0088cc;}
 .thread-info{overflow:auto;}.thread-info li{float:left;margin-right:16px;opacity:0.5;filter:alpha(opacity=50);font-weight:bold;}.thread-info li a{color:#333333;}
 .posts-list{margin-top:12px;margin-bottom:20px;}.posts-list .well-post{margin:0px;margin-bottom:16px;overflow:auto;padding:16px 0px;}.posts-list .well-post .post-author{overflow:auto;float:left;width:284px;position:relative;bottom:4px;}.posts-list .well-post .post-author .avatar-normal{float:left;margin:4px 0px;margin-left:16px;width:80px;height:80px;-webkit-box-shadow:0px 0px 4px #999999;-moz-box-shadow:0px 0px 4px #999999;box-shadow:0px 0px 4px #999999;}
 .posts-list .well-post .post-author .post-bit{float:left;margin-left:12px;padding-top:4px;font-weight:bold;font-size:120%;}.posts-list .well-post .post-author .post-bit p{margin:0px;}

+ 8 - 6
static/sora/css/sora/forums.less

@@ -13,9 +13,7 @@
         color: lighten(@textColor, 45%);
         font-size: 100%;
       }
-    } 
-    
-    margin-bottom: 12px;
+    }
   }
   
   .category-important {
@@ -40,7 +38,7 @@
     }
     
     .forum-icon {
-      background-color: @linkColor; 
+      background-color: @grayLighter; 
       .border-radius(3px);
       float: left;
       padding: 3px 6px;
@@ -49,6 +47,10 @@
       margin-bottom: -4px;
     }
     
+    .forum-new {
+      background-color: @linkColor !important; 
+    }
+    
     .redirect-icon {
       background-color: @green; 
     }
@@ -95,8 +97,8 @@
       font-size: 110%;
       line-height: 100%;
       
-      a:link, a:active, a:visited {
-        color: @textColor;
+      .read-forum {
+        color: lighten(desaturate(@linkColor, 65%), 20%);
       }
     }
     

+ 30 - 0
static/sora/css/sora/threads.less

@@ -1,5 +1,35 @@
 // Misago Threads Styles
 // --------------------------------------------------
+.subforums-list {
+  margin: 0px;
+  position: relative;
+  bottom: 32px;
+  
+  .category {
+    margin-bottom: -24px;
+  }
+}
+
+// Threads list
+// --------------------------------------------------
+.threads-list {
+  .thread-icon {
+    background-color: @grayLight; 
+    .border-radius(3px);
+    padding: 5px 6px;
+  }
+  
+  .thread-closed {
+    background-color: @red; 
+  }
+  
+  .thread-new {
+    background-color: @linkColor; 
+  }
+}
+
+// Thread view
+// --------------------------------------------------
 .thread-info {
   overflow: auto;
   

+ 4 - 3
templates/sora/category.html

@@ -14,13 +14,14 @@
 
 {% block content %}
 <div class="page-header">
+  <ul class="breadcrumb">
+    {{ self.breadcrumb() }}</li>
+  </ul>
   <h1>{{ category.name }}{% if category.description %} <small>{{ category.description }}</small>{% endif %}</h1>
 </div>
 <div class="forums-list">
   <div class="category{% if category.style %} {{ category.style }}{% endif %}">
-    {% for forum in forums_list %}
-    {{ macros.draw_forum(category, forum) }}
-    {% endfor %}
+    {{ macros.draw_forums(category, 12) }}
   </div>
 </div>
 {% endblock %}

+ 0 - 3
templates/sora/forums_list.html

@@ -1,3 +0,0 @@
-{% load i18n %}
-{% load url from future %}
-

+ 1 - 3
templates/sora/index.html

@@ -16,9 +16,7 @@
       {% for category in forums_list %}{% if category.subforums %}
       <div class="category{% if category.style %} {{ category.style }}{% endif %}">
         <h2>{{ category.name }}{% if category.description %} <small><strong>{{ category.description }}</strong></small>{% endif %}</h2>
-        {% for forum in category.subforums %}
-        {{ macros.draw_forum(category, forum) }}
-        {% endfor %}
+        {{ macros.draw_forums(category, 8) }}
       </div>{% endif %}{% endfor %}
     </div>
   </div>

+ 44 - 19
templates/sora/macros.html

@@ -26,27 +26,52 @@
   		{%- endif %} icon-white"></i></span></div>
 {%- endmacro %}
 
-{# Render forum on list #}
-{% macro draw_forum(category, forum) %}
-<div class="well well-forum{% if forum.style %} {{ forum.style }}{% endif %}">
-  <div class="forum-icon{% if forum.type == 'redirect' %} redirect-icon{% endif %}"><i class="icon-{% if forum.type == 'redirect' %}circle-arrow-right{% else %}comment{% endif %} icon-white"></i></div>
-  <div class="forum-details">
-    <div class="pull-left">
-      <h3><a href="{{ forum.type|url(slug=forum.slug, forum=forum.id) }}">{{ forum.name }}</a></h3>
-      {% if forum.description %}<div class="muted">{{ forum.description }}</div>{% endif %}
-    </div>
-    {% if forum.type == 'redirect' %}
-    <div class="pull-right forum-stat stat-redirects">
-      <span class="stat {% if forum.redirects_delta > 0 %}positive{% else %}stag{% endif %}">{% if forum.redirects_delta > 0 %}+{{ forum.redirects_delta }}{% else %}{{ forum.redirects }}{% endif %}</span> <span class="muted">{% trans %}clicks{% endtrans %}</span>
-    </div>
-    {% else %}
-    <div class="pull-right forum-stat stat-posts">
-      <span class="stat {% if forum.posts_delta > 0 %}positive{% else %}stag{% endif %}">{% if forum.posts_delta > 0 %}+{{ forum.posts_delta }}{% else %}{{ forum.posts }}{% endif %}</span> <span class="muted">{% trans %}posts{% endtrans %}</span>
-    </div>
-    <div class="pull-right forum-stat stat-threads">
-      <span class="stat {% if forum.threads_delta > 0 %}positive{% else %}stag{% endif %}">{% if forum.posts_delta > 0 %}+{{ forum.threads_delta }}{% else %}{{ forum.threads }}{% endif %}</span> <span class="muted">{% trans %}threads{% endtrans %}</span>
+{# Render forums list #}
+{% macro draw_forums(category, width=12) %}
+{% if category.template != 'row' %}
+<div class="row">
+  {% for forum in category.subforums %}
+    {{ draw_forum(category, forum, width) }}
+    {% if not loop.last and ((category.template == 'half' and loop.index is even()) or (category.template == 'quad' and loop.index is divisibleby(4))) %}
     </div>
+    <div class="row">
     {% endif %}
+  {% endfor %}
+</div>
+{% else %}
+{% for forum in category.subforums %}
+  {{ draw_forum(category, forum, width) }}
+{% endfor %}
+{% endif %}
+{% endmacro %}
+
+{# Render forum on list #}
+{% macro draw_forum(category, forum, width=12) %}
+<div{% if category.template != 'row' %} class="{% if category.template == 'half' %}span{{ widthratio(50, 100, width) }}{% elif category.template == 'quarter' %}span{{ widthratio(25, 100, width) }}{% endif %}"{% endif %}>
+  <div class="well well-forum{% if forum.style %} {{ forum.style }}{% endif %}">
+    <div class="forum-icon {% if forum.type == 'redirect' %} redirect-icon{% elif not forum.is_read %} forum-new{% endif %}"><i class="icon-{% if forum.type == 'redirect' %}circle-arrow-right{% else %}comment{% endif %} icon-white"></i></div>
+    {% if category.template != 'quarter' %}<div class="forum-details">{% endif %}
+      <div class="pull-left">
+        <h3><a href="{{ forum.type|url(slug=forum.slug, forum=forum.id) }}"{% if forum.type != 'redirect' and forum.is_read %} class="read-forum"{% endif %}>{{ forum.name }}</a></h3>
+        {% if forum.description %}<div class="muted">{{ forum.description }}</div>{% endif %}
+      </div>
+      {% if category.template != 'quarter' %}
+      {% if forum.type == 'redirect' %}
+      <div class="pull-right forum-stat stat-redirects">
+        <span class="stat {% if forum.redirects_delta > 0 %}positive{% else %}stag{% endif %}">{% if forum.redirects_delta > 0 %}+{{ forum.redirects_delta }}{% else %}{{ forum.redirects }}{% endif %}</span> <span class="muted">{% trans %}clicks{% endtrans %}</span>
+      </div>
+      {% else %}
+      <div class="pull-right forum-stat stat-posts">
+        <span class="stat {% if forum.posts_delta > 0 %}positive{% else %}stag{% endif %}">{% if forum.posts_delta > 0 %}+{{ forum.posts_delta }}{% else %}{{ forum.posts }}{% endif %}</span> <span class="muted">{% trans %}posts{% endtrans %}</span>
+      </div>
+      {% if category.template != 'half' %}
+      <div class="pull-right forum-stat stat-threads">
+        <span class="stat {% if forum.threads_delta > 0 %}positive{% else %}stag{% endif %}">{% if forum.posts_delta > 0 %}+{{ forum.threads_delta }}{% else %}{{ forum.threads }}{% endif %}</span> <span class="muted">{% trans %}threads{% endtrans %}</span>
+      </div>
+      {% endif %}
+      {% endif %}
+      {% endif %}
+    {% if category.template != 'quarter' %}</div>{% endif %}
   </div>
 </div>
 {% endmacro %}

+ 13 - 3
templates/sora/threads/list.html

@@ -19,6 +19,14 @@
   </ul>
   <h1>{{ forum.name }}{% if forum.description %} <small>{{ forum.description }}</small>{% endif %}</h1>
 </div>
+{% if forum.subforums %}
+<div class="forums-list subforums-list">
+  <h3>{% trans %}Subforums{% endtrans %}:</h3>
+  <div class="category{% if forum.style %} {{ forum.style }}{% endif %}">
+    {{ macros.draw_forums(forum, 12) }}
+  </div>
+</div>
+{% endif %}
 {% if message %}{{ macros.draw_message(message) }}{% endif %}
 <div class="list-nav">
   {{ pager() }}
@@ -28,7 +36,7 @@
   </ul>
   {% endif %}
 </div>
-<table class="table table-striped">
+<table class="table table-striped threads-list">
   <thead>
     <tr>
       <th style="width: 1%;">&nbsp;</th>
@@ -41,8 +49,10 @@
   <tbody>
     {% for thread in threads %}
     <tr>
-      <td>[ICON]</td>
-      <td><a href="{% url 'thread' thread=thread.pk, slug=thread.slug %}"><strong>{{ thread.name }}</strong></a>[LABELS][JUMP] [ICONS]</td>
+      <td><span class="thread-icon{% if not thread.is_read %} {% if thread.closed %}thread-closed{% else %}thread-new{% endif %}{% endif %}"><i class="icon-{% if thread.closed %}remove{% else %}comment{% endif %} icon-white"></i></span></td>
+      <td>
+        <a href="{% url 'thread' thread=thread.pk, slug=thread.slug %}">{% if not thread.is_read %}<strong>{{ thread.name }}</strong>{% else %}{{ thread.name }}{% endif %}</a>[LABELS][JUMP] [ICONS]
+      </td>
       <td>{% if thread.start_poster_id %}<a href="{% url 'user' user=thread.start_poster_id, username=thread.start_poster_slug %}" class="tooltip-top" title="{{ thread.start|reltimesince }}">{{ thread.start_poster_name }}</a>{% else %}<em class="tooltip-top muted" title="{{ thread.start|reltimesince }}">{{ thread.start_poster_name }}</em>{% endif %}</td>
       <td>{{ thread.replies|intcomma }}</td>
       <td>{% if thread.last_poster_id %}<a href="{% url 'user' user=thread.last_poster_id, username=thread.last_poster_slug %}" class="tooltip-top" title="{{ thread.last|reltimesince }}">{{ thread.last_poster_name }}</a>{% else %}<em class="tooltip-top muted" title="{{ thread.last|reltimesince }}">{{ thread.last_poster_name }}</em>{% endif %}</td>