Skip to content

Commit 829ae76

Browse files
authored
Merge pull request #242 from AvaCodeSolutions/ui-improvement
UI enhancement
2 parents c79c0b6 + 8ac0317 commit 829ae76

14 files changed

Lines changed: 736 additions & 61 deletions

File tree

django_email_learning/platform/api/serializers.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,8 @@ def to_django_model(self, course_id: int) -> Course:
152152
if self.reset_imap_connection:
153153
course.imap_connection = None
154154
if self.image is not None:
155-
course.replace_image(self.image)
155+
if self.image != "SKIP":
156+
course.replace_image(self.image)
156157
if not self.image:
157158
course.image = None
158159

django_email_learning/platform/api/views.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from django.contrib.auth.models import User
1515
from django.contrib.auth.forms import PasswordResetForm
1616
from datetime import timedelta, datetime
17+
from urllib.parse import urlparse
1718
from pydantic import ValidationError
1819
from enum import StrEnum
1920
from django_email_learning.platform.api import serializers
@@ -41,6 +42,7 @@
4142
import uuid
4243
import json
4344
import logging
45+
import posixpath
4446

4547

4648
logger = logging.getLogger(__name__)
@@ -610,6 +612,7 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
610612

611613

612614
@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
615+
@method_decorator(accessible_for(roles={"admin", "editor"}), name="delete")
613616
class FileView(View):
614617
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
615618
uploaded_file = request.FILES.get("file")
@@ -631,6 +634,45 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
631634
file_url = default_storage.url(file_path)
632635
return JsonResponse({"file_url": file_url, "file_path": file_path}, status=201)
633636

637+
def delete(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
638+
try:
639+
payload = json.loads(request.body or "{}")
640+
except json.JSONDecodeError:
641+
return JsonResponse({"error": "Invalid request body"}, status=400)
642+
643+
file_path = payload.get("file_path")
644+
file_url = payload.get("file_url")
645+
646+
if not file_path and file_url:
647+
parsed_url_path = urlparse(file_url).path
648+
media_url = settings.MEDIA_URL or "/media/"
649+
normalized_media_url = (
650+
media_url if media_url.endswith("/") else f"{media_url}/"
651+
)
652+
if parsed_url_path.startswith(normalized_media_url):
653+
file_path = parsed_url_path[len(normalized_media_url) :]
654+
655+
if not file_path:
656+
return JsonResponse({"error": "file_path is required"}, status=400)
657+
658+
normalized_file_path = posixpath.normpath(str(file_path)).lstrip("/")
659+
path_parts = normalized_file_path.split("/")
660+
organization_id = str(kwargs["organization_id"])
661+
662+
if (
663+
len(path_parts) < 4
664+
or path_parts[0] != "uploads"
665+
or path_parts[2] != organization_id
666+
or ".." in path_parts
667+
):
668+
return JsonResponse({"error": "Invalid file path"}, status=400)
669+
670+
if not default_storage.exists(normalized_file_path):
671+
return JsonResponse({"error": "File not found"}, status=404)
672+
673+
default_storage.delete(normalized_file_path)
674+
return JsonResponse({"message": "File deleted successfully"}, status=200)
675+
634676

635677
@method_decorator(is_an_organization_member(), name="post")
636678
class UpdateSessionView(View):

django_email_learning/platform/views.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,10 @@ def get_locale_messages(self) -> Dict[str, str]:
162162
"course_title": _("Course Title"),
163163
"course_description": _("Course Description"),
164164
"course_slug": _("Course Slug"),
165+
"slug_tooltip": _(
166+
"The slug is a unique identifier for the course used in URLs and API endpoints. It should be lowercase, contain no spaces (use hyphens instead), and be unique across all courses for your organization. Once set, the slug cannot be changed."
167+
),
168+
"slug_no_space": _("Slug cannot contain spaces. Use hyphens instead."),
165169
"add_imap_connection": _("Add IMAP Connection"),
166170
"imap_connection_tooltip": _(
167171
"You don't need an IMAP connection to build your course, but you will need one if you want your users to interact via email. For example, they can sign up or check their progress just by sending a message. This is a great solution if your audience has limited platform access."
@@ -233,6 +237,20 @@ def get_locale_messages(self) -> Dict[str, str]:
233237
"lesson_waiting_tooltip": _(
234238
"Set the amount of time that we should wait after the previous lesson or quiz submission before sending this lesson"
235239
),
240+
"upload": _("Upload"),
241+
"uploaded_image_preview": _("Uploaded image preview"),
242+
"add_image_to_editor": _("Add image to editor"),
243+
"remove_uploaded_image": _("Remove uploaded image"),
244+
"confirm_delete_uploaded_image": _("Confirm image deletion"),
245+
"delete_uploaded_image_warning": _(
246+
"Please confirm this image is not used anywhere else. Deleting it will break existing links."
247+
),
248+
"uploaded_image_used_in_editor_error": _(
249+
"This image is already used in the editor content. Remove it from the content before deleting the file."
250+
),
251+
"uploaded_image_delete_failed": _(
252+
"Failed to delete image file. Please try again."
253+
),
236254
"days": _("Days"),
237255
"hours": _("Hours"),
238256
"back": _("Back"),

frontend/platform/course/components/ContentTable.jsx

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ const ContentTable = ({ courseId, eventHandler, loaded = false }) => {
1111
const [isDragging, setIsDragging] = useState(false);
1212
const [draggedContentId, setDraggedContentId] = useState(null);
1313

14-
const startDrag = (contentId) => {
14+
const startDrag = (event, contentId) => {
15+
event.preventDefault();
1516
setIsDragging(true);
1617
setDraggedContentId(contentId);
1718
}
@@ -48,6 +49,18 @@ const ContentTable = ({ courseId, eventHandler, loaded = false }) => {
4849
return () => window.removeEventListener('pointerup', onPointerUp);
4950
}, []);
5051

52+
useEffect(() => {
53+
if (isDragging) {
54+
document.body.style.userSelect = 'none';
55+
} else {
56+
document.body.style.userSelect = '';
57+
}
58+
59+
return () => {
60+
document.body.style.userSelect = '';
61+
};
62+
}, [isDragging]);
63+
5164
const deleteContent = (contentId) => {
5265
eventHandler({ type: 'delete_content', content: contentList.find(content => content.id === contentId)});
5366
}
@@ -115,7 +128,26 @@ const ContentTable = ({ courseId, eventHandler, loaded = false }) => {
115128
<TableBody>
116129
{contentList.map((content) => (
117130
<TableRow
118-
key={content.id} {...(isDragging && draggedContentId === content.id && { sx: { backgroundColor: 'background.main', boxShadow: 2 } })}
131+
key={content.id}
132+
sx={{
133+
transition: 'transform 120ms ease, box-shadow 120ms ease, background-color 120ms ease',
134+
...(isDragging && draggedContentId === content.id
135+
? {
136+
backgroundColor: 'background.box',
137+
transform: 'translateY(-2px) scale(1.005)',
138+
filter: (theme) => theme.palette.mode === 'dark'
139+
? 'drop-shadow(0 2px 4px rgba(0,0,0,0.22))'
140+
: 'drop-shadow(0 2px 4px rgba(16,24,40,0.08))',
141+
borderTop: '1px solid',
142+
borderBottom: '1px solid',
143+
borderColor: 'primary.main',
144+
'& > td': {
145+
backgroundColor: 'background.box',
146+
},
147+
}
148+
: {}
149+
),
150+
}}
119151
onMouseOver={() => {
120152
if (isDragging && draggedContentId !== content.id) {
121153
const draggedIndex = contentList.findIndex(c => c.id === draggedContentId);
@@ -130,7 +162,7 @@ const ContentTable = ({ courseId, eventHandler, loaded = false }) => {
130162
}
131163
}}>
132164
{ userRole !== 'viewer' && <TableCell align={direction == 'rtl' ? 'right' : 'left'} sx={{ cursor: 'grab', width: '40px', padding: '8px 0', textAlign: 'center' }}><DragIndicatorIcon fontSize="small"
133-
onMouseDown={() => startDrag(content.id)}
165+
onMouseDown={(event) => startDrag(event, content.id)}
134166
/></TableCell>}
135167
<TableCell align={direction == 'rtl' ? 'right' : 'left'}><Typography
136168
onClick={() => {let event = {type: 'content_clicked', content_id: content.id}; eventHandler(event);}}

0 commit comments

Comments
 (0)