Initial commit

Signed-off-by: Alek Ratzloff <alekratz@gmail.com>
This commit is contained in:
2022-05-03 18:02:04 -07:00
commit 126db039e2
27 changed files with 1033 additions and 0 deletions

0
board/__init__.py Normal file
View File

3
board/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
board/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class BoardConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'board'

View File

64
board/models.py Normal file
View File

@@ -0,0 +1,64 @@
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.urls import reverse
from django.utils import timezone
class Board(models.Model):
# The short URL name for the board
url = models.CharField(max_length=255, null=False, blank=False, unique=True)
# Human-readable name for the board
name = models.CharField(max_length=255, null=False, blank=False)
@property
def threads(self):
return Post.objects.filter(board=self, op=None)
class Post(models.Model):
# Board that this post was made on
board = models.ForeignKey("Board", on_delete=models.CASCADE)
# Thread that this is a part of
op = models.ForeignKey(
"self", null=True, on_delete=models.CASCADE, related_name="all_replies"
)
# Post that this is replying to
reply = models.ForeignKey(
"self", null=True, on_delete=models.CASCADE, related_name="replies"
)
# User's supplied name for this post
name = models.CharField(max_length=255, null=True, blank=True)
# User's supplied subject for this post
subject = models.CharField(max_length=255, null=True, blank=True)
# Text of this post
text = models.TextField(max_length=10000, null=False, blank=True)
# The IP address of the user that made this post
ip = models.GenericIPAddressField()
# Creation time
created = models.DateTimeField(auto_now_add=True)
# Last bump time
last_bump = models.DateTimeField(auto_now_add=True)
# TODO : images
def get_absolute_url(self):
if self.op is None:
return reverse(
"board:post_detail", kwargs={"url": self.board.url, "id": self.id}
)
else:
return (
reverse(
"board:post_detail",
kwargs={"url": self.board.url, "id": self.op.id},
)
+ f"#p{self.id}"
)
@receiver(post_save, sender=Post)
def post_created(sender, instance, created, **kwargs):
if created and instance.op:
instance.op.last_bump = timezone.now()
instance.op.save()

File diff suppressed because one or more lines are too long

1
board/static/board/jquery.js vendored Symbolic link
View File

@@ -0,0 +1 @@
jquery-3.6.0.min.js

View File

@@ -0,0 +1,9 @@
function doQuote(sender) {
let id_text = $("#id_text");
let caret = id_text[0].selectionStart;
let text = id_text.val();
let to_add = ">>" + sender.target.innerText + "\n";
id_text.val(text.substring(0, caret) + to_add + text.substring(caret));
}
$(document).on("click", ".post_id", doQuote);

View File

@@ -0,0 +1,84 @@
hr {
color: #ededed;
}
.column {
float: left;
width: 33.33%;
}
/* Clear floats after the columns */
.row:after {
content: "";
display: table;
clear: both;
}
/* Posts */
/*.post_body { }*/
.post_id {
cursor: pointer;
}
.post_id:hover {
color: #888;
}
.post_subject {
font-weight: bold;
}
.post_name {
font-weight: bold;
}
.post {
background-color: #d9d9d9;
padding: 10px;
}
.reply {
background-color: #eee;
padding: 10px;
margin: 5px 0 0 0;
}
.replies_elided {
font-weight: italic;
font-size: small;
}
.quote {
color: #595;
}
.post_link {
text-decoration: underline;
}
.post_link_broken {
text-decoration: line-through;
}
/* Misc */
a:link {
color:#555;
text-decoration: none;
}
a:visited {
color: #555;
text-decoration: none;
}
a:hover {
color: #888;
text-decoration: none;
}
a:active {
color: #888;
text-decoration: none;
}

View File

@@ -0,0 +1,18 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>{% block title %}{% if title %}{{title}}{% else %}Index{% endif %}{% endblock %}</title>
<link rel="stylesheet" href="{% static 'board/style.css' %}">
<script src="{% static 'board/jquery.js' %}"></script>
<script src="{% static 'board/post.js' %}"></script>
</head>
<body>
<main>
{% block content %}{% endblock content %}
</main>
</body>
</html>

View File

@@ -0,0 +1,57 @@
{% extends "board/base.html" %}
{% block title %}
{% with title=board.url %}
{{ block.super }}
{% endwith %}
{% endblock title %}
{% block content %}
{# board header #}
<div class="row">
<div class="column">&nbsp;</div>
<div class="column">
<h1>/{{ board.url }}/ - {{ board.name }}</h1>
</div>
<div class="column">&nbsp;</div>
</div>
<hr />
{# post creation form #}
<div class="row">
<div class="column">&nbsp;</div>
<div class="column">
<div class="row">
<h2>Create a new thread</h2>
</div>
<div class="row">
<form method="post" action="{% url 'board:board_detail' url=board.url %}">
{% csrf_token %}
<table>
{{ form.as_table }}
<tr><td>&nbsp;</td><td><input type="submit" value="Submit" /></td></tr>
</table>
</form>
</div>
</div>
<div class="column">&nbsp;</div>
</div>
<hr />
{# posts #}
{% for post in threads %}
<div class="row post" id="p{{post.id}}">
{# TODO we need some way to parameterize the last N threads #}
{% with reply_link=True replies_elided=post.replies.all|length|add:"-3" %}
{% include "board/post_snippet.html" %}
{% endwith %}
{# get the last 3 replies #}
{% for post in post.replies.all|dictsortreversed:"id"|slice:":3" reversed %}
<div class="row reply" id="p{{post.id}}">
{% include "board/post_snippet.html" %}
</div>
{% endfor %}
</div>
<hr />
{% endfor %}
{% endblock content %}

View File

@@ -0,0 +1,9 @@
{% extends "board/base.html" %}
{% block content %}
<div class="row">
<h1>/{{ board.url }}/</h1>
</div>
<div class="row">
{% include "board/post_create_form.html" %}
</div>
{% endblock content %}

View File

@@ -0,0 +1,43 @@
{% extends "board/base.html" %}
{% block title %}
{% with title=board.url %}
{{ block.super }}
{% endwith %}
{% endblock title %}
{% block content %}
{# board header #}
<div class="row">
<div class="column">&nbsp;</div>
<div class="column">
<h1>/{{ board.url }}/ - {{ board.name }}</h1>
</div>
<div class="column">&nbsp;</div>
</div>
<hr />
{# post creation form #}
<div class="row">
<div class="column">&nbsp;</div>
<div class="column">
<form method="post" action="{% url 'board:post_detail' url=board.url id=post.id %}">
{% csrf_token %}
<table>
{{ form.as_table }}
<tr><td>&nbsp;</td><td><input type="submit" value="Submit" /></td></tr>
</table>
</form>
</div>
<div class="column">&nbsp;</div>
</div>
<hr />
{# posts #}
<div class="row post">
{% include "board/post_snippet.html" %}
{% for post in post.replies.all %}
<div class="row reply">
{% include "board/post_snippet.html" %}
</div>
{% endfor %}
</div>
{% endblock content %}

View File

@@ -0,0 +1,21 @@
{% load post_body %}
<div id="p{{post.id}}">
{# TODO images #}
<a href="#p{{post.id}}">#.</a>
<span class="post_id">{{post.id}}</span>
{% if post.subject %}
<span class="post_subject">{{post.subject}}</span>
{% endif %}
by <span class="post_name">{{post.name|default:"Anonymous"}}</span>
at {{post.created}}
{% if reply_link %}
[<a href="{{post.get_absolute_url}}">Reply</a>]
{% endif %}
{% if replies_elided > 0 %}
<br/>
<span class="replies_elided">
({{replies_elided}} replies elided, click reply to view)
</span>
{% endif %}
<p class="post_body">{{post|post_body|safe}}</p>
</div>

View File

View File

@@ -0,0 +1,93 @@
import re
from django import template
from board.models import Post
REPLY_START_RE = re.compile(r"^&gt;&gt;\d+")
REPLY_RE = re.compile(r"&gt;&gt;\d+")
QUOTE_RE = re.compile(r"&gt;.+$")
register = template.Library()
def htmlspecialchars(t: str):
return (
t.replace("&", "&amp;")
.replace('"', "&quot;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
class ReplyBuilder:
def __init__(self, text: str):
self.text = text
self.index = 0
self.final = ""
@property
def c(self):
return self.lookahead(0)
@property
def n(self):
return self.lookahead(1)
@property
def remain(self):
return self.text[self.index :]
def lookahead(self, n):
if (self.index + n) < len(self.text):
return self.text[self.index + n]
else:
return None
def adv(self, n=1):
self.index += n
def build(self) -> str:
while self.c:
if self.remain[:8] == "&gt;&gt;" and self.lookahead(8) in "0123456789":
self.do_reply()
elif self.remain[:4] == "&gt;":
self.do_quote()
else:
self.final += self.c
self.adv()
return self.final
def do_quote(self):
self.adv(4)
self.final += '<span class="quote">&gt;'
while self.c and self.c != "\n":
if self.remain[:8] == "&gt;&gt;":
self.do_reply()
else:
self.final += self.c
self.adv()
self.final += "</span>"
def do_reply(self):
# Skip past &gt;&gt;
self.adv(8)
# Get the post ID
post_id = ""
while self.c and self.c in "0123456789":
post_id += self.c
self.adv()
post = Post.objects.filter(id=int(post_id)).first()
if post:
self.final += f'<a class="post_link" href="{post.get_absolute_url()}">&gt;&gt;{post_id}</a>'
else:
self.final += f'<span class="post_link_broken">&gt;&gt;{post_id}</span>'
@register.filter(name="post_body")
def post_body(post: Post) -> str:
text = htmlspecialchars(post.text)
text = ReplyBuilder(text).build()
# Finally, replace linebreaks
text = text.replace("\n", "<br/>")
return text

3
board/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

10
board/urls.py Normal file
View File

@@ -0,0 +1,10 @@
from django.urls import path
from board.views import *
app_name = "board"
urlpatterns = [
path("<slug:url>/", BoardView.as_view(), name="board_detail"),
path("<slug:url>/page/<int:page>/", BoardView.as_view(), name="board_detail"),
path("<slug:url>/post/<int:id>/", PostView.as_view(), name="post_detail"),
]

92
board/views.py Normal file
View File

@@ -0,0 +1,92 @@
from django.http import Http404, HttpResponseRedirect
from django.shortcuts import render, get_object_or_404
from django.views.generic import DetailView
from django.views.generic.edit import CreateView
from board.models import Post, Board
__all__ = ("BoardView", "PostView")
# TODO bump order calculation. this is kind of expensive and should *really*
# only be run every few seconds rather than every post.
# This is a scale problem and not a high priority.
def get_client_ip(request):
"Get the IP address of a client-side request. Shamelessly copy/pasted from StackOverflow."
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
if x_forwarded_for:
ip = x_forwarded_for.split(",")[0]
else:
ip = request.META.get("REMOTE_ADDR")
return ip
class BoardView(CreateView):
model = Post
fields = ["subject", "name", "text"]
slug_field = "url"
slug_url_kwarg = "url"
template_name = "board/board_detail.html"
def get_context_data(self, **kwargs):
board_url = self.kwargs["url"]
board = get_object_or_404(Board, url=board_url)
kwargs["board"] = board
page = self.kwargs.get("page", 1)
# TODO - allow max number of pages to be specified per-board
if page not in range(1, 10 + 1):
raise Http404()
PER_PAGE = 10
start = (page - 1) * PER_PAGE
end = start + PER_PAGE
kwargs["threads"] = board.threads.order_by("-last_bump")[start:end]
kwargs["page"] = page
return super(BoardView, self).get_context_data(**kwargs)
def form_valid(self, form):
board_url = self.kwargs["url"]
board = get_object_or_404(Board, url=board_url)
form.instance.board = board
form.instance.ip = get_client_ip(self.request)
self.object = form.save()
return HttpResponseRedirect(self.get_success_url())
class PostView(CreateView):
model = Post
fields = ["name", "text"]
slug_field = "url"
slug_url_kwarg = "url"
template_name = "board/post_detail.html"
def get_context_data(self, **kwargs):
board_url = self.kwargs["url"]
kwargs["board"] = get_object_or_404(Board, url=board_url)
post_id = self.kwargs["id"]
kwargs["post"] = get_object_or_404(Post, id=post_id)
return super(PostView, self).get_context_data(**kwargs)
def form_valid(self, form):
board_url = self.kwargs["url"]
board = get_object_or_404(Board, url=board_url)
post_id = self.kwargs["id"]
post = get_object_or_404(Post, id=post_id)
form.instance.board = board
form.instance.ip = get_client_ip(self.request)
form.instance.op = post
form.instance.reply = post
self.object = form.save()
return HttpResponseRedirect(post.get_absolute_url())