diff --git a/public/editor-settings.toml b/public/editor-settings.toml index 32e761456..7e6679b36 100644 --- a/public/editor-settings.toml +++ b/public/editor-settings.toml @@ -18,7 +18,9 @@ callbackSystem = "OPENCAST" [opencast] # Connect to develop.opencast.org and use the default demo user -url = 'https://develop.opencast.org' +# url = 'https://develop.opencast.org' +# When using the vite proxy, leave url empty so requests go through the dev server +url = '' name = "admin" password = "opencast" @@ -48,3 +50,6 @@ spanish = { lang = "es" } [thumbnail] show = true + +[comments] +show = true diff --git a/src/config.ts b/src/config.ts index 109be73c7..fdb47123b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -79,6 +79,9 @@ interface iSettings { mainFlavor: string, defaultVideoFlavor: Flavor | undefined, }; + comments?: { + show: boolean, + }, } /** @@ -124,6 +127,9 @@ const defaultSettings: iSettings = { mainFlavor: "chapters", defaultVideoFlavor: undefined, }, + comments: { + show: false, + }, }; let configFileSettings: iSettings; let urlParameterSettings: iSettings; @@ -430,6 +436,9 @@ const SCHEMA = { show: types.boolean, simpleMode: types.boolean, }, + comments: { + show: types.boolean, + }, }; const merge = (a: iSettings, b: iSettings) => { diff --git a/src/cssStyles.tsx b/src/cssStyles.tsx index 0087f5d12..c96b235a7 100644 --- a/src/cssStyles.tsx +++ b/src/cssStyles.tsx @@ -71,7 +71,7 @@ export const basicButtonStyle = (theme: Theme) => css({ * CSS for deactivated buttons */ export const deactivatedButtonStyle = css({ - borderRadius: "10px", + borderRadius: "5px", cursor: "pointer", opacity: "0.6", // Flex position child elements @@ -83,7 +83,7 @@ export const deactivatedButtonStyle = css({ }); /** - * CSS for nagivation styled buttons + * CSS for navigation styled buttons */ export const navigationButtonStyle = (theme: Theme) => css({ width: "200px", diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 88087ea87..1a73ea076 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -7,6 +7,7 @@ "chapters-button": "Chapters", "thumbnail-button": "Thumbnail", "metadata-button": "Metadata", + "comments-button": "Comments", "keyboard-controls-button": "Keyboard Controls", "tooltip-aria": "Main Navigation" }, @@ -246,7 +247,8 @@ "error-text": "An error has occurred. Please wait a bit and try again.", "goBack-button": "No, take me back", "callback-button-system": "Back to {{system}}", - "callback-button-generic": "Back to previous system" + "callback-button-generic": "Back to previous system", + "loading": "Loading..." }, "trackSelection": { @@ -331,6 +333,46 @@ "editTitle": "Chapter Editor" }, + "comments": { + "no-comments": "No comments yet. Be the first to comment!", + "resolved": "Resolved", + "open": "Open", + "reply": "Reply", + "delete": "Delete", + "add-comment": "Add Comment", + "placeholder": "Write your comment...", + "select-reason": "Select a reason...", + "submit": "Submit", + "saving": "Saving...", + "reply-to": "Reply to", + "reply-placeholder": "Write your reply to", + "mark-resolved": "Mark as resolved", + "cancel": "Cancel", + "pending": "Pending", + "pending-tooltip": "Pending changes need to be saved in the Finish tab.", + "reasons": { + "cutting": "Cutting required", + "review": "Review required", + "async": "A/V tracks asynchronous", + "audio_issue": "Missing or defective audio track", + "cancelled": "Canceled event", + "conflicting_metadata": "Conflicting metadata", + "improper_point": "Improper in or out point", + "missing_agreement": "Missing agreement", + "other": "Other", + "privacy": "Privacy concern", + "segmentation": "Inaccurate segmentation", + "unknown_creator": "Creator unknown", + "video_issue": "Video distorted or cropped", + "wrong_input_format": "Input file format not supported", + "wrong_metadata": "Metadata needs correction", + "wrong_series_publication": "Wrong series or publication channel", + "wrong_workflow": "Wrong workflow", + "processing_failure": "Processing failure", + "admin_ui_notes": "Notes in Admin UI" + } + }, + "keyboardControls": { "header": "Shortcuts", "defaultGroupName": "General", diff --git a/src/main/Comments.tsx b/src/main/Comments.tsx new file mode 100644 index 000000000..b1cbd306d --- /dev/null +++ b/src/main/Comments.tsx @@ -0,0 +1,543 @@ +import { useState } from "react"; +import { css } from "@emotion/react"; +import { useTheme } from "../themes"; +import Select from "react-select"; +import { + basicButtonStyle, + checkboxStyle as generalCheckboxStyle, + titleStyleBold, + backgroundBoxStyle, + selectFieldStyle, +} from "../cssStyles"; +import { useTranslation } from "react-i18next"; +import { LuClock9, LuReply, LuTrash2 } from "react-icons/lu"; +import { ThemedTooltip } from "./Tooltip"; + +import { useAppDispatch, useAppSelector } from "../redux/store"; +import { + addComment, + addReply, + deleteComment, + deleteReply, + updateResolvedStatus, + selectComments, + selectCommentReasons, + selectStatus, +} from "../redux/commentSlice"; +import { Comment, CommentReply } from "../types"; +import { settings } from "../config"; + +/** + * Component for managing comments + */ +const Comments: React.FC = () => { + const theme = useTheme(); + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + + const comments = useAppSelector(selectComments); + const commentReasons = useAppSelector(selectCommentReasons); + const status = useAppSelector(selectStatus); + + // Local state for forms + const [replyToComment, setReplyToComment] = useState(false); + const [replyCommentId, setReplyCommentId] = useState(undefined); + const [originalComment, setOriginalComment] = useState(undefined); + const [commentReplyText, setCommentReplyText] = useState(""); + const [commentReplyIsResolved, setCommentReplyIsResolved] = useState(false); + + const [newCommentText, setNewCommentText] = useState(""); + const [commentReason, setCommentReason] = useState(""); + + // Comments are fetched together with video info via fetchVideoInformation + + // Handlers + const handleSaveComment = () => { + if (!settings.id || !newCommentText || !commentReason) { + return; + } + + dispatch(addComment({ + reason: commentReason, + text: newCommentText, + })); + setNewCommentText(""); + setCommentReason(""); + }; + + const handleReplyTo = (comment: Comment) => { + setReplyToComment(true); + setReplyCommentId(comment.id); + setOriginalComment(comment); + setCommentReplyIsResolved(comment.resolvedStatus); + }; + + const handleExitReplyMode = () => { + setReplyToComment(false); + setReplyCommentId(undefined); + setOriginalComment(undefined); + setCommentReplyText(""); + setCommentReplyIsResolved(false); + }; + + const handleSaveReply = () => { + if (!settings.id || !originalComment || !commentReplyText) { + return; + } + + dispatch(addReply({ + commentId: originalComment.id, + text: commentReplyText, + })); + // Update resolved status locally (allows toggling resolved/open) + dispatch(updateResolvedStatus({ commentId: originalComment.id, resolved: commentReplyIsResolved })); + handleExitReplyMode(); + }; + + const handleDeleteComment = (comment: Comment) => { + dispatch(deleteComment(comment.id)); + }; + + const handleDeleteReply = (comment: Comment, reply: CommentReply) => { + dispatch(deleteReply({ commentId: comment.id, replyId: reply.id })); + }; + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleString(undefined, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + }; + + // Styles + const containerStyle = css({ + margin: "auto", + width: "100%", + maxWidth: "1200px", + display: "flex", + flexDirection: "column", + height: "100%", + }); + + const commentContainerStyle = css({ + display: "flex", + flexDirection: "column", + gap: "16px", + flex: 1, + minHeight: 0, + overflowY: "auto", + marginBottom: "20px", + maxWidth: "100%", // Prevent children from overflowing + // Modern scrollbar styling + "&::-webkit-scrollbar": { + width: "8px", + background: theme.background, + }, + "&::-webkit-scrollbar-thumb": { + background: theme.text, + borderRadius: "5px", + }, + }); + + const commentCardStyle = (isActive: boolean) => css([ + backgroundBoxStyle(theme), + { + border: isActive ? `${theme.button_outline}` : theme.menuBorder, + }, + ]); + + const replyCardStyle = css({ + marginTop: "20px", + marginLeft: "20px", + }); + + const commentHeaderStyle = css({ + display: "flex", + alignItems: "center", + gap: "12px", + marginBottom: "8px", + flexWrap: "wrap", + }); + + const authorStyle = css({ + fontWeight: "bold", + fontSize: "1em", + color: theme.text, + }); + + const badgeStyle = (color?: string) => css({ + fontSize: "0.75em", + color: color || theme.text, + border: `1px solid ${color || theme.text}`, + padding: "2px 8px", + borderRadius: "9999px", + }); + + // Badge component with optional tooltip support + const Badge: React.FC<{ color?: string; tooltip?: string; children: React.ReactNode }> = ({ + color, + tooltip, + children, + }) => { + const badge = {children}; + if (tooltip) { + return {badge}; + } + return badge; + }; + + const commentTextStyle = css({ + marginTop: "8px", + marginBottom: "12px", + lineHeight: "1.5", + whiteSpace: "pre-wrap", + color: theme.text, + }); + + const actionButtonsStyle = css({ + display: "flex", + gap: "12px", + marginTop: "8px", + justifyContent: "flex-end", + }); + + const actionButtonStyle = css([ + basicButtonStyle(theme), + { + padding: "6px 12px", + border: `1px solid ${theme.text}`, + color: theme.text, + background: "transparent", + gap: "6px", + minWidth: 0, + "&:hover": { + background: theme.text, + borderColor: theme.text, + color: theme.menu_background, + }, + outline: "none", + }, + ]); + + const deleteButtonStyle = css([ + basicButtonStyle(theme), + { + padding: "6px 12px", + border: `1px solid ${theme.error}`, + color: theme.error, + background: "transparent", + gap: "6px", + minWidth: 0, + "&:hover": { + background: theme.error, + color: theme.menu_background, + }, + outline: "none", + }, + ]); + + const textareaStyle = css({ + width: "100%", + maxWidth: "100%", + minHeight: "100px", + padding: "12px", + borderRadius: "5px", + border: theme.menuBorder, + background: theme.element_bg, + color: theme.text, + fontSize: "1em", + fontFamily: "inherit", + resize: "none", + boxSizing: "border-box", + transition: "border-color 0.2s", + }); + + const selectContainerStyle = css({ + width: "100%", + maxWidth: "500px", + }); + + const rowContainerStyle = css({ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + flexWrap: "wrap", + gap: "12px", + marginTop: "12px", + }); + + const checkboxStyle = css([ + generalCheckboxStyle(theme), + { + width: "18px", + height: "18px", + cursor: "pointer", + }, + ]); + + const buttonContainerStyle = css({ + display: "flex", + gap: "12px", + }); + + const submitButtonStyle = (disabled: boolean) => css([ + basicButtonStyle(theme), + { + padding: "10px 20px", + fontSize: "1em", + fontWeight: "bold", + border: theme.menuBorder, + background: disabled ? theme.menu_background : "#4caf50", + color: disabled ? theme.text : theme.menu_background, + cursor: disabled ? "not-allowed" : "pointer", + opacity: disabled ? 0.6 : 1, + "&:hover": { + background: disabled ? theme.menu_background : "#45a049", + color: disabled ? theme.text : "white", + }, + "&:focus": { + borderColor: theme.metadata_highlight, + }, + outline: "none", + }, + ]); + + const cancelButtonStyle = css([ + basicButtonStyle(theme), + { + padding: "10px 20px", + fontSize: "1em", + border: `1px solid ${theme.error}`, + color: theme.error, + background: "transparent", + "&:hover": { + background: theme.error, + color: theme.menu_background, + }, + outline: "none", + }, + ]); + + const loadingStyle = css({ + textAlign: "center", + padding: "40px", + fontSize: "1.2em", + color: theme.text, + }); + + const emptyStateStyle = css({ + textAlign: "center", + padding: "40px", + color: theme.text, + opacity: 0.7, + }); + + // Render loading state + if (status === "loading") { + return ( +
+
{t("various.loading")}
+
+ ); + } + + return ( +
+

{t("mainMenu.comments-button")}

+ + {/* Comments List */} +
+ {comments.length === 0 ? ( +
+ {t("comments.no-comments")} +
+ ) : ( + comments.map(comment => ( +
+ {/* Comment Header */} +
+
+
+ {comment.author} + {comment.pending && ( + + {t("comments.pending")} + + )} + + {t(comment.resolvedStatus ? "comments.resolved" : "comments.open")} + + + {t(`comments.reasons.${comment.reason}` as never)} + +
+
+ + + {formatDate(comment.creationDate)} + +
+ + {/* Comment Text */} +
{comment.text}
+ + {/* Action Buttons */} +
+ + +
+ + {/* Replies */} + {comment.replies.map(reply => ( +
+
+
+ {reply.author} + {reply.pending && ( + + {t("comments.pending")} + + )} +
+ + + {formatDate(reply.creationDate)} + +
+
+ {reply.text} +
+
+ +
+
+ ))} +
+ )) + )} +
+ + {/* Add Comment Form (hidden when replying) */} + {!replyToComment && ( +
+