Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ dependencies = [
"pymarker~=1.2",
"sentry-sdk[django]~=2.20",
"Sphinx~=8.1",
"django-prose-editor[sanitize]>=0.25.1",
]

[dependency-groups]
Expand Down
26 changes: 24 additions & 2 deletions src/blog/admin.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from django.contrib import admin
from django.utils.html import format_html

from blog.models import Category, Clipping, Post, PostImage
from blog.models import IMAGE_BASE_PATH, Category, Clipping, Post, PostImage

admin.site.register(Category)
admin.site.register(PostImage)


@admin.register(Post)
Expand All @@ -19,3 +19,25 @@ def get_queryset(self, request):
class ClippingAdmin(admin.ModelAdmin):
list_display = ("title", "id", "created", "modified", "display_date")
ordering = ("-created",)


@admin.register(PostImage)
class PostImageAdmin(admin.ModelAdmin):
list_display = ("image", "copy_button", "filename", "description", "created")
ordering = ("-created",)

def filename(self, obj):
return obj.file.name.lstrip(IMAGE_BASE_PATH)

def image(self, obj):
if obj.file:
return format_html('<img src="{}" width="100" />', obj.file.url)
return ""

def copy_button(self, obj):
if obj.file:
return format_html(
'<button type="button" onclick="navigator.clipboard.writeText(\'{}\')">Copy URL</button>',
obj.file.url,
)
return ""
2 changes: 1 addition & 1 deletion src/blog/jinja2/blog/detail.jinja2
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<h2>{{ post.title }}</h2>
{% endblock page_title %}
<div id=post>
<p>{{ post.body |safe }}</p>
<p>{{ post.formatted_body |safe }}</p>
{% for image in images %}
<div class="post-image">
<img class="post-image" src="{{ image.file.url }}" />
Expand Down
2 changes: 1 addition & 1 deletion src/blog/jinja2/blog/post_preview.jinja2
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<a href="{{ url('post_detail', args=[post.pk]) }}">{{ post.title }}</a>
</h3>
<p>
{{ post.body[:PREVIEW_SIZE] | safe }}... <a href="{{ url('post_detail', args=[post.pk]) }}">{{ _("Read More") }}</a>
{{ post.excerpt[:PREVIEW_SIZE] | safe }}... <a href="{{ url('post_detail', args=[post.pk]) }}">{{ _("Read More") }}</a>
</p>
</div>
{% endfor %}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Generated by Django 6.0.3 on 2026-04-02 14:50

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('blog', '0009_update_clipping_display_dates'),
]

operations = [
migrations.RenameField(
model_name='post',
old_name='body',
new_name='excerpt',
),
migrations.AddField(
model_name='post',
name='formatted_body',
field=models.TextField(default=''),
),
migrations.AlterField(
model_name='clipping',
name='display_date',
field=models.DateField(db_index=True),
),
]
18 changes: 18 additions & 0 deletions src/blog/migrations/0011_update_formatted_bodies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from django.db import migrations, models

def populate_formatted_bodies(apps, schema_editor):
Post = apps.get_model('blog', 'Post')
for obj in Post.objects.all():
obj.formatted_body = obj.excerpt
obj.save()


class Migration(migrations.Migration):

dependencies = [
('blog', '0010_rename_body_post_excerpt_post_formatted_body_and_more'),
]

operations = [
migrations.RunPython(populate_formatted_bodies, reverse_code=migrations.RunPython.noop),
]
30 changes: 29 additions & 1 deletion src/blog/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.core.files.storage import default_storage
from django.db import models
from django_extensions.db.models import TimeStampedModel
from django_prose_editor.fields import ProseEditorField

from users.models import Profile

Expand Down Expand Up @@ -55,9 +56,36 @@ class Post(TimeStampedModel):
null=True,
blank=True,
)
body = models.TextField()
excerpt = models.TextField()
categories = models.ManyToManyField(Category, related_name="posts", blank=True)
images = models.ManyToManyField(PostImage, related_name="posts", blank=True)
formatted_body = ProseEditorField(
default="",
extensions={
# Core text formatting
"Bold": True,
"Italic": True,
"Strike": True,
"Underline": True,
"AddImage": True,
# Structure
"Heading": {"levels": [1, 2, 3]},
"BulletList": True,
"OrderedList": True,
"ListItem": True,
"Blockquote": True,
# Advanced extensions
"Link": {
"enableTarget": True, # Enable "open in new window"
"protocols": ["http", "https"], # Limit protocols
},
# Editor capabilities
"History": True, # Enables undo/redo
"HTML": True, # Allows HTML view
"Typographic": True, # Enables typographic chars
},
sanitize=True,
)

def __str__(self):
return self.title
Expand Down
117 changes: 117 additions & 0 deletions src/blog/static/js/prose_image.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { Node, updateAttrsDialog } from "django-prose-editor/editor";

const imageDialogImpl = (editor, attrs, options) => {
const properties = {
src: {
type: "string",
title: gettext("Source"),
required: true,
},
alt: {
type: "string",
title: gettext("Alt Text"),
required: true,
},
title: {
type: "string",
title: gettext("Title"),
},
width: {
type: "number",
title: gettext("Width"),
},
height: {
type: "number",
title: gettext("Height"),
},
};

return updateAttrsDialog(properties, {
title: gettext("Add or edit image"),
})(editor, attrs);
};

const ImageDialog = async (editor, attrs, options) => {
attrs = attrs || {};
attrs = await imageDialogImpl(editor, attrs, options);
if (attrs) {
return attrs;
}
};

export const AddImage = Node.create({
name: "image",
content: "inline*",
group: "block",
isolating: true,

addAttributes() {
return {
src: {
default: null,
},
alt: {
default: null,
},
title: {
default: null,
},
width: {
default: null,
},
height: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: "img[src]",
getAttrs: (dom) => ({
src: dom.getAttribute("src"),
alt: dom.getAttribute("alt"),
title: dom.getAttribute("title"),
width: dom.getAttribute("width"),
height: dom.getAttribute("height"),
}),
},
];
},
renderHTML({ HTMLAttributes }) {
return ["img", HTMLAttributes];
},
addMenuItems({ editor, buttons, menu }) {
menu.defineItem({
name: "addImage",
groups: "link",
command: (editor) => editor.chain().addImage().focus().run(),
button: buttons.material("image", "Add image"),
enabled: (editor) => true,
active: (editor) => editor.isActive("image"),
});
},
addCommands() {
return {
...this.parent?.(),
addImage:
() =>
({ editor }) => {
const attrs = editor.getAttributes(this.name);

ImageDialog(editor, attrs, this.options).then((attrs) => {
if (attrs) {
editor
.chain()
.focus()
.insertContent({
type: "image",
attrs: attrs,
})
.run();
}
});
},
};
},
});
5 changes: 4 additions & 1 deletion src/blog/tests/test_post_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ def test_post_details_work(self):
"""
# Create a sample post
post = Post.objects.create(
title="Test Post", body="This is a test post.", status=PostStatus.PUBLISHED
title="Test Post",
excerpt="This is a test post.",
formatted_body="This is the body of the test post.",
status=PostStatus.PUBLISHED,
)
# Create a sample image for the post
image_1 = PostImage.objects.create(
Expand Down
12 changes: 8 additions & 4 deletions src/blog/tests/test_view_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ def test_main_page_shows_all_posts(self):
for i in range(0, 10):
Post.objects.create(
title=f"Test Post {i}",
body=f"This is the body of test post {i}.",
excerpt=f"This is the excerpt of test post {i}.",
formatted_body=f"This is the body of test post {i}.",
status=PostStatus.PUBLISHED,
)
response = self.client.get(reverse("blog_index"))
Expand All @@ -39,7 +40,8 @@ def test_main_page_with_page_number_negative(self):
for i in range(0, 10):
Post.objects.create(
title=f"Test Post {i}",
body=f"This is the body of test post {i}.",
excerpt=f"This is the excerpt of test post {i}.",
formatted_body=f"This is the body of test post {i}.",
status=PostStatus.PUBLISHED,
)
# Request with a negative page number should return the first page
Expand All @@ -62,7 +64,8 @@ def test_main_page_with_page_invalid_number(self):
for i in range(0, 10):
Post.objects.create(
title=f"Test Post {i}",
body=f"This is the body of test post {i}.",
excerpt=f"This is the excerpt of test post {i}.",
formatted_body=f"This is the body of test post {i}.",
status=PostStatus.PUBLISHED,
)
# Request with an invalid page number should return the first page
Expand All @@ -85,7 +88,8 @@ def test_main_page_with_htmx(self):
for i in range(0, 10):
Post.objects.create(
title=f"Test Post {i}",
body=f"This is the body of test post {i}.",
excerpt=f"This is the excerpt of test post {i}.",
formatted_body=f"This is the body of test post {i}.",
status=PostStatus.PUBLISHED,
)
response = self.client.get(
Expand Down
15 changes: 15 additions & 0 deletions src/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import environ
import sentry_sdk
from django.utils.translation import gettext_lazy as _
from django_prose_editor.config import html_tags
from sentry_sdk.integrations.django import DjangoIntegration

ROOT_DIR = environ.Path(__file__) - 3 # three folders back (/jandig/src/config)
Expand Down Expand Up @@ -99,6 +100,7 @@ def traces_sampler(sampling_context):
"rest_framework_simplejwt",
"rest_framework.authtoken",
"rest_framework",
"django_prose_editor",
]

INTERNAL_APPS = [
Expand All @@ -113,6 +115,19 @@ def traces_sampler(sampling_context):
TOOLBAR_ENABLED = env.bool("DEBUG_TOOLBAR", False)


DJANGO_PROSE_EDITOR_EXTENSIONS = [
{
"js": ["/static/js/prose_image.js"],
"extensions": {
"AddImage": html_tags(
tags=["img"],
attributes={"img": ["src", "alt", "title", "width", "height"]},
)
},
},
]


def debug(_):
return TOOLBAR_ENABLED

Expand Down
Loading
Loading