import { useIsMobile } from '@/hooks/useIsMobile'; import AddTimeLineItemModal from '@/pages/story/components/AddTimeLineItemModal'; 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, MoreOutlined, PlusOutlined, ShareAltOutlined, SyncOutlined, TeamOutlined, VerticalAlignTopOutlined, } from '@ant-design/icons'; import { PageContainer } from '@ant-design/pro-components'; import { history, useParams, useRequest } from '@umijs/max'; import { Button, Dropdown, Empty, FloatButton, message, Space, Spin, Tag } from 'antd'; import type { MenuProps } from 'antd'; import { PullToRefresh } from 'antd-mobile'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import './detail.css'; import CollaboratorModal from './components/CollaboratorModal'; type QueryParams = StoryItemTimeQueryParams & { current?: number; pageSize?: number }; type LoadDirection = 'init' | 'older' | 'newer' | 'refresh'; const normalizeStoryDetailResponse = (response: any): StoryType | undefined => { if (!response) return undefined; if (response?.data) return response.data as StoryType; return response as StoryType; }; const normalizeStoryItemsResponse = (response: any): StoryItem[] => { if (Array.isArray(response?.list)) return response.list as StoryItem[]; if (Array.isArray(response?.data?.list)) return response.data.list as StoryItem[]; if (Array.isArray(response?.data)) return response.data as StoryItem[]; return []; }; const normalizeTimeParam = (value: StoryItem['storyItemTime'] | undefined): string | undefined => { if (!value) return undefined; if (Array.isArray(value)) { const [year, month, day, hour = 0, minute = 0, second = 0] = value; return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')} ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}`; } return String(value); }; const getTimeValue = (time: StoryItem['storyItemTime'] | undefined): number => { if (!time) return 0; if (Array.isArray(time)) { const [year, month, day, hour = 0, minute = 0, second = 0] = time; return new Date(year, month - 1, day, hour, minute, second).getTime(); } return new Date(String(time)).getTime(); }; const getDateKey = (time: StoryItem['storyItemTime'] | undefined): { label: string; sortValue: number } => { if (Array.isArray(time)) { const [year, month, day] = time; return { label: `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`, sortValue: new Date(year, month - 1, day).getTime(), }; } if (time) { const datePart = String(time).split(' ')[0]; return { label: datePart, sortValue: new Date(datePart).getTime(), }; } return { label: 'Undated', sortValue: 0, }; }; const groupItemsByDate = (items: StoryItem[]) => { const groups: Record = {}; items.forEach((item) => { const { label, sortValue } = getDateKey(item.storyItemTime); if (!groups[label]) { groups[label] = { dateKey: label, items: [], sortValue }; } groups[label].items.push(item); }); Object.values(groups).forEach((group) => { group.items.sort((a, b) => getTimeValue(a.storyItemTime) - getTimeValue(b.storyItemTime)); }); return groups; }; const mergeGroupOrder = (items: StoryItem[], dateKey: string, newItems: StoryItem[]) => { const groups = groupItemsByDate(items); if (!groups[dateKey]) { return items; } groups[dateKey].items = newItems; return Object.values(groups) .sort((a, b) => b.sortValue - a.sortValue) .flatMap((group) => group.items); }; const Index = () => { const isMobile = useIsMobile(); const { id: lineId } = useParams<{ id: string }>(); const containerRef = useRef(null); const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [hasMoreOld, setHasMoreOld] = useState(true); const [hasMoreNew, setHasMoreNew] = useState(true); const [openAddItemModal, setOpenAddItemModal] = useState(false); const [currentItem, setCurrentItem] = useState(); const [currentOption, setCurrentOption] = useState<'add' | 'edit' | 'addSubItem' | 'editSubItem'>(); const [openDetailDrawer, setOpenDetailDrawer] = useState(false); const [openCollaboratorModal, setOpenCollaboratorModal] = useState(false); const [detailItem, setDetailItem] = useState(); const [pagination, setPagination] = useState({ current: 1, pageSize: 30 }); const [isRefreshing, setIsRefreshing] = useState(false); const [showScrollTop, setShowScrollTop] = useState(false); const [currentTimeArr, setCurrentTimeArr] = useState< [StoryItem['storyItemTime'] | undefined, StoryItem['storyItemTime'] | undefined] >([undefined, undefined]); const [loadDirection, setLoadDirection] = useState('init'); const hasShownNoMoreOldRef = useRef(false); const hasShownNoMoreNewRef = useRef(false); const { data: response, run } = useRequest( (params?: QueryParams) => queryStoryItem({ storyInstanceId: lineId, pageSize: pagination.pageSize, ...params }), { manual: true }, ); const { data: detailResponse, run: queryDetail, loading: queryDetailLoading } = useRequest( () => queryStoryDetail(lineId ?? ''), ); const detail = normalizeStoryDetailResponse(detailResponse); 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([]); setPagination({ current: 1, pageSize: 30 }); setLoadDirection('refresh'); setLoading(true); setIsRefreshing(false); setHasMoreOld(true); setHasMoreNew(true); hasShownNoMoreOldRef.current = false; hasShownNoMoreNewRef.current = false; void run({ current: 1 }); void queryDetail(); }, [queryDetail, run]); const openSharePreview = useCallback(() => { if (!storyId) { message.warning('This story is not ready for preview yet.'); return; } history.push(`/share/preview/${storyId}`); }, [storyId]); const openShareStudio = useCallback(() => { if (!storyId) { message.warning('This story is not ready for Share Studio yet.'); return; } 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); setHasMoreNew(true); setLoadDirection('init'); setLoading(true); setIsRefreshing(false); hasShownNoMoreOldRef.current = false; hasShownNoMoreNewRef.current = false; void queryDetail(); void run(); }, [lineId, queryDetail, run]); useEffect(() => { if (!response) return; const fetched = normalizeStoryItemsResponse(response); const pageSize = pagination.pageSize; const noMore = fetched.length < pageSize; if (!fetched.length) { if (loadDirection === 'older') { setHasMoreOld(false); } else if (loadDirection === 'newer') { setHasMoreNew(false); } else { setItems([]); } setLoading(false); setIsRefreshing(false); setLoadDirection('init'); return; } if (loadDirection === 'older') { setItems((prev) => { const next = [...prev, ...fetched]; setCurrentTimeArr([next[0]?.storyItemTime, next[next.length - 1]?.storyItemTime]); return next; }); setHasMoreOld(!noMore); if (noMore && !hasShownNoMoreOldRef.current) { hasShownNoMoreOldRef.current = true; message.info('No older moments left.'); } } else if (loadDirection === 'newer') { setItems((prev) => { const next = [...fetched, ...prev]; setCurrentTimeArr([next[0]?.storyItemTime, next[next.length - 1]?.storyItemTime]); return next; }); setHasMoreNew(!noMore); if (noMore && !hasShownNoMoreNewRef.current) { hasShownNoMoreNewRef.current = true; message.info('No newer moments found.'); } } else { setItems(fetched); setCurrentTimeArr([fetched[0]?.storyItemTime, fetched[fetched.length - 1]?.storyItemTime]); setHasMoreOld(!noMore); setHasMoreNew(true); } setLoading(false); setIsRefreshing(false); setLoadDirection('init'); }, [loadDirection, pagination.pageSize, response]); const loadOlder = useCallback(() => { if (loading || !hasMoreOld) { return; } const beforeTime = items[items.length - 1]?.storyItemTime || currentTimeArr[1]; if (!beforeTime) return; const nextPage = pagination.current + 1; setPagination((prev) => ({ ...prev, current: nextPage })); setLoadDirection('older'); setLoading(true); void run({ current: nextPage, beforeTime: normalizeTimeParam(beforeTime) }); }, [currentTimeArr, hasMoreOld, items, loading, pagination.current, run]); const loadNewer = useCallback(() => { if (loading || !hasMoreNew || isRefreshing) { return; } const afterTime = items[0]?.storyItemTime || currentTimeArr[0]; if (!afterTime) return; setPagination((prev) => ({ ...prev, current: 1 })); setLoadDirection('newer'); setIsRefreshing(true); setLoading(true); void run({ current: 1, afterTime: normalizeTimeParam(afterTime) }); }, [currentTimeArr, hasMoreNew, isRefreshing, items, loading, run]); useEffect(() => { const container = containerRef.current; if (!container) return; const handleScroll = () => { const { scrollTop, scrollHeight, clientHeight } = container; setShowScrollTop(scrollTop > 300); if (scrollHeight - scrollTop - clientHeight < 200) { loadOlder(); } }; container.addEventListener('scroll', handleScroll); return () => container.removeEventListener('scroll', handleScroll); }, [loadOlder]); const handleRefresh = useCallback(() => { if (isRefreshing) return; setIsRefreshing(true); setLoadDirection('refresh'); setPagination((prev) => ({ ...prev, current: 1 })); hasShownNoMoreOldRef.current = false; hasShownNoMoreNewRef.current = false; setLoading(true); void run({ current: 1 }); void queryDetail(); }, [isRefreshing, queryDetail, run]); const scrollToTop = () => { containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); }; const groupedItems = useMemo(() => groupItemsByDate(items), [items]); const summaryDescription = detail?.description || 'Build the timeline, curate a cover, and turn the story into a polished public page from Share Studio.'; const extraContent = (() => { if (isMobile) { const menuItems: MenuProps['items'] = [ { key: 'share', label: shareActionLabel, icon: shareState === 'public' ? : , onClick: openShareSurface, }, { key: 'studio', label: 'Share Studio', icon: , onClick: openShareStudio, }, { key: 'refresh', label: 'Refresh', icon: , onClick: handleRefresh, }, ]; if (canManageCollaborators) { menuItems.unshift({ key: 'collaborators', label: 'Collaborators', icon: , onClick: () => setOpenCollaboratorModal(true), }); } return ( {canManageCollaborators && ( )} ); })(); return ( history.push('/story')} title={queryDetailLoading ? 'Loading story...' : detail?.title || 'Story timeline'} extra={extraContent} > {detail && (
Story canvas

{detail.title || 'Untitled story'}

{summaryDescription}

{detail.storyTime || 'No story time set'} {Number(detail.itemCount || 0)} moments {detail.updateTime || 'No recent update'} {shareStatusText}
{canEdit && ( )}
)}
{items.length > 0 ? ( { loadNewer(); }} disabled={!isMobile} > {hasMoreNew && !isMobile && (
)} {Object.values(groupedItems) .sort((a, b) => b.sortValue - a.sortValue) .map(({ dateKey, items: dateItems, sortValue }) => (

{dateKey}

{ setCurrentItem(item); setCurrentOption(option); setOpenAddItemModal(true); }} onOpenDetail={(item) => { setDetailItem(item); setOpenDetailDrawer(true); }} disableEdit={!canEdit} refresh={refreshStory} onOrderChange={(changedDateKey, newItems) => { setItems((prev) => mergeGroupOrder(prev, changedDateKey, newItems)); }} />
))} {loading &&
Loading moments...
} {!loading && !hasMoreOld &&
All historical moments are loaded.
} {showScrollTop && (
)}
) : (
{loading ? ( <>
Loading timeline data...
) : ( <>
Start with a first memory, then curate it in Share Studio.
)}
)}
{ setCurrentOption('add'); setCurrentItem(undefined); setOpenAddItemModal(true); }} icon={} disabled={!canEdit} type="primary" style={{ right: 24, bottom: 24 }} /> setOpenAddItemModal(false)} onOk={() => { setOpenAddItemModal(false); refreshStory(); }} storyId={lineId} /> {detailItem && ( { try { if (!detailItem.instanceId) return; const removeResponse = await removeStoryItem(detailItem.instanceId); if (removeResponse.code === 200) { message.success('Moment deleted.'); setOpenDetailDrawer(false); refreshStory(); return; } message.error('Delete failed.'); } catch (error) { message.error('Delete failed.'); } }} disableEdit={!canEdit} handOption={(item, option) => { setCurrentItem(item); setCurrentOption(option); setOpenDetailDrawer(false); setOpenAddItemModal(true); }} /> )} setOpenCollaboratorModal(false)} storyId={lineId ?? ''} />
); }; export default Index;