feat(分享): 新增分享模板风格选择与访客反馈功能
All checks were successful
test/timeline-frontend/pipeline/head This commit looks good

新增分享模板风格选择功能,支持编辑、电影和剪贴簿三种风格
添加访客反馈系统,包括查看次数统计和留言功能
优化分享状态管理,区分公开、草稿和预览状态
扩展分享配置数据模型,支持模板风格和反馈统计
重构分享页面样式,根据模板风格应用不同主题
This commit is contained in:
2026-03-18 14:05:07 +08:00
parent e616ea375c
commit f8ab9966d4
15 changed files with 1198 additions and 135 deletions

View File

@@ -21,6 +21,12 @@ import {
type TimelineArchiveMoment, type TimelineArchiveMoment,
type TimelineArchiveSummary, type TimelineArchiveSummary,
} from '@/pages/story/service'; } from '@/pages/story/service';
import {
getStoryPublicShareId,
getStoryShareActionLabel,
getStoryShareState,
getStoryShareStatusText,
} from '@/pages/story/utils/shareState';
import './index.less'; import './index.less';
type ArchiveBucketType = 'location' | 'tag'; type ArchiveBucketType = 'location' | 'tag';
@@ -164,6 +170,8 @@ const buildLocalArchiveSummary = async (): Promise<ArchivePageSummary> => {
const moments: TimelineArchiveMoment[] = []; const moments: TimelineArchiveMoment[] = [];
itemResponses.forEach(({ story, items }) => { itemResponses.forEach(({ story, items }) => {
const publicShareId = getStoryPublicShareId(story);
const shareState = getStoryShareState(story);
items.forEach((item, index) => { items.forEach((item, index) => {
const preview = getPreviewMedia(item); const preview = getPreviewMedia(item);
const tags = inferTags(item); const tags = inferTags(item);
@@ -171,7 +179,9 @@ const buildLocalArchiveSummary = async (): Promise<ArchivePageSummary> => {
const moment: TimelineArchiveMoment = { const moment: TimelineArchiveMoment = {
key: item.instanceId || `${story.instanceId || 'story'}-${index}`, key: item.instanceId || `${story.instanceId || 'story'}-${index}`,
storyInstanceId: story.instanceId, storyInstanceId: story.instanceId,
storyShareId: story.shareId, storyShareId: publicShareId,
shareConfigured: shareState !== 'preview',
sharePublished: shareState === 'public',
storyTitle: story.title || 'Untitled story', storyTitle: story.title || 'Untitled story',
storyTime: typeof story.storyTime === 'string' ? story.storyTime : undefined, storyTime: typeof story.storyTime === 'string' ? story.storyTime : undefined,
itemInstanceId: item.instanceId, itemInstanceId: item.instanceId,
@@ -197,7 +207,9 @@ const buildLocalArchiveSummary = async (): Promise<ArchivePageSummary> => {
count: 0, count: 0,
subtitle: story.title || 'Untitled story', subtitle: story.title || 'Untitled story',
storyInstanceId: story.instanceId, storyInstanceId: story.instanceId,
storyShareId: story.shareId, storyShareId: publicShareId,
shareConfigured: shareState !== 'preview',
sharePublished: shareState === 'public',
coverInstanceId: preview.coverInstanceId, coverInstanceId: preview.coverInstanceId,
coverSrc: preview.coverSrc, coverSrc: preview.coverSrc,
sampleMomentTitle: moment.itemTitle, sampleMomentTitle: moment.itemTitle,
@@ -207,7 +219,9 @@ const buildLocalArchiveSummary = async (): Promise<ArchivePageSummary> => {
current.sampleMomentTitle = current.sampleMomentTitle || moment.itemTitle; current.sampleMomentTitle = current.sampleMomentTitle || moment.itemTitle;
current.coverInstanceId = current.coverInstanceId || preview.coverInstanceId; current.coverInstanceId = current.coverInstanceId || preview.coverInstanceId;
current.coverSrc = current.coverSrc || preview.coverSrc; 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); locationMap.set(item.location, current);
} }
@@ -218,7 +232,9 @@ const buildLocalArchiveSummary = async (): Promise<ArchivePageSummary> => {
count: 0, count: 0,
subtitle: story.title || 'Untitled story', subtitle: story.title || 'Untitled story',
storyInstanceId: story.instanceId, storyInstanceId: story.instanceId,
storyShareId: story.shareId, storyShareId: publicShareId,
shareConfigured: shareState !== 'preview',
sharePublished: shareState === 'public',
coverInstanceId: preview.coverInstanceId, coverInstanceId: preview.coverInstanceId,
coverSrc: preview.coverSrc, coverSrc: preview.coverSrc,
sampleMomentTitle: moment.itemTitle, sampleMomentTitle: moment.itemTitle,
@@ -228,7 +244,9 @@ const buildLocalArchiveSummary = async (): Promise<ArchivePageSummary> => {
current.sampleMomentTitle = current.sampleMomentTitle || moment.itemTitle; current.sampleMomentTitle = current.sampleMomentTitle || moment.itemTitle;
current.coverInstanceId = current.coverInstanceId || preview.coverInstanceId; current.coverInstanceId = current.coverInstanceId || preview.coverInstanceId;
current.coverSrc = current.coverSrc || preview.coverSrc; 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); tagMap.set(tag, current);
}); });
}); });
@@ -240,7 +258,7 @@ const buildLocalArchiveSummary = async (): Promise<ArchivePageSummary> => {
locations: Array.from(locationMap.values()).sort((a, b) => b.count - a.count).slice(0, 8), 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), tags: Array.from(tagMap.values()).sort((a, b) => b.count - a.count).slice(0, 12),
moments: moments.sort((a, b) => b.sortValue - a.sortValue), 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, videoMoments: moments.filter((moment) => moment.hasVideo).length,
}; };
}; };
@@ -469,7 +487,7 @@ const ArchivePage: React.FC = () => {
<p> <p>
This cluster currently surfaces <strong>{matchedMoments.length}</strong> moments from This cluster currently surfaces <strong>{matchedMoments.length}</strong> moments from
<strong> {data.storiesIndexed}</strong> indexed stories. <strong>{data.shareableStories}</strong> <strong> {data.storiesIndexed}</strong> indexed stories. <strong>{data.shareableStories}</strong>
{` `}stories are already close to public-share quality. {` `}stories already have a saved share draft or a live public page.
</p> </p>
<span>{activeBucket.subtitle}</span> <span>{activeBucket.subtitle}</span>
</div> </div>
@@ -479,10 +497,19 @@ const ArchivePage: React.FC = () => {
) : matchedMoments.length > 0 ? ( ) : matchedMoments.length > 0 ? (
<div className="smart-archive__moment-list"> <div className="smart-archive__moment-list">
{matchedMoments.map((moment) => { {matchedMoments.map((moment) => {
const shareMeta = {
instanceId: moment.storyInstanceId,
publicShareId: moment.storyShareId,
shareConfigured: moment.shareConfigured,
sharePublished: moment.sharePublished,
};
const previewPath = moment.storyInstanceId const previewPath = moment.storyInstanceId
? `/share/preview/${moment.storyInstanceId}` ? `/share/preview/${moment.storyInstanceId}`
: '/story'; : '/story';
const shareState = getStoryShareState(shareMeta);
const sharePath = moment.storyShareId ? `/share/${moment.storyShareId}` : previewPath; const sharePath = moment.storyShareId ? `/share/${moment.storyShareId}` : previewPath;
const shareActionLabel =
shareState === 'public' ? 'Open public share' : getStoryShareActionLabel(shareMeta);
return ( return (
<article className="smart-archive__moment-card" key={moment.key}> <article className="smart-archive__moment-card" key={moment.key}>
@@ -517,6 +544,7 @@ const ArchivePage: React.FC = () => {
<div className="smart-archive__moment-foot"> <div className="smart-archive__moment-foot">
<span>{moment.itemTime}</span> <span>{moment.itemTime}</span>
<span>{moment.mediaCount} media items</span> <span>{moment.mediaCount} media items</span>
<span>{getStoryShareStatusText(shareMeta)}</span>
</div> </div>
<div className="smart-archive__moment-actions"> <div className="smart-archive__moment-actions">
<Button <Button
@@ -528,7 +556,7 @@ const ArchivePage: React.FC = () => {
Open story Open story
</Button> </Button>
<Button onClick={() => history.push(sharePath)}> <Button onClick={() => history.push(sharePath)}>
<ShareAltOutlined /> {moment.storyShareId ? 'Open public share' : 'Open preview'} <ShareAltOutlined /> {shareActionLabel}
</Button> </Button>
</div> </div>
</div> </div>
@@ -549,7 +577,7 @@ const ArchivePage: React.FC = () => {
<section className="smart-archive__footer-panel"> <section className="smart-archive__footer-panel">
<CompassOutlined /> <CompassOutlined />
<div> <div>
<strong>{data.shareableStories} stories are already close to being worth sharing outside the app</strong> <strong>{data.shareableStories} stories already have a saved share draft or a live public page</strong>
<p> <p>
Add stronger cover moments and clearer descriptions, then shape the share page. That is where 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. this product starts to feel memorable instead of merely functional.

View File

@@ -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 type { StoryType } from '@/pages/story/data.d';
import { queryCurrent } from '@/pages/account/center/service'; import { queryCurrent } from '@/pages/account/center/service';
import { queryTimelineList } from '@/pages/story/service'; import { queryTimelineList } from '@/pages/story/service';
@@ -375,4 +375,3 @@ const Home: React.FC = () => {
export default Home; export default Home;

View File

@@ -4,6 +4,12 @@ import { Button, Empty, Skeleton, Tag } from 'antd';
import React from 'react'; import React from 'react';
import type { StoryType } from '@/pages/story/data.d'; import type { StoryType } from '@/pages/story/data.d';
import { queryTimelineList, queryTimelineYearlyReport } from '@/pages/story/service'; import { queryTimelineList, queryTimelineYearlyReport } from '@/pages/story/service';
import {
getStoryShareActionLabel,
getStorySharePath,
getStoryShareState,
getStoryShareStatusText,
} from '@/pages/story/utils/shareState';
import './index.less'; import './index.less';
type ReviewSummary = { type ReviewSummary = {
@@ -11,7 +17,7 @@ type ReviewSummary = {
stories: StoryType[]; stories: StoryType[];
totalStories: number; totalStories: number;
totalMoments: number; totalMoments: number;
sharedStories: number; shareReadyStories: number;
busiestMonth: { label: string; count: number }; busiestMonth: { label: string; count: number };
monthlyCounts: Array<{ label: string; count: number }>; monthlyCounts: Array<{ label: string; count: number }>;
featuredStory?: StoryType; featuredStory?: StoryType;
@@ -114,7 +120,7 @@ const YearlyReviewPage: React.FC = () => {
typeof report?.totalMoments === 'number' typeof report?.totalMoments === 'number'
? report.totalMoments ? report.totalMoments
: inYear.reduce((sum, story) => sum + Number(story.itemCount || 0), 0), : 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, busiestMonth,
monthlyCounts, monthlyCounts,
featuredStory, featuredStory,
@@ -138,11 +144,8 @@ const YearlyReviewPage: React.FC = () => {
const maxCount = Math.max(...data.monthlyCounts.map((item) => item.count), 1); const maxCount = Math.max(...data.monthlyCounts.map((item) => item.count), 1);
const featuredStoryId = data.featuredStory?.instanceId; const featuredStoryId = data.featuredStory?.instanceId;
const featuredSharePath = data.featuredStory?.shareId const featuredSharePath = getStorySharePath(data.featuredStory) || (featuredStoryId ? `/share/preview/${featuredStoryId}` : '/story');
? `/share/${data.featuredStory.shareId}` const featuredShareState = getStoryShareState(data.featuredStory);
: featuredStoryId
? `/share/preview/${featuredStoryId}`
: '/story';
return ( return (
<div className="yearly-review"> <div className="yearly-review">
@@ -155,7 +158,7 @@ const YearlyReviewPage: React.FC = () => {
<p> <p>
You captured {data.totalStories} storylines and {data.totalMoments} moments this year. 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.topLocation && data.topLocation !== '-' ? ` Top place: ${data.topLocation}.` : ''}
{data.topTag && data.topTag !== '-' ? ` Signature theme: ${data.topTag}.` : ''} {data.topTag && data.topTag !== '-' ? ` Signature theme: ${data.topTag}.` : ''}
</p> </p>
@@ -187,7 +190,7 @@ const YearlyReviewPage: React.FC = () => {
</article> </article>
<article> <article>
<span>Share-ready</span> <span>Share-ready</span>
<strong>{data.sharedStories}</strong> <strong>{data.shareReadyStories}</strong>
</article> </article>
</section> </section>
@@ -228,12 +231,12 @@ const YearlyReviewPage: React.FC = () => {
<div className="yearly-review__feature-card"> <div className="yearly-review__feature-card">
<div className="yearly-review__feature-meta"> <div className="yearly-review__feature-meta">
<Tag bordered={false}>{data.featuredStory.ownerName || 'My story'}</Tag> <Tag bordered={false}>{data.featuredStory.ownerName || 'My story'}</Tag>
{data.featuredStory.shareId ? ( {featuredShareState === 'public' ? (
<span> <span>
<ShareAltOutlined /> Public share is available <ShareAltOutlined /> Public share is available
</span> </span>
) : ( ) : (
<span>Preview is ready even before the public endpoint is finished</span> <span>{getStoryShareStatusText(data.featuredStory)}</span>
)} )}
</div> </div>
<h3>{data.featuredStory.title || 'Untitled story'}</h3> <h3>{data.featuredStory.title || 'Untitled story'}</h3>
@@ -257,7 +260,7 @@ const YearlyReviewPage: React.FC = () => {
Keep editing Keep editing
</Button> </Button>
<Button onClick={() => history.push(featuredSharePath)}> <Button onClick={() => history.push(featuredSharePath)}>
<ShareAltOutlined /> {data.featuredStory.shareId ? 'Open public share' : 'Open preview'} <ShareAltOutlined /> {featuredShareState === 'public' ? 'Open public share' : getStoryShareActionLabel(data.featuredStory)}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,5 @@
import TimelineImage from '@/components/TimelineImage'; import TimelineImage from '@/components/TimelineImage';
import classNames from 'classnames';
import { import {
CalendarOutlined, CalendarOutlined,
CameraOutlined, CameraOutlined,
@@ -32,6 +33,12 @@ const resolveVideoSrc = (videoInstanceId?: string) => {
const StoryShowcase: React.FC<StoryShowcaseProps> = ({ data, actions, embedded = false }) => { const StoryShowcase: React.FC<StoryShowcaseProps> = ({ data, actions, embedded = false }) => {
const { styles } = useStyles(); const { styles } = useStyles();
const heroVideoSrc = resolveVideoSrc(data.hero?.videoInstanceId); const heroVideoSrc = resolveVideoSrc(data.hero?.videoInstanceId);
const themeClass =
data.templateStyle === 'cinematic'
? styles.themeCinematic
: data.templateStyle === 'scrapbook'
? styles.themeScrapbook
: styles.themeEditorial;
const showcaseCard = ( const showcaseCard = (
<div className={embedded ? styles.embeddedShell : styles.shell}> <div className={embedded ? styles.embeddedShell : styles.shell}>
@@ -234,10 +241,14 @@ const StoryShowcase: React.FC<StoryShowcaseProps> = ({ data, actions, embedded =
); );
if (embedded) { if (embedded) {
return <div className={styles.embeddedPage}>{showcaseCard}</div>; return <div className={classNames(styles.embeddedPage, styles.themeBase, themeClass)}>{showcaseCard}</div>;
} }
return <PageContainer ghost className={styles.sharePage}>{showcaseCard}</PageContainer>; return (
<PageContainer ghost className={classNames(styles.sharePage, styles.themeBase, themeClass)}>
{showcaseCard}
</PageContainer>
);
}; };
export default StoryShowcase; export default StoryShowcase;

View File

@@ -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 { history, request, useParams, useRequest } from '@umijs/max';
import { Button, Result, Skeleton, message } from 'antd'; import { Avatar, Button, Input, Result, Skeleton, message } from 'antd';
import React from 'react'; import classNames from 'classnames';
import React, { useState } from 'react';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import StoryShowcase from './StoryShowcase'; import StoryShowcase from './StoryShowcase';
import {
createPublicShareComment,
queryPublicShareFeedback,
recordPublicShareView,
type StoryShareFeedback,
} from './feedback';
import { normalizePublicShowcase } from './showcase-utils'; import { normalizePublicShowcase } from './showcase-utils';
import useStyles from './style.style'; import useStyles from './style.style';
@@ -28,11 +36,50 @@ interface PublicStoryItem {
shareTitle?: string; shareTitle?: string;
shareDescription?: string; shareDescription?: string;
shareQuote?: string; shareQuote?: string;
templateStyle?: 'editorial' | 'cinematic' | 'scrapbook';
heroMomentId?: string; heroMomentId?: string;
featuredMomentIds?: string[]; featuredMomentIds?: string[];
featuredMoments?: PublicStoryItem[]; 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 fetchShareStory = async (shareId: string) => {
const candidates = [ const candidates = [
`/api/story/item/public/story/item/${shareId}`, `/api/story/item/public/story/item/${shareId}`,
@@ -57,20 +104,91 @@ const fetchShareStory = async (shareId: string) => {
return undefined; 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 SharePage: React.FC = () => {
const { styles } = useStyles(); const { styles } = useStyles();
const { shareId } = useParams<{ shareId: string }>(); const { shareId } = useParams<{ shareId: string }>();
const [guestName, setGuestName] = useState(getStoredGuestName);
const [commentContent, setCommentContent] = useState('');
const [commentSubmitting, setCommentSubmitting] = useState(false);
const shareRequest = useRequest( const shareRequest = useRequest(
async () => { async () => {
if (!shareId) return undefined; 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], 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; const { loading } = shareRequest;
if (loading) { 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 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 ( return (
<> <>
<Helmet> <Helmet>
<title>{showcase.title}</title> <title>{showcase.title}</title>
<meta name="description" content={showcase.description} /> <meta name="description" content={showcase.description} />
<meta name="theme-color" content={themeColor} />
<meta property="og:title" content={showcase.title} /> <meta property="og:title" content={showcase.title} />
<meta property="og:description" content={showcase.description} /> <meta property="og:description" content={showcase.description} />
<meta property="og:type" content="article" />
<meta property="og:url" content={pageUrl} />
{metaImageUrl && <meta property="og:image" content={metaImageUrl} />}
{metaImageUrl && <meta property="og:image:alt" content={showcase.title} />}
<meta name="twitter:card" content={metaImageUrl ? 'summary_large_image' : 'summary'} />
<meta name="twitter:title" content={showcase.title} />
<meta name="twitter:description" content={showcase.description} />
{metaImageUrl && <meta name="twitter:image" content={metaImageUrl} />}
</Helmet> </Helmet>
<StoryShowcase <PageContainer ghost className={classNames(styles.sharePage, styles.themeBase, themeClass)}>
data={showcase} <StoryShowcase
actions={[ data={showcase}
{ embedded
key: 'home', actions={[
label: 'Open Timeline', {
primary: true, key: 'home',
icon: <HomeOutlined />, label: 'Open Timeline',
onClick: () => history.push('/home'), primary: true,
}, icon: <HomeOutlined />,
{ onClick: () => history.push('/home'),
key: 'copy',
label: 'Copy link',
icon: <CopyOutlined />,
onClick: async () => {
await navigator.clipboard.writeText(pageUrl);
message.success('Public share link copied');
}, },
}, {
{ key: 'copy',
key: 'back', label: 'Copy link',
label: 'Back home', icon: <CopyOutlined />,
icon: <ArrowLeftOutlined />, onClick: async () => {
onClick: () => history.push('/home'), await navigator.clipboard.writeText(pageUrl);
}, message.success('Public share link copied');
]} },
/> },
{
key: 'back',
label: 'Back home',
icon: <ArrowLeftOutlined />,
onClick: () => history.push('/home'),
},
]}
/>
<section className={styles.feedbackSection}>
<div className={styles.feedbackPanel}>
<div className={styles.feedbackHeader}>
<div>
<span className={styles.sectionEyebrow}>Audience Feedback</span>
<h2>Let the share page collect lightweight reactions</h2>
<p>
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.
</p>
</div>
<div className={styles.feedbackSummary}>
<article className={styles.feedbackSummaryItem}>
<span>Views</span>
<strong>{feedback?.viewCount || 0}</strong>
</article>
<article className={styles.feedbackSummaryItem}>
<span>Guest notes</span>
<strong>{feedback?.commentCount || 0}</strong>
</article>
</div>
</div>
<div className={styles.feedbackGrid}>
<div className={styles.feedbackForm}>
<p className={styles.feedbackHint}>
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.
</p>
<label className={styles.feedbackLabel}>
<span>Your name</span>
<Input
maxLength={40}
placeholder="Guest"
value={guestName}
onChange={(event) => setGuestName(event.target.value)}
/>
</label>
<label className={styles.feedbackLabel}>
<span>Your note</span>
<Input.TextArea
autoSize={{ minRows: 4, maxRows: 7 }}
maxLength={280}
placeholder="Share what stood out, what this memory reminded you of, or why this page felt worth opening."
value={commentContent}
onChange={(event) => setCommentContent(event.target.value)}
/>
</label>
<Button
type="primary"
onClick={() => void submitComment()}
loading={commentSubmitting}
icon={<MessageOutlined />}
>
Post note
</Button>
</div>
<div className={styles.feedbackList}>
{comments.length > 0 ? (
comments.map((comment) => (
<article className={styles.feedbackCard} key={comment.instanceId}>
<Avatar size={42}>{(comment.visitorName || 'G').slice(0, 1).toUpperCase()}</Avatar>
<div>
<div className={styles.feedbackMeta}>
<strong className={styles.feedbackName}>{comment.visitorName || 'Guest'}</strong>
<span className={styles.feedbackTime}>
{new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(comment.createTime))}
</span>
</div>
<p className={styles.feedbackContent}>{comment.content}</p>
</div>
</article>
))
) : (
<div className={styles.feedbackEmpty}>
<RiseOutlined /> This page has not received a guest note yet. The first thoughtful reaction
often makes sharing feel real.
</div>
)}
</div>
</div>
</div>
</section>
</PageContainer>
</> </>
); );
}; };

View File

@@ -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 <T>(
shareId: string,
path: string,
options?: Record<string, unknown>,
) => {
const candidates = [`/api/public/story/${shareId}${path}`, `/public/story/${shareId}${path}`];
for (const url of candidates) {
try {
return await request<T>(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,
});
};

View File

@@ -4,10 +4,15 @@ import { Button, Result, Skeleton, message } from 'antd';
import React from 'react'; import React from 'react';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import type { StoryItem, StoryType } from '@/pages/story/data.d'; 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 StoryShowcase from '../StoryShowcase';
import { buildPreviewShowcase } from '../showcase-utils'; import { buildPreviewShowcase } from '../showcase-utils';
import { loadShareDraft } from '../shareDraft'; import { buildShareDraftSeed, getPublicShareId, resolveShareDraft } from '../shareDraft';
import useStyles from '../style.style'; import useStyles from '../style.style';
type StoryItemsResponse = { type StoryItemsResponse = {
@@ -17,25 +22,41 @@ type StoryItemsResponse = {
list?: StoryItem[]; list?: StoryItem[];
}; };
type ShareConfigResponse = {
data?: StoryShareConfig;
};
const normalizeItems = (response: StoryItemsResponse | undefined) => { const normalizeItems = (response: StoryItemsResponse | undefined) => {
if (Array.isArray(response?.data?.list)) return response.data.list; if (Array.isArray(response?.data?.list)) return response.data.list;
if (Array.isArray(response?.list)) return response.list; if (Array.isArray(response?.list)) return response.list;
return [] as StoryItem[]; 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 PreviewSharePage: React.FC = () => {
const { styles } = useStyles(); const { styles } = useStyles();
const { storyId } = useParams<{ storyId: string }>(); const { storyId } = useParams<{ storyId: string }>();
const previewRequest = useRequest( const previewRequest = useRequest(
async () => { async () => {
if (!storyId) return undefined; if (!storyId) return undefined;
const [storyResponse, itemsResponse] = await Promise.all([ const [storyResponse, itemsResponse, shareConfigResponse] = await Promise.all([
queryStoryDetail(storyId), queryStoryDetail(storyId),
queryStoryItem({ storyInstanceId: storyId, current: 1, pageSize: 60 }), queryStoryItem({ storyInstanceId: storyId, current: 1, pageSize: 60 }),
queryStoryShareConfig(storyId).catch(() => undefined),
]); ]);
return { return {
story: storyResponse.data as StoryType, story: storyResponse.data as StoryType,
items: normalizeItems(itemsResponse as StoryItemsResponse), 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; const { loading } = previewRequest;
if (loading) { if (loading) {
@@ -69,10 +92,19 @@ const PreviewSharePage: React.FC = () => {
); );
} }
const draft = loadShareDraft(storyId); const shareConfig = payload.shareConfig;
const showcase = buildPreviewShowcase(payload.story, payload.items, draft); 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 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}`; const studioUrl = `/share/studio/${storyId}`;
return ( return (

View File

@@ -1,13 +1,18 @@
import type { StoryShareConfig } from '@/pages/story/service';
export type ShareDraft = { export type ShareDraft = {
storyId: string; storyId: string;
title?: string; title?: string;
description?: string; description?: string;
quote?: string; quote?: string;
templateStyle?: 'editorial' | 'cinematic' | 'scrapbook';
heroMomentId?: string; heroMomentId?: string;
featuredMomentIds?: string[]; featuredMomentIds?: string[];
updatedAt?: string; updatedAt?: string;
}; };
export type ShareDraftSeed = Omit<ShareDraft, 'storyId'>;
const STORAGE_KEY = 'timeline_share_drafts_v1'; const STORAGE_KEY = 'timeline_share_drafts_v1';
type DraftMap = Record<string, ShareDraft>; type DraftMap = Record<string, ShareDraft>;
@@ -57,3 +62,70 @@ export const clearShareDraft = (storyId: string) => {
delete draftMap[storyId]; delete draftMap[storyId];
writeDraftMap(draftMap); 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<StoryShareConfig, 'publicShareId' | 'published' | 'shareId'>,
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;
};

View File

@@ -1,5 +1,6 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { StoryItem, StoryType } from '@/pages/story/data.d'; import type { StoryItem, StoryType } from '@/pages/story/data.d';
import type { ShareTemplateStyle } from '@/pages/story/service';
import type { ShareDraft } from './shareDraft'; import type { ShareDraft } from './shareDraft';
export type ShareAction = { export type ShareAction = {
@@ -37,6 +38,7 @@ export type ShareMoment = {
}; };
export type StoryShowcaseData = { export type StoryShowcaseData = {
templateStyle: ShareTemplateStyle;
badge: string; badge: string;
title: string; title: string;
description: string; description: string;
@@ -73,11 +75,14 @@ type PublicStoryItem = {
shareTitle?: string; shareTitle?: string;
shareDescription?: string; shareDescription?: string;
shareQuote?: string; shareQuote?: string;
templateStyle?: ShareTemplateStyle;
heroMomentId?: string; heroMomentId?: string;
featuredMomentIds?: string[]; featuredMomentIds?: string[];
featuredMoments?: PublicStoryItem[]; featuredMoments?: PublicStoryItem[];
}; };
export const DEFAULT_SHARE_TEMPLATE: ShareTemplateStyle = 'editorial';
const pad = (value: number) => String(value).padStart(2, '0'); const pad = (value: number) => String(value).padStart(2, '0');
export const formatStoryTime = (value?: string | number[]) => { 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.'; 'Shape one strong story page and the whole product starts to feel more shareable.';
return { return {
templateStyle: draft?.templateStyle || DEFAULT_SHARE_TEMPLATE,
badge: story.shareId ? 'Share Preview' : 'Story Preview', badge: story.shareId ? 'Share Preview' : 'Story Preview',
title: draftTitle || story.title || 'Untitled story', title: draftTitle || story.title || 'Untitled story',
description: description:
@@ -294,6 +300,7 @@ export const normalizePublicShowcase = (
const locationLabel = buildLocationLabel(featuredMoments, storyItem.location); const locationLabel = buildLocationLabel(featuredMoments, storyItem.location);
return { return {
templateStyle: storyItem.templateStyle || DEFAULT_SHARE_TEMPLATE,
badge: 'Public Share', badge: 'Public Share',
title: storyItem.shareTitle || storyItem.title || 'Memory share', title: storyItem.shareTitle || storyItem.title || 'Memory share',
description, description,

View File

@@ -5,6 +5,7 @@ import {
DeleteOutlined, DeleteOutlined,
EyeOutlined, EyeOutlined,
PlayCircleOutlined, PlayCircleOutlined,
RiseOutlined,
SaveOutlined, SaveOutlined,
ShareAltOutlined, ShareAltOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
@@ -22,8 +23,19 @@ import {
unpublishStoryShare, unpublishStoryShare,
} from '@/pages/story/service'; } from '@/pages/story/service';
import StoryShowcase from '../StoryShowcase'; import StoryShowcase from '../StoryShowcase';
import { buildPreviewShowcase, formatStoryTime } from '../showcase-utils'; import {
import { clearShareDraft, loadShareDraft, saveShareDraft, type ShareDraft } from '../shareDraft'; buildPreviewShowcase,
DEFAULT_SHARE_TEMPLATE,
formatStoryTime,
} from '../showcase-utils';
import {
buildShareDraftSeed,
clearShareDraft,
getPublicShareId,
resolveShareDraft,
saveShareDraft,
type ShareDraft,
} from '../shareDraft';
import useStyles from '../style.style'; import useStyles from '../style.style';
const { TextArea } = Input; const { TextArea } = Input;
@@ -48,10 +60,33 @@ type DraftFormState = {
title: string; title: string;
description: string; description: string;
quote: string; quote: string;
templateStyle: 'editorial' | 'cinematic' | 'scrapbook';
heroMomentId?: string; heroMomentId?: string;
featuredMomentIds: 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) => { const normalizeItems = (response: StoryItemsResponse | undefined) => {
if (Array.isArray(response?.data?.list)) return response.data.list; if (Array.isArray(response?.data?.list)) return response.data.list;
if (Array.isArray(response?.list)) return response.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) => { const normalizeShareConfig = (response: { data?: StoryShareConfig } | StoryShareConfig | undefined) => {
if (!response) return undefined; if (!response) return undefined;
if ('data' in response) return response.data; if ('storyId' in response) return response;
return response; return response.data;
}; };
const getMomentPreview = (item: StoryItem) => { const getMomentPreview = (item: StoryItem) => {
@@ -98,13 +133,25 @@ const getDefaultFeaturedMomentIds = (items: StoryItem[], heroMomentId?: string)
return result; 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 = ( const buildInitialDraft = (
storyId: string, storyId: string,
story: StoryType, story: StoryType,
items: StoryItem[], items: StoryItem[],
shareConfig?: StoryShareConfig, shareConfig?: StoryShareConfig,
) => { ) => {
const saved = loadShareDraft(storyId); const saved = resolveShareDraft(storyId, buildShareDraftSeed(shareConfig));
const configFeaturedMomentIds = shareConfig?.featuredMomentIds?.filter(Boolean) || []; const configFeaturedMomentIds = shareConfig?.featuredMomentIds?.filter(Boolean) || [];
const defaultHero = shareConfig?.heroMomentId || items.find(hasPreviewMedia)?.instanceId; const defaultHero = shareConfig?.heroMomentId || items.find(hasPreviewMedia)?.instanceId;
const featuredMomentIds = const featuredMomentIds =
@@ -116,6 +163,7 @@ const buildInitialDraft = (
title: saved?.title || shareConfig?.title || story.title || '', title: saved?.title || shareConfig?.title || story.title || '',
description: saved?.description || shareConfig?.description || story.description || '', description: saved?.description || shareConfig?.description || story.description || '',
quote: saved?.quote || shareConfig?.quote || story.description || '', quote: saved?.quote || shareConfig?.quote || story.description || '',
templateStyle: saved?.templateStyle || shareConfig?.templateStyle || DEFAULT_SHARE_TEMPLATE,
heroMomentId: saved?.heroMomentId || shareConfig?.heroMomentId || defaultHero, heroMomentId: saved?.heroMomentId || shareConfig?.heroMomentId || defaultHero,
featuredMomentIds, featuredMomentIds,
} satisfies DraftFormState; } satisfies DraftFormState;
@@ -128,6 +176,7 @@ const ShareStudioPage: React.FC = () => {
title: '', title: '',
description: '', description: '',
quote: '', quote: '',
templateStyle: DEFAULT_SHARE_TEMPLATE,
heroMomentId: undefined, heroMomentId: undefined,
featuredMomentIds: [], featuredMomentIds: [],
}); });
@@ -144,10 +193,13 @@ const ShareStudioPage: React.FC = () => {
queryStoryItem({ storyInstanceId: storyId, current: 1, pageSize: 80 }), queryStoryItem({ storyInstanceId: storyId, current: 1, pageSize: 80 }),
queryStoryShareConfig(storyId).catch(() => undefined), queryStoryShareConfig(storyId).catch(() => undefined),
]); ]);
const shareConfig = normalizeShareConfig(
shareConfigResponse as ShareConfigResponse | StoryShareConfig | undefined,
);
return { return {
story: normalizeStory(storyResponse as StoryDetailResponse), story: normalizeStory(storyResponse as StoryDetailResponse),
items: normalizeItems(itemsResponse as StoryItemsResponse), 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; if (!storyId || !payload?.story) return;
setPersistedShareConfig(payload.shareConfig); setPersistedShareConfig(payload.shareConfig);
setDraft(buildInitialDraft(storyId, payload.story, payload.items, payload.shareConfig)); setDraft(buildInitialDraft(storyId, payload.story, payload.items, payload.shareConfig));
setActiveShareId(payload.story.shareId); setActiveShareId(getPublicShareId(payload.shareConfig, payload.story.shareId));
}, [payload, storyId]); }, [payload, storyId]);
const showcaseDraft = useMemo<ShareDraft | undefined>(() => { const showcaseDraft = useMemo<ShareDraft | undefined>(() => {
@@ -174,9 +226,10 @@ const ShareStudioPage: React.FC = () => {
title: draft.title, title: draft.title,
description: draft.description, description: draft.description,
quote: draft.quote, quote: draft.quote,
templateStyle: draft.templateStyle,
heroMomentId: draft.heroMomentId, heroMomentId: draft.heroMomentId,
featuredMomentIds: draft.featuredMomentIds, featuredMomentIds: draft.featuredMomentIds,
updatedAt: loadShareDraft(storyId)?.updatedAt, updatedAt: resolveShareDraft(storyId, buildShareDraftSeed(payload.shareConfig))?.updatedAt,
}; };
}, [draft, payload, storyId]); }, [draft, payload, storyId]);
@@ -208,6 +261,10 @@ const ShareStudioPage: React.FC = () => {
const previewUrl = `/share/preview/${storyId}`; const previewUrl = `/share/preview/${storyId}`;
const coverCandidates = payload.items.filter(hasPreviewMedia).slice(0, 18); const coverCandidates = payload.items.filter(hasPreviewMedia).slice(0, 18);
const spotlightCandidates = payload.items.filter((item) => Boolean(item.instanceId)).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 copyLink = async (relativeUrl: string, successMessage: string) => {
const fullUrl = `${window.location.origin}${relativeUrl}`; const fullUrl = `${window.location.origin}${relativeUrl}`;
@@ -227,6 +284,7 @@ const ShareStudioPage: React.FC = () => {
title: draft.title.trim(), title: draft.title.trim(),
description: draft.description.trim(), description: draft.description.trim(),
quote: draft.quote.trim(), quote: draft.quote.trim(),
templateStyle: draft.templateStyle,
heroMomentId: draft.heroMomentId, heroMomentId: draft.heroMomentId,
featuredMomentIds: draft.featuredMomentIds, featuredMomentIds: draft.featuredMomentIds,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
@@ -241,6 +299,7 @@ const ShareStudioPage: React.FC = () => {
title: draft.title.trim(), title: draft.title.trim(),
description: draft.description.trim(), description: draft.description.trim(),
quote: draft.quote.trim(), quote: draft.quote.trim(),
templateStyle: draft.templateStyle,
heroMomentId: draft.heroMomentId, heroMomentId: draft.heroMomentId,
featuredMomentIds: draft.featuredMomentIds, featuredMomentIds: draft.featuredMomentIds,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
@@ -292,6 +351,7 @@ const ShareStudioPage: React.FC = () => {
title: nextDraft.title, title: nextDraft.title,
description: nextDraft.description, description: nextDraft.description,
quote: nextDraft.quote, quote: nextDraft.quote,
templateStyle: nextDraft.templateStyle,
featuredMomentIds: nextDraft.featuredMomentIds, featuredMomentIds: nextDraft.featuredMomentIds,
}); });
const nextShareId = response?.data?.shareId; const nextShareId = response?.data?.shareId;
@@ -301,14 +361,19 @@ const ShareStudioPage: React.FC = () => {
setActiveShareId(nextShareId); setActiveShareId(nextShareId);
setPersistedShareConfig({ setPersistedShareConfig({
shareId: nextShareId, shareId: nextShareId,
publicShareId: nextShareId,
storyId, storyId,
heroMomentId: draft.heroMomentId, heroMomentId: draft.heroMomentId,
title: nextDraft.title, title: nextDraft.title,
description: nextDraft.description, description: nextDraft.description,
quote: nextDraft.quote, quote: nextDraft.quote,
templateStyle: nextDraft.templateStyle,
featuredMomentIds: nextDraft.featuredMomentIds || [], featuredMomentIds: nextDraft.featuredMomentIds || [],
published: true, published: true,
updatedAt: new Date().toISOString(), 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.'); message.success(shareReady ? 'Public share updated.' : 'Public share is now live.');
} catch (error) { } catch (error) {
@@ -328,13 +393,19 @@ const ShareStudioPage: React.FC = () => {
current current
? { ? {
...current, ...current,
shareId: current.shareId || activeShareId,
publicShareId: undefined,
heroMomentId: draft.heroMomentId, heroMomentId: draft.heroMomentId,
title: draft.title.trim(), title: draft.title.trim(),
description: draft.description.trim(), description: draft.description.trim(),
quote: draft.quote.trim(), quote: draft.quote.trim(),
templateStyle: draft.templateStyle,
featuredMomentIds: draft.featuredMomentIds, featuredMomentIds: draft.featuredMomentIds,
published: false, published: false,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
viewCount: current.viewCount || 0,
commentCount: current.commentCount || 0,
recentComments: current.recentComments || [],
} }
: undefined, : undefined,
); );
@@ -382,7 +453,9 @@ const ShareStudioPage: React.FC = () => {
description={ description={
shareReady shareReady
? `This story is now reachable on ${publicUrl}. Republishing keeps the same public link and refreshes the hero moment.` ? `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'} type={shareReady ? 'success' : 'info'}
showIcon showIcon
@@ -396,6 +469,49 @@ const ShareStudioPage: React.FC = () => {
showIcon showIcon
/> />
<div className={styles.studioSectionHead}>
<div>
<span className={styles.sectionEyebrow}>Audience Signals</span>
<h3>See whether the public page is actually landing</h3>
</div>
<span>{shareReady ? 'Live feedback updates on the public route' : 'Feedback history stays attached to the saved share id'}</span>
</div>
<div className={styles.studioInsightGrid}>
<article className={styles.studioInsightCard}>
<span>Public views</span>
<strong>{feedbackViewCount}</strong>
</article>
<article className={styles.studioInsightCard}>
<span>Guest notes</span>
<strong>{feedbackCommentCount}</strong>
</article>
<article className={styles.studioInsightCard}>
<span>Current state</span>
<strong>{shareReady ? 'Live' : persistedShareConfig?.shareId ? 'Paused' : 'Draft'}</strong>
</article>
</div>
{feedbackComments.length > 0 ? (
<div className={styles.studioCommentList}>
{feedbackComments.map((comment) => (
<article className={styles.studioCommentCard} key={comment.instanceId}>
<div className={styles.studioCommentMeta}>
<strong className={styles.studioCommentName}>{comment.visitorName || 'Guest'}</strong>
<span className={styles.studioCommentTime}>{formatFeedbackTime(comment.createTime)}</span>
</div>
<p className={styles.studioCommentBody}>{comment.content}</p>
</article>
))}
</div>
) : (
<p className={styles.studioHint}>
{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.'}
</p>
)}
<div className={styles.studioFields}> <div className={styles.studioFields}>
<label className={styles.studioField}> <label className={styles.studioField}>
<span>Preview title</span> <span>Preview title</span>
@@ -429,6 +545,31 @@ const ShareStudioPage: React.FC = () => {
</label> </label>
</div> </div>
<div className={styles.studioSectionHead}>
<div>
<span className={styles.sectionEyebrow}>Template</span>
<h3>Choose the page mood</h3>
</div>
<span>{draft.templateStyle}</span>
</div>
<div className={styles.templateGrid}>
{TEMPLATE_OPTIONS.map((option) => {
const active = draft.templateStyle === option.value;
return (
<button
className={`${styles.templateOption} ${active ? styles.templateOptionActive : ''}`}
key={option.value}
onClick={() => setDraft((current) => ({ ...current, templateStyle: option.value }))}
type="button"
>
<strong>{option.title}</strong>
<span>{option.description}</span>
</button>
);
})}
</div>
<div className={styles.studioSectionHead}> <div className={styles.studioSectionHead}>
<div> <div>
<span className={styles.sectionEyebrow}>Cover Selection</span> <span className={styles.sectionEyebrow}>Cover Selection</span>
@@ -555,6 +696,9 @@ const ShareStudioPage: React.FC = () => {
<Button icon={<CopyOutlined />} onClick={() => void copyLink(publicUrl, 'Public link copied')}> <Button icon={<CopyOutlined />} onClick={() => void copyLink(publicUrl, 'Public link copied')}>
Copy public link Copy public link
</Button> </Button>
<Button icon={<RiseOutlined />} onClick={() => void studioRequest.refresh()}>
Refresh signals
</Button>
<Button danger loading={unpublishing} onClick={() => void handleUnpublish()}> <Button danger loading={unpublishing} onClick={() => void handleUnpublish()}>
Turn off public share Turn off public share
</Button> </Button>

View File

@@ -1,13 +1,79 @@
import { createStyles } from 'antd-style'; import { createStyles } from 'antd-style';
export default createStyles(({ css, token }) => ({ export default createStyles(({ css, token }) => ({
sharePage: css` themeBase: css`
min-height: 100vh; --share-page-bg:
padding: 24px 0 48px;
background:
radial-gradient(circle at top left, rgba(17, 78, 121, 0.16), transparent 28%), 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%), radial-gradient(circle at bottom right, rgba(201, 111, 67, 0.14), transparent 24%),
linear-gradient(180deg, #f7fbff 0%, #eef3f7 100%); 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` embeddedPage: css`
background: transparent; background: transparent;
@@ -23,8 +89,8 @@ export default createStyles(({ css, token }) => ({
card: css` card: css`
border-radius: 32px; border-radius: 32px;
overflow: hidden; overflow: hidden;
box-shadow: 0 28px 80px rgba(15, 23, 42, 0.12); box-shadow: var(--share-card-shadow);
background: rgba(255, 255, 255, 0.9); background: var(--share-card-bg);
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
`, `,
heroHeader: css` heroHeader: css`
@@ -39,20 +105,20 @@ export default createStyles(({ css, token }) => ({
min-width: 280px; min-width: 280px;
`, `,
heroBadge: css` heroBadge: css`
background: rgba(15, 23, 42, 0.08); background: var(--share-badge-bg);
color: ${token.colorText}; color: var(--share-badge-color);
border-radius: 999px; border-radius: 999px;
padding: 6px 12px; padding: 6px 12px;
`, `,
title: css` title: css`
margin: 14px 0 10px !important; margin: 14px 0 10px !important;
font-family: Georgia, 'Times New Roman', 'Songti SC', serif; font-family: var(--share-title-font);
letter-spacing: -0.03em; letter-spacing: -0.03em;
`, `,
description: css` description: css`
max-width: 780px; max-width: 780px;
font-size: 16px; font-size: 16px;
color: ${token.colorTextSecondary}; color: var(--share-text-secondary);
line-height: 1.8; line-height: 1.8;
`, `,
metaRow: css` metaRow: css`
@@ -70,11 +136,11 @@ export default createStyles(({ css, token }) => ({
strong { strong {
display: block; display: block;
color: ${token.colorText}; color: var(--share-text-color);
} }
span { span {
color: ${token.colorTextSecondary}; color: var(--share-text-secondary);
font-size: 13px; font-size: 13px;
} }
`, `,
@@ -84,9 +150,9 @@ export default createStyles(({ css, token }) => ({
gap: 8px; gap: 8px;
padding: 10px 14px; padding: 10px 14px;
border-radius: 999px; border-radius: 999px;
background: rgba(255, 255, 255, 0.76); background: var(--share-chip-bg);
border: 1px solid rgba(15, 23, 42, 0.08); border: 1px solid var(--share-border-color);
color: ${token.colorTextSecondary}; color: var(--share-text-secondary);
`, `,
actionRow: css` actionRow: css`
display: flex; display: flex;
@@ -104,7 +170,7 @@ export default createStyles(({ css, token }) => ({
min-height: 420px; min-height: 420px;
border-radius: 28px; border-radius: 28px;
overflow: hidden; 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; position: relative;
`, `,
heroImage: css` heroImage: css`
@@ -135,19 +201,19 @@ export default createStyles(({ css, token }) => ({
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
`, `,
statCard: css` statCard: css`
background: rgba(255, 255, 255, 0.8); background: var(--share-stat-bg);
border: 1px solid rgba(15, 23, 42, 0.08); border: 1px solid var(--share-border-color);
border-radius: 22px; border-radius: 22px;
min-height: 118px; min-height: 118px;
padding: 18px; padding: 18px;
span { span {
color: ${token.colorTextSecondary}; color: var(--share-text-secondary);
display: block; display: block;
} }
strong { strong {
color: ${token.colorText}; color: var(--share-text-color);
display: block; display: block;
font-size: clamp(24px, 3vw, 36px); font-size: clamp(24px, 3vw, 36px);
margin-top: 14px; margin-top: 14px;
@@ -155,14 +221,14 @@ export default createStyles(({ css, token }) => ({
} }
`, `,
quoteCard: css` quoteCard: css`
background: linear-gradient(145deg, rgba(201, 111, 67, 0.12), rgba(17, 78, 121, 0.12)); background: var(--share-quote-bg);
border: 1px solid rgba(15, 23, 42, 0.08); border: 1px solid var(--share-border-color);
border-radius: 22px; border-radius: 22px;
padding: 20px; padding: 20px;
p { p {
margin: 12px 0 0; margin: 12px 0 0;
color: ${token.colorTextSecondary}; color: var(--share-text-secondary);
line-height: 1.9; line-height: 1.9;
font-size: 15px; font-size: 15px;
} }
@@ -179,8 +245,8 @@ export default createStyles(({ css, token }) => ({
h2 { h2 {
margin: 6px 0 0; margin: 6px 0 0;
color: ${token.colorText}; color: var(--share-text-color);
font-family: Georgia, 'Times New Roman', 'Songti SC', serif; font-family: var(--share-title-font);
font-size: 30px; font-size: 30px;
letter-spacing: -0.03em; letter-spacing: -0.03em;
} }
@@ -190,11 +256,11 @@ export default createStyles(({ css, token }) => ({
font-size: 11px; font-size: 11px;
letter-spacing: 0.16em; letter-spacing: 0.16em;
text-transform: uppercase; text-transform: uppercase;
color: ${token.colorTextTertiary}; color: var(--share-text-tertiary);
font-weight: 700; font-weight: 700;
`, `,
sectionMeta: css` sectionMeta: css`
color: ${token.colorTextSecondary}; color: var(--share-text-secondary);
`, `,
momentList: css` momentList: css`
display: grid; display: grid;
@@ -204,8 +270,8 @@ export default createStyles(({ css, token }) => ({
display: grid; display: grid;
gap: 16px; gap: 16px;
grid-template-columns: 220px minmax(0, 1fr); grid-template-columns: 220px minmax(0, 1fr);
background: rgba(255, 255, 255, 0.78); background: var(--share-moment-bg);
border: 1px solid rgba(15, 23, 42, 0.08); border: 1px solid var(--share-border-color);
border-radius: 24px; border-radius: 24px;
padding: 16px; padding: 16px;
`, `,
@@ -238,7 +304,7 @@ export default createStyles(({ css, token }) => ({
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: linear-gradient(145deg, rgba(17, 78, 121, 0.14), rgba(201, 111, 67, 0.16)); 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-size: 64px;
font-family: Georgia, 'Times New Roman', 'Songti SC', serif; font-family: Georgia, 'Times New Roman', 'Songti SC', serif;
`, `,
@@ -260,14 +326,14 @@ export default createStyles(({ css, token }) => ({
h3 { h3 {
margin: 12px 0 8px; margin: 12px 0 8px;
color: ${token.colorText}; color: var(--share-text-color);
font-size: 28px; font-size: 28px;
font-family: Georgia, 'Times New Roman', 'Songti SC', serif; font-family: var(--share-title-font);
letter-spacing: -0.03em; letter-spacing: -0.03em;
} }
p { p {
color: ${token.colorTextSecondary}; color: var(--share-text-secondary);
line-height: 1.8; line-height: 1.8;
margin: 0; margin: 0;
flex: 1; flex: 1;
@@ -280,14 +346,14 @@ export default createStyles(({ css, token }) => ({
flex-wrap: wrap; flex-wrap: wrap;
span { span {
color: ${token.colorTextSecondary}; color: var(--share-text-secondary);
} }
`, `,
momentFoot: css` momentFoot: css`
display: flex; display: flex;
gap: 16px; gap: 16px;
flex-wrap: wrap; flex-wrap: wrap;
border-top: 1px solid rgba(15, 23, 42, 0.08); border-top: 1px solid var(--share-border-color);
padding-top: 14px; padding-top: 14px;
margin-top: 16px; margin-top: 16px;
@@ -295,7 +361,7 @@ export default createStyles(({ css, token }) => ({
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
color: ${token.colorTextSecondary}; color: var(--share-text-secondary);
} }
`, `,
gallery: css` gallery: css`
@@ -323,7 +389,7 @@ export default createStyles(({ css, token }) => ({
margin-top: 28px; margin-top: 28px;
padding: 18px 20px; padding: 18px 20px;
border-radius: 20px; border-radius: 20px;
background: ${token.colorFillAlter}; background: var(--share-footer-bg);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
@@ -384,7 +450,7 @@ export default createStyles(({ css, token }) => ({
gap: 8px; gap: 8px;
span { span {
color: ${token.colorText}; color: var(--share-text-color);
font-weight: 600; font-weight: 600;
} }
`, `,
@@ -397,14 +463,14 @@ export default createStyles(({ css, token }) => ({
h3 { h3 {
margin: 6px 0 0; margin: 6px 0 0;
color: ${token.colorText}; color: var(--share-text-color);
font-family: Georgia, 'Times New Roman', 'Songti SC', serif; font-family: Georgia, 'Times New Roman', 'Songti SC', serif;
font-size: 24px; font-size: 24px;
letter-spacing: -0.03em; letter-spacing: -0.03em;
} }
span { span {
color: ${token.colorTextSecondary}; color: var(--share-text-secondary);
} }
`, `,
coverGrid: css` coverGrid: css`
@@ -415,7 +481,7 @@ export default createStyles(({ css, token }) => ({
coverOption: css` coverOption: css`
appearance: none; appearance: none;
background: rgba(255, 255, 255, 0.82); 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; border-radius: 20px;
cursor: pointer; cursor: pointer;
padding: 10px; padding: 10px;
@@ -475,12 +541,12 @@ export default createStyles(({ css, token }) => ({
margin-top: 10px; margin-top: 10px;
strong { strong {
color: ${token.colorText}; color: var(--share-text-color);
font-size: 14px; font-size: 14px;
} }
span { span {
color: ${token.colorTextSecondary}; color: var(--share-text-secondary);
font-size: 12px; font-size: 12px;
} }
`, `,
@@ -490,6 +556,237 @@ export default createStyles(({ css, token }) => ({
gap: 12px; gap: 12px;
margin-top: 22px; 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)': { '@media (max-width: 1100px)': {
studioLayout: { studioLayout: {
@@ -498,6 +795,9 @@ export default createStyles(({ css, token }) => ({
studioPreviewPanel: { studioPreviewPanel: {
position: 'static', position: 'static',
}, },
feedbackGrid: {
gridTemplateColumns: '1fr',
},
}, },
'@media (max-width: 960px)': { '@media (max-width: 960px)': {
@@ -518,6 +818,9 @@ export default createStyles(({ css, token }) => ({
alignItems: 'flex-start', alignItems: 'flex-start',
flexDirection: 'column', flexDirection: 'column',
}, },
studioInsightGrid: {
gridTemplateColumns: '1fr',
},
}, },
'@media (max-width: 640px)': { '@media (max-width: 640px)': {
@@ -530,5 +833,8 @@ export default createStyles(({ css, token }) => ({
coverGrid: { coverGrid: {
gridTemplateColumns: '1fr 1fr', gridTemplateColumns: '1fr 1fr',
}, },
templateGrid: {
gridTemplateColumns: '1fr',
},
}, },
})); }));

View File

@@ -4,6 +4,12 @@ import SortableTimelineGrid from '@/pages/story/components/SortableTimelineGrid'
import TimelineItemDrawer from '@/pages/story/components/TimelineItemDrawer'; import TimelineItemDrawer from '@/pages/story/components/TimelineItemDrawer';
import type { StoryItem, StoryItemTimeQueryParams, StoryType } from '@/pages/story/data'; import type { StoryItem, StoryItemTimeQueryParams, StoryType } from '@/pages/story/data';
import { queryStoryDetail, queryStoryItem, removeStoryItem } from '@/pages/story/service'; 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 { judgePermission } from '@/pages/story/utils/utils';
import { import {
EyeOutlined, EyeOutlined,
@@ -156,6 +162,10 @@ const Index = () => {
const storyId = lineId || detail?.instanceId; const storyId = lineId || detail?.instanceId;
const canEdit = judgePermission(detail?.permissionType ?? null, 'edit'); const canEdit = judgePermission(detail?.permissionType ?? null, 'edit');
const canManageCollaborators = judgePermission(detail?.permissionType ?? null, 'auth'); 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(() => { const refreshStory = useCallback(() => {
setItems([]); setItems([]);
@@ -189,6 +199,16 @@ const Index = () => {
history.push(`/share/studio/${storyId}`); history.push(`/share/studio/${storyId}`);
}, [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(() => { useEffect(() => {
setItems([]); setItems([]);
setHasMoreOld(true); setHasMoreOld(true);
@@ -334,10 +354,10 @@ const Index = () => {
if (isMobile) { if (isMobile) {
const menuItems: MenuProps['items'] = [ const menuItems: MenuProps['items'] = [
{ {
key: 'preview', key: 'share',
label: 'Preview', label: shareActionLabel,
icon: <EyeOutlined />, icon: shareState === 'public' ? <ShareAltOutlined /> : <EyeOutlined />,
onClick: openSharePreview, onClick: openShareSurface,
}, },
{ {
key: 'studio', key: 'studio',
@@ -372,7 +392,7 @@ const Index = () => {
return ( return (
<Space wrap> <Space wrap>
<Button icon={<EyeOutlined />} onClick={openSharePreview}> <Button icon={<EyeOutlined />} onClick={openSharePreview}>
Preview {shareActionLabel}
</Button> </Button>
<Button icon={<ShareAltOutlined />} onClick={openShareStudio}> <Button icon={<ShareAltOutlined />} onClick={openShareStudio}>
Share Studio Share Studio
@@ -418,14 +438,18 @@ const Index = () => {
<span>{detail.storyTime || 'No story time set'}</span> <span>{detail.storyTime || 'No story time set'}</span>
<span>{Number(detail.itemCount || 0)} moments</span> <span>{Number(detail.itemCount || 0)} moments</span>
<span>{detail.updateTime || 'No recent update'}</span> <span>{detail.updateTime || 'No recent update'}</span>
<span>{shareStatusText}</span>
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'flex-start' }}> <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'flex-start' }}>
<Button type="primary" icon={<ShareAltOutlined />} onClick={openShareStudio}> <Button type="primary" icon={<ShareAltOutlined />} onClick={openShareStudio}>
Open Studio Open Studio
</Button> </Button>
<Button icon={<EyeOutlined />} onClick={openSharePreview}> <Button
Preview icon={shareState === 'public' ? <ShareAltOutlined /> : <EyeOutlined />}
onClick={openShareSurface}
>
{shareActionLabel}
</Button> </Button>
{canEdit && ( {canEdit && (
<Button <Button

View File

@@ -12,6 +12,13 @@ import AuthorizeStoryModal from './components/AuthorizeStoryModal';
import OperationModal from './components/OperationModal'; import OperationModal from './components/OperationModal';
import type { StoryItem, StoryType } from './data.d'; import type { StoryItem, StoryType } from './data.d';
import { addStory, deleteStory, queryTimelineList, searchStoryItems, updateStory } from './service'; import { addStory, deleteStory, queryTimelineList, searchStoryItems, updateStory } from './service';
import {
getStoryPublicShareId,
getStoryShareActionLabel,
getStoryShareMenuLabel,
getStoryShareState,
getStoryShareStatusText,
} from './utils/shareState';
import useStyles from './style.style'; import useStyles from './style.style';
const { Search } = Input; const { Search } = Input;
@@ -221,14 +228,15 @@ export const BasicList: FC = () => {
const shareStory = async (story?: StoryType) => { const shareStory = async (story?: StoryType) => {
if (!story) return; if (!story) return;
if (story.shareId) { const publicShareId = getStoryPublicShareId(story);
const shareLink = `${window.location.origin}/share/${story.shareId}`; if (publicShareId) {
const shareLink = `${window.location.origin}/share/${publicShareId}`;
const copied = await copyText(shareLink); const copied = await copyText(shareLink);
if (copied) { if (copied) {
message.success('Public share link copied.'); message.success('Public share link copied.');
} else { } else {
history.push(`/share/${story.shareId}`); history.push(`/share/${publicShareId}`);
message.info('Clipboard is unavailable, opening the public page instead.'); message.info('Clipboard is unavailable, opening the public page instead.');
} }
@@ -236,7 +244,9 @@ export const BasicList: FC = () => {
} }
openSharePreview(story); openSharePreview(story);
message.info('Opening share preview.'); message.info(
getStoryShareState(story) === 'draft' ? 'Opening draft preview.' : 'Opening share preview.',
);
}; };
const editAndDelete = (key: string | number, currentItem: StoryType) => { const editAndDelete = (key: string | number, currentItem: StoryType) => {
@@ -321,7 +331,7 @@ export const BasicList: FC = () => {
} }
items.push({ key: 'studio', label: 'Share Studio' }); items.push({ key: 'studio', label: 'Share Studio' });
items.push({ key: 'share', label: item.shareId ? 'Copy share link' : 'Open preview' }); items.push({ key: 'share', label: getStoryShareMenuLabel(item) });
if (items.length === 0) return null; if (items.length === 0) return null;
@@ -403,7 +413,7 @@ export const BasicList: FC = () => {
> >
<span>{featuredStory.storyTime || 'No story time set'}</span> <span>{featuredStory.storyTime || 'No story time set'}</span>
<span>{Number(featuredStory.itemCount || 0)} moments</span> <span>{Number(featuredStory.itemCount || 0)} moments</span>
<span>{featuredStory.shareId ? 'Public share is ready' : 'Preview available'}</span> <span>{getStoryShareStatusText(featuredStory)}</span>
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 8, alignItems: 'flex-start', flexWrap: 'wrap' }}>
@@ -423,7 +433,7 @@ export const BasicList: FC = () => {
void shareStory(featuredStory); void shareStory(featuredStory);
}} }}
> >
{featuredStory.shareId ? 'Copy Link' : 'Preview'} {getStoryShareActionLabel(featuredStory)}
</Button> </Button>
<Button <Button
onClick={(event) => { onClick={(event) => {
@@ -540,7 +550,7 @@ export const BasicList: FC = () => {
Studio Studio
</Button>, </Button>,
<Button key="share" type="link" onClick={() => void shareStory(item)}> <Button key="share" type="link" onClick={() => void shareStory(item)}>
{item.shareId ? 'Copy Link' : 'Preview'} {getStoryShareActionLabel(item)}
</Button>, </Button>,
] ]
} }

View File

@@ -17,27 +17,44 @@ export type StorySharePublishPayload = {
title?: string; title?: string;
description?: string; description?: string;
quote?: string; quote?: string;
templateStyle?: ShareTemplateStyle;
featuredMomentIds?: string[]; featuredMomentIds?: string[];
}; };
export type ShareTemplateStyle = 'editorial' | 'cinematic' | 'scrapbook';
export type StorySharePublishResult = { export type StorySharePublishResult = {
storyId: string; storyId: string;
shareId: string; shareId: string;
heroMomentId: string; heroMomentId: string;
templateStyle?: ShareTemplateStyle;
publicPath: string; publicPath: string;
publishedAt?: string; publishedAt?: string;
}; };
export type StoryShareRecentComment = {
instanceId: string;
shareId: string;
visitorName: string;
content: string;
createTime: string;
};
export type StoryShareConfig = { export type StoryShareConfig = {
shareId?: string; shareId?: string;
publicShareId?: string;
storyId: string; storyId: string;
heroMomentId?: string; heroMomentId?: string;
title?: string; title?: string;
description?: string; description?: string;
quote?: string; quote?: string;
templateStyle?: ShareTemplateStyle;
featuredMomentIds: string[]; featuredMomentIds: string[];
published?: boolean; published?: boolean;
updatedAt?: string; updatedAt?: string;
viewCount?: number;
commentCount?: number;
recentComments?: StoryShareRecentComment[];
}; };
export type TimelineArchiveBucket = { export type TimelineArchiveBucket = {
@@ -47,6 +64,8 @@ export type TimelineArchiveBucket = {
subtitle: string; subtitle: string;
storyInstanceId?: string; storyInstanceId?: string;
storyShareId?: string; storyShareId?: string;
shareConfigured?: boolean;
sharePublished?: boolean;
coverInstanceId?: string; coverInstanceId?: string;
coverSrc?: string; coverSrc?: string;
sampleMomentTitle?: string; sampleMomentTitle?: string;
@@ -56,6 +75,8 @@ export type TimelineArchiveMoment = {
key: string; key: string;
storyInstanceId?: string; storyInstanceId?: string;
storyShareId?: string; storyShareId?: string;
shareConfigured?: boolean;
sharePublished?: boolean;
storyTitle: string; storyTitle: string;
storyTime?: string; storyTime?: string;
itemInstanceId?: string; itemInstanceId?: string;

View File

@@ -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;
};