From 157d4c11c86544cc493bc95f91c761ef7b99b4e2 Mon Sep 17 00:00:00 2001 From: yingshinlee Date: Tue, 9 Jun 2026 23:49:42 +0800 Subject: [PATCH 1/7] feat: add quote image generator from text selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let readers select text in an article and generate a shareable quote image (download as JPG / Web Share). Extends the existing TextSelectionPopover with a second button next to "quote to comment". - New component: src/components/QuoteImageDialog (Card / Content / presets / gql) - 6 neutral color presets + 3 sizes (1:1, 4:5, 9:16) with platform hints - auto font-scaling + 80-char cap so long selections never overflow - QR code (qrcode) links back to the article; rendered via html-to-image - 七日書 (WritingChallenge) articles automatically swap the Matters wordmark for the seven-day-book logo (silent detection via campaigns) - TextSelectionPopover: add icon-only "金句圖" button, thread article in - ArticleDetail/Content + gql: pass article + spread QuoteImageArticle fragment - analytics: register quote_image_* click events - deps: html-to-image, qrcode (+ @types/qrcode) Co-Authored-By: Claude Opus 4.8 --- package.json | 3 + .../images/seven-day-book-logo-dark.svg | 12 + .../images/seven-day-book-logo-white.svg | 12 + src/common/utils/analytics.ts | 3 + src/components/QuoteImageDialog/Card.tsx | 100 +++++++ src/components/QuoteImageDialog/Content.tsx | 246 ++++++++++++++++++ src/components/QuoteImageDialog/gql.ts | 52 ++++ src/components/QuoteImageDialog/index.tsx | 38 +++ src/components/QuoteImageDialog/presets.ts | 64 +++++ .../QuoteImageDialog/styles.module.css | 180 +++++++++++++ src/components/TextSelectionPopover/index.tsx | 46 +++- src/components/index.tsx | 1 + src/views/ArticleDetail/Content/index.tsx | 8 +- src/views/ArticleDetail/gql.ts | 3 + src/views/ArticleDetail/index.tsx | 1 + 15 files changed, 763 insertions(+), 6 deletions(-) create mode 100644 public/static/images/seven-day-book-logo-dark.svg create mode 100644 public/static/images/seven-day-book-logo-white.svg create mode 100644 src/components/QuoteImageDialog/Card.tsx create mode 100644 src/components/QuoteImageDialog/Content.tsx create mode 100644 src/components/QuoteImageDialog/gql.ts create mode 100644 src/components/QuoteImageDialog/index.tsx create mode 100644 src/components/QuoteImageDialog/presets.ts create mode 100644 src/components/QuoteImageDialog/styles.module.css diff --git a/package.json b/package.json index d2c462d426..043f09a8a3 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "formik": "^2.4.6", "graphql": "^16.11.0", "graphql-tag": "^2.12.6", + "html-to-image": "^1.11.13", "husky": "^9.1.7", "js-base64": "^3.7.7", "js-cookie": "^3.0.5", @@ -95,6 +96,7 @@ "number-precision": "^1.6.0", "path-to-regexp": "^8.2.0", "photoswipe": "^5.4.4", + "qrcode": "^1.5.4", "react": "^18.3.1", "react-aria-components": "^1.10.1", "react-beautiful-dnd": "^13.1.1", @@ -143,6 +145,7 @@ "@types/jump.js": "^1.0.6", "@types/lodash": "^4.17.17", "@types/nprogress": "0.2.3", + "@types/qrcode": "^1.5.5", "@types/react": "^18.3.22", "@types/react-beautiful-dnd": "^13.1.8", "@types/react-dom": "^18.3.7", diff --git a/public/static/images/seven-day-book-logo-dark.svg b/public/static/images/seven-day-book-logo-dark.svg new file mode 100644 index 0000000000..96e18fa5c5 --- /dev/null +++ b/public/static/images/seven-day-book-logo-dark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/static/images/seven-day-book-logo-white.svg b/public/static/images/seven-day-book-logo-white.svg new file mode 100644 index 0000000000..65f5a9c033 --- /dev/null +++ b/public/static/images/seven-day-book-logo-white.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/common/utils/analytics.ts b/src/common/utils/analytics.ts index a29187e50a..dafa666254 100644 --- a/src/common/utils/analytics.ts +++ b/src/common/utils/analytics.ts @@ -90,6 +90,9 @@ export interface ClickButtonProp { | 'edited' | 'appreciate' | 'article_content_quote' + | 'article_content_quote_image' + | 'quote_image_download' + | 'quote_image_share' | 'comment_open' | 'comment_close' | 'comment_placeholder' diff --git a/src/components/QuoteImageDialog/Card.tsx b/src/components/QuoteImageDialog/Card.tsx new file mode 100644 index 0000000000..1a8a933e61 --- /dev/null +++ b/src/components/QuoteImageDialog/Card.tsx @@ -0,0 +1,100 @@ +import { forwardRef } from 'react' + +import SevenDayBookLogoDark from '@/public/static/images/seven-day-book-logo-dark.svg' +import SevenDayBookLogoWhite from '@/public/static/images/seven-day-book-logo-white.svg' + +import { + clampQuote, + fitFontSize, + type QuoteSize, + type QuoteStyle, +} from './presets' +import styles from './styles.module.css' + +const FONT_SERIF = "'Noto Serif TC','Songti TC','Songti SC','NSimSun',serif" +const FONT_SANS = + "'PingFang TC','PingFang SC','Microsoft JhengHei','Noto Sans TC',sans-serif" + +export type QuoteCardProps = { + quote: string + author: string + title: string + /** QR Code 的 data URL(由 Content 以 qrcode 產生) */ + qrDataUrl: string + style: QuoteStyle + size: QuoteSize + isSevenDayBook: boolean +} + +/** + * 金句卡片本體。固定以 1080px 寬度渲染(size.w/size.h), + * 由 Content 以 CSS transform 縮放預覽、以 html-to-image 原尺寸截圖。 + */ +export const QuoteCard = forwardRef( + ({ quote, author, title, qrDataUrl, style: s, size, isSevenDayBook }, ref) => { + const { text } = clampQuote(quote) + const fontFamily = s.font === 'serif' ? FONT_SERIF : FONT_SANS + const letterSpacing = s.wide ? '0.06em' : s.font === 'serif' ? '0.02em' : 'normal' + const lineHeight = s.airy ? 1.8 : 1.65 + const fontSize = fitFontSize(text.length, s.airy) + + const Logo = s.logo === 'white' ? SevenDayBookLogoWhite : SevenDayBookLogoDark + + return ( +
+
+
+ “ +
+
+ {text} +
+
+ + {author} +
+
+ +
+
+
+ 原文 + {title} +
+ {isSevenDayBook ? ( +
+ +
+ ) : ( +
+ Matters · matters.town +
+ )} +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + QR code +
+
+
+ ) + } +) + +QuoteCard.displayName = 'QuoteCard' diff --git a/src/components/QuoteImageDialog/Content.tsx b/src/components/QuoteImageDialog/Content.tsx new file mode 100644 index 0000000000..605ea9fb5e --- /dev/null +++ b/src/components/QuoteImageDialog/Content.tsx @@ -0,0 +1,246 @@ +import { toJpeg } from 'html-to-image' +import QRCode from 'qrcode' +import { useEffect, useMemo, useRef, useState } from 'react' +import { defineMessages, FormattedMessage, useIntl } from 'react-intl' + +import { analytics, isMobile } from '~/common/utils' +import { Dialog } from '~/components' + +import { QuoteCard } from './Card' +import { clampQuote, MAX_QUOTE_LEN, QUOTE_SIZES, QUOTE_STYLES } from './presets' +import styles from './styles.module.css' + +const PREVIEW_WIDTH = 320 // 預覽寬度(卡片以此等比縮放顯示) + +const styleNames = defineMessages({ + warm: { defaultMessage: 'Cream', id: 'QuoteImage.style.warm' }, + sky: { defaultMessage: 'Sky', id: 'QuoteImage.style.sky' }, + coral: { defaultMessage: 'Coral', id: 'QuoteImage.style.coral' }, + ink: { defaultMessage: 'Ink', id: 'QuoteImage.style.ink' }, + pine: { defaultMessage: 'Pine', id: 'QuoteImage.style.pine' }, + mint: { defaultMessage: 'Mint', id: 'QuoteImage.style.mint' }, +}) +const sizeNames = defineMessages({ + square: { defaultMessage: 'Square 1:1', id: 'QuoteImage.size.square' }, + portrait: { defaultMessage: 'Portrait 4:5', id: 'QuoteImage.size.portrait' }, + story: { defaultMessage: 'Story 9:16', id: 'QuoteImage.size.story' }, +}) +const sizeNotes = defineMessages({ + square: { defaultMessage: 'IG / FB post', id: 'QuoteImage.sizeNote.square' }, + portrait: { defaultMessage: 'IG post · most eye-catching', id: 'QuoteImage.sizeNote.portrait' }, + story: { defaultMessage: 'IG / FB story · Threads', id: 'QuoteImage.sizeNote.story' }, +}) + +export type QuoteImageDialogContentProps = { + closeDialog: () => void + quote: string + author: string + title: string + /** 文章連結,用於產生 QR Code */ + shareLink: string + isSevenDayBook: boolean +} + +const QuoteImageDialogContent: React.FC = ({ + closeDialog, + quote, + author, + title, + shareLink, + isSevenDayBook, +}) => { + const intl = useIntl() + const cardRef = useRef(null) + + const [styleId, setStyleId] = useState('pine') + const [sizeId, setSizeId] = useState('portrait') + const [qrDataUrl, setQrDataUrl] = useState('') + + const style = QUOTE_STYLES.find((s) => s.id === styleId) || QUOTE_STYLES[0] + const size = QUOTE_SIZES.find((s) => s.id === sizeId) || QUOTE_SIZES[1] + const { truncated, original } = useMemo(() => clampQuote(quote), [quote]) + + // 依風格 / 連結重新產生 QR Code(顏色跟著風格走) + useEffect(() => { + let active = true + QRCode.toDataURL(shareLink || ' ', { + margin: 0, + width: 300, + color: { dark: style.qrDark, light: style.qrLight }, + }) + .then((url) => active && setQrDataUrl(url)) + .catch(() => active && setQrDataUrl('')) + return () => { + active = false + } + }, [shareLink, style.qrDark, style.qrLight]) + + const previewScale = PREVIEW_WIDTH / size.w + + const generate = async () => { + if (!cardRef.current) return null + return toJpeg(cardRef.current, { + quality: 0.95, + pixelRatio: 2, + width: size.w, + height: size.h, + style: { transform: 'none' }, + }) + } + + const onDownload = async () => { + const url = await generate() + if (!url) return + analytics.trackEvent('click_button', { + type: 'quote_image_download', + pageType: 'article_detail', + }) + const a = document.createElement('a') + a.href = url + a.download = `matters-quote-${style.id}-${size.id}.jpg` + a.click() + } + + const onShare = async () => { + const url = await generate() + if (!url) return + analytics.trackEvent('click_button', { + type: 'quote_image_share', + pageType: 'article_detail', + }) + try { + const blob = await (await fetch(url)).blob() + const file = new File([blob], 'matters-quote.jpg', { type: 'image/jpeg' }) + if (isMobile() && navigator.canShare?.({ files: [file] })) { + await navigator.share({ files: [file] }) + return + } + } catch { + // fall through to download + } + await onDownload() + } + + return ( + <> + + } + /> + + +
+
+ +
+
+ + {truncated && ( +

+ +

+ )} + +
+ + + +
+ {QUOTE_STYLES.map((s) => ( + + ))} +
+
+ +
+ + + +
+ {QUOTE_SIZES.map((s) => ( + + ))} +
+
+
+ + + + } + color="green" + onClick={onDownload} + /> + } + color="greyDarker" + onClick={onShare} + /> + + } + smUpBtns={ + <> + + } + color="green" + onClick={onDownload} + /> + } + color="greyDarker" + onClick={onShare} + /> + + } + /> + + ) +} + +export default QuoteImageDialogContent diff --git a/src/components/QuoteImageDialog/gql.ts b/src/components/QuoteImageDialog/gql.ts new file mode 100644 index 0000000000..88d2220576 --- /dev/null +++ b/src/components/QuoteImageDialog/gql.ts @@ -0,0 +1,52 @@ +import gql from 'graphql-tag' + +import { QuoteImageArticleFragment } from '~/gql/graphql' + +/** + * 金句卡片需要的文章欄位。 + * campaigns 用來判斷文章是否屬於「七日書」活動(WritingChallenge)。 + */ +export const fragments = { + article: gql` + fragment QuoteImageArticle on Article { + id + title + shortHash + author { + id + displayName + userName + } + campaigns { + campaign { + id + shortHash + ... on WritingChallenge { + id + nameZhHant: name(input: { language: zh_hant }) + nameZhHans: name(input: { language: zh_hans }) + nameEn: name(input: { language: en }) + } + } + } + } + `, +} + +/** + * 判斷文章是否屬於「七日書」活動。 + * + * 目前以活動名稱包含「七日書」為準(涵蓋歷屆七日書)。 + * TODO: 若需更精確,可改為比對一份設定好的七日書 campaign shortHash 清單 + * (例如放在 src/common/enums 或環境變數),避免名稱被更動時誤判。 + */ +export const isSevenDayBookArticle = ( + article?: QuoteImageArticleFragment | null +): boolean => { + if (!article?.campaigns?.length) return false + return article.campaigns.some(({ campaign }) => { + if (campaign?.__typename !== 'WritingChallenge') return false + const names = [campaign.nameZhHant, campaign.nameZhHans] + return names.some((n) => !!n && n.includes('七日書')) + }) +} diff --git a/src/components/QuoteImageDialog/index.tsx b/src/components/QuoteImageDialog/index.tsx new file mode 100644 index 0000000000..77971153e9 --- /dev/null +++ b/src/components/QuoteImageDialog/index.tsx @@ -0,0 +1,38 @@ +import dynamic from 'next/dynamic' + +import { Dialog, SpinnerBlock, useDialogSwitch } from '~/components' + +import { type QuoteImageDialogContentProps } from './Content' + +export type QuoteImageDialogProps = { + children: ({ openDialog }: { openDialog: () => void }) => React.ReactNode +} & Omit + +const DynamicContent = dynamic(() => import('./Content'), { + ssr: false, + loading: () => , +}) + +const BaseDialog = ({ children, ...props }: QuoteImageDialogProps) => { + const { show, openDialog, closeDialog } = useDialogSwitch(true) + + return ( + <> + {children({ openDialog })} + + + + + + ) +} + +export const QuoteImageDialog = (props: QuoteImageDialogProps) => { + return ( + }> + {({ openDialog }) => <>{props.children({ openDialog })}} + + ) +} + +export { fragments, isSevenDayBookArticle } from './gql' diff --git a/src/components/QuoteImageDialog/presets.ts b/src/components/QuoteImageDialog/presets.ts new file mode 100644 index 0000000000..362659c679 --- /dev/null +++ b/src/components/QuoteImageDialog/presets.ts @@ -0,0 +1,64 @@ +/** + * 金句卡片的風格與尺寸設定。 + * 顏色取自 thematters/design-system token;命名採中性色調。 + */ + +export type QuoteStyle = { + id: string + name: string // i18n key 後綴,對應 lang 檔的 styleXxx + swatch: string // 風格選擇器上的色塊 + bg: string + quoteColor: string + accent: string + sub: string + font: 'serif' | 'sans' + weight?: number + airy?: boolean + wide?: boolean + /** 七日書 logo 版本:深底用 white,淺底用 dark */ + logo?: 'dark' | 'white' + qrDark: string + qrLight: string +} + +export type QuoteSize = { + id: string + name: string // i18n key 後綴 + w: number + h: number +} + +export const QUOTE_STYLES: QuoteStyle[] = [ + { id: 'warm', name: 'Cream', swatch: '#c0a46b', bg: '#faf7f0', quoteColor: '#333333', accent: '#c0a46b', sub: '#c58463', font: 'serif', logo: 'dark', qrDark: '#c0a46b', qrLight: '#faf7f0' }, + { id: 'sky', name: 'Sky', swatch: '#85d8ff', bg: '#F0F9FE', quoteColor: '#045898', accent: '#1999D0', sub: '#1999D0', font: 'sans', weight: 300, airy: true, logo: 'dark', qrDark: '#1999D0', qrLight: '#F0F9FE' }, + { id: 'coral', name: 'Coral', swatch: '#dc7871', bg: '#ffe8e8', quoteColor: '#333333', accent: '#dc7871', sub: '#d577aa', font: 'sans', weight: 600, logo: 'dark', qrDark: '#dc7871', qrLight: '#ffe8e8' }, + { id: 'ink', name: 'Ink', swatch: '#000000', bg: '#000000', quoteColor: '#ffffff', accent: '#c0a46b', sub: '#c0a46b', font: 'serif', wide: true, logo: 'white', qrDark: '#c0a46b', qrLight: '#000000' }, + { id: 'pine', name: 'Pine', swatch: '#0d6763', bg: '#0d6763', quoteColor: '#faf7f0', accent: '#40bfa5', sub: '#a9d9cf', font: 'serif', logo: 'white', qrDark: '#faf7f0', qrLight: '#0d6763' }, + { id: 'mint', name: 'Mint', swatch: '#70b388', bg: 'linear-gradient(160deg,#f2faf7 0%,#f2fbd9 100%)', quoteColor: '#246802', accent: '#70b388', sub: '#70b388', font: 'serif', logo: 'dark', qrDark: '#246802', qrLight: '#f7fbef' }, +] + +export const QUOTE_SIZES: QuoteSize[] = [ + { id: 'square', name: 'Square', w: 1080, h: 1080 }, + { id: 'portrait', name: 'Portrait', w: 1080, h: 1350 }, + { id: 'story', name: 'Story', w: 1080, h: 1920 }, +] + +/** 金句字數上限(超過自動截斷,金句以精煉為佳) */ +export const MAX_QUOTE_LEN = 80 + +/** 依字數自動縮放字級,確保長句也塞得進安全區、不壓到頁尾 */ +export const fitFontSize = (len: number, airy?: boolean): number => { + if (len <= 16) return airy ? 72 : 78 + if (len <= 30) return airy ? 60 : 66 + if (len <= 48) return 56 + if (len <= 64) return 48 + return 42 +} + +export const clampQuote = (raw: string) => { + const text = (raw || '').trim().replace(/\s+/g, ' ') + if (text.length > MAX_QUOTE_LEN) { + return { text: text.slice(0, MAX_QUOTE_LEN) + '…', truncated: true, original: text.length } + } + return { text, truncated: false, original: text.length } +} diff --git a/src/components/QuoteImageDialog/styles.module.css b/src/components/QuoteImageDialog/styles.module.css new file mode 100644 index 0000000000..8b79160ebf --- /dev/null +++ b/src/components/QuoteImageDialog/styles.module.css @@ -0,0 +1,180 @@ +/* === 金句卡片本體(固定 1080px 設計尺寸) === */ +.card { + position: relative; + overflow: hidden; + display: block; +} + +.inner { + position: absolute; + top: 118px; + left: 110px; + right: 110px; + bottom: 300px; + display: flex; + flex-direction: column; + justify-content: center; + overflow: hidden; +} + +.mark { + font-size: 120px; + line-height: 0.6; + margin-bottom: 18px; + font-family: 'Noto Serif TC', 'Songti TC', serif; + opacity: 0.9; +} + +.quote { + /* font-size / color / line-height 由元件 inline 指定 */ +} + +.author { + margin-top: 44px; + font-size: 40px; + font-weight: 600; +} + +.author .dash { + margin-right: 14px; + opacity: 0.7; +} + +.foot { + position: absolute; + left: 110px; + right: 110px; + bottom: 90px; + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 32px; +} + +.title { + display: -webkit-box; + max-width: 640px; + font-size: 30px; + font-family: 'PingFang TC', sans-serif; + line-height: 1.5; + opacity: 0.95; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.title .label { + display: block; + margin-bottom: 10px; + font-size: 22px; + letter-spacing: 0.2em; + opacity: 0.6; +} + +.brand { + margin-top: 14px; + font-size: 24px; + font-family: 'PingFang TC', sans-serif; + opacity: 0.7; +} + +.brandLogo { + margin-top: 16px; +} + +.brandLogo svg { + display: block; + width: auto; + height: 60px; +} + +.qr { + flex: 0 0 auto; + width: 150px; + height: 150px; + padding: 12px; + border-radius: 14px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); +} + +.qr img { + display: block; + width: 100%; + height: 100%; +} + +/* === 對話框內容 === */ +.preview { + display: flex; + justify-content: center; + padding: var(--sp24, 24px); + background: var(--color-grey-lighter); + border-radius: var(--sp12, 12px); +} + +.stage { + transform-origin: top left; +} + +.truncHint { + margin: var(--sp12, 12px) 0 0; + font-size: var(--text13, 13px); + color: var(--color-function-warn, #dba34f); +} + +.group { + margin-top: var(--sp16, 16px); +} + +.groupLabel { + display: block; + margin-bottom: var(--sp8, 8px); + font-size: var(--text13, 13px); + font-weight: var(--font-semibold, 600); + color: var(--color-grey-darker); +} + +.opts { + display: flex; + flex-wrap: wrap; + gap: var(--sp8, 8px); +} + +.opt { + display: flex; + align-items: center; + gap: 6px; + padding: 7px 12px; + font-size: var(--text14, 14px); + background: var(--color-white); + border: 1.5px solid var(--color-grey-light); + border-radius: 9px; + cursor: pointer; +} + +.opt.active { + font-weight: var(--font-semibold, 600); + color: var(--color-brand-legacy-green, #0d6763); + background: var(--color-brand-legacy-green-lighter, #f2faf7); + border-color: var(--color-brand-legacy-green, #0d6763); +} + +.swatch { + display: inline-block; + width: 14px; + height: 14px; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 4px; +} + +.sizeName { + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.sizeNote { + font-size: 11px; + font-weight: 400; + color: var(--color-grey-dark); +} diff --git a/src/components/TextSelectionPopover/index.tsx b/src/components/TextSelectionPopover/index.tsx index 6fa256a5c7..fc9d4859c3 100644 --- a/src/components/TextSelectionPopover/index.tsx +++ b/src/components/TextSelectionPopover/index.tsx @@ -2,15 +2,23 @@ import classNames from 'classnames' import { useEffect, useRef, useState } from 'react' import IconComment from '@/public/static/icons/24px/comment.svg' +import IconImage from '@/public/static/icons/24px/image.svg' import { OPEN_COMMENT_LIST_DRAWER } from '~/common/enums' import { isElementInViewport } from '~/common/utils' import { analytics } from '~/common/utils' -import { Icon, useCommentEditorContext } from '~/components' +import { + Icon, + isSevenDayBookArticle, + QuoteImageDialog, + useCommentEditorContext, +} from '~/components' +import { QuoteImageArticleFragment } from '~/gql/graphql' import styles from './styles.module.css' interface TextSelectionPopoverProps { targetElement: HTMLElement + article?: QuoteImageArticleFragment | null } const isSelectionCrossingParagraphs = (selection: Selection): boolean => { @@ -53,8 +61,10 @@ const isValidSelection = ( export const TextSelectionPopover = ({ targetElement, + article, }: TextSelectionPopoverProps) => { const [selection, setSelection] = useState() + const [selectionText, setSelectionText] = useState('') const [position, setPosition] = useState>() // { x, y } const ref = useRef(null) const { fallbackEditor, getCurrentEditor } = useCommentEditorContext() @@ -118,6 +128,9 @@ export const TextSelectionPopover = ({ setSelection(activeSelection?.toString() || '') } + // 金句卡片用純文字 + setSelectionText(activeSelection?.toString() || '') + const rect = (activeSelection as Selection) .getRangeAt(0) .getBoundingClientRect() @@ -186,10 +199,33 @@ export const TextSelectionPopover = ({ - {/* - */} + + + + + {({ openDialog }) => ( + + )} + )} diff --git a/src/components/index.tsx b/src/components/index.tsx index 4297e37892..063ca4de65 100644 --- a/src/components/index.tsx +++ b/src/components/index.tsx @@ -76,6 +76,7 @@ export * from './Interaction' export * from './Layout' export * from './Notice' export * from './Protected' +export * from './QuoteImageDialog' export * from './ReCaptcha' export * from './Search' export * from './TagDigest' diff --git a/src/views/ArticleDetail/Content/index.tsx b/src/views/ArticleDetail/Content/index.tsx index c7ad6a1000..cb6a417559 100644 --- a/src/views/ArticleDetail/Content/index.tsx +++ b/src/views/ArticleDetail/Content/index.tsx @@ -13,7 +13,10 @@ import { ViewerContext, } from '~/components' import { useReadTimer } from '~/components/Hook' -import { ReadArticleMutation } from '~/gql/graphql' +import { + QuoteImageArticleFragment, + ReadArticleMutation, +} from '~/gql/graphql' import styles from './styles.module.css' @@ -27,10 +30,12 @@ const READ_ARTICLE = gql` const Content = ({ articleId, + article, content, indentFirstLine, }: { articleId: string + article?: QuoteImageArticleFragment | null content: string indentFirstLine: boolean }) => { @@ -140,6 +145,7 @@ const Content = ({ {!isInArticleDetailHistory && contentContainer.current && ( )} diff --git a/src/views/ArticleDetail/gql.ts b/src/views/ArticleDetail/gql.ts index 278cd5e3b9..782702569e 100644 --- a/src/views/ArticleDetail/gql.ts +++ b/src/views/ArticleDetail/gql.ts @@ -1,5 +1,6 @@ import gql from 'graphql-tag' +import { fragments as quoteImageFragments } from '~/components/QuoteImageDialog' import { UserDigest } from '~/components/UserDigest' import { AuthorSidebar } from './AuthorSidebar' @@ -77,7 +78,9 @@ const articlePublicFragment = gql` ...SupportWidgetArticlePrivate ...ChannelArticlePublic ...ChannelArticlePrivate + ...QuoteImageArticle } + ${quoteImageFragments.article} ${Header.fragments.article} ${AuthorSidebar.fragments.article} ${MetaInfo.fragments.article} diff --git a/src/views/ArticleDetail/index.tsx b/src/views/ArticleDetail/index.tsx index 49af917aca..22c07e0349 100644 --- a/src/views/ArticleDetail/index.tsx +++ b/src/views/ArticleDetail/index.tsx @@ -355,6 +355,7 @@ const BaseArticleDetail = ({ <> From a15eb59ace98faf78d67cfa10dab20ab6b1f9fbc Mon Sep 17 00:00:00 2001 From: yingshinlee Date: Wed, 10 Jun 2026 00:05:43 +0800 Subject: [PATCH 2/7] feat: add Violet & Slate quote styles (8 total) Co-Authored-By: Claude Opus 4.8 --- src/components/QuoteImageDialog/Content.tsx | 2 ++ src/components/QuoteImageDialog/presets.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/components/QuoteImageDialog/Content.tsx b/src/components/QuoteImageDialog/Content.tsx index 605ea9fb5e..7bfd7a0e4b 100644 --- a/src/components/QuoteImageDialog/Content.tsx +++ b/src/components/QuoteImageDialog/Content.tsx @@ -19,6 +19,8 @@ const styleNames = defineMessages({ ink: { defaultMessage: 'Ink', id: 'QuoteImage.style.ink' }, pine: { defaultMessage: 'Pine', id: 'QuoteImage.style.pine' }, mint: { defaultMessage: 'Mint', id: 'QuoteImage.style.mint' }, + violet: { defaultMessage: 'Violet', id: 'QuoteImage.style.violet' }, + slate: { defaultMessage: 'Slate', id: 'QuoteImage.style.slate' }, }) const sizeNames = defineMessages({ square: { defaultMessage: 'Square 1:1', id: 'QuoteImage.size.square' }, diff --git a/src/components/QuoteImageDialog/presets.ts b/src/components/QuoteImageDialog/presets.ts index 362659c679..2f6ca5c442 100644 --- a/src/components/QuoteImageDialog/presets.ts +++ b/src/components/QuoteImageDialog/presets.ts @@ -35,6 +35,8 @@ export const QUOTE_STYLES: QuoteStyle[] = [ { id: 'ink', name: 'Ink', swatch: '#000000', bg: '#000000', quoteColor: '#ffffff', accent: '#c0a46b', sub: '#c0a46b', font: 'serif', wide: true, logo: 'white', qrDark: '#c0a46b', qrLight: '#000000' }, { id: 'pine', name: 'Pine', swatch: '#0d6763', bg: '#0d6763', quoteColor: '#faf7f0', accent: '#40bfa5', sub: '#a9d9cf', font: 'serif', logo: 'white', qrDark: '#faf7f0', qrLight: '#0d6763' }, { id: 'mint', name: 'Mint', swatch: '#70b388', bg: 'linear-gradient(160deg,#f2faf7 0%,#f2fbd9 100%)', quoteColor: '#246802', accent: '#70b388', sub: '#70b388', font: 'serif', logo: 'dark', qrDark: '#246802', qrLight: '#f7fbef' }, + { id: 'violet', name: 'Violet', swatch: '#5a43e5', bg: '#5a43e5', quoteColor: '#f5f3ff', accent: '#b9aef4', sub: '#d5cffe', font: 'sans', weight: 500, logo: 'white', qrDark: '#f5f3ff', qrLight: '#5a43e5' }, + { id: 'slate', name: 'Slate', swatch: '#9a9aa0', bg: '#f4f4f5', quoteColor: '#2b2b2e', accent: '#9a9aa0', sub: '#8c8c92', font: 'sans', weight: 400, logo: 'dark', qrDark: '#333333', qrLight: '#f4f4f5' }, ] export const QUOTE_SIZES: QuoteSize[] = [ From 49befecdff40302f1e8a3469ee6b5b044fa6795f Mon Sep 17 00:00:00 2001 From: yingshinlee Date: Wed, 10 Jun 2026 00:20:59 +0800 Subject: [PATCH 3/7] refine Violet (muted) & Slate (visible grey) quote styles --- src/components/QuoteImageDialog/presets.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/QuoteImageDialog/presets.ts b/src/components/QuoteImageDialog/presets.ts index 2f6ca5c442..d41334f4fd 100644 --- a/src/components/QuoteImageDialog/presets.ts +++ b/src/components/QuoteImageDialog/presets.ts @@ -35,8 +35,8 @@ export const QUOTE_STYLES: QuoteStyle[] = [ { id: 'ink', name: 'Ink', swatch: '#000000', bg: '#000000', quoteColor: '#ffffff', accent: '#c0a46b', sub: '#c0a46b', font: 'serif', wide: true, logo: 'white', qrDark: '#c0a46b', qrLight: '#000000' }, { id: 'pine', name: 'Pine', swatch: '#0d6763', bg: '#0d6763', quoteColor: '#faf7f0', accent: '#40bfa5', sub: '#a9d9cf', font: 'serif', logo: 'white', qrDark: '#faf7f0', qrLight: '#0d6763' }, { id: 'mint', name: 'Mint', swatch: '#70b388', bg: 'linear-gradient(160deg,#f2faf7 0%,#f2fbd9 100%)', quoteColor: '#246802', accent: '#70b388', sub: '#70b388', font: 'serif', logo: 'dark', qrDark: '#246802', qrLight: '#f7fbef' }, - { id: 'violet', name: 'Violet', swatch: '#5a43e5', bg: '#5a43e5', quoteColor: '#f5f3ff', accent: '#b9aef4', sub: '#d5cffe', font: 'sans', weight: 500, logo: 'white', qrDark: '#f5f3ff', qrLight: '#5a43e5' }, - { id: 'slate', name: 'Slate', swatch: '#9a9aa0', bg: '#f4f4f5', quoteColor: '#2b2b2e', accent: '#9a9aa0', sub: '#8c8c92', font: 'sans', weight: 400, logo: 'dark', qrDark: '#333333', qrLight: '#f4f4f5' }, + { id: 'violet', name: 'Violet', swatch: '#5b5080', bg: '#514775', quoteColor: '#f4f1fb', accent: '#c3b6e8', sub: '#d6cdee', font: 'sans', weight: 500, logo: 'white', qrDark: '#f4f1fb', qrLight: '#514775' }, + { id: 'slate', name: 'Slate', swatch: '#c9cace', bg: '#e7e8ec', quoteColor: '#2b2b2e', accent: '#7f7f88', sub: '#6f6f78', font: 'sans', weight: 400, logo: 'dark', qrDark: '#333333', qrLight: '#e7e8ec' }, ] export const QUOTE_SIZES: QuoteSize[] = [ From 9a5e63337e8264092ff4b61eafa2ed053bc0a197 Mon Sep 17 00:00:00 2001 From: yingshinlee Date: Thu, 11 Jun 2026 22:14:56 +0800 Subject: [PATCH 4/7] 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 = ({