diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000000..ef595a51f6 --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,24 @@ +{ + "features": { + "ghcr.io/devcontainers-extra/features/pnpm:2": { + "version": "2.0.5", + "resolved": "ghcr.io/devcontainers-extra/features/pnpm@sha256:694c2b6182435c9e9c06f6071728b087c1181b38c18cecf0defe2ab8c11cddd6", + "integrity": "sha256:694c2b6182435c9e9c06f6071728b087c1181b38c18cecf0defe2ab8c11cddd6" + }, + "ghcr.io/devcontainers/features/go:1": { + "version": "1.3.4", + "resolved": "ghcr.io/devcontainers/features/go@sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032", + "integrity": "sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032" + }, + "ghcr.io/devcontainers/features/java:1": { + "version": "1.8.0", + "resolved": "ghcr.io/devcontainers/features/java@sha256:9663ce0219ff85786e87901ce5f0a59f488edd5f99b46015192cda48468b233a", + "integrity": "sha256:9663ce0219ff85786e87901ce5f0a59f488edd5f99b46015192cda48468b233a" + }, + "ghcr.io/devcontainers/features/terraform:1": { + "version": "1.4.2", + "resolved": "ghcr.io/devcontainers/features/terraform@sha256:250d08e39c99d9e959e091e74527c22c04eeae728b424da99f002423c7902f82", + "integrity": "sha256:250d08e39c99d9e959e091e74527c22c04eeae728b424da99f002423c7902f82" + } + } +} diff --git a/apps/backend/apps/admin/src/group/group.resolver.ts b/apps/backend/apps/admin/src/group/group.resolver.ts index 25aeba63f6..98f3207170 100644 --- a/apps/backend/apps/admin/src/group/group.resolver.ts +++ b/apps/backend/apps/admin/src/group/group.resolver.ts @@ -98,6 +98,7 @@ export class GroupResolver { } @Resolver(() => CourseNotice) +@UseGroupLeaderGuard() export class CourseNoticeResolver { constructor(private readonly courseNoticeService: CourseNoticeService) {} diff --git a/apps/backend/apps/client/src/group/group.service.ts b/apps/backend/apps/client/src/group/group.service.ts index c8cc7adb49..f75f534d9c 100644 --- a/apps/backend/apps/client/src/group/group.service.ts +++ b/apps/backend/apps/client/src/group/group.service.ts @@ -1235,9 +1235,7 @@ export class GroupService { ) { comment = { ...comment, - content: '', - createdBy: null, - createdById: null + content: '' } } if (!comment.replyOnId) { diff --git a/apps/backend/libs/auth/src/roles/group-leader.guard.ts b/apps/backend/libs/auth/src/roles/group-leader.guard.ts index 722cdefe84..87f9950893 100644 --- a/apps/backend/libs/auth/src/roles/group-leader.guard.ts +++ b/apps/backend/libs/auth/src/roles/group-leader.guard.ts @@ -26,12 +26,10 @@ export class GroupLeaderGuard implements CanActivate { let groupId: number if (context.getType() === 'graphql') { request = GqlExecutionContext.create(context).getContext().req - groupId = parseInt( - context - .getArgs() - .find((arg) => typeof arg === 'object' && 'groupId' in arg) - ?.groupId ?? 0 - ) + const gqlArg = context + .getArgs() + .find((arg) => typeof arg === 'object' && arg !== null) + groupId = parseInt(gqlArg?.groupId ?? gqlArg?.input?.groupId ?? 0) } else { request = context.switchToHttp().getRequest() groupId = parseInt(request.params.groupId) diff --git a/apps/frontend/app/(client)/(main)/course/[courseId]/_components/CourseNoticeColumns.tsx b/apps/frontend/app/(client)/(main)/course/[courseId]/_components/CourseNoticeColumns.tsx new file mode 100644 index 0000000000..6c8ace8ee2 --- /dev/null +++ b/apps/frontend/app/(client)/(main)/course/[courseId]/_components/CourseNoticeColumns.tsx @@ -0,0 +1,66 @@ +'use client' + +import { cn, dateFormatter } from '@/libs/utils' +import type { ColumnDef } from '@tanstack/react-table' + +export interface CourseNoticeRow { + id: number + no: string + title: string + createdBy: string + date: string + isRead: boolean + isFixed: boolean +} + +export const courseNoticeColumns: ColumnDef[] = [ + { + accessorKey: 'no', + header: 'NO', + cell: ({ row }) => ( +
+ {row.original.no} +
+ ), + enableSorting: false + }, + { + accessorKey: 'title', + header: 'Title', + cell: ({ row }) => ( +
+ {row.original.title} + {!row.original.isRead && ( + + )} +
+ ), + enableSorting: false + }, + { + accessorKey: 'date', + header: 'Date', + cell: ({ row }) => ( + + {row.original.date + ? dateFormatter(row.original.date, 'YY-MM-DD HH:mm') + : '-'} + + ), + enableSorting: false + }, + { + accessorKey: 'createdBy', + header: 'Writer', + cell: ({ row }) => ( + {row.original.createdBy} + ), + enableSorting: false + } +] diff --git a/apps/frontend/app/(client)/(main)/course/[courseId]/_components/CourseNoticeTable.tsx b/apps/frontend/app/(client)/(main)/course/[courseId]/_components/CourseNoticeTable.tsx new file mode 100644 index 0000000000..ced3409845 --- /dev/null +++ b/apps/frontend/app/(client)/(main)/course/[courseId]/_components/CourseNoticeTable.tsx @@ -0,0 +1,192 @@ +'use client' + +import { + DataTable, + DataTablePagination, + DataTableRoot +} from '@/app/admin/_components/table' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/shadcn/dropdown-menu' +import { cn, safeFetcherWithAuth } from '@/libs/utils' +import ArrowDownIcon from '@/public/icons/arrow-down.svg' +import type { + CourseNoticeListItem, + CourseNoticeListResponse +} from '@/types/type' +import { useQuery } from '@tanstack/react-query' +import { useMemo, useState } from 'react' +import { + courseNoticeColumns, + type CourseNoticeRow +} from './CourseNoticeColumns' + +type FilterType = 'all' | 'unread' +type OrderType = 'latest' | 'oldest' + +interface CourseNoticeTableProps { + courseId: number +} + +const getTime = (notice: CourseNoticeListItem) => + new Date(notice.createTime ?? notice.updateTime ?? 0).getTime() + +export function CourseNoticeTable({ courseId }: CourseNoticeTableProps) { + const [filterType, setFilterType] = useState('all') + const [orderType, setOrderType] = useState() + + let orderLabel = 'Order' + + if (orderType === 'latest') { + orderLabel = 'Latest' + } else if (orderType === 'oldest') { + orderLabel = 'Oldest' + } + + const { data: notices = [] } = useQuery({ + queryKey: ['courseNotices', courseId], + queryFn: async () => { + const [fixedRes, normalRes] = await Promise.all([ + safeFetcherWithAuth + .get(`course/${courseId}/notice/all`, { + searchParams: { + take: '100', + fixed: 'true', + readFilter: 'all', + order: 'createTime-desc' + } + }) + .json(), + safeFetcherWithAuth + .get(`course/${courseId}/notice/all`, { + searchParams: { + take: '100', + fixed: 'false', + readFilter: 'all', + order: 'createTime-desc' + } + }) + .json() + ]) + return [...fixedRes.data, ...normalRes.data] + }, + enabled: Boolean(courseId) + }) + + const tableData: CourseNoticeRow[] = useMemo(() => { + const filtered = + filterType === 'unread' ? notices.filter((n) => !n.isRead) : notices + const noMap = new Map( + [...filtered] + .sort((a, b) => getTime(a) - getTime(b)) + .map((n, i) => [n.id, i + 1]) + ) + return [...filtered] + .sort((a, b) => { + if (a.isFixed !== b.isFixed) { + return a.isFixed ? -1 : 1 + } + return orderType === 'oldest' + ? getTime(a) - getTime(b) + : getTime(b) - getTime(a) + }) + .map((n) => ({ + id: n.id, + no: String(noMap.get(n.id) ?? 0).padStart(2, '0'), + title: n.title, + createdBy: n.createdBy ?? 'Unknown', + date: n.createTime ?? n.updateTime ?? '', + isRead: n.isRead, + isFixed: n.isFixed + })) + }, [notices, filterType, orderType]) + + return ( + +
+ + NOTICE + + +
+ + + + + + + setOrderType('latest')} + className="cursor-pointer rounded-[10px] text-sm leading-[22.4px] text-neutral-500" + > + Latest + + setOrderType('oldest')} + className="cursor-pointer rounded-[10px] text-sm leading-[22.4px] text-neutral-500" + > + Oldest + + + + +
+ {(['all', 'unread'] as const).map((type) => ( + + ))} +
+
+
+ + `/course/${courseId}/notice/${row.id}`} + /> + +
+ +
+
+ ) +} diff --git a/apps/frontend/app/(client)/(main)/course/[courseId]/_components/CourseSidebar.tsx b/apps/frontend/app/(client)/(main)/course/[courseId]/_components/CourseSidebar.tsx index c8a82b6da5..139887acab 100644 --- a/apps/frontend/app/(client)/(main)/course/[courseId]/_components/CourseSidebar.tsx +++ b/apps/frontend/app/(client)/(main)/course/[courseId]/_components/CourseSidebar.tsx @@ -9,6 +9,7 @@ import { usePathname } from 'next/navigation' import { useState } from 'react' import { FaAnglesLeft, FaAnglesRight } from 'react-icons/fa6' import { + NoticeIcon, AssignmentIcon, ExerciseIcon, QnaIcon @@ -24,6 +25,11 @@ export function CourseSidebar({ courseId }: CourseSidebarProps) { const pathname = usePathname() const navItems = [ + { + name: 'Notice', + path: `/course/${courseId}/notice` as const, + icon: NoticeIcon + }, { name: 'Assignment', path: `/course/${courseId}/assignment` as const, diff --git a/apps/frontend/app/(client)/(main)/course/[courseId]/notice/[noticeId]/_components/NoticeCommentCard.tsx b/apps/frontend/app/(client)/(main)/course/[courseId]/notice/[noticeId]/_components/NoticeCommentCard.tsx new file mode 100644 index 0000000000..ca141512bf --- /dev/null +++ b/apps/frontend/app/(client)/(main)/course/[courseId]/notice/[noticeId]/_components/NoticeCommentCard.tsx @@ -0,0 +1,326 @@ +'use client' + +import { cn, dateFormatter } from '@/libs/utils' +import ExclamationMarkIcon from '@/public/icons/exclamation_mark.svg' +import LockBlueIcon from '@/public/icons/lock_blue.svg' +import TrashcanIcon from '@/public/icons/trashcan2_grey.svg' +import type { CourseNoticeCommentItem } from '@/types/type' +import { BiSolidPencil } from 'react-icons/bi' +import { IoTime } from 'react-icons/io5' + +interface NoticeCommentCardProps { + comment: CourseNoticeCommentItem + replyCount?: number + isReply?: boolean + profileUsername?: string + isAdmin?: boolean + isReplyOpen: boolean + editingCommentId: number | null + onReplyToggle: (commentId: number) => void + onEditStart: (comment: CourseNoticeCommentItem) => void + onDelete: (commentId: number) => void + renderEditEditor: () => React.ReactNode + hasReplySection?: boolean +} + +export function NoticeCommentCard({ + comment, + replyCount = 0, + isReply = false, + profileUsername, + isAdmin = false, + hasReplySection = false, + isReplyOpen, + editingCommentId, + onReplyToggle, + onEditStart, + onDelete, + renderEditEditor +}: NoticeCommentCardProps) { + const writerUsername = comment.createdBy?.username ?? '' + const writerStudentId = comment.createdBy?.studentId ?? '' + + const maskedStudentId = writerStudentId + ? `${writerStudentId.slice(0, 4)}${'#'.repeat( + Math.max(writerStudentId.length - 4, 0) + )}` + : '' + + const isMine = Boolean( + profileUsername && writerUsername && profileUsername === writerUsername + ) + + const isEdited = + new Date(comment.createdTime).getTime() !== + new Date(comment.updateTime).getTime() + + const isDeleted = comment.isDeleted + const isHiddenMasked = + comment.isSecret && !comment.isDeleted && comment.content === '' + const isMasked = isDeleted || isHiddenMasked + + const canEdit = isMine && !isDeleted && Boolean(comment.createdBy) + const canDelete = + !isDeleted && Boolean(comment.createdBy) && (isMine || isAdmin) + + const displayWriter = (() => { + if (isDeleted) { + return '' + } + + if (!writerUsername) { + return '' + } + + if (maskedStudentId) { + return `${writerUsername} (${maskedStudentId})` + } + + return writerUsername + })() + + const displayContent = (() => { + if (isDeleted) { + return comment.replyOnId + ? 'This is a Deleted Reply' + : 'This is a Deleted Comment' + } + + if (isHiddenMasked) { + return comment.replyOnId + ? 'This is a Hidden Reply' + : 'This is a Hidden Comment' + } + + return comment.content + })() + + if (isReply && isDeleted) { + return ( +
+ {displayWriter && ( +
{displayWriter}
+ )} +
+ + {displayContent} +
+
+ ) + } + + if (isReply && isHiddenMasked) { + return ( +
+ {displayWriter && ( +
{displayWriter}
+ )} +
+ + + {dateFormatter(comment.createdTime, 'YYYY-MM-DD HH:mm:ss')} + +
+
+ + {displayContent} +
+
+ ) + } + + if (!isReply && isHiddenMasked) { + return ( +
+ {displayWriter && ( +
+ {displayWriter} + + + Hidden + +
+ )} +
+ + + {dateFormatter(comment.createdTime, 'YYYY-MM-DD HH:mm:ss')} + +
+
+ + {displayContent} +
+ +
+ ) + } + + const isEditing = editingCommentId === comment.id + + const containerClassName = (() => { + if (isReply && isEditing) { + return 'bg-color-neutral-99 rounded-xl px-6 py-6' + } + + if (isReply) { + return 'bg-transparent p-6' + } + + return cn( + 'border px-6 py-6', + hasReplySection ? 'rounded-b-none rounded-t-xl' : 'rounded-xl' + ) + })() + + const borderClassName = (() => { + if (isReply) { + return '' + } + + if (!isMasked && isMine) { + return 'border-primary' + } + + return 'border-color-neutral-95' + })() + + return ( +
+ {!isDeleted && ( +
+
+ {displayWriter ? ( +
+ {displayWriter} + + {comment.isSecret && Boolean(comment.createdBy) && ( + + + Hidden + + )} +
+ ) : null} + +
+ + + {dateFormatter( + isEdited ? comment.updateTime : comment.createdTime, + 'YYYY-MM-DD HH:mm:ss' + )} + + + {isEdited && ( + + Modified + + )} +
+
+ + {(canEdit || canDelete) && ( +
+ {canEdit && ( + + )} + + {canDelete && ( + + )} +
+ )} +
+ )} + + {!isReply && ( +
+ {isMasked ? ( + + ) : ( + comment.isSecret && + !comment.isDeleted && + !comment.createdBy && ( + + ) + )} + {displayContent} +
+ )} + + {isReply && !isDeleted && ( +
+ {comment.content} +
+ )} + + {!isReply && ( + + )} + + {editingCommentId === comment.id && ( +
{renderEditEditor()}
+ )} +
+ ) +} diff --git a/apps/frontend/app/(client)/(main)/course/[courseId]/notice/[noticeId]/_components/NoticeCommentEditor.tsx b/apps/frontend/app/(client)/(main)/course/[courseId]/notice/[noticeId]/_components/NoticeCommentEditor.tsx new file mode 100644 index 0000000000..6cc10f28d7 --- /dev/null +++ b/apps/frontend/app/(client)/(main)/course/[courseId]/notice/[noticeId]/_components/NoticeCommentEditor.tsx @@ -0,0 +1,130 @@ +'use client' + +import { Button } from '@/components/shadcn/button' +import { useEffect, useRef } from 'react' +import { BiSolidPencil } from 'react-icons/bi' + +interface NoticeCommentEditorProps { + value: string + setValue: (value: string) => void + secret: boolean + setSecret: (value: boolean) => void + onSubmit: () => void + placeholder: string + submitText: string + disabled: boolean + compact?: boolean + autoResize?: boolean + isReplyEdit?: boolean +} + +export function NoticeCommentEditor({ + value, + setValue, + secret, + setSecret, + onSubmit, + placeholder, + compact = false, + autoResize = false, + submitText = 'Post', + disabled = false, + isReplyEdit = false +}: NoticeCommentEditorProps) { + const textareaRef = useRef(null) + + useEffect(() => { + if (!autoResize || !textareaRef.current) { + return + } + textareaRef.current.style.height = 'auto' + textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px` + }, [value, autoResize]) + + return ( +
+
+
+
+