Skip to content

Commit c96af74

Browse files
authored
Merge pull request #904 from memeLab/rich-text-django-prose
Add django-prose-editor for Post editing
2 parents 1a6f223 + 40b9f76 commit c96af74

File tree

12 files changed

+312
-10
lines changed

12 files changed

+312
-10
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ dependencies = [
2727
"pymarker~=1.2",
2828
"sentry-sdk[django]~=2.20",
2929
"Sphinx~=8.1",
30+
"django-prose-editor[sanitize]>=0.25.1",
3031
]
3132

3233
[dependency-groups]

src/blog/admin.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from django.contrib import admin
2+
from django.utils.html import format_html
23

3-
from blog.models import Category, Clipping, Post, PostImage
4+
from blog.models import IMAGE_BASE_PATH, Category, Clipping, Post, PostImage
45

56
admin.site.register(Category)
6-
admin.site.register(PostImage)
77

88

99
@admin.register(Post)
@@ -19,3 +19,25 @@ def get_queryset(self, request):
1919
class ClippingAdmin(admin.ModelAdmin):
2020
list_display = ("title", "id", "created", "modified", "display_date")
2121
ordering = ("-created",)
22+
23+
24+
@admin.register(PostImage)
25+
class PostImageAdmin(admin.ModelAdmin):
26+
list_display = ("image", "copy_button", "filename", "description", "created")
27+
ordering = ("-created",)
28+
29+
def filename(self, obj):
30+
return obj.file.name.lstrip(IMAGE_BASE_PATH)
31+
32+
def image(self, obj):
33+
if obj.file:
34+
return format_html('<img src="{}" width="100" />', obj.file.url)
35+
return ""
36+
37+
def copy_button(self, obj):
38+
if obj.file:
39+
return format_html(
40+
'<button type="button" onclick="navigator.clipboard.writeText(\'{}\')">Copy URL</button>',
41+
obj.file.url,
42+
)
43+
return ""

src/blog/jinja2/blog/detail.jinja2

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
<h2>{{ post.title }}</h2>
88
{% endblock page_title %}
99
<div id=post>
10-
<p>{{ post.body |safe }}</p>
10+
<p>{{ post.formatted_body |safe }}</p>
1111
{% for image in images %}
1212
<div class="post-image">
1313
<img class="post-image" src="{{ image.file.url }}" />

src/blog/jinja2/blog/post_preview.jinja2

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<a href="{{ url('post_detail', args=[post.pk]) }}">{{ post.title }}</a>
55
</h3>
66
<p>
7-
{{ post.body[:PREVIEW_SIZE] | safe }}... <a href="{{ url('post_detail', args=[post.pk]) }}">{{ _("Read More") }}</a>
7+
{{ post.excerpt[:PREVIEW_SIZE] | safe }}... <a href="{{ url('post_detail', args=[post.pk]) }}">{{ _("Read More") }}</a>
88
</p>
99
</div>
1010
{% endfor %}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Generated by Django 6.0.3 on 2026-04-02 14:50
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('blog', '0009_update_clipping_display_dates'),
10+
]
11+
12+
operations = [
13+
migrations.RenameField(
14+
model_name='post',
15+
old_name='body',
16+
new_name='excerpt',
17+
),
18+
migrations.AddField(
19+
model_name='post',
20+
name='formatted_body',
21+
field=models.TextField(default=''),
22+
),
23+
migrations.AlterField(
24+
model_name='clipping',
25+
name='display_date',
26+
field=models.DateField(db_index=True),
27+
),
28+
]
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from django.db import migrations, models
2+
3+
def populate_formatted_bodies(apps, schema_editor):
4+
Post = apps.get_model('blog', 'Post')
5+
for obj in Post.objects.all():
6+
obj.formatted_body = obj.excerpt
7+
obj.save()
8+
9+
10+
class Migration(migrations.Migration):
11+
12+
dependencies = [
13+
('blog', '0010_rename_body_post_excerpt_post_formatted_body_and_more'),
14+
]
15+
16+
operations = [
17+
migrations.RunPython(populate_formatted_bodies, reverse_code=migrations.RunPython.noop),
18+
]

src/blog/models.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from django.core.files.storage import default_storage
22
from django.db import models
33
from django_extensions.db.models import TimeStampedModel
4+
from django_prose_editor.fields import ProseEditorField
45

56
from users.models import Profile
67

@@ -55,9 +56,36 @@ class Post(TimeStampedModel):
5556
null=True,
5657
blank=True,
5758
)
58-
body = models.TextField()
59+
excerpt = models.TextField()
5960
categories = models.ManyToManyField(Category, related_name="posts", blank=True)
6061
images = models.ManyToManyField(PostImage, related_name="posts", blank=True)
62+
formatted_body = ProseEditorField(
63+
default="",
64+
extensions={
65+
# Core text formatting
66+
"Bold": True,
67+
"Italic": True,
68+
"Strike": True,
69+
"Underline": True,
70+
"AddImage": True,
71+
# Structure
72+
"Heading": {"levels": [1, 2, 3]},
73+
"BulletList": True,
74+
"OrderedList": True,
75+
"ListItem": True,
76+
"Blockquote": True,
77+
# Advanced extensions
78+
"Link": {
79+
"enableTarget": True, # Enable "open in new window"
80+
"protocols": ["http", "https"], # Limit protocols
81+
},
82+
# Editor capabilities
83+
"History": True, # Enables undo/redo
84+
"HTML": True, # Allows HTML view
85+
"Typographic": True, # Enables typographic chars
86+
},
87+
sanitize=True,
88+
)
6189

6290
def __str__(self):
6391
return self.title

src/blog/static/js/prose_image.js

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { Node, updateAttrsDialog } from "django-prose-editor/editor";
2+
3+
const imageDialogImpl = (editor, attrs, options) => {
4+
const properties = {
5+
src: {
6+
type: "string",
7+
title: gettext("Source"),
8+
required: true,
9+
},
10+
alt: {
11+
type: "string",
12+
title: gettext("Alt Text"),
13+
required: true,
14+
},
15+
title: {
16+
type: "string",
17+
title: gettext("Title"),
18+
},
19+
width: {
20+
type: "number",
21+
title: gettext("Width"),
22+
},
23+
height: {
24+
type: "number",
25+
title: gettext("Height"),
26+
},
27+
};
28+
29+
return updateAttrsDialog(properties, {
30+
title: gettext("Add or edit image"),
31+
})(editor, attrs);
32+
};
33+
34+
const ImageDialog = async (editor, attrs, options) => {
35+
attrs = attrs || {};
36+
attrs = await imageDialogImpl(editor, attrs, options);
37+
if (attrs) {
38+
return attrs;
39+
}
40+
};
41+
42+
export const AddImage = Node.create({
43+
name: "image",
44+
content: "inline*",
45+
group: "block",
46+
isolating: true,
47+
48+
addAttributes() {
49+
return {
50+
src: {
51+
default: null,
52+
},
53+
alt: {
54+
default: null,
55+
},
56+
title: {
57+
default: null,
58+
},
59+
width: {
60+
default: null,
61+
},
62+
height: {
63+
default: null,
64+
},
65+
};
66+
},
67+
parseHTML() {
68+
return [
69+
{
70+
tag: "img[src]",
71+
getAttrs: (dom) => ({
72+
src: dom.getAttribute("src"),
73+
alt: dom.getAttribute("alt"),
74+
title: dom.getAttribute("title"),
75+
width: dom.getAttribute("width"),
76+
height: dom.getAttribute("height"),
77+
}),
78+
},
79+
];
80+
},
81+
renderHTML({ HTMLAttributes }) {
82+
return ["img", HTMLAttributes];
83+
},
84+
addMenuItems({ editor, buttons, menu }) {
85+
menu.defineItem({
86+
name: "addImage",
87+
groups: "link",
88+
command: (editor) => editor.chain().addImage().focus().run(),
89+
button: buttons.material("image", "Add image"),
90+
enabled: (editor) => true,
91+
active: (editor) => editor.isActive("image"),
92+
});
93+
},
94+
addCommands() {
95+
return {
96+
...this.parent?.(),
97+
addImage:
98+
() =>
99+
({ editor }) => {
100+
const attrs = editor.getAttributes(this.name);
101+
102+
ImageDialog(editor, attrs, this.options).then((attrs) => {
103+
if (attrs) {
104+
editor
105+
.chain()
106+
.focus()
107+
.insertContent({
108+
type: "image",
109+
attrs: attrs,
110+
})
111+
.run();
112+
}
113+
});
114+
},
115+
};
116+
},
117+
});

src/blog/tests/test_post_details.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ def test_post_details_work(self):
1111
"""
1212
# Create a sample post
1313
post = Post.objects.create(
14-
title="Test Post", body="This is a test post.", status=PostStatus.PUBLISHED
14+
title="Test Post",
15+
excerpt="This is a test post.",
16+
formatted_body="This is the body of the test post.",
17+
status=PostStatus.PUBLISHED,
1518
)
1619
# Create a sample image for the post
1720
image_1 = PostImage.objects.create(

src/blog/tests/test_view_index.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ def test_main_page_shows_all_posts(self):
1414
for i in range(0, 10):
1515
Post.objects.create(
1616
title=f"Test Post {i}",
17-
body=f"This is the body of test post {i}.",
17+
excerpt=f"This is the excerpt of test post {i}.",
18+
formatted_body=f"This is the body of test post {i}.",
1819
status=PostStatus.PUBLISHED,
1920
)
2021
response = self.client.get(reverse("blog_index"))
@@ -39,7 +40,8 @@ def test_main_page_with_page_number_negative(self):
3940
for i in range(0, 10):
4041
Post.objects.create(
4142
title=f"Test Post {i}",
42-
body=f"This is the body of test post {i}.",
43+
excerpt=f"This is the excerpt of test post {i}.",
44+
formatted_body=f"This is the body of test post {i}.",
4345
status=PostStatus.PUBLISHED,
4446
)
4547
# Request with a negative page number should return the first page
@@ -62,7 +64,8 @@ def test_main_page_with_page_invalid_number(self):
6264
for i in range(0, 10):
6365
Post.objects.create(
6466
title=f"Test Post {i}",
65-
body=f"This is the body of test post {i}.",
67+
excerpt=f"This is the excerpt of test post {i}.",
68+
formatted_body=f"This is the body of test post {i}.",
6669
status=PostStatus.PUBLISHED,
6770
)
6871
# Request with an invalid page number should return the first page
@@ -85,7 +88,8 @@ def test_main_page_with_htmx(self):
8588
for i in range(0, 10):
8689
Post.objects.create(
8790
title=f"Test Post {i}",
88-
body=f"This is the body of test post {i}.",
91+
excerpt=f"This is the excerpt of test post {i}.",
92+
formatted_body=f"This is the body of test post {i}.",
8993
status=PostStatus.PUBLISHED,
9094
)
9195
response = self.client.get(

0 commit comments

Comments
 (0)