From 9a5e63337e8264092ff4b61eafa2ed053bc0a197 Mon Sep 17 00:00:00 2001 From: yingshinlee Date: Thu, 11 Jun 2026 22:14:56 +0800 Subject: [PATCH 01/13] feat(campaign): discussion board on campaign detail page - CampaignDetail/Discussion: compact module (desktop right aside) with input box + latest 3 comments + view-all dialog; mobile renders a one-line chip entry that opens the dialog - reuse CircleComment family: CircleCommentForm/Dialog accept campaignId and type campaignDiscussion; ReplyButton/DropdownActions resolve campaign node - 240-char cap with counter on campaign comments (matches moments) - layout: cover height halved (23.26% -> 12%), side participants avatars trimmed 60 -> 12 to make room for the discussion module Co-Authored-By: Claude Fable 5 --- src/common/enums/index.ts | 2 + src/common/enums/text.ts | 3 + .../CircleComment/Content/index.tsx | 6 +- .../CircleComment/DropdownActions/index.tsx | 11 +- .../FooterActions/ReplyButton/index.tsx | 7 +- .../CommentForm/index.tsx | 25 +- .../CommentForm/styles.module.css | 11 + .../Forms/CircleCommentForm/index.tsx | 30 ++- .../Forms/CircleCommentForm/styles.module.css | 14 +- .../CampaignDetail/Discussion/Dialog.tsx | 177 ++++++++++++++ src/views/CampaignDetail/Discussion/gql.ts | 63 +++++ src/views/CampaignDetail/Discussion/index.tsx | 218 ++++++++++++++++++ .../Discussion/styles.module.css | 60 +++++ .../InfoHeader/styles.module.css | 5 +- .../CampaignDetail/SideParticipants/index.tsx | 3 +- src/views/CampaignDetail/index.tsx | 28 +++ 16 files changed, 648 insertions(+), 15 deletions(-) create mode 100644 src/views/CampaignDetail/Discussion/Dialog.tsx create mode 100644 src/views/CampaignDetail/Discussion/gql.ts create mode 100644 src/views/CampaignDetail/Discussion/index.tsx create mode 100644 src/views/CampaignDetail/Discussion/styles.module.css diff --git a/src/common/enums/index.ts b/src/common/enums/index.ts index 9a7c853655..c249722555 100644 --- a/src/common/enums/index.ts +++ b/src/common/enums/index.ts @@ -67,6 +67,8 @@ export const MAX_ARTICLE_COLLECT_LENGTH = 3 export const MAX_MOMENT_CONTENT_LENGTH = 240 export const MAX_MOMENT_COMMENT_LENGTH = 240 +// campaign discussion comment length cap (matches 短動態 = 240) +export const MAX_CAMPAIGN_COMMENT_LENGTH = 240 export const MAX_FIGURE_CAPTION_LENGTH = 100 export const MAX_TAG_CONTENT_LENGTH = 50 diff --git a/src/common/enums/text.ts b/src/common/enums/text.ts index 963c3d2ef1..a9e5569b5c 100644 --- a/src/common/enums/text.ts +++ b/src/common/enums/text.ts @@ -3,16 +3,19 @@ export const COMMENT_TYPE_TEXT = { article: '評論', circleBroadcast: '廣播', circleDiscussion: '眾聊', + campaignDiscussion: '留言', }, zh_hans: { article: '评论', circleBroadcast: '广播', circleDiscussion: '众聊', + campaignDiscussion: '留言', }, en: { article: 'comment', circleBroadcast: 'broadcast', circleDiscussion: 'thread', + campaignDiscussion: 'comment', }, } diff --git a/src/components/CircleComment/Content/index.tsx b/src/components/CircleComment/Content/index.tsx index 07981b1db2..adcbbeada6 100644 --- a/src/components/CircleComment/Content/index.tsx +++ b/src/components/CircleComment/Content/index.tsx @@ -63,6 +63,10 @@ export const CircleCommentContent = ({ const { content, state } = comment const isBlocked = comment.author?.isBlocked + // campaign discussion: collapse long comments after fewer lines (tunable), + // since they are capped at 240 chars and would never hit the default of 10 + const expandLimit = type === 'campaignDiscussion' ? 4 : limit + const contentClasses = classNames({ [styles.content]: true, [size ? styles[`size${size}`] : '']: !!size, @@ -96,7 +100,7 @@ export const CircleCommentContent = ({ <> { const { isArchived, isBanned, isFrozen } = viewer const circle = comment.node.__typename === 'Circle' ? comment.node : undefined + const campaign = + comment.node.__typename === 'WritingChallenge' ? comment.node : undefined const targetAuthor = circle?.owner const isTargetAuthor = viewer.id === targetAuthor?.id @@ -222,7 +230,8 @@ const DropdownActions = (props: DropdownActionsProps) => { BaseDropdownActions as React.ComponentType, CircleCommentFormDialog, { - circleId: circle?.id || '', + circleId: circle?.id, + campaignId: campaign?.id, type, commentId: comment.id, defaultContent: comment.content, diff --git a/src/components/CircleComment/FooterActions/ReplyButton/index.tsx b/src/components/CircleComment/FooterActions/ReplyButton/index.tsx index 178176b3d6..605c5f0ac9 100644 --- a/src/components/CircleComment/FooterActions/ReplyButton/index.tsx +++ b/src/components/CircleComment/FooterActions/ReplyButton/index.tsx @@ -44,6 +44,9 @@ const fragments = { id } } + ... on WritingChallenge { + id + } } parentComment { id @@ -86,6 +89,7 @@ const ReplyButton = ({ const { id, parentComment, author, node } = comment const circle = node.__typename === 'Circle' ? node : undefined + const campaign = node.__typename === 'WritingChallenge' ? node : undefined const submitCallback = () => { if (replySubmitCallback) { @@ -126,7 +130,8 @@ const ReplyButton = ({ return ( = ({ replyToId, parentId, circleId, + campaignId, type, defaultContent, @@ -65,7 +68,7 @@ const CommentForm: React.FC = ({ const formStorageKey = formStorage.genCircleCommentKey({ authorId: viewer.id, - circleId, + circleId: circleId ?? campaignId ?? '', type, commentId, parentId, @@ -77,7 +80,12 @@ const CommentForm: React.FC = ({ defaultContent || '' ) - const isValid = stripHtml(content).length > 0 + // campaign discussion comments are capped at 240 chars (like 短動態) + const maxLength = + type === 'campaignDiscussion' ? MAX_CAMPAIGN_COMMENT_LENGTH : undefined + const contentLength = stripHtml(content).length + const isOverLength = maxLength !== undefined && contentLength > maxLength + const isValid = contentLength > 0 && !isOverLength const handleSubmit = async (event?: React.FormEvent) => { const mentions = dom.getAttributes('data-id', content) @@ -88,6 +96,7 @@ const CommentForm: React.FC = ({ content, replyTo: replyToId, circleId, + campaignId, parentId, type, mentions, @@ -181,6 +190,14 @@ const CommentForm: React.FC = ({ window.dispatchEvent(new CustomEvent(formStorageKey)) } /> + {maxLength !== undefined && ( +

+ {contentLength} / {maxLength} +

+ )} diff --git a/src/components/Dialogs/CircleCommentFormDialog/CommentForm/styles.module.css b/src/components/Dialogs/CircleCommentFormDialog/CommentForm/styles.module.css index 2e64c69459..a8a31b19bc 100644 --- a/src/components/Dialogs/CircleCommentFormDialog/CommentForm/styles.module.css +++ b/src/components/Dialogs/CircleCommentFormDialog/CommentForm/styles.module.css @@ -11,3 +11,14 @@ flex-grow: 1; overflow-y: auto; } + +.counter { + margin-top: var(--sp8); + font-size: var(--text12); + color: var(--color-grey); + text-align: right; +} + +.counter[data-over='true'] { + color: var(--color-red); +} diff --git a/src/components/Forms/CircleCommentForm/index.tsx b/src/components/Forms/CircleCommentForm/index.tsx index 769b42eb03..b3b7b35781 100644 --- a/src/components/Forms/CircleCommentForm/index.tsx +++ b/src/components/Forms/CircleCommentForm/index.tsx @@ -3,6 +3,7 @@ import dynamic from 'next/dynamic' import { useContext, useState } from 'react' import { useIntl } from 'react-intl' +import { MAX_CAMPAIGN_COMMENT_LENGTH } from '~/common/enums' import { dom, formStorage, stripHtml } from '~/common/utils' import { Button, @@ -24,13 +25,18 @@ const CommentEditor = dynamic(() => import('~/components/Editor/Comment'), { loading: () => , }) -export type CircleCommentFormType = 'circleDiscussion' | 'circleBroadcast' +export type CircleCommentFormType = + | 'circleDiscussion' + | 'circleBroadcast' + | 'campaignDiscussion' export interface CircleCommentFormProps { commentId?: string replyToId?: string parentId?: string - circleId: string + // exactly one of circleId / campaignId is set, depending on `type` + circleId?: string + campaignId?: string type: CircleCommentFormType defaultContent?: string | null @@ -44,6 +50,7 @@ export const CircleCommentForm: React.FC = ({ replyToId, parentId, circleId, + campaignId, type, defaultContent, @@ -64,7 +71,7 @@ export const CircleCommentForm: React.FC = ({ const formStorageKey = formStorage.genCircleCommentKey({ authorId: viewer.id, - circleId, + circleId: circleId ?? campaignId ?? '', type, commentId, parentId, @@ -77,7 +84,13 @@ export const CircleCommentForm: React.FC = ({ defaultContent || '' ) - const isValid = stripHtml(content).length > 0 + // campaign discussion comments are capped at 240 chars (like 短動態) + const maxLength = + type === 'campaignDiscussion' ? MAX_CAMPAIGN_COMMENT_LENGTH : undefined + const contentLength = stripHtml(content).length + const isOverLength = + maxLength !== undefined && contentLength > maxLength + const isValid = contentLength > 0 && !isOverLength const handleSubmit = async (event?: React.FormEvent) => { const mentions = dom.getAttributes('data-id', content) @@ -87,6 +100,7 @@ export const CircleCommentForm: React.FC = ({ content, replyTo: replyToId, circleId, + campaignId, parentId, type, mentions, @@ -161,6 +175,14 @@ export const CircleCommentForm: React.FC = ({