diff --git a/src/pages/archive/index.tsx b/src/pages/archive/index.tsx index 0e19d08..cfb5483 100644 --- a/src/pages/archive/index.tsx +++ b/src/pages/archive/index.tsx @@ -21,6 +21,12 @@ import { type TimelineArchiveMoment, type TimelineArchiveSummary, } from '@/pages/story/service'; +import { + getStoryPublicShareId, + getStoryShareActionLabel, + getStoryShareState, + getStoryShareStatusText, +} from '@/pages/story/utils/shareState'; import './index.less'; type ArchiveBucketType = 'location' | 'tag'; @@ -164,6 +170,8 @@ const buildLocalArchiveSummary = async (): Promise => { const moments: TimelineArchiveMoment[] = []; itemResponses.forEach(({ story, items }) => { + const publicShareId = getStoryPublicShareId(story); + const shareState = getStoryShareState(story); items.forEach((item, index) => { const preview = getPreviewMedia(item); const tags = inferTags(item); @@ -171,7 +179,9 @@ const buildLocalArchiveSummary = async (): Promise => { const moment: TimelineArchiveMoment = { key: item.instanceId || `${story.instanceId || 'story'}-${index}`, storyInstanceId: story.instanceId, - storyShareId: story.shareId, + storyShareId: publicShareId, + shareConfigured: shareState !== 'preview', + sharePublished: shareState === 'public', storyTitle: story.title || 'Untitled story', storyTime: typeof story.storyTime === 'string' ? story.storyTime : undefined, itemInstanceId: item.instanceId, @@ -197,7 +207,9 @@ const buildLocalArchiveSummary = async (): Promise => { count: 0, subtitle: story.title || 'Untitled story', storyInstanceId: story.instanceId, - storyShareId: story.shareId, + storyShareId: publicShareId, + shareConfigured: shareState !== 'preview', + sharePublished: shareState === 'public', coverInstanceId: preview.coverInstanceId, coverSrc: preview.coverSrc, sampleMomentTitle: moment.itemTitle, @@ -207,7 +219,9 @@ const buildLocalArchiveSummary = async (): Promise => { current.sampleMomentTitle = current.sampleMomentTitle || moment.itemTitle; current.coverInstanceId = current.coverInstanceId || preview.coverInstanceId; current.coverSrc = current.coverSrc || preview.coverSrc; - current.storyShareId = current.storyShareId || story.shareId; + current.storyShareId = current.storyShareId || publicShareId; + current.shareConfigured = current.shareConfigured || shareState !== 'preview'; + current.sharePublished = current.sharePublished || shareState === 'public'; locationMap.set(item.location, current); } @@ -218,7 +232,9 @@ const buildLocalArchiveSummary = async (): Promise => { count: 0, subtitle: story.title || 'Untitled story', storyInstanceId: story.instanceId, - storyShareId: story.shareId, + storyShareId: publicShareId, + shareConfigured: shareState !== 'preview', + sharePublished: shareState === 'public', coverInstanceId: preview.coverInstanceId, coverSrc: preview.coverSrc, sampleMomentTitle: moment.itemTitle, @@ -228,7 +244,9 @@ const buildLocalArchiveSummary = async (): Promise => { current.sampleMomentTitle = current.sampleMomentTitle || moment.itemTitle; current.coverInstanceId = current.coverInstanceId || preview.coverInstanceId; current.coverSrc = current.coverSrc || preview.coverSrc; - current.storyShareId = current.storyShareId || story.shareId; + current.storyShareId = current.storyShareId || publicShareId; + current.shareConfigured = current.shareConfigured || shareState !== 'preview'; + current.sharePublished = current.sharePublished || shareState === 'public'; tagMap.set(tag, current); }); }); @@ -240,7 +258,7 @@ const buildLocalArchiveSummary = async (): Promise => { locations: Array.from(locationMap.values()).sort((a, b) => b.count - a.count).slice(0, 8), tags: Array.from(tagMap.values()).sort((a, b) => b.count - a.count).slice(0, 12), moments: moments.sort((a, b) => b.sortValue - a.sortValue), - shareableStories: stories.filter((story) => Boolean(story.shareId)).length, + shareableStories: stories.filter((story) => getStoryShareState(story) !== 'preview').length, videoMoments: moments.filter((moment) => moment.hasVideo).length, }; }; @@ -469,7 +487,7 @@ const ArchivePage: React.FC = () => {

This cluster currently surfaces {matchedMoments.length} moments from {data.storiesIndexed} indexed stories. {data.shareableStories} - {` `}stories are already close to public-share quality. + {` `}stories already have a saved share draft or a live public page.

{activeBucket.subtitle} @@ -479,10 +497,19 @@ const ArchivePage: React.FC = () => { ) : matchedMoments.length > 0 ? (
{matchedMoments.map((moment) => { + const shareMeta = { + instanceId: moment.storyInstanceId, + publicShareId: moment.storyShareId, + shareConfigured: moment.shareConfigured, + sharePublished: moment.sharePublished, + }; const previewPath = moment.storyInstanceId ? `/share/preview/${moment.storyInstanceId}` : '/story'; + const shareState = getStoryShareState(shareMeta); const sharePath = moment.storyShareId ? `/share/${moment.storyShareId}` : previewPath; + const shareActionLabel = + shareState === 'public' ? 'Open public share' : getStoryShareActionLabel(shareMeta); return (
@@ -517,6 +544,7 @@ const ArchivePage: React.FC = () => {
{moment.itemTime} {moment.mediaCount} media items + {getStoryShareStatusText(shareMeta)}
@@ -549,7 +577,7 @@ const ArchivePage: React.FC = () => {
- {data.shareableStories} stories are already close to being worth sharing outside the app + {data.shareableStories} stories already have a saved share draft or a live public page

Add stronger cover moments and clearer descriptions, then shape the share page. That is where this product starts to feel memorable instead of merely functional. diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx index 32b0a16..03ae2d4 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -1,4 +1,4 @@ -import type { ImageItem } from '@/pages/gallery/typings'; +import type { ImageItem } from '@/pages/gallery/typings'; import type { StoryType } from '@/pages/story/data.d'; import { queryCurrent } from '@/pages/account/center/service'; import { queryTimelineList } from '@/pages/story/service'; @@ -375,4 +375,3 @@ const Home: React.FC = () => { export default Home; - diff --git a/src/pages/review/index.tsx b/src/pages/review/index.tsx index 4cdced0..cca38ba 100644 --- a/src/pages/review/index.tsx +++ b/src/pages/review/index.tsx @@ -4,6 +4,12 @@ import { Button, Empty, Skeleton, Tag } from 'antd'; import React from 'react'; import type { StoryType } from '@/pages/story/data.d'; import { queryTimelineList, queryTimelineYearlyReport } from '@/pages/story/service'; +import { + getStoryShareActionLabel, + getStorySharePath, + getStoryShareState, + getStoryShareStatusText, +} from '@/pages/story/utils/shareState'; import './index.less'; type ReviewSummary = { @@ -11,7 +17,7 @@ type ReviewSummary = { stories: StoryType[]; totalStories: number; totalMoments: number; - sharedStories: number; + shareReadyStories: number; busiestMonth: { label: string; count: number }; monthlyCounts: Array<{ label: string; count: number }>; featuredStory?: StoryType; @@ -114,7 +120,7 @@ const YearlyReviewPage: React.FC = () => { typeof report?.totalMoments === 'number' ? report.totalMoments : inYear.reduce((sum, story) => sum + Number(story.itemCount || 0), 0), - sharedStories: inYear.filter((story) => Boolean(story.shareId)).length, + shareReadyStories: inYear.filter((story) => getStoryShareState(story) !== 'preview').length, busiestMonth, monthlyCounts, featuredStory, @@ -138,11 +144,8 @@ const YearlyReviewPage: React.FC = () => { const maxCount = Math.max(...data.monthlyCounts.map((item) => item.count), 1); const featuredStoryId = data.featuredStory?.instanceId; - const featuredSharePath = data.featuredStory?.shareId - ? `/share/${data.featuredStory.shareId}` - : featuredStoryId - ? `/share/preview/${featuredStoryId}` - : '/story'; + const featuredSharePath = getStorySharePath(data.featuredStory) || (featuredStoryId ? `/share/preview/${featuredStoryId}` : '/story'); + const featuredShareState = getStoryShareState(data.featuredStory); return (

@@ -155,7 +158,7 @@ const YearlyReviewPage: React.FC = () => {

You captured {data.totalStories} storylines and {data.totalMoments} moments this year. {` `} - {data.sharedStories} of them are already close to public-share quality. + {data.shareReadyStories} of them already have a public share or a saved share draft. {data.topLocation && data.topLocation !== '-' ? ` Top place: ${data.topLocation}.` : ''} {data.topTag && data.topTag !== '-' ? ` Signature theme: ${data.topTag}.` : ''}

@@ -187,7 +190,7 @@ const YearlyReviewPage: React.FC = () => {
Share-ready - {data.sharedStories} + {data.shareReadyStories}
@@ -228,12 +231,12 @@ const YearlyReviewPage: React.FC = () => {
{data.featuredStory.ownerName || 'My story'} - {data.featuredStory.shareId ? ( + {featuredShareState === 'public' ? ( Public share is available ) : ( - Preview is ready even before the public endpoint is finished + {getStoryShareStatusText(data.featuredStory)} )}

{data.featuredStory.title || 'Untitled story'}

@@ -257,7 +260,7 @@ const YearlyReviewPage: React.FC = () => { Keep editing
@@ -271,4 +274,4 @@ const YearlyReviewPage: React.FC = () => { ); }; -export default YearlyReviewPage; \ No newline at end of file +export default YearlyReviewPage; diff --git a/src/pages/share/StoryShowcase.tsx b/src/pages/share/StoryShowcase.tsx index fd0a0e6..56f92c2 100644 --- a/src/pages/share/StoryShowcase.tsx +++ b/src/pages/share/StoryShowcase.tsx @@ -1,4 +1,5 @@ import TimelineImage from '@/components/TimelineImage'; +import classNames from 'classnames'; import { CalendarOutlined, CameraOutlined, @@ -32,6 +33,12 @@ const resolveVideoSrc = (videoInstanceId?: string) => { const StoryShowcase: React.FC = ({ data, actions, embedded = false }) => { const { styles } = useStyles(); const heroVideoSrc = resolveVideoSrc(data.hero?.videoInstanceId); + const themeClass = + data.templateStyle === 'cinematic' + ? styles.themeCinematic + : data.templateStyle === 'scrapbook' + ? styles.themeScrapbook + : styles.themeEditorial; const showcaseCard = (
@@ -234,10 +241,14 @@ const StoryShowcase: React.FC = ({ data, actions, embedded = ); if (embedded) { - return
{showcaseCard}
; + return
{showcaseCard}
; } - return {showcaseCard}; + return ( + + {showcaseCard} + + ); }; -export default StoryShowcase; \ No newline at end of file +export default StoryShowcase; diff --git a/src/pages/share/[shareId].tsx b/src/pages/share/[shareId].tsx index 8c19b12..fd093ed 100644 --- a/src/pages/share/[shareId].tsx +++ b/src/pages/share/[shareId].tsx @@ -1,9 +1,17 @@ -import { ArrowLeftOutlined, CopyOutlined, HomeOutlined } from '@ant-design/icons'; +import { ArrowLeftOutlined, CopyOutlined, HomeOutlined, MessageOutlined, RiseOutlined } from '@ant-design/icons'; +import { PageContainer } from '@ant-design/pro-components'; import { history, request, useParams, useRequest } from '@umijs/max'; -import { Button, Result, Skeleton, message } from 'antd'; -import React from 'react'; +import { Avatar, Button, Input, Result, Skeleton, message } from 'antd'; +import classNames from 'classnames'; +import React, { useState } from 'react'; import { Helmet } from 'react-helmet'; import StoryShowcase from './StoryShowcase'; +import { + createPublicShareComment, + queryPublicShareFeedback, + recordPublicShareView, + type StoryShareFeedback, +} from './feedback'; import { normalizePublicShowcase } from './showcase-utils'; import useStyles from './style.style'; @@ -28,11 +36,50 @@ interface PublicStoryItem { shareTitle?: string; shareDescription?: string; shareQuote?: string; + templateStyle?: 'editorial' | 'cinematic' | 'scrapbook'; heroMomentId?: string; featuredMomentIds?: string[]; featuredMoments?: PublicStoryItem[]; } +const resolveMetaImageUrl = ( + hero: { + imageInstanceId?: string; + src?: string; + thumbnailInstanceId?: string; + } | undefined, +) => { + if (!hero) return undefined; + const origin = typeof window !== 'undefined' ? window.location.origin : ''; + + if (hero.imageInstanceId) { + return origin ? `${origin}/api/file/image/${hero.imageInstanceId}` : `/api/file/image/${hero.imageInstanceId}`; + } + if (hero.thumbnailInstanceId) { + return origin + ? `${origin}/api/file/image/${hero.thumbnailInstanceId}` + : `/api/file/image/${hero.thumbnailInstanceId}`; + } + if (!hero.src) { + return undefined; + } + if (hero.src.startsWith('http://') || hero.src.startsWith('https://')) { + return hero.src; + } + return origin ? `${origin}${hero.src.startsWith('/') ? hero.src : `/${hero.src}`}` : hero.src; +}; + +const getThemeColor = (templateStyle?: PublicStoryItem['templateStyle']) => { + switch (templateStyle) { + case 'cinematic': + return '#111827'; + case 'scrapbook': + return '#fff3e0'; + default: + return '#eef3f7'; + } +}; + const fetchShareStory = async (shareId: string) => { const candidates = [ `/api/story/item/public/story/item/${shareId}`, @@ -57,20 +104,91 @@ const fetchShareStory = async (shareId: string) => { return undefined; }; +const normalizeFeedback = (response: unknown): StoryShareFeedback | undefined => { + if (!response || typeof response !== 'object') return undefined; + + if ('shareId' in response) { + return response as StoryShareFeedback; + } + + const firstLayer = (response as { data?: unknown }).data; + if (!firstLayer || typeof firstLayer !== 'object') { + return undefined; + } + + if ('shareId' in firstLayer) { + return firstLayer as StoryShareFeedback; + } + + const secondLayer = (firstLayer as { data?: unknown }).data; + if (secondLayer && typeof secondLayer === 'object' && 'shareId' in secondLayer) { + return secondLayer as StoryShareFeedback; + } + + return undefined; +}; + +const GUEST_NAME_STORAGE_KEY = 'timeline_public_share_guest_name'; +const VIEW_SESSION_PREFIX = 'timeline_public_share_viewed:'; + +const getStoredGuestName = () => { + if (typeof window === 'undefined') return ''; + return window.localStorage.getItem(GUEST_NAME_STORAGE_KEY) || ''; +}; + +const saveGuestName = (value: string) => { + if (typeof window === 'undefined') return; + window.localStorage.setItem(GUEST_NAME_STORAGE_KEY, value); +}; + +const hasRecordedView = (shareId: string) => { + if (typeof window === 'undefined') return false; + return window.sessionStorage.getItem(`${VIEW_SESSION_PREFIX}${shareId}`) === '1'; +}; + +const markRecordedView = (shareId: string) => { + if (typeof window === 'undefined') return; + window.sessionStorage.setItem(`${VIEW_SESSION_PREFIX}${shareId}`, '1'); +}; + const SharePage: React.FC = () => { const { styles } = useStyles(); const { shareId } = useParams<{ shareId: string }>(); + const [guestName, setGuestName] = useState(getStoredGuestName); + const [commentContent, setCommentContent] = useState(''); + const [commentSubmitting, setCommentSubmitting] = useState(false); const shareRequest = useRequest( async () => { if (!shareId) return undefined; - return fetchShareStory(shareId); + const shouldRecordView = !hasRecordedView(shareId); + const [story, feedbackResponse] = await Promise.all([ + fetchShareStory(shareId), + shouldRecordView + ? recordPublicShareView(shareId).then((response) => { + markRecordedView(shareId); + return response; + }) + : queryPublicShareFeedback(shareId), + ]); + + return { + story, + feedback: normalizeFeedback(feedbackResponse), + }; }, { refreshDeps: [shareId], }, ); - const storyItem = shareRequest.data as PublicStoryItem | undefined; + const payload = shareRequest.data as + | { + story?: PublicStoryItem; + feedback?: StoryShareFeedback; + } + | undefined; + const storyItem = payload?.story; + const feedback = payload?.feedback; const { loading } = shareRequest; if (loading) { @@ -96,44 +214,199 @@ const SharePage: React.FC = () => { ); } - const showcase = normalizePublicShowcase(storyItem, shareId); + const baseShowcase = normalizePublicShowcase(storyItem, shareId); + const showcase = feedback + ? { + ...baseShowcase, + stats: [ + { label: 'Views', value: feedback.viewCount || 0 }, + { label: 'Guest notes', value: feedback.commentCount || 0 }, + ...baseShowcase.stats, + ], + } + : baseShowcase; const pageUrl = typeof window !== 'undefined' ? window.location.href : `/share/${shareId}`; + const metaImageUrl = resolveMetaImageUrl(showcase.hero); + const themeColor = getThemeColor(showcase.templateStyle); + const themeClass = + showcase.templateStyle === 'cinematic' + ? styles.themeCinematic + : showcase.templateStyle === 'scrapbook' + ? styles.themeScrapbook + : styles.themeEditorial; + const comments = feedback?.comments || []; + + const submitComment = async () => { + if (!shareId) return; + const content = commentContent.trim(); + + if (!content) { + message.warning('Write a short note before posting.'); + return; + } + + setCommentSubmitting(true); + try { + const response = await createPublicShareComment(shareId, { + visitorName: guestName.trim() || undefined, + content, + }); + const nextFeedback = normalizeFeedback(response); + if (!nextFeedback) { + throw new Error('Missing feedback payload'); + } + shareRequest.mutate({ + story: storyItem, + feedback: nextFeedback, + }); + saveGuestName(guestName.trim()); + setCommentContent(''); + message.success('Your note is now part of the share page.'); + } catch (error) { + console.error('Failed to create public share comment', error); + message.error('Failed to post your note right now.'); + } finally { + setCommentSubmitting(false); + } + }; return ( <> {showcase.title} + + + + {metaImageUrl && } + {metaImageUrl && } + + + + {metaImageUrl && } - , - onClick: () => history.push('/home'), - }, - { - key: 'copy', - label: 'Copy link', - icon: , - onClick: async () => { - await navigator.clipboard.writeText(pageUrl); - message.success('Public share link copied'); + + , + onClick: () => history.push('/home'), }, - }, - { - key: 'back', - label: 'Back home', - icon: , - onClick: () => history.push('/home'), - }, - ]} - /> + { + key: 'copy', + label: 'Copy link', + icon: , + onClick: async () => { + await navigator.clipboard.writeText(pageUrl); + message.success('Public share link copied'); + }, + }, + { + key: 'back', + label: 'Back home', + icon: , + onClick: () => history.push('/home'), + }, + ]} + /> + +
+
+
+
+ Audience Feedback +

Let the share page collect lightweight reactions

+

+ Public pages work better when they can gather a bit of proof of life. Views show reach, + and guest notes make the story feel seen. +

+
+
+
+ Views + {feedback?.viewCount || 0} +
+
+ Guest notes + {feedback?.commentCount || 0} +
+
+
+ +
+
+

+ Leave a short message for the story owner. Names are optional, and this keeps the public + page lightweight instead of turning into a full social feed. +

+ + + +
+ +
+ {comments.length > 0 ? ( + comments.map((comment) => ( +
+ {(comment.visitorName || 'G').slice(0, 1).toUpperCase()} +
+
+ {comment.visitorName || 'Guest'} + + {new Intl.DateTimeFormat('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(comment.createTime))} + +
+

{comment.content}

+
+
+ )) + ) : ( +
+ This page has not received a guest note yet. The first thoughtful reaction + often makes sharing feel real. +
+ )} +
+
+
+
+
); }; diff --git a/src/pages/share/feedback.ts b/src/pages/share/feedback.ts new file mode 100644 index 0000000..1c3f1df --- /dev/null +++ b/src/pages/share/feedback.ts @@ -0,0 +1,61 @@ +import { request } from '@umijs/max'; + +export type StoryShareGuestComment = { + instanceId: string; + shareId: string; + visitorName: string; + content: string; + createTime: string; +}; + +export type StoryShareFeedback = { + shareId: string; + viewCount: number; + commentCount: number; + comments: StoryShareGuestComment[]; +}; + +export type StoryShareCommentPayload = { + visitorName?: string; + content: string; +}; + +const requestPublicFeedback = async ( + shareId: string, + path: string, + options?: Record, +) => { + const candidates = [`/api/public/story/${shareId}${path}`, `/public/story/${shareId}${path}`]; + + for (const url of candidates) { + try { + return await request(url, { + skipErrorHandler: true, + ...options, + }); + } catch (error) { + console.warn('Failed to request public share feedback from', url, error); + } + } + + return undefined; +}; + +export const queryPublicShareFeedback = async (shareId: string, limit = 8) => { + return requestPublicFeedback(shareId, `/feedback?limit=${limit}`, { method: 'GET' }); +}; + +export const recordPublicShareView = async (shareId: string, limit = 8) => { + return requestPublicFeedback(shareId, `/feedback/view?limit=${limit}`, { method: 'POST' }); +}; + +export const createPublicShareComment = async ( + shareId: string, + payload: StoryShareCommentPayload, + limit = 8, +) => { + return requestPublicFeedback(shareId, `/feedback/comment?limit=${limit}`, { + method: 'POST', + data: payload, + }); +}; diff --git a/src/pages/share/preview/[storyId].tsx b/src/pages/share/preview/[storyId].tsx index d54f4f8..f95d07e 100644 --- a/src/pages/share/preview/[storyId].tsx +++ b/src/pages/share/preview/[storyId].tsx @@ -4,10 +4,15 @@ import { Button, Result, Skeleton, message } from 'antd'; import React from 'react'; import { Helmet } from 'react-helmet'; import type { StoryItem, StoryType } from '@/pages/story/data.d'; -import { queryStoryDetail, queryStoryItem } from '@/pages/story/service'; +import { + queryStoryDetail, + queryStoryItem, + queryStoryShareConfig, + type StoryShareConfig, +} from '@/pages/story/service'; import StoryShowcase from '../StoryShowcase'; import { buildPreviewShowcase } from '../showcase-utils'; -import { loadShareDraft } from '../shareDraft'; +import { buildShareDraftSeed, getPublicShareId, resolveShareDraft } from '../shareDraft'; import useStyles from '../style.style'; type StoryItemsResponse = { @@ -17,25 +22,41 @@ type StoryItemsResponse = { list?: StoryItem[]; }; +type ShareConfigResponse = { + data?: StoryShareConfig; +}; + const normalizeItems = (response: StoryItemsResponse | undefined) => { if (Array.isArray(response?.data?.list)) return response.data.list; if (Array.isArray(response?.list)) return response.list; return [] as StoryItem[]; }; +const normalizeShareConfig = ( + response: ShareConfigResponse | StoryShareConfig | undefined, +): StoryShareConfig | undefined => { + if (!response) return undefined; + if ('storyId' in response) return response; + return response.data; +}; + const PreviewSharePage: React.FC = () => { const { styles } = useStyles(); const { storyId } = useParams<{ storyId: string }>(); const previewRequest = useRequest( async () => { if (!storyId) return undefined; - const [storyResponse, itemsResponse] = await Promise.all([ + const [storyResponse, itemsResponse, shareConfigResponse] = await Promise.all([ queryStoryDetail(storyId), queryStoryItem({ storyInstanceId: storyId, current: 1, pageSize: 60 }), + queryStoryShareConfig(storyId).catch(() => undefined), ]); return { story: storyResponse.data as StoryType, items: normalizeItems(itemsResponse as StoryItemsResponse), + shareConfig: normalizeShareConfig( + shareConfigResponse as ShareConfigResponse | StoryShareConfig | undefined, + ), }; }, { @@ -43,7 +64,9 @@ const PreviewSharePage: React.FC = () => { }, ); - const payload = previewRequest.data as { story: StoryType; items: StoryItem[] } | undefined; + const payload = previewRequest.data as + | { story: StoryType; items: StoryItem[]; shareConfig?: StoryShareConfig } + | undefined; const { loading } = previewRequest; if (loading) { @@ -69,10 +92,19 @@ const PreviewSharePage: React.FC = () => { ); } - const draft = loadShareDraft(storyId); - const showcase = buildPreviewShowcase(payload.story, payload.items, draft); + const shareConfig = payload.shareConfig; + const draft = resolveShareDraft(storyId, buildShareDraftSeed(shareConfig)); + const publicShareId = getPublicShareId(shareConfig, payload.story.shareId); + const showcase = buildPreviewShowcase( + { + ...payload.story, + shareId: publicShareId, + }, + payload.items, + draft, + ); const previewUrl = typeof window !== 'undefined' ? window.location.href : `/share/preview/${storyId}`; - const publicUrl = payload.story.shareId ? `/share/${payload.story.shareId}` : undefined; + const publicUrl = publicShareId ? `/share/${publicShareId}` : undefined; const studioUrl = `/share/studio/${storyId}`; return ( @@ -129,4 +161,4 @@ const PreviewSharePage: React.FC = () => { ); }; -export default PreviewSharePage; \ No newline at end of file +export default PreviewSharePage; diff --git a/src/pages/share/shareDraft.ts b/src/pages/share/shareDraft.ts index f13bcd9..3d9d950 100644 --- a/src/pages/share/shareDraft.ts +++ b/src/pages/share/shareDraft.ts @@ -1,13 +1,18 @@ +import type { StoryShareConfig } from '@/pages/story/service'; + export type ShareDraft = { storyId: string; title?: string; description?: string; quote?: string; + templateStyle?: 'editorial' | 'cinematic' | 'scrapbook'; heroMomentId?: string; featuredMomentIds?: string[]; updatedAt?: string; }; +export type ShareDraftSeed = Omit; + const STORAGE_KEY = 'timeline_share_drafts_v1'; type DraftMap = Record; @@ -57,3 +62,70 @@ export const clearShareDraft = (storyId: string) => { delete draftMap[storyId]; writeDraftMap(draftMap); }; + +export const buildShareDraftSeed = ( + shareConfig?: StoryShareConfig, +): ShareDraftSeed | undefined => { + if (!shareConfig) return undefined; + + return { + title: shareConfig.title, + description: shareConfig.description, + quote: shareConfig.quote, + templateStyle: shareConfig.templateStyle, + heroMomentId: shareConfig.heroMomentId, + featuredMomentIds: shareConfig.featuredMomentIds || [], + updatedAt: shareConfig.updatedAt, + }; +}; + +export const getPublicShareId = ( + shareConfig?: Pick, + fallbackShareId?: string, +) => { + if (shareConfig?.publicShareId) { + return shareConfig.publicShareId; + } + + if (shareConfig?.published && shareConfig.shareId) { + return shareConfig.shareId; + } + + return fallbackShareId; +}; + +const getUpdatedAtValue = (updatedAt?: string) => { + if (!updatedAt) return 0; + const timestamp = new Date(updatedAt).getTime(); + return Number.isNaN(timestamp) ? 0 : timestamp; +}; + +export const resolveShareDraft = ( + storyId: string, + remoteDraft?: ShareDraftSeed, +): ShareDraft | undefined => { + const localDraft = loadShareDraft(storyId); + + if (!remoteDraft) { + return localDraft; + } + + const normalizedRemoteDraft: ShareDraft = { + storyId, + title: remoteDraft.title, + description: remoteDraft.description, + quote: remoteDraft.quote, + templateStyle: remoteDraft.templateStyle, + heroMomentId: remoteDraft.heroMomentId, + featuredMomentIds: remoteDraft.featuredMomentIds || [], + updatedAt: remoteDraft.updatedAt, + }; + + if (!localDraft) { + return normalizedRemoteDraft; + } + + return getUpdatedAtValue(localDraft.updatedAt) >= getUpdatedAtValue(remoteDraft.updatedAt) + ? localDraft + : normalizedRemoteDraft; +}; diff --git a/src/pages/share/showcase-utils.ts b/src/pages/share/showcase-utils.ts index cfd39dc..df397e0 100644 --- a/src/pages/share/showcase-utils.ts +++ b/src/pages/share/showcase-utils.ts @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; import type { StoryItem, StoryType } from '@/pages/story/data.d'; +import type { ShareTemplateStyle } from '@/pages/story/service'; import type { ShareDraft } from './shareDraft'; export type ShareAction = { @@ -37,6 +38,7 @@ export type ShareMoment = { }; export type StoryShowcaseData = { + templateStyle: ShareTemplateStyle; badge: string; title: string; description: string; @@ -73,11 +75,14 @@ type PublicStoryItem = { shareTitle?: string; shareDescription?: string; shareQuote?: string; + templateStyle?: ShareTemplateStyle; heroMomentId?: string; featuredMomentIds?: string[]; featuredMoments?: PublicStoryItem[]; }; +export const DEFAULT_SHARE_TEMPLATE: ShareTemplateStyle = 'editorial'; + const pad = (value: number) => String(value).padStart(2, '0'); export const formatStoryTime = (value?: string | number[]) => { @@ -235,6 +240,7 @@ export const buildPreviewShowcase = ( 'Shape one strong story page and the whole product starts to feel more shareable.'; return { + templateStyle: draft?.templateStyle || DEFAULT_SHARE_TEMPLATE, badge: story.shareId ? 'Share Preview' : 'Story Preview', title: draftTitle || story.title || 'Untitled story', description: @@ -294,6 +300,7 @@ export const normalizePublicShowcase = ( const locationLabel = buildLocationLabel(featuredMoments, storyItem.location); return { + templateStyle: storyItem.templateStyle || DEFAULT_SHARE_TEMPLATE, badge: 'Public Share', title: storyItem.shareTitle || storyItem.title || 'Memory share', description, diff --git a/src/pages/share/studio/[storyId].tsx b/src/pages/share/studio/[storyId].tsx index 3148a51..e1ceb79 100644 --- a/src/pages/share/studio/[storyId].tsx +++ b/src/pages/share/studio/[storyId].tsx @@ -5,6 +5,7 @@ import { DeleteOutlined, EyeOutlined, PlayCircleOutlined, + RiseOutlined, SaveOutlined, ShareAltOutlined, } from '@ant-design/icons'; @@ -22,8 +23,19 @@ import { unpublishStoryShare, } from '@/pages/story/service'; import StoryShowcase from '../StoryShowcase'; -import { buildPreviewShowcase, formatStoryTime } from '../showcase-utils'; -import { clearShareDraft, loadShareDraft, saveShareDraft, type ShareDraft } from '../shareDraft'; +import { + buildPreviewShowcase, + DEFAULT_SHARE_TEMPLATE, + formatStoryTime, +} from '../showcase-utils'; +import { + buildShareDraftSeed, + clearShareDraft, + getPublicShareId, + resolveShareDraft, + saveShareDraft, + type ShareDraft, +} from '../shareDraft'; import useStyles from '../style.style'; const { TextArea } = Input; @@ -48,10 +60,33 @@ type DraftFormState = { title: string; description: string; quote: string; + templateStyle: 'editorial' | 'cinematic' | 'scrapbook'; heroMomentId?: string; featuredMomentIds: string[]; }; +const TEMPLATE_OPTIONS: Array<{ + value: DraftFormState['templateStyle']; + title: string; + description: string; +}> = [ + { + value: 'editorial', + title: 'Editorial', + description: 'Clean, magazine-like, and balanced for most stories.', + }, + { + value: 'cinematic', + title: 'Cinematic', + description: 'Darker, moodier, and more dramatic for standout visual stories.', + }, + { + value: 'scrapbook', + title: 'Scrapbook', + description: 'Warmer, softer, and more personal for intimate memory pages.', + }, +]; + const normalizeItems = (response: StoryItemsResponse | undefined) => { if (Array.isArray(response?.data?.list)) return response.data.list; if (Array.isArray(response?.list)) return response.list; @@ -66,8 +101,8 @@ const normalizeStory = (response: StoryDetailResponse | undefined) => { const normalizeShareConfig = (response: { data?: StoryShareConfig } | StoryShareConfig | undefined) => { if (!response) return undefined; - if ('data' in response) return response.data; - return response; + if ('storyId' in response) return response; + return response.data; }; const getMomentPreview = (item: StoryItem) => { @@ -98,13 +133,25 @@ const getDefaultFeaturedMomentIds = (items: StoryItem[], heroMomentId?: string) return result; }; +const formatFeedbackTime = (value?: string) => { + if (!value) return 'Recently'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(date); +}; + const buildInitialDraft = ( storyId: string, story: StoryType, items: StoryItem[], shareConfig?: StoryShareConfig, ) => { - const saved = loadShareDraft(storyId); + const saved = resolveShareDraft(storyId, buildShareDraftSeed(shareConfig)); const configFeaturedMomentIds = shareConfig?.featuredMomentIds?.filter(Boolean) || []; const defaultHero = shareConfig?.heroMomentId || items.find(hasPreviewMedia)?.instanceId; const featuredMomentIds = @@ -116,6 +163,7 @@ const buildInitialDraft = ( title: saved?.title || shareConfig?.title || story.title || '', description: saved?.description || shareConfig?.description || story.description || '', quote: saved?.quote || shareConfig?.quote || story.description || '', + templateStyle: saved?.templateStyle || shareConfig?.templateStyle || DEFAULT_SHARE_TEMPLATE, heroMomentId: saved?.heroMomentId || shareConfig?.heroMomentId || defaultHero, featuredMomentIds, } satisfies DraftFormState; @@ -128,6 +176,7 @@ const ShareStudioPage: React.FC = () => { title: '', description: '', quote: '', + templateStyle: DEFAULT_SHARE_TEMPLATE, heroMomentId: undefined, featuredMomentIds: [], }); @@ -144,10 +193,13 @@ const ShareStudioPage: React.FC = () => { queryStoryItem({ storyInstanceId: storyId, current: 1, pageSize: 80 }), queryStoryShareConfig(storyId).catch(() => undefined), ]); + const shareConfig = normalizeShareConfig( + shareConfigResponse as ShareConfigResponse | StoryShareConfig | undefined, + ); return { story: normalizeStory(storyResponse as StoryDetailResponse), items: normalizeItems(itemsResponse as StoryItemsResponse), - shareConfig: normalizeShareConfig(shareConfigResponse as ShareConfigResponse | StoryShareConfig | undefined), + shareConfig, }; }, { @@ -164,7 +216,7 @@ const ShareStudioPage: React.FC = () => { if (!storyId || !payload?.story) return; setPersistedShareConfig(payload.shareConfig); setDraft(buildInitialDraft(storyId, payload.story, payload.items, payload.shareConfig)); - setActiveShareId(payload.story.shareId); + setActiveShareId(getPublicShareId(payload.shareConfig, payload.story.shareId)); }, [payload, storyId]); const showcaseDraft = useMemo(() => { @@ -174,9 +226,10 @@ const ShareStudioPage: React.FC = () => { title: draft.title, description: draft.description, quote: draft.quote, + templateStyle: draft.templateStyle, heroMomentId: draft.heroMomentId, featuredMomentIds: draft.featuredMomentIds, - updatedAt: loadShareDraft(storyId)?.updatedAt, + updatedAt: resolveShareDraft(storyId, buildShareDraftSeed(payload.shareConfig))?.updatedAt, }; }, [draft, payload, storyId]); @@ -208,6 +261,10 @@ const ShareStudioPage: React.FC = () => { const previewUrl = `/share/preview/${storyId}`; const coverCandidates = payload.items.filter(hasPreviewMedia).slice(0, 18); const spotlightCandidates = payload.items.filter((item) => Boolean(item.instanceId)).slice(0, 18); + const feedbackComments = persistedShareConfig?.recentComments || []; + const feedbackViewCount = Number(persistedShareConfig?.viewCount || 0); + const feedbackCommentCount = Number(persistedShareConfig?.commentCount || 0); + const hasFeedbackHistory = feedbackViewCount > 0 || feedbackCommentCount > 0 || feedbackComments.length > 0; const copyLink = async (relativeUrl: string, successMessage: string) => { const fullUrl = `${window.location.origin}${relativeUrl}`; @@ -227,6 +284,7 @@ const ShareStudioPage: React.FC = () => { title: draft.title.trim(), description: draft.description.trim(), quote: draft.quote.trim(), + templateStyle: draft.templateStyle, heroMomentId: draft.heroMomentId, featuredMomentIds: draft.featuredMomentIds, updatedAt: new Date().toISOString(), @@ -241,6 +299,7 @@ const ShareStudioPage: React.FC = () => { title: draft.title.trim(), description: draft.description.trim(), quote: draft.quote.trim(), + templateStyle: draft.templateStyle, heroMomentId: draft.heroMomentId, featuredMomentIds: draft.featuredMomentIds, updatedAt: new Date().toISOString(), @@ -292,6 +351,7 @@ const ShareStudioPage: React.FC = () => { title: nextDraft.title, description: nextDraft.description, quote: nextDraft.quote, + templateStyle: nextDraft.templateStyle, featuredMomentIds: nextDraft.featuredMomentIds, }); const nextShareId = response?.data?.shareId; @@ -301,14 +361,19 @@ const ShareStudioPage: React.FC = () => { setActiveShareId(nextShareId); setPersistedShareConfig({ shareId: nextShareId, + publicShareId: nextShareId, storyId, heroMomentId: draft.heroMomentId, title: nextDraft.title, description: nextDraft.description, quote: nextDraft.quote, + templateStyle: nextDraft.templateStyle, featuredMomentIds: nextDraft.featuredMomentIds || [], published: true, updatedAt: new Date().toISOString(), + viewCount: persistedShareConfig?.viewCount || 0, + commentCount: persistedShareConfig?.commentCount || 0, + recentComments: persistedShareConfig?.recentComments || [], }); message.success(shareReady ? 'Public share updated.' : 'Public share is now live.'); } catch (error) { @@ -328,13 +393,19 @@ const ShareStudioPage: React.FC = () => { current ? { ...current, + shareId: current.shareId || activeShareId, + publicShareId: undefined, heroMomentId: draft.heroMomentId, title: draft.title.trim(), description: draft.description.trim(), quote: draft.quote.trim(), + templateStyle: draft.templateStyle, featuredMomentIds: draft.featuredMomentIds, published: false, updatedAt: new Date().toISOString(), + viewCount: current.viewCount || 0, + commentCount: current.commentCount || 0, + recentComments: current.recentComments || [], } : undefined, ); @@ -382,7 +453,9 @@ const ShareStudioPage: React.FC = () => { description={ shareReady ? `This story is now reachable on ${publicUrl}. Republishing keeps the same public link and refreshes the hero moment.` - : 'Draft details still stay in local storage, and publishing now creates a real public link from the selected hero moment.' + : persistedShareConfig?.shareId + ? 'The public link is currently turned off, but the saved share draft and any past audience feedback are still preserved here.' + : 'Draft details still stay in local storage, and publishing now creates a real public link from the selected hero moment.' } type={shareReady ? 'success' : 'info'} showIcon @@ -396,6 +469,49 @@ const ShareStudioPage: React.FC = () => { showIcon /> +
+
+ Audience Signals +

See whether the public page is actually landing

+
+ {shareReady ? 'Live feedback updates on the public route' : 'Feedback history stays attached to the saved share id'} +
+ +
+
+ Public views + {feedbackViewCount} +
+
+ Guest notes + {feedbackCommentCount} +
+
+ Current state + {shareReady ? 'Live' : persistedShareConfig?.shareId ? 'Paused' : 'Draft'} +
+
+ + {feedbackComments.length > 0 ? ( +
+ {feedbackComments.map((comment) => ( +
+
+ {comment.visitorName || 'Guest'} + {formatFeedbackTime(comment.createTime)} +
+

{comment.content}

+
+ ))} +
+ ) : ( +

+ {hasFeedbackHistory + ? 'Feedback counters are available, but there are no recent guest notes in the current summary window.' + : 'Once the public page starts getting opened and commented on, the strongest audience signals will show up here.'} +

+ )} +
+
+
+ Template +

Choose the page mood

+
+ {draft.templateStyle} +
+ +
+ {TEMPLATE_OPTIONS.map((option) => { + const active = draft.templateStyle === option.value; + return ( + + ); + })} +
+
Cover Selection @@ -555,6 +696,9 @@ const ShareStudioPage: React.FC = () => { + diff --git a/src/pages/share/style.style.ts b/src/pages/share/style.style.ts index 95694c0..f40393b 100644 --- a/src/pages/share/style.style.ts +++ b/src/pages/share/style.style.ts @@ -1,13 +1,79 @@ import { createStyles } from 'antd-style'; export default createStyles(({ css, token }) => ({ - sharePage: css` - min-height: 100vh; - padding: 24px 0 48px; - background: + themeBase: css` + --share-page-bg: radial-gradient(circle at top left, rgba(17, 78, 121, 0.16), transparent 28%), radial-gradient(circle at bottom right, rgba(201, 111, 67, 0.14), transparent 24%), linear-gradient(180deg, #f7fbff 0%, #eef3f7 100%); + --share-card-bg: rgba(255, 255, 255, 0.9); + --share-card-shadow: 0 28px 80px rgba(15, 23, 42, 0.12); + --share-badge-bg: rgba(15, 23, 42, 0.08); + --share-badge-color: ${token.colorText}; + --share-text-color: ${token.colorText}; + --share-text-secondary: ${token.colorTextSecondary}; + --share-text-tertiary: ${token.colorTextTertiary}; + --share-border-color: rgba(15, 23, 42, 0.08); + --share-chip-bg: rgba(255, 255, 255, 0.76); + --share-title-font: Georgia, 'Times New Roman', 'Songti SC', serif; + --share-cover-bg: linear-gradient(135deg, rgba(15, 23, 42, 0.92), rgba(29, 78, 216, 0.68)); + --share-stat-bg: rgba(255, 255, 255, 0.8); + --share-quote-bg: linear-gradient(145deg, rgba(201, 111, 67, 0.12), rgba(17, 78, 121, 0.12)); + --share-moment-bg: rgba(255, 255, 255, 0.78); + --share-footer-bg: ${token.colorFillAlter}; + `, + themeEditorial: css` + --share-page-bg: + radial-gradient(circle at top left, rgba(17, 78, 121, 0.16), transparent 28%), + radial-gradient(circle at bottom right, rgba(201, 111, 67, 0.14), transparent 24%), + linear-gradient(180deg, #f7fbff 0%, #eef3f7 100%); + `, + themeCinematic: css` + --share-page-bg: + radial-gradient(circle at 20% 0%, rgba(245, 158, 11, 0.16), transparent 24%), + radial-gradient(circle at 100% 100%, rgba(127, 29, 29, 0.2), transparent 30%), + linear-gradient(180deg, #0b1220 0%, #1a2235 100%); + --share-card-bg: rgba(10, 15, 28, 0.84); + --share-card-shadow: 0 30px 90px rgba(2, 6, 23, 0.48); + --share-badge-bg: rgba(245, 158, 11, 0.16); + --share-badge-color: #f8d38b; + --share-text-color: #f8fafc; + --share-text-secondary: rgba(226, 232, 240, 0.78); + --share-text-tertiary: rgba(226, 232, 240, 0.58); + --share-border-color: rgba(148, 163, 184, 0.18); + --share-chip-bg: rgba(15, 23, 42, 0.48); + --share-title-font: Georgia, 'Times New Roman', 'Songti SC', serif; + --share-cover-bg: linear-gradient(135deg, rgba(17, 24, 39, 0.96), rgba(120, 53, 15, 0.76)); + --share-stat-bg: rgba(17, 24, 39, 0.72); + --share-quote-bg: linear-gradient(145deg, rgba(120, 53, 15, 0.28), rgba(30, 41, 59, 0.36)); + --share-moment-bg: rgba(15, 23, 42, 0.72); + --share-footer-bg: rgba(15, 23, 42, 0.7); + `, + themeScrapbook: css` + --share-page-bg: + radial-gradient(circle at top right, rgba(236, 72, 153, 0.12), transparent 26%), + radial-gradient(circle at bottom left, rgba(14, 165, 233, 0.14), transparent 28%), + linear-gradient(180deg, #fff7ef 0%, #f5efe7 100%); + --share-card-bg: rgba(255, 252, 248, 0.92); + --share-card-shadow: 0 22px 70px rgba(120, 53, 15, 0.16); + --share-badge-bg: rgba(236, 72, 153, 0.12); + --share-badge-color: #9d174d; + --share-text-color: #3f2b1e; + --share-text-secondary: rgba(92, 63, 44, 0.78); + --share-text-tertiary: rgba(92, 63, 44, 0.58); + --share-border-color: rgba(120, 53, 15, 0.12); + --share-chip-bg: rgba(255, 250, 244, 0.84); + --share-title-font: 'Trebuchet MS', 'Avenir Next', 'PingFang SC', sans-serif; + --share-cover-bg: linear-gradient(135deg, rgba(252, 211, 77, 0.28), rgba(236, 72, 153, 0.22)); + --share-stat-bg: rgba(255, 248, 240, 0.9); + --share-quote-bg: linear-gradient(145deg, rgba(251, 191, 36, 0.18), rgba(14, 165, 233, 0.14)); + --share-moment-bg: rgba(255, 251, 246, 0.86); + --share-footer-bg: rgba(255, 243, 224, 0.88); + `, + sharePage: css` + min-height: 100vh; + padding: 24px 0 48px; + background: var(--share-page-bg); `, embeddedPage: css` background: transparent; @@ -23,8 +89,8 @@ export default createStyles(({ css, token }) => ({ card: css` border-radius: 32px; overflow: hidden; - box-shadow: 0 28px 80px rgba(15, 23, 42, 0.12); - background: rgba(255, 255, 255, 0.9); + box-shadow: var(--share-card-shadow); + background: var(--share-card-bg); backdrop-filter: blur(18px); `, heroHeader: css` @@ -39,20 +105,20 @@ export default createStyles(({ css, token }) => ({ min-width: 280px; `, heroBadge: css` - background: rgba(15, 23, 42, 0.08); - color: ${token.colorText}; + background: var(--share-badge-bg); + color: var(--share-badge-color); border-radius: 999px; padding: 6px 12px; `, title: css` margin: 14px 0 10px !important; - font-family: Georgia, 'Times New Roman', 'Songti SC', serif; + font-family: var(--share-title-font); letter-spacing: -0.03em; `, description: css` max-width: 780px; font-size: 16px; - color: ${token.colorTextSecondary}; + color: var(--share-text-secondary); line-height: 1.8; `, metaRow: css` @@ -70,11 +136,11 @@ export default createStyles(({ css, token }) => ({ strong { display: block; - color: ${token.colorText}; + color: var(--share-text-color); } span { - color: ${token.colorTextSecondary}; + color: var(--share-text-secondary); font-size: 13px; } `, @@ -84,9 +150,9 @@ export default createStyles(({ css, token }) => ({ gap: 8px; padding: 10px 14px; border-radius: 999px; - background: rgba(255, 255, 255, 0.76); - border: 1px solid rgba(15, 23, 42, 0.08); - color: ${token.colorTextSecondary}; + background: var(--share-chip-bg); + border: 1px solid var(--share-border-color); + color: var(--share-text-secondary); `, actionRow: css` display: flex; @@ -104,7 +170,7 @@ export default createStyles(({ css, token }) => ({ min-height: 420px; border-radius: 28px; overflow: hidden; - background: linear-gradient(135deg, rgba(15, 23, 42, 0.92), rgba(29, 78, 216, 0.68)); + background: var(--share-cover-bg); position: relative; `, heroImage: css` @@ -135,19 +201,19 @@ export default createStyles(({ css, token }) => ({ grid-template-columns: repeat(2, minmax(0, 1fr)); `, statCard: css` - background: rgba(255, 255, 255, 0.8); - border: 1px solid rgba(15, 23, 42, 0.08); + background: var(--share-stat-bg); + border: 1px solid var(--share-border-color); border-radius: 22px; min-height: 118px; padding: 18px; span { - color: ${token.colorTextSecondary}; + color: var(--share-text-secondary); display: block; } strong { - color: ${token.colorText}; + color: var(--share-text-color); display: block; font-size: clamp(24px, 3vw, 36px); margin-top: 14px; @@ -155,14 +221,14 @@ export default createStyles(({ css, token }) => ({ } `, quoteCard: css` - background: linear-gradient(145deg, rgba(201, 111, 67, 0.12), rgba(17, 78, 121, 0.12)); - border: 1px solid rgba(15, 23, 42, 0.08); + background: var(--share-quote-bg); + border: 1px solid var(--share-border-color); border-radius: 22px; padding: 20px; p { margin: 12px 0 0; - color: ${token.colorTextSecondary}; + color: var(--share-text-secondary); line-height: 1.9; font-size: 15px; } @@ -179,8 +245,8 @@ export default createStyles(({ css, token }) => ({ h2 { margin: 6px 0 0; - color: ${token.colorText}; - font-family: Georgia, 'Times New Roman', 'Songti SC', serif; + color: var(--share-text-color); + font-family: var(--share-title-font); font-size: 30px; letter-spacing: -0.03em; } @@ -190,11 +256,11 @@ export default createStyles(({ css, token }) => ({ font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; - color: ${token.colorTextTertiary}; + color: var(--share-text-tertiary); font-weight: 700; `, sectionMeta: css` - color: ${token.colorTextSecondary}; + color: var(--share-text-secondary); `, momentList: css` display: grid; @@ -204,8 +270,8 @@ export default createStyles(({ css, token }) => ({ display: grid; gap: 16px; grid-template-columns: 220px minmax(0, 1fr); - background: rgba(255, 255, 255, 0.78); - border: 1px solid rgba(15, 23, 42, 0.08); + background: var(--share-moment-bg); + border: 1px solid var(--share-border-color); border-radius: 24px; padding: 16px; `, @@ -238,7 +304,7 @@ export default createStyles(({ css, token }) => ({ align-items: center; justify-content: center; background: linear-gradient(145deg, rgba(17, 78, 121, 0.14), rgba(201, 111, 67, 0.16)); - color: ${token.colorText}; + color: var(--share-text-color); font-size: 64px; font-family: Georgia, 'Times New Roman', 'Songti SC', serif; `, @@ -260,14 +326,14 @@ export default createStyles(({ css, token }) => ({ h3 { margin: 12px 0 8px; - color: ${token.colorText}; + color: var(--share-text-color); font-size: 28px; - font-family: Georgia, 'Times New Roman', 'Songti SC', serif; + font-family: var(--share-title-font); letter-spacing: -0.03em; } p { - color: ${token.colorTextSecondary}; + color: var(--share-text-secondary); line-height: 1.8; margin: 0; flex: 1; @@ -280,14 +346,14 @@ export default createStyles(({ css, token }) => ({ flex-wrap: wrap; span { - color: ${token.colorTextSecondary}; + color: var(--share-text-secondary); } `, momentFoot: css` display: flex; gap: 16px; flex-wrap: wrap; - border-top: 1px solid rgba(15, 23, 42, 0.08); + border-top: 1px solid var(--share-border-color); padding-top: 14px; margin-top: 16px; @@ -295,7 +361,7 @@ export default createStyles(({ css, token }) => ({ display: inline-flex; align-items: center; gap: 6px; - color: ${token.colorTextSecondary}; + color: var(--share-text-secondary); } `, gallery: css` @@ -323,7 +389,7 @@ export default createStyles(({ css, token }) => ({ margin-top: 28px; padding: 18px 20px; border-radius: 20px; - background: ${token.colorFillAlter}; + background: var(--share-footer-bg); display: flex; align-items: center; gap: 10px; @@ -384,7 +450,7 @@ export default createStyles(({ css, token }) => ({ gap: 8px; span { - color: ${token.colorText}; + color: var(--share-text-color); font-weight: 600; } `, @@ -397,14 +463,14 @@ export default createStyles(({ css, token }) => ({ h3 { margin: 6px 0 0; - color: ${token.colorText}; + color: var(--share-text-color); font-family: Georgia, 'Times New Roman', 'Songti SC', serif; font-size: 24px; letter-spacing: -0.03em; } span { - color: ${token.colorTextSecondary}; + color: var(--share-text-secondary); } `, coverGrid: css` @@ -415,7 +481,7 @@ export default createStyles(({ css, token }) => ({ coverOption: css` appearance: none; background: rgba(255, 255, 255, 0.82); - border: 1px solid rgba(15, 23, 42, 0.08); + border: 1px solid var(--share-border-color); border-radius: 20px; cursor: pointer; padding: 10px; @@ -475,12 +541,12 @@ export default createStyles(({ css, token }) => ({ margin-top: 10px; strong { - color: ${token.colorText}; + color: var(--share-text-color); font-size: 14px; } span { - color: ${token.colorTextSecondary}; + color: var(--share-text-secondary); font-size: 12px; } `, @@ -490,6 +556,237 @@ export default createStyles(({ css, token }) => ({ gap: 12px; margin-top: 22px; `, + templateGrid: css` + display: grid; + gap: 12px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + `, + templateOption: css` + appearance: none; + text-align: left; + border-radius: 20px; + border: 1px solid rgba(15, 23, 42, 0.08); + background: rgba(255, 255, 255, 0.82); + padding: 16px; + cursor: pointer; + transition: + transform 180ms ease, + border-color 180ms ease, + box-shadow 180ms ease; + + &:hover { + transform: translateY(-1px); + border-color: rgba(17, 78, 121, 0.28); + box-shadow: 0 14px 30px rgba(15, 23, 42, 0.08); + } + + strong { + display: block; + color: ${token.colorText}; + margin-bottom: 8px; + } + + span { + color: ${token.colorTextSecondary}; + line-height: 1.6; + font-size: 13px; + } + `, + templateOptionActive: css` + border-color: rgba(17, 78, 121, 0.34); + background: linear-gradient(145deg, rgba(17, 78, 121, 0.12), rgba(255, 255, 255, 0.96)); + box-shadow: 0 18px 34px rgba(15, 23, 42, 0.08); + `, + studioInsightGrid: css` + display: grid; + gap: 12px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-bottom: 18px; + `, + studioInsightCard: css` + border-radius: 22px; + border: 1px solid rgba(15, 23, 42, 0.08); + background: rgba(255, 255, 255, 0.82); + padding: 16px; + + span { + display: block; + color: ${token.colorTextSecondary}; + font-size: 12px; + } + + strong { + display: block; + color: ${token.colorText}; + font-size: 28px; + margin-top: 10px; + line-height: 1; + } + `, + studioHint: css` + margin: 0; + color: ${token.colorTextSecondary}; + line-height: 1.7; + `, + studioCommentList: css` + display: grid; + gap: 12px; + `, + studioCommentCard: css` + border-radius: 20px; + border: 1px solid rgba(15, 23, 42, 0.08); + background: rgba(255, 255, 255, 0.82); + padding: 16px; + `, + studioCommentMeta: css` + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + flex-wrap: wrap; + margin-bottom: 8px; + `, + studioCommentName: css` + color: ${token.colorText}; + font-weight: 700; + `, + studioCommentTime: css` + color: ${token.colorTextTertiary}; + font-size: 12px; + `, + studioCommentBody: css` + margin: 0; + color: ${token.colorTextSecondary}; + line-height: 1.75; + white-space: pre-wrap; + `, + feedbackSection: css` + max-width: 1180px; + margin: 20px auto 0; + padding: 0 16px; + `, + feedbackPanel: css` + border-radius: 28px; + border: 1px solid var(--share-border-color); + background: var(--share-card-bg); + backdrop-filter: blur(18px); + box-shadow: var(--share-card-shadow); + padding: 24px; + `, + feedbackHeader: css` + display: flex; + justify-content: space-between; + gap: 16px; + align-items: end; + flex-wrap: wrap; + margin-bottom: 18px; + + h2 { + margin: 6px 0 0; + color: var(--share-text-color); + font-family: var(--share-title-font); + font-size: 28px; + letter-spacing: -0.03em; + } + + p { + margin: 10px 0 0; + color: var(--share-text-secondary); + } + `, + feedbackSummary: css` + display: flex; + gap: 12px; + flex-wrap: wrap; + `, + feedbackSummaryItem: css` + min-width: 120px; + border-radius: 20px; + border: 1px solid var(--share-border-color); + background: var(--share-stat-bg); + padding: 14px 16px; + + span { + display: block; + color: var(--share-text-secondary); + font-size: 12px; + } + + strong { + display: block; + color: var(--share-text-color); + font-size: 26px; + margin-top: 8px; + line-height: 1; + } + `, + feedbackGrid: css` + display: grid; + gap: 18px; + grid-template-columns: minmax(280px, 0.82fr) minmax(0, 1.18fr); + `, + feedbackForm: css` + display: grid; + gap: 14px; + align-content: start; + `, + feedbackLabel: css` + display: grid; + gap: 8px; + + span { + color: var(--share-text-color); + font-weight: 600; + } + `, + feedbackHint: css` + color: var(--share-text-secondary); + line-height: 1.7; + margin: 0; + `, + feedbackList: css` + display: grid; + gap: 12px; + align-content: start; + `, + feedbackCard: css` + display: grid; + gap: 12px; + grid-template-columns: auto minmax(0, 1fr); + border-radius: 22px; + border: 1px solid var(--share-border-color); + background: var(--share-moment-bg); + padding: 16px; + `, + feedbackMeta: css` + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 6px; + `, + feedbackName: css` + color: var(--share-text-color); + font-weight: 700; + `, + feedbackTime: css` + color: var(--share-text-tertiary); + font-size: 12px; + `, + feedbackContent: css` + margin: 0; + color: var(--share-text-secondary); + line-height: 1.75; + white-space: pre-wrap; + `, + feedbackEmpty: css` + border-radius: 22px; + border: 1px dashed var(--share-border-color); + padding: 18px; + color: var(--share-text-secondary); + background: rgba(255, 255, 255, 0.38); + `, '@media (max-width: 1100px)': { studioLayout: { @@ -498,6 +795,9 @@ export default createStyles(({ css, token }) => ({ studioPreviewPanel: { position: 'static', }, + feedbackGrid: { + gridTemplateColumns: '1fr', + }, }, '@media (max-width: 960px)': { @@ -518,6 +818,9 @@ export default createStyles(({ css, token }) => ({ alignItems: 'flex-start', flexDirection: 'column', }, + studioInsightGrid: { + gridTemplateColumns: '1fr', + }, }, '@media (max-width: 640px)': { @@ -530,5 +833,8 @@ export default createStyles(({ css, token }) => ({ coverGrid: { gridTemplateColumns: '1fr 1fr', }, + templateGrid: { + gridTemplateColumns: '1fr', + }, }, -})); \ No newline at end of file +})); diff --git a/src/pages/story/detail.tsx b/src/pages/story/detail.tsx index 95f3953..2756679 100644 --- a/src/pages/story/detail.tsx +++ b/src/pages/story/detail.tsx @@ -4,6 +4,12 @@ import SortableTimelineGrid from '@/pages/story/components/SortableTimelineGrid' import TimelineItemDrawer from '@/pages/story/components/TimelineItemDrawer'; import type { StoryItem, StoryItemTimeQueryParams, StoryType } from '@/pages/story/data'; import { queryStoryDetail, queryStoryItem, removeStoryItem } from '@/pages/story/service'; +import { + getStoryShareActionLabel, + getStorySharePath, + getStoryShareState, + getStoryShareStatusText, +} from '@/pages/story/utils/shareState'; import { judgePermission } from '@/pages/story/utils/utils'; import { EyeOutlined, @@ -156,6 +162,10 @@ const Index = () => { const storyId = lineId || detail?.instanceId; const canEdit = judgePermission(detail?.permissionType ?? null, 'edit'); const canManageCollaborators = judgePermission(detail?.permissionType ?? null, 'auth'); + const shareState = getStoryShareState(detail); + const shareStatusText = getStoryShareStatusText(detail); + const shareActionLabel = + shareState === 'public' ? 'Open Public Share' : getStoryShareActionLabel(detail); const refreshStory = useCallback(() => { setItems([]); @@ -189,6 +199,16 @@ const Index = () => { history.push(`/share/studio/${storyId}`); }, [storyId]); + const openShareSurface = useCallback(() => { + const sharePath = getStorySharePath(detail); + if (!sharePath) { + message.warning('This story is not ready for sharing yet.'); + return; + } + + history.push(sharePath); + }, [detail]); + useEffect(() => { setItems([]); setHasMoreOld(true); @@ -334,10 +354,10 @@ const Index = () => { if (isMobile) { const menuItems: MenuProps['items'] = [ { - key: 'preview', - label: 'Preview', - icon: , - onClick: openSharePreview, + key: 'share', + label: shareActionLabel, + icon: shareState === 'public' ? : , + onClick: openShareSurface, }, { key: 'studio', @@ -372,7 +392,7 @@ const Index = () => { return (
- {canEdit && (
@@ -423,7 +433,7 @@ export const BasicList: FC = () => { void shareStory(featuredStory); }} > - {featuredStory.shareId ? 'Copy Link' : 'Preview'} + {getStoryShareActionLabel(featuredStory)} , , ] } diff --git a/src/pages/story/service.ts b/src/pages/story/service.ts index 692c6c6..c86b441 100644 --- a/src/pages/story/service.ts +++ b/src/pages/story/service.ts @@ -17,27 +17,44 @@ export type StorySharePublishPayload = { title?: string; description?: string; quote?: string; + templateStyle?: ShareTemplateStyle; featuredMomentIds?: string[]; }; +export type ShareTemplateStyle = 'editorial' | 'cinematic' | 'scrapbook'; + export type StorySharePublishResult = { storyId: string; shareId: string; heroMomentId: string; + templateStyle?: ShareTemplateStyle; publicPath: string; publishedAt?: string; }; +export type StoryShareRecentComment = { + instanceId: string; + shareId: string; + visitorName: string; + content: string; + createTime: string; +}; + export type StoryShareConfig = { shareId?: string; + publicShareId?: string; storyId: string; heroMomentId?: string; title?: string; description?: string; quote?: string; + templateStyle?: ShareTemplateStyle; featuredMomentIds: string[]; published?: boolean; updatedAt?: string; + viewCount?: number; + commentCount?: number; + recentComments?: StoryShareRecentComment[]; }; export type TimelineArchiveBucket = { @@ -47,6 +64,8 @@ export type TimelineArchiveBucket = { subtitle: string; storyInstanceId?: string; storyShareId?: string; + shareConfigured?: boolean; + sharePublished?: boolean; coverInstanceId?: string; coverSrc?: string; sampleMomentTitle?: string; @@ -56,6 +75,8 @@ export type TimelineArchiveMoment = { key: string; storyInstanceId?: string; storyShareId?: string; + shareConfigured?: boolean; + sharePublished?: boolean; storyTitle: string; storyTime?: string; itemInstanceId?: string; diff --git a/src/pages/story/utils/shareState.ts b/src/pages/story/utils/shareState.ts new file mode 100644 index 0000000..8f80603 --- /dev/null +++ b/src/pages/story/utils/shareState.ts @@ -0,0 +1,72 @@ +export type StoryShareMeta = { + instanceId?: string; + publicShareId?: string; + shareId?: string; + shareConfigured?: boolean; + sharePublished?: boolean; +}; + +export type StoryShareState = 'public' | 'draft' | 'preview'; + +export const getStoryPublicShareId = (story?: StoryShareMeta) => + story?.publicShareId || story?.shareId; + +export const getStoryShareState = (story?: StoryShareMeta): StoryShareState => { + const publicShareId = getStoryPublicShareId(story); + + if (story?.sharePublished || publicShareId) { + return 'public'; + } + + if (story?.shareConfigured) { + return 'draft'; + } + + return 'preview'; +}; + +export const getStoryShareStatusText = (story?: StoryShareMeta) => { + switch (getStoryShareState(story)) { + case 'public': + return 'Public share is live'; + case 'draft': + return 'Share draft is ready'; + default: + return 'Preview available'; + } +}; + +export const getStoryShareActionLabel = (story?: StoryShareMeta) => { + switch (getStoryShareState(story)) { + case 'public': + return 'Copy Link'; + case 'draft': + return 'Draft Preview'; + default: + return 'Preview'; + } +}; + +export const getStoryShareMenuLabel = (story?: StoryShareMeta) => { + switch (getStoryShareState(story)) { + case 'public': + return 'Copy share link'; + case 'draft': + return 'Open draft preview'; + default: + return 'Open preview'; + } +}; + +export const getStorySharePath = (story?: StoryShareMeta) => { + const publicShareId = getStoryPublicShareId(story); + if (publicShareId) { + return `/share/${publicShareId}`; + } + + if (story?.instanceId) { + return `/share/preview/${story.instanceId}`; + } + + return undefined; +};