Files
timeline-frontend/src/pages/story/detail.tsx

626 lines
20 KiB
TypeScript
Raw Normal View History

import { useIsMobile } from '@/hooks/useIsMobile';
2025-08-05 19:02:14 +08:00
import AddTimeLineItemModal from '@/pages/story/components/AddTimeLineItemModal';
import SortableTimelineGrid from '@/pages/story/components/SortableTimelineGrid';
2026-01-19 18:09:37 +08:00
import TimelineItemDrawer from '@/pages/story/components/TimelineItemDrawer';
import type { StoryItem, StoryItemTimeQueryParams, StoryType } from '@/pages/story/data';
2026-01-19 18:09:37 +08:00
import { queryStoryDetail, queryStoryItem, removeStoryItem } from '@/pages/story/service';
import { judgePermission } from '@/pages/story/utils/utils';
import {
EyeOutlined,
MoreOutlined,
PlusOutlined,
ShareAltOutlined,
SyncOutlined,
TeamOutlined,
VerticalAlignTopOutlined,
} from '@ant-design/icons';
2025-08-05 19:02:14 +08:00
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';
2026-01-19 18:09:37 +08:00
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<string, { dateKey: string; items: StoryItem[]; sortValue: number }> = {};
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);
};
2025-08-05 19:02:14 +08:00
const Index = () => {
const isMobile = useIsMobile();
2025-08-05 19:02:14 +08:00
const { id: lineId } = useParams<{ id: string }>();
const containerRef = useRef<HTMLDivElement>(null);
2025-08-07 19:48:36 +08:00
const [items, setItems] = useState<StoryItem[]>([]);
2025-08-05 19:02:14 +08:00
const [loading, setLoading] = useState(false);
const [hasMoreOld, setHasMoreOld] = useState(true);
const [hasMoreNew, setHasMoreNew] = useState(true);
2025-08-05 19:02:14 +08:00
const [openAddItemModal, setOpenAddItemModal] = useState(false);
const [currentItem, setCurrentItem] = useState<StoryItem>();
const [currentOption, setCurrentOption] = useState<'add' | 'edit' | 'addSubItem' | 'editSubItem'>();
2026-01-19 18:09:37 +08:00
const [openDetailDrawer, setOpenDetailDrawer] = useState(false);
const [openCollaboratorModal, setOpenCollaboratorModal] = useState(false);
2026-01-19 18:09:37 +08:00
const [detailItem, setDetailItem] = useState<StoryItem>();
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<LoadDirection>('init');
const hasShownNoMoreOldRef = useRef(false);
const hasShownNoMoreNewRef = useRef(false);
2025-08-07 19:48:36 +08:00
const { data: response, run } = useRequest(
(params?: QueryParams) =>
queryStoryItem({ storyInstanceId: lineId, pageSize: pagination.pageSize, ...params }),
{ manual: true },
2025-08-05 19:02:14 +08:00
);
2025-08-07 19:48:36 +08:00
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 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]);
2025-08-07 19:48:36 +08:00
2025-08-05 19:02:14 +08:00
useEffect(() => {
setItems([]);
2025-08-08 17:42:07 +08:00
setHasMoreOld(true);
setHasMoreNew(true);
setLoadDirection('init');
setLoading(true);
setIsRefreshing(false);
hasShownNoMoreOldRef.current = false;
hasShownNoMoreNewRef.current = false;
void queryDetail();
void run();
}, [lineId, queryDetail, run]);
2025-08-05 19:02:14 +08:00
useEffect(() => {
2025-08-07 19:48:36 +08:00
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;
2025-08-07 19:48:36 +08:00
}
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);
2025-08-08 17:42:07 +08:00
}
2025-08-07 19:48:36 +08:00
setLoading(false);
2025-08-08 17:42:07 +08:00
setIsRefreshing(false);
setLoadDirection('init');
}, [loadDirection, pagination.pageSize, response]);
2025-08-05 19:02:14 +08:00
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(() => {
2026-01-19 18:09:37 +08:00
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]);
2025-08-08 17:42:07 +08:00
useEffect(() => {
2026-01-19 18:09:37 +08:00
const container = containerRef.current;
if (!container) return;
2025-08-08 17:42:07 +08:00
const handleScroll = () => {
2026-01-19 18:09:37 +08:00
const { scrollTop, scrollHeight, clientHeight } = container;
setShowScrollTop(scrollTop > 300);
2026-01-19 18:09:37 +08:00
if (scrollHeight - scrollTop - clientHeight < 200) {
loadOlder();
}
};
2026-01-19 18:09:37 +08:00
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, [loadOlder]);
2025-08-08 17:42:07 +08:00
const handleRefresh = useCallback(() => {
2025-08-08 17:42:07 +08:00
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]);
2025-08-08 17:42:07 +08:00
const scrollToTop = () => {
containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
2026-01-19 18:09:37 +08:00
};
const groupedItems = useMemo(() => groupItemsByDate(items), [items]);
2025-08-08 17:42:07 +08:00
const summaryDescription =
detail?.description ||
'Build the timeline, curate a cover, and turn the story into a polished public page from Share Studio.';
2026-01-19 18:09:37 +08:00
const extraContent = (() => {
if (isMobile) {
const menuItems: MenuProps['items'] = [
{
key: 'preview',
label: 'Preview',
icon: <EyeOutlined />,
onClick: openSharePreview,
},
{
key: 'studio',
label: 'Share Studio',
icon: <ShareAltOutlined />,
onClick: openShareStudio,
},
{
key: 'refresh',
label: 'Refresh',
icon: <SyncOutlined />,
onClick: handleRefresh,
},
];
if (canManageCollaborators) {
menuItems.unshift({
key: 'collaborators',
label: 'Collaborators',
icon: <TeamOutlined />,
onClick: () => setOpenCollaboratorModal(true),
});
2025-08-07 19:48:36 +08:00
}
return (
<Dropdown menu={{ items: menuItems }} placement="bottomRight">
<Button icon={<MoreOutlined />} type="text" />
</Dropdown>
);
}
return (
<Space wrap>
<Button icon={<EyeOutlined />} onClick={openSharePreview}>
Preview
</Button>
<Button icon={<ShareAltOutlined />} onClick={openShareStudio}>
Share Studio
</Button>
{canManageCollaborators && (
<Button icon={<TeamOutlined />} onClick={() => setOpenCollaboratorModal(true)}>
Collaborators
</Button>
)}
<Button icon={<SyncOutlined />} onClick={handleRefresh} loading={isRefreshing}>
Refresh
2025-08-08 17:42:07 +08:00
</Button>
</Space>
);
})();
return (
<PageContainer
onBack={() => history.push('/story')}
title={queryDetailLoading ? 'Loading story...' : detail?.title || 'Story timeline'}
extra={extraContent}
2025-08-05 19:02:14 +08:00
>
{detail && (
<div
style={{
marginBottom: 20,
padding: isMobile ? 20 : 24,
borderRadius: 24,
border: '1px solid rgba(24, 144, 255, 0.12)',
background: 'linear-gradient(140deg, rgba(24,144,255,0.10), rgba(19,194,194,0.08))',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
<div style={{ flex: '1 1 320px', minWidth: 0 }}>
<Tag bordered={false} color="processing">
Story canvas
</Tag>
<h2 style={{ margin: '12px 0 8px', fontSize: isMobile ? 24 : 30 }}>
{detail.title || 'Untitled story'}
</h2>
<p style={{ margin: 0, color: '#5f6b7f', lineHeight: 1.75 }}>{summaryDescription}</p>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', marginTop: 16, color: '#5f6b7f' }}>
<span>{detail.storyTime || 'No story time set'}</span>
<span>{Number(detail.itemCount || 0)} moments</span>
<span>{detail.updateTime || 'No recent update'}</span>
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'flex-start' }}>
<Button type="primary" icon={<ShareAltOutlined />} onClick={openShareStudio}>
Open Studio
</Button>
<Button icon={<EyeOutlined />} onClick={openSharePreview}>
Preview
</Button>
{canEdit && (
<Button
icon={<PlusOutlined />}
onClick={() => {
setCurrentOption('add');
setCurrentItem(undefined);
setOpenAddItemModal(true);
}}
>
Add Moment
</Button>
)}
</div>
</div>
</div>
)}
2025-08-07 19:48:36 +08:00
<div
className="timeline"
ref={containerRef}
style={{
height: 'calc(100vh - 200px)',
2026-01-19 18:09:37 +08:00
overflow: 'auto',
position: 'relative',
padding: '0 8px',
2025-08-07 19:48:36 +08:00
}}
>
{items.length > 0 ? (
<PullToRefresh
onRefresh={async () => {
loadNewer();
}}
disabled={!isMobile}
>
{hasMoreNew && !isMobile && (
<div style={{ textAlign: 'center', padding: '12px 0' }}>
<Button onClick={loadNewer} loading={isRefreshing}>
Load Newer Moments
</Button>
</div>
)}
2026-01-19 18:09:37 +08:00
{Object.values(groupedItems)
.sort((a, b) => b.sortValue - a.sortValue)
.map(({ dateKey, items: dateItems, sortValue }) => (
<div key={dateKey}>
<h2 className="timeline-section-header">{dateKey}</h2>
<SortableTimelineGrid
items={dateItems}
dateKey={dateKey}
sortValue={sortValue}
handleOption={(item, option) => {
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));
}}
/>
2026-01-19 18:09:37 +08:00
</div>
))}
{loading && <div className="load-indicator">Loading moments...</div>}
{!loading && !hasMoreOld && <div className="no-more-data">All historical moments are loaded.</div>}
2025-08-08 17:42:07 +08:00
{showScrollTop && (
<div
style={{
2026-01-19 18:09:37 +08:00
position: 'fixed',
right: 24,
bottom: 88,
2026-01-19 18:09:37 +08:00
zIndex: 10,
}}
>
<Button type="primary" shape="circle" icon={<VerticalAlignTopOutlined />} onClick={scrollToTop} />
2025-08-07 19:48:36 +08:00
</div>
)}
</PullToRefresh>
2025-08-07 19:48:36 +08:00
) : (
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
textAlign: 'center',
}}
>
2025-08-08 17:42:07 +08:00
{loading ? (
<>
<Spin size="large" />
<div style={{ marginTop: 16, fontSize: 16, color: '#666' }}>Loading timeline data...</div>
2025-08-08 17:42:07 +08:00
</>
) : (
<>
<Empty
description="No moments yet"
image={Empty.PRESENTED_IMAGE_SIMPLE}
imageStyle={{ height: 60 }}
>
<div style={{ fontSize: 16, color: '#999', marginBottom: 16 }}>
Start with a first memory, then curate it in Share Studio.
</div>
</Empty>
<Button
type="primary"
size="large"
disabled={!canEdit}
onClick={() => {
setCurrentOption('add');
setCurrentItem(undefined);
setOpenAddItemModal(true);
}}
>
Add First Moment
</Button>
</>
2025-08-08 17:42:07 +08:00
)}
2025-08-07 19:48:36 +08:00
</div>
)}
2025-08-05 19:02:14 +08:00
</div>
2025-08-07 19:48:36 +08:00
<FloatButton
onClick={() => {
setCurrentOption('add');
setCurrentItem(undefined);
2025-08-07 19:48:36 +08:00
setOpenAddItemModal(true);
}}
icon={<PlusOutlined />}
disabled={!canEdit}
type="primary"
style={{ right: 24, bottom: 24 }}
2025-08-07 19:48:36 +08:00
/>
2025-08-05 19:02:14 +08:00
<AddTimeLineItemModal
visible={openAddItemModal}
initialValues={currentItem}
option={currentOption || 'add'}
onCancel={() => setOpenAddItemModal(false)}
2025-08-05 19:02:14 +08:00
onOk={() => {
setOpenAddItemModal(false);
refreshStory();
2025-08-05 19:02:14 +08:00
}}
storyId={lineId}
/>
2026-01-19 18:09:37 +08:00
{detailItem && (
<TimelineItemDrawer
storyItem={detailItem}
open={openDetailDrawer}
setOpen={setOpenDetailDrawer}
handleDelete={async () => {
try {
if (!detailItem.instanceId) return;
const removeResponse = await removeStoryItem(detailItem.instanceId);
if (removeResponse.code === 200) {
message.success('Moment deleted.');
2026-01-19 18:09:37 +08:00
setOpenDetailDrawer(false);
refreshStory();
return;
2026-01-19 18:09:37 +08:00
}
message.error('Delete failed.');
2026-01-19 18:09:37 +08:00
} catch (error) {
message.error('Delete failed.');
2026-01-19 18:09:37 +08:00
}
}}
disableEdit={!canEdit}
handOption={(item, option) => {
2026-01-19 18:09:37 +08:00
setCurrentItem(item);
setCurrentOption(option);
setOpenDetailDrawer(false);
setOpenAddItemModal(true);
}}
/>
)}
<CollaboratorModal
visible={openCollaboratorModal}
onCancel={() => setOpenCollaboratorModal(false)}
storyId={lineId ?? ''}
/>
2025-08-05 19:02:14 +08:00
</PageContainer>
);
};
2025-12-31 14:30:03 +08:00
export default Index;