diff --git a/backend/coreapp/admin.py b/backend/coreapp/admin.py index b076f08fe..41b24b46e 100644 --- a/backend/coreapp/admin.py +++ b/backend/coreapp/admin.py @@ -1,5 +1,6 @@ from django.contrib import admin +from .models.comment import Comment from .models.course import Course, CourseChapter, CourseScenario from .models.github import GitHubUser from .models.preset import Preset @@ -26,3 +27,4 @@ admin.site.register(Course) admin.site.register(CourseChapter) admin.site.register(CourseScenario) +admin.site.register(Comment) diff --git a/backend/coreapp/migrations/0054_comment.py b/backend/coreapp/migrations/0054_comment.py new file mode 100644 index 000000000..e7fe1b16f --- /dev/null +++ b/backend/coreapp/migrations/0054_comment.py @@ -0,0 +1,48 @@ +# Generated by Django 5.0.3 on 2024-05-08 19:59 + +import coreapp.models.comment +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("coreapp", "0053_rename_mwcps2_compilers"), + ] + + operations = [ + migrations.CreateModel( + name="Comment", + fields=[ + ( + "slug", + models.SlugField( + default=coreapp.models.comment.gen_comment_id, + primary_key=True, + serialize=False, + ), + ), + ("text", models.TextField(max_length=5000)), + ("creation_time", models.DateTimeField(auto_now_add=True)), + ( + "owner", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="coreapp.profile", + ), + ), + ( + "scratch", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="coreapp.scratch", + ), + ), + ], + options={ + "ordering": ["-creation_time"], + }, + ), + ] diff --git a/backend/coreapp/migrations/0055_alter_comment_options.py b/backend/coreapp/migrations/0055_alter_comment_options.py new file mode 100644 index 000000000..63b12fb85 --- /dev/null +++ b/backend/coreapp/migrations/0055_alter_comment_options.py @@ -0,0 +1,16 @@ +# Generated by Django 5.0.3 on 2024-05-10 21:54 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("coreapp", "0054_comment"), + ] + + operations = [ + migrations.AlterModelOptions( + name="comment", + options={"ordering": ["creation_time"]}, + ), + ] diff --git a/backend/coreapp/models/comment.py b/backend/coreapp/models/comment.py new file mode 100644 index 000000000..5b8c6a7b2 --- /dev/null +++ b/backend/coreapp/models/comment.py @@ -0,0 +1,26 @@ +from django.db import models +from django.utils.crypto import get_random_string +from .profile import Profile +from .scratch import Scratch + + +def gen_comment_id() -> str: + return get_random_string(length=32) + + +class Comment(models.Model): + slug = models.SlugField(primary_key=True, default=gen_comment_id) + scratch = models.ForeignKey( + Scratch, null=True, blank=False, on_delete=models.SET_NULL) + owner = models.ForeignKey( + Profile, null=True, blank=False, on_delete=models.SET_NULL) + text = models.TextField(max_length=5000) + creation_time = models.DateTimeField(auto_now_add=True) + # TODO: Add replies + # TODO: Add last_updated for editing + + class Meta: + ordering = ["-creation_time"] + + def __str__(self) -> str: + return f'{self.creation_time} - {self.owner} - {self.scratch} - {self.text[:50]}' diff --git a/backend/coreapp/serializers.py b/backend/coreapp/serializers.py index 880588bf8..413f8effe 100644 --- a/backend/coreapp/serializers.py +++ b/backend/coreapp/serializers.py @@ -20,6 +20,7 @@ from .models.profile import Profile from .models.project import Project, ProjectMember from .models.scratch import Scratch +from .models.comment import Comment def serialize_profile(request: Request, profile: Profile) -> Dict[str, Any]: @@ -316,3 +317,28 @@ class ProjectMemberSerializer(serializers.ModelSerializer[ProjectMember]): class Meta: model = ProjectMember fields = ["username"] + + +class CommentSerializer(serializers.ModelSerializer[Comment]): + owner = ProfileField(read_only=True) + + class Meta: + model = Comment + fields = [ + "slug", + "text", + "owner", + "scratch", + "creation_time", + ] + + def create(self, validated_data: Any) -> Comment: + comment = Comment.objects.create(**validated_data) + return comment + + # def validate(self, data: Dict[str, Any]) -> Dict[str, Any]: + # """ + # TODO: Validate that the scratch and the user are both valid to allow comment + # creation + # """ + # return True diff --git a/backend/coreapp/tests/test_comment.py b/backend/coreapp/tests/test_comment.py new file mode 100644 index 000000000..560d6c8fd --- /dev/null +++ b/backend/coreapp/tests/test_comment.py @@ -0,0 +1,4 @@ +from coreapp.tests.common import BaseTestCase + +class CommentTest(BaseTestCase): + pass diff --git a/backend/coreapp/urls.py b/backend/coreapp/urls.py index 6e270e9f5..00a00cc31 100644 --- a/backend/coreapp/urls.py +++ b/backend/coreapp/urls.py @@ -9,6 +9,7 @@ project, scratch, user, + comment, ) urlpatterns = [ @@ -24,18 +25,29 @@ *scratch.router.urls, *preset.router.urls, *project.router.urls, + *comment.router.urls, path("user", user.CurrentUser.as_view(), name="current-user"), path( "user/scratches", user.CurrentUserScratchList.as_view(), name="current-user-scratches", ), + path( + "user/comments", + user.CurrentUserCommentList.as_view(), + name="current-user-comments", + ), path("users/", user.user, name="user-detail"), path( "users//scratches", user.UserScratchList.as_view(), name="user-scratches", ), + path( + "users//comments", + user.UserCommentList.as_view(), + name="user-comments", + ), # TODO: remove path("compilers", compiler.CompilerDetail.as_view(), name="compilers"), path("libraries", library.LibraryDetail.as_view(), name="libraries"), diff --git a/backend/coreapp/views/comment.py b/backend/coreapp/views/comment.py new file mode 100644 index 000000000..95080db46 --- /dev/null +++ b/backend/coreapp/views/comment.py @@ -0,0 +1,85 @@ +from typing import Any, Optional + +import django_filters + +from rest_framework.pagination import CursorPagination +from rest_framework import mixins, filters, status +from rest_framework.viewsets import GenericViewSet +from rest_framework.response import Response +from rest_framework.request import Request +from rest_framework.exceptions import APIException +from rest_framework.routers import DefaultRouter + +from ..models.comment import Comment +from ..models.github import GitHubUser +from ..models.profile import Profile +from ..models.scratch import Scratch +from ..serializers import CommentSerializer +from django.contrib.auth.models import User + + +class GithubLoginException(APIException): + status_code = status.HTTP_403_FORBIDDEN + default_detail = "You must be logged in to Github to perform this action." + + +class ScratchSlugException(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = "Invalid Scratch Slug" + + +class CommentPagination(CursorPagination): + ordering = "-creation_time" + page_size = 50 + page_size_query_param = "page_size" + + +class CommentViewSet( + mixins.CreateModelMixin, + mixins.DestroyModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.ListModelMixin, + GenericViewSet, # type: ignore +): + queryset = Comment.objects.all() + pagination_class = CommentPagination + filterset_fields = ["scratch", "slug"] + filter_backends = [ + django_filters.rest_framework.DjangoFilterBackend, + filters.SearchFilter, + ] + search_fields = ["scratch", "owner"] + serializer_class = CommentSerializer + + def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: + user: Optional[User] = request.profile.user + + if not user: + raise GithubLoginException() + gh_user: Optional[GitHubUser] = user.github + if not gh_user: + raise GithubLoginException() + profile: Profile = Profile.objects.get(user=request.profile.user) + if not profile: + raise GithubLoginException() + scratch = Scratch.objects.get(slug=request.GET.get('scratch_id')) + if not scratch: + raise ScratchSlugException() + + serializer = CommentSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + comment = serializer.save() + + comment.owner = profile + comment.scratch = scratch + comment.save() + + return Response( + CommentSerializer(comment, context={"request": request}).data, + status=status.HTTP_201_CREATED, + ) + + +router = DefaultRouter(trailing_slash=False) +router.register(r"comment", CommentViewSet) diff --git a/backend/coreapp/views/user.py b/backend/coreapp/views/user.py index 5934c9858..8980d49a9 100644 --- a/backend/coreapp/views/user.py +++ b/backend/coreapp/views/user.py @@ -10,7 +10,9 @@ from ..models.github import GitHubUser from ..models.profile import Profile from ..models.scratch import Scratch -from ..serializers import TerseScratchSerializer, serialize_profile +from ..models.comment import Comment +from ..serializers import TerseScratchSerializer, serialize_profile, CommentSerializer +from .comment import CommentPagination from .scratch import ScratchPagination @@ -71,6 +73,30 @@ def get_queryset(self) -> QuerySet[Scratch]: return Scratch.objects.filter(owner__user__username=self.kwargs["username"]) +class CurrentUserCommentList(generics.ListAPIView): # type: ignore + """ + Gets the current user's comments + """ + + pagination_class = CommentPagination + serializer_class = CommentSerializer + + def get_queryset(self) -> QuerySet[Comment]: + return Comment.objects.filter(owner=self.request.profile) + + +class UserCommentList(generics.ListAPIView): # type: ignore + """ + Gets a user's comments + """ + + pagination_class = CommentPagination + serializer_class = CommentSerializer + + def get_queryset(self) -> QuerySet[Comment]: + return Comment.objects.filter(owner__user__username=self.kwargs["username"]) + + @api_view(["GET"]) # type: ignore def user(request: Request, username: str) -> Response: """ diff --git a/frontend/package.json b/frontend/package.json index e5717afeb..9d1e43666 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,6 +33,7 @@ "ansi-to-react": "^6.1.6", "classnames": "^2.5.1", "codemirror": "^6.0.1", + "date-fns": "^3.6.0", "dotenv": "^16.4.5", "downshift": "9.0.0", "fast-myers-diff": "^3.2.0", @@ -46,7 +47,6 @@ "react-contenteditable": "^3.3.7", "react-dom": "^18.2.0", "react-laag": "^2.0.5", - "react-timeago": "^7.2.0", "react-virtualized-auto-sizer": "^1.0.24", "react-window": "^1.8.10", "sass": "^1.72.0", diff --git a/frontend/src/components/Dropdown.module.scss b/frontend/src/components/Dropdown.module.scss new file mode 100644 index 000000000..dfa5f4f2a --- /dev/null +++ b/frontend/src/components/Dropdown.module.scss @@ -0,0 +1,54 @@ + +.options { + display: none; + color: var(--g1600); + background: var(--g200); + position: absolute; + overflow-y: auto; + max-height: 320px; + z-index: 20; + width: max-content; + border-radius: 10px; + + &.open { + display: block; + } + + &.open:empty { + display: none; + } + + .option { + color: var(--g1600); + background: var(--g200); + border-radius: 5px; + padding-right: 3px; + padding-left: 3px; + width: 100%; + } + + .option:hover { + background: var(--g1600); + color: var(--g200); + + } +} + +.options::-webkit-scrollbar { + width: 6px; +} + +.options::-webkit-scrollbar-track { + box-shadow: inset 0 0 5px var(--g200); + border-radius: 10px; +} + +.options::-webkit-scrollbar-thumb { + background: var(--g1600); + border-radius: 10px; +} + +.options::-webkit-scrollbar-thumb:hover { + background: gray; +} + diff --git a/frontend/src/components/Dropdown.tsx b/frontend/src/components/Dropdown.tsx new file mode 100644 index 000000000..01e96b12b --- /dev/null +++ b/frontend/src/components/Dropdown.tsx @@ -0,0 +1,62 @@ +import { ReactNode, useCallback, useEffect, useRef, useState } from "react" + +import classNames from "classnames" + +import styles from "./Dropdown.module.scss" + +export type Props = { + options: { [key: string]: (event: any) => void } + className?: string + children: ReactNode +} + +export default function Dropdown({ options, children, className }: Props) { + const [isOpen, setIsOpen] = useState(false) + const ref = useRef(null) + + const toggleDropdown = () => { + setIsOpen(!isOpen) + } + + const closeDropdown = useCallback(() => { + setIsOpen(false) + }, []) + + useEffect(() => { + const listener = event => { + if (!ref?.current || ref.current.contains(event.target)) { + return + } + closeDropdown() + } + + document.addEventListener("mousedown", listener) + document.addEventListener("touchstart", listener) + return () => { + document.removeEventListener("mousedown", listener) + document.addEventListener("touchstart", listener) + } + }, [closeDropdown, ref]) + + return ( +
+ +
+ {Object.entries(options).map(([value, onChange]) => + + )} +
+
+ ) +} diff --git a/frontend/src/components/Scratch/AboutScratch.tsx b/frontend/src/components/Scratch/AboutScratch.tsx index b318440d2..d37dbce27 100644 --- a/frontend/src/components/Scratch/AboutScratch.tsx +++ b/frontend/src/components/Scratch/AboutScratch.tsx @@ -1,6 +1,6 @@ import Link from "next/link" -import TimeAgo from "react-timeago" +import { formatDistanceToNowStrict } from "date-fns" import useSWR from "swr" import { Scratch, Preset, get, usePreset } from "@/lib/api" @@ -78,11 +78,11 @@ export default function AboutScratch({ scratch, setScratch }: Props) { }

Created

- + {formatDistanceToNowStrict(scratch.last_updated)} ago

Modified

- + {formatDistanceToNowStrict(scratch.last_updated)} ago
diff --git a/frontend/src/components/Scratch/Comments.module.scss b/frontend/src/components/Scratch/Comments.module.scss new file mode 100644 index 000000000..6e5563194 --- /dev/null +++ b/frontend/src/components/Scratch/Comments.module.scss @@ -0,0 +1,78 @@ +.holder { + height: 100%; + position: relative; +} + +.inputSection { + width: 100%; + position: absolute; + left: 0; + bottom: 0; + display: contents; + + form { + width: 100%; + + textarea { + appearance: none; + width: 100%; + padding: 8px 10px; + height: 33px; + color: var(--g1200); + background: var(--g200); + font: 0.8rem var(--monospace); + border: 1px solid var(--g500); + border-radius: 4px; + bottom: 0; + left: 0; + padding-right: 2em; + outline: none !important; + overflow: hidden; + resize: none; + + &::-webkit-input-placeholder { + color: var(--g700); + } + } + } + + .submit { + position: absolute; + border: none; + transform: translateY(-15%); + right: 0; + bottom: 0; + } +} + +.commentsBody { + display: flex; + flex-direction: column-reverse; +} + +.metadata { + display: flex; + padding-left: 5px; + color: var(--g900); + + >span { + flex-grow: 1; + } +} + +.text { + overflow-wrap: break-word; + white-space: pre-wrap; +} + +.refresh { + position: fixed; + z-index: 2; +} + +.counter { + float: right; + padding-bottom: 4px; + font: 0.8rem var(--monospace); + color: var(--g1200); +} diff --git a/frontend/src/components/Scratch/Comments.tsx b/frontend/src/components/Scratch/Comments.tsx new file mode 100644 index 000000000..ff72cb4f0 --- /dev/null +++ b/frontend/src/components/Scratch/Comments.tsx @@ -0,0 +1,255 @@ +"use client" + +import { ChangeEvent, useCallback, useRef, useState } from "react" + +import { useRouter } from "next/navigation" + +import { ArrowUpIcon, KebabHorizontalIcon, CheckIcon } from "@primer/octicons-react" +import { formatDistanceToNowStrict } from "date-fns" +import useSWR, { mutate } from "swr" + +import Loading from "@/components/loading.svg" +import * as api from "@/lib/api" +import { Comment } from "@/lib/api" +import { commentUrl } from "@/lib/api/urls" + +import AsyncButton from "../AsyncButton" +import Button from "../Button" +import Dropdown from "../Dropdown" +import UserLink from "../user/UserLink" + +import styles from "./Comments.module.scss" + +const maxTextLength = 5000 + +async function deleteComment(comment: Comment) { + try { + await api.delete_(commentUrl(comment), {}) + alert("Comment deleted") + } catch (e) { + alert(`Error Deleting Comment: ${e}`) + } +} + +function EditComment({ comment, stopEditing, submit }: { comment: any, stopEditing: () => void, submit: (text: string) => Promise }) { + const [text, setText] = useState(comment.text) + const textareaRef = useRef(null) + + const handleInput = (e: ChangeEvent) => { + if (textareaRef.current) { + textareaRef.current.style.height = "auto" + textareaRef.current.style.height = `${e.target.scrollHeight + 2}px` + } + setText(e.target.value) + } + + return ( +
+
+

+
+
+
+
+
+