2026-02-12 16:55:05 +08:00
|
|
|
import { useIsMobile } from '@/hooks/useIsMobile';
|
2025-08-05 19:02:14 +08:00
|
|
|
import AddTimeLineItemModal from '@/pages/story/components/AddTimeLineItemModal';
|
2026-02-24 10:33:10 +08:00
|
|
|
import SortableTimelineGrid from '@/pages/story/components/SortableTimelineGrid';
|
2026-01-19 18:09:37 +08:00
|
|
|
import TimelineItemDrawer from '@/pages/story/components/TimelineItemDrawer';
|
2026-03-16 19:30:45 +08:00
|
|
|
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';
|
2026-02-12 16:55:05 +08:00
|
|
|
import { judgePermission } from '@/pages/story/utils/utils';
|
2026-03-16 19:30:45 +08:00
|
|
|
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';
|
2026-02-12 16:55:05 +08:00
|
|
|
import { history, useParams, useRequest } from '@umijs/max';
|
2026-03-16 19:30:45 +08:00
|
|
|
import { Button, Dropdown, Empty, FloatButton, message, Space, Spin, Tag } from 'antd';
|
|
|
|
|
import type { MenuProps } from 'antd';
|
2026-02-12 16:55:05 +08:00
|
|
|
import { PullToRefresh } from 'antd-mobile';
|
2026-03-16 19:30:45 +08:00
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
2026-01-19 18:09:37 +08:00
|
|
|
import './detail.css';
|
2026-02-24 10:33:10 +08:00
|
|
|
import CollaboratorModal from './components/CollaboratorModal';
|
2025-12-26 15:12:49 +08:00
|
|
|
|
2026-03-16 19:30:45 +08:00
|
|
|
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 = () => {
|
2026-02-12 16:55:05 +08:00
|
|
|
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);
|
2026-03-16 19:30:45 +08:00
|
|
|
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>();
|
2026-03-16 19:30:45 +08:00
|
|
|
const [currentOption, setCurrentOption] = useState<'add' | 'edit' | 'addSubItem' | 'editSubItem'>();
|
2026-01-19 18:09:37 +08:00
|
|
|
const [openDetailDrawer, setOpenDetailDrawer] = useState(false);
|
2026-02-24 10:33:10 +08:00
|
|
|
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 });
|
2026-03-16 19:30:45 +08:00
|
|
|
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');
|
2025-12-26 15:12:49 +08:00
|
|
|
const hasShownNoMoreOldRef = useRef(false);
|
|
|
|
|
const hasShownNoMoreNewRef = useRef(false);
|
|
|
|
|
|
2025-08-07 19:48:36 +08:00
|
|
|
const { data: response, run } = useRequest(
|
2026-03-16 19:30:45 +08:00
|
|
|
(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
|
|
|
|
2026-03-16 19:30:45 +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(() => {
|
2026-03-16 19:30:45 +08:00
|
|
|
setItems([]);
|
2025-08-08 17:42:07 +08:00
|
|
|
setHasMoreOld(true);
|
|
|
|
|
setHasMoreNew(true);
|
2025-12-26 15:12:49 +08:00
|
|
|
setLoadDirection('init');
|
|
|
|
|
setLoading(true);
|
2026-03-16 19:30:45 +08:00
|
|
|
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;
|
2026-03-16 19:30:45 +08:00
|
|
|
|
|
|
|
|
const fetched = normalizeStoryItemsResponse(response);
|
2025-12-26 15:12:49 +08:00
|
|
|
const pageSize = pagination.pageSize;
|
2026-03-16 19:30:45 +08:00
|
|
|
const noMore = fetched.length < pageSize;
|
2025-12-26 15:12:49 +08:00
|
|
|
|
|
|
|
|
if (!fetched.length) {
|
|
|
|
|
if (loadDirection === 'older') {
|
|
|
|
|
setHasMoreOld(false);
|
|
|
|
|
} else if (loadDirection === 'newer') {
|
|
|
|
|
setHasMoreNew(false);
|
2026-03-16 19:30:45 +08:00
|
|
|
} else {
|
2026-02-12 16:55:05 +08:00
|
|
|
setItems([]);
|
2025-12-26 15:12:49 +08:00
|
|
|
}
|
2026-03-16 19:30:45 +08:00
|
|
|
|
2025-12-26 15:12:49 +08:00
|
|
|
setLoading(false);
|
|
|
|
|
setIsRefreshing(false);
|
|
|
|
|
setLoadDirection('init');
|
|
|
|
|
return;
|
2025-08-07 19:48:36 +08:00
|
|
|
}
|
|
|
|
|
|
2025-12-26 15:12:49 +08:00
|
|
|
if (loadDirection === 'older') {
|
|
|
|
|
setItems((prev) => {
|
|
|
|
|
const next = [...prev, ...fetched];
|
|
|
|
|
setCurrentTimeArr([next[0]?.storyItemTime, next[next.length - 1]?.storyItemTime]);
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
setHasMoreOld(!noMore);
|
2026-03-16 19:30:45 +08:00
|
|
|
|
2025-12-26 15:12:49 +08:00
|
|
|
if (noMore && !hasShownNoMoreOldRef.current) {
|
|
|
|
|
hasShownNoMoreOldRef.current = true;
|
2026-03-16 19:30:45 +08:00
|
|
|
message.info('No older moments left.');
|
2025-12-26 15:12:49 +08:00
|
|
|
}
|
|
|
|
|
} else if (loadDirection === 'newer') {
|
|
|
|
|
setItems((prev) => {
|
|
|
|
|
const next = [...fetched, ...prev];
|
|
|
|
|
setCurrentTimeArr([next[0]?.storyItemTime, next[next.length - 1]?.storyItemTime]);
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
setHasMoreNew(!noMore);
|
2026-03-16 19:30:45 +08:00
|
|
|
|
2025-12-26 15:12:49 +08:00
|
|
|
if (noMore && !hasShownNoMoreNewRef.current) {
|
|
|
|
|
hasShownNoMoreNewRef.current = true;
|
2026-03-16 19:30:45 +08:00
|
|
|
message.info('No newer moments found.');
|
2025-12-26 15:12:49 +08:00
|
|
|
}
|
2026-03-16 19:30:45 +08:00
|
|
|
} else {
|
2025-12-26 15:12:49 +08:00
|
|
|
setItems(fetched);
|
2026-03-16 19:30:45 +08:00
|
|
|
setCurrentTimeArr([fetched[0]?.storyItemTime, fetched[fetched.length - 1]?.storyItemTime]);
|
2025-12-26 15:12:49 +08:00
|
|
|
setHasMoreOld(!noMore);
|
2026-03-16 19:30:45 +08:00
|
|
|
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);
|
2025-12-26 15:12:49 +08:00
|
|
|
setLoadDirection('init');
|
2026-03-16 19:30:45 +08:00
|
|
|
}, [loadDirection, pagination.pageSize, response]);
|
2025-08-05 19:02:14 +08:00
|
|
|
|
2025-12-26 15:12:49 +08:00
|
|
|
const loadOlder = useCallback(() => {
|
|
|
|
|
if (loading || !hasMoreOld) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-16 19:30:45 +08:00
|
|
|
|
2025-12-26 15:12:49 +08:00
|
|
|
const beforeTime = items[items.length - 1]?.storyItemTime || currentTimeArr[1];
|
|
|
|
|
if (!beforeTime) return;
|
2026-03-16 19:30:45 +08:00
|
|
|
|
2025-12-26 15:12:49 +08:00
|
|
|
const nextPage = pagination.current + 1;
|
|
|
|
|
setPagination((prev) => ({ ...prev, current: nextPage }));
|
|
|
|
|
setLoadDirection('older');
|
|
|
|
|
setLoading(true);
|
2026-03-16 19:30:45 +08:00
|
|
|
void run({ current: nextPage, beforeTime: normalizeTimeParam(beforeTime) });
|
|
|
|
|
}, [currentTimeArr, hasMoreOld, items, loading, pagination.current, run]);
|
2025-12-26 15:12:49 +08:00
|
|
|
|
|
|
|
|
const loadNewer = useCallback(() => {
|
2026-01-19 18:09:37 +08:00
|
|
|
if (loading || !hasMoreNew || isRefreshing) {
|
2025-12-26 15:12:49 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const afterTime = items[0]?.storyItemTime || currentTimeArr[0];
|
|
|
|
|
if (!afterTime) return;
|
2026-03-16 19:30:45 +08:00
|
|
|
|
2025-12-26 15:12:49 +08:00
|
|
|
setPagination((prev) => ({ ...prev, current: 1 }));
|
|
|
|
|
setLoadDirection('newer');
|
|
|
|
|
setIsRefreshing(true);
|
|
|
|
|
setLoading(true);
|
2026-03-16 19:30:45 +08:00
|
|
|
void run({ current: 1, afterTime: normalizeTimeParam(afterTime) });
|
|
|
|
|
}, [currentTimeArr, hasMoreNew, isRefreshing, items, loading, run]);
|
2025-08-08 17:42:07 +08:00
|
|
|
|
2025-12-26 15:12:49 +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
|
|
|
|
2025-12-26 15:12:49 +08:00
|
|
|
const handleScroll = () => {
|
2026-01-19 18:09:37 +08:00
|
|
|
const { scrollTop, scrollHeight, clientHeight } = container;
|
|
|
|
|
setShowScrollTop(scrollTop > 300);
|
2026-02-12 16:55:05 +08:00
|
|
|
|
2026-01-19 18:09:37 +08:00
|
|
|
if (scrollHeight - scrollTop - clientHeight < 200) {
|
|
|
|
|
loadOlder();
|
|
|
|
|
}
|
2025-12-26 15:12:49 +08:00
|
|
|
};
|
|
|
|
|
|
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
|
|
|
|
2026-03-16 19:30:45 +08:00
|
|
|
const handleRefresh = useCallback(() => {
|
2025-08-08 17:42:07 +08:00
|
|
|
if (isRefreshing) return;
|
|
|
|
|
|
|
|
|
|
setIsRefreshing(true);
|
2025-12-26 15:12:49 +08:00
|
|
|
setLoadDirection('refresh');
|
|
|
|
|
setPagination((prev) => ({ ...prev, current: 1 }));
|
|
|
|
|
hasShownNoMoreOldRef.current = false;
|
|
|
|
|
hasShownNoMoreNewRef.current = false;
|
2026-03-16 19:30:45 +08:00
|
|
|
setLoading(true);
|
|
|
|
|
void run({ current: 1 });
|
|
|
|
|
void queryDetail();
|
|
|
|
|
}, [isRefreshing, queryDetail, run]);
|
2025-08-08 17:42:07 +08:00
|
|
|
|
|
|
|
|
const scrollToTop = () => {
|
2026-03-16 19:30:45 +08:00
|
|
|
containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
|
2026-01-19 18:09:37 +08:00
|
|
|
};
|
|
|
|
|
|
2026-03-16 19:30:45 +08:00
|
|
|
const groupedItems = useMemo(() => groupItemsByDate(items), [items]);
|
2025-08-08 17:42:07 +08:00
|
|
|
|
2026-03-16 19:30:45 +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
|
|
|
|
2026-03-16 19:30:45 +08:00
|
|
|
const extraContent = (() => {
|
2026-02-24 10:33:10 +08:00
|
|
|
if (isMobile) {
|
|
|
|
|
const menuItems: MenuProps['items'] = [
|
2026-03-16 19:30:45 +08:00
|
|
|
{
|
|
|
|
|
key: 'preview',
|
|
|
|
|
label: 'Preview',
|
|
|
|
|
icon: <EyeOutlined />,
|
|
|
|
|
onClick: openSharePreview,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'studio',
|
|
|
|
|
label: 'Share Studio',
|
|
|
|
|
icon: <ShareAltOutlined />,
|
|
|
|
|
onClick: openShareStudio,
|
|
|
|
|
},
|
2026-02-24 10:33:10 +08:00
|
|
|
{
|
|
|
|
|
key: 'refresh',
|
2026-03-16 19:30:45 +08:00
|
|
|
label: 'Refresh',
|
2026-02-24 10:33:10 +08:00
|
|
|
icon: <SyncOutlined />,
|
2026-03-16 19:30:45 +08:00
|
|
|
onClick: handleRefresh,
|
2026-02-24 10:33:10 +08:00
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
2026-03-16 19:30:45 +08:00
|
|
|
if (canManageCollaborators) {
|
2026-02-24 10:33:10 +08:00
|
|
|
menuItems.unshift({
|
|
|
|
|
key: 'collaborators',
|
2026-03-16 19:30:45 +08:00
|
|
|
label: 'Collaborators',
|
2026-02-24 10:33:10 +08:00
|
|
|
icon: <TeamOutlined />,
|
|
|
|
|
onClick: () => setOpenCollaboratorModal(true),
|
|
|
|
|
});
|
2025-08-07 19:48:36 +08:00
|
|
|
}
|
2026-02-24 10:33:10 +08:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dropdown menu={{ items: menuItems }} placement="bottomRight">
|
|
|
|
|
<Button icon={<MoreOutlined />} type="text" />
|
|
|
|
|
</Dropdown>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-16 19:30:45 +08:00
|
|
|
<Space wrap>
|
|
|
|
|
<Button icon={<EyeOutlined />} onClick={openSharePreview}>
|
|
|
|
|
Preview
|
|
|
|
|
</Button>
|
|
|
|
|
<Button icon={<ShareAltOutlined />} onClick={openShareStudio}>
|
|
|
|
|
Share Studio
|
|
|
|
|
</Button>
|
|
|
|
|
{canManageCollaborators && (
|
2026-02-24 10:33:10 +08:00
|
|
|
<Button icon={<TeamOutlined />} onClick={() => setOpenCollaboratorModal(true)}>
|
2026-03-16 19:30:45 +08:00
|
|
|
Collaborators
|
2026-02-24 10:33:10 +08:00
|
|
|
</Button>
|
|
|
|
|
)}
|
2026-03-16 19:30:45 +08:00
|
|
|
<Button icon={<SyncOutlined />} onClick={handleRefresh} loading={isRefreshing}>
|
|
|
|
|
Refresh
|
2025-08-08 17:42:07 +08:00
|
|
|
</Button>
|
2026-02-24 10:33:10 +08:00
|
|
|
</Space>
|
|
|
|
|
);
|
2026-03-16 19:30:45 +08:00
|
|
|
})();
|
2026-02-24 10:33:10 +08:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<PageContainer
|
|
|
|
|
onBack={() => history.push('/story')}
|
2026-03-16 19:30:45 +08:00
|
|
|
title={queryDetailLoading ? 'Loading story...' : detail?.title || 'Story timeline'}
|
|
|
|
|
extra={extraContent}
|
2025-08-05 19:02:14 +08:00
|
|
|
>
|
2026-03-16 19:30:45 +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',
|
2025-12-26 15:12:49 +08:00
|
|
|
position: 'relative',
|
2026-03-16 19:30:45 +08:00
|
|
|
padding: '0 8px',
|
2025-08-07 19:48:36 +08:00
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{items.length > 0 ? (
|
2026-03-16 19:30:45 +08:00
|
|
|
<PullToRefresh
|
|
|
|
|
onRefresh={async () => {
|
|
|
|
|
loadNewer();
|
|
|
|
|
}}
|
|
|
|
|
disabled={!isMobile}
|
|
|
|
|
>
|
2026-02-12 16:55:05 +08:00
|
|
|
{hasMoreNew && !isMobile && (
|
|
|
|
|
<div style={{ textAlign: 'center', padding: '12px 0' }}>
|
|
|
|
|
<Button onClick={loadNewer} loading={isRefreshing}>
|
2026-03-16 19:30:45 +08:00
|
|
|
Load Newer Moments
|
2026-02-12 16:55:05 +08:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-16 19:30:45 +08:00
|
|
|
|
2026-01-19 18:09:37 +08:00
|
|
|
{Object.values(groupedItems)
|
2026-02-24 10:33:10 +08:00
|
|
|
.sort((a, b) => b.sortValue - a.sortValue)
|
|
|
|
|
.map(({ dateKey, items: dateItems, sortValue }) => (
|
2026-02-12 16:55:05 +08:00
|
|
|
<div key={dateKey}>
|
|
|
|
|
<h2 className="timeline-section-header">{dateKey}</h2>
|
2026-02-24 10:33:10 +08:00
|
|
|
<SortableTimelineGrid
|
|
|
|
|
items={dateItems}
|
|
|
|
|
dateKey={dateKey}
|
|
|
|
|
sortValue={sortValue}
|
2026-03-16 19:30:45 +08:00
|
|
|
handleOption={(item, option) => {
|
2026-02-24 10:33:10 +08:00
|
|
|
setCurrentItem(item);
|
|
|
|
|
setCurrentOption(option);
|
|
|
|
|
setOpenAddItemModal(true);
|
|
|
|
|
}}
|
2026-03-16 19:30:45 +08:00
|
|
|
onOpenDetail={(item) => {
|
2026-02-24 10:33:10 +08:00
|
|
|
setDetailItem(item);
|
|
|
|
|
setOpenDetailDrawer(true);
|
|
|
|
|
}}
|
2026-03-16 19:30:45 +08:00
|
|
|
disableEdit={!canEdit}
|
|
|
|
|
refresh={refreshStory}
|
2026-02-24 10:33:10 +08:00
|
|
|
onOrderChange={(changedDateKey, newItems) => {
|
2026-03-16 19:30:45 +08:00
|
|
|
setItems((prev) => mergeGroupOrder(prev, changedDateKey, newItems));
|
2026-02-24 10:33:10 +08:00
|
|
|
}}
|
|
|
|
|
/>
|
2026-01-19 18:09:37 +08:00
|
|
|
</div>
|
2026-02-12 16:55:05 +08:00
|
|
|
))}
|
|
|
|
|
|
2026-03-16 19:30:45 +08:00
|
|
|
{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 && (
|
2025-12-26 15:12:49 +08:00
|
|
|
<div
|
|
|
|
|
style={{
|
2026-01-19 18:09:37 +08:00
|
|
|
position: 'fixed',
|
|
|
|
|
right: 24,
|
2026-03-16 19:30:45 +08:00
|
|
|
bottom: 88,
|
2026-01-19 18:09:37 +08:00
|
|
|
zIndex: 10,
|
2025-12-26 15:12:49 +08:00
|
|
|
}}
|
|
|
|
|
>
|
2026-03-16 19:30:45 +08:00
|
|
|
<Button type="primary" shape="circle" icon={<VerticalAlignTopOutlined />} onClick={scrollToTop} />
|
2025-08-07 19:48:36 +08:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-02-12 16:55:05 +08:00
|
|
|
</PullToRefresh>
|
2025-08-07 19:48:36 +08:00
|
|
|
) : (
|
2025-12-26 15:12:49 +08:00
|
|
|
<div
|
|
|
|
|
style={{
|
2026-02-12 16:55:05 +08:00
|
|
|
display: 'flex',
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
height: '100%',
|
2025-12-26 15:12:49 +08:00
|
|
|
textAlign: 'center',
|
|
|
|
|
}}
|
|
|
|
|
>
|
2025-08-08 17:42:07 +08:00
|
|
|
{loading ? (
|
|
|
|
|
<>
|
|
|
|
|
<Spin size="large" />
|
2026-03-16 19:30:45 +08:00
|
|
|
<div style={{ marginTop: 16, fontSize: 16, color: '#666' }}>Loading timeline data...</div>
|
2025-08-08 17:42:07 +08:00
|
|
|
</>
|
2025-12-26 15:12:49 +08:00
|
|
|
) : (
|
2026-02-12 16:55:05 +08:00
|
|
|
<>
|
|
|
|
|
<Empty
|
2026-03-16 19:30:45 +08:00
|
|
|
description="No moments yet"
|
2026-02-12 16:55:05 +08:00
|
|
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
2026-03-16 19:30:45 +08:00
|
|
|
imageStyle={{ height: 60 }}
|
2026-02-12 16:55:05 +08:00
|
|
|
>
|
2026-03-16 19:30:45 +08:00
|
|
|
<div style={{ fontSize: 16, color: '#999', marginBottom: 16 }}>
|
|
|
|
|
Start with a first memory, then curate it in Share Studio.
|
2026-02-12 16:55:05 +08:00
|
|
|
</div>
|
|
|
|
|
</Empty>
|
|
|
|
|
<Button
|
|
|
|
|
type="primary"
|
|
|
|
|
size="large"
|
2026-03-16 19:30:45 +08:00
|
|
|
disabled={!canEdit}
|
2026-02-12 16:55:05 +08:00
|
|
|
onClick={() => {
|
|
|
|
|
setCurrentOption('add');
|
|
|
|
|
setCurrentItem(undefined);
|
|
|
|
|
setOpenAddItemModal(true);
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-03-16 19:30:45 +08:00
|
|
|
Add First Moment
|
2026-02-12 16:55:05 +08:00
|
|
|
</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>
|
2026-03-16 19:30:45 +08:00
|
|
|
|
2025-08-07 19:48:36 +08:00
|
|
|
<FloatButton
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setCurrentOption('add');
|
2025-12-26 15:12:49 +08:00
|
|
|
setCurrentItem(undefined);
|
2025-08-07 19:48:36 +08:00
|
|
|
setOpenAddItemModal(true);
|
|
|
|
|
}}
|
2025-12-26 15:12:49 +08:00
|
|
|
icon={<PlusOutlined />}
|
2026-03-16 19:30:45 +08:00
|
|
|
disabled={!canEdit}
|
2025-12-26 15:12:49 +08:00
|
|
|
type="primary"
|
2026-03-16 19:30:45 +08:00
|
|
|
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}
|
2025-12-26 15:12:49 +08:00
|
|
|
option={currentOption || 'add'}
|
2026-03-16 19:30:45 +08:00
|
|
|
onCancel={() => setOpenAddItemModal(false)}
|
2025-08-05 19:02:14 +08:00
|
|
|
onOk={() => {
|
|
|
|
|
setOpenAddItemModal(false);
|
2026-03-16 19:30:45 +08:00
|
|
|
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;
|
2026-03-16 19:30:45 +08:00
|
|
|
|
|
|
|
|
const removeResponse = await removeStoryItem(detailItem.instanceId);
|
|
|
|
|
if (removeResponse.code === 200) {
|
|
|
|
|
message.success('Moment deleted.');
|
2026-01-19 18:09:37 +08:00
|
|
|
setOpenDetailDrawer(false);
|
2026-03-16 19:30:45 +08:00
|
|
|
refreshStory();
|
|
|
|
|
return;
|
2026-01-19 18:09:37 +08:00
|
|
|
}
|
2026-03-16 19:30:45 +08:00
|
|
|
|
|
|
|
|
message.error('Delete failed.');
|
2026-01-19 18:09:37 +08:00
|
|
|
} catch (error) {
|
2026-03-16 19:30:45 +08:00
|
|
|
message.error('Delete failed.');
|
2026-01-19 18:09:37 +08:00
|
|
|
}
|
|
|
|
|
}}
|
2026-03-16 19:30:45 +08:00
|
|
|
disableEdit={!canEdit}
|
|
|
|
|
handOption={(item, option) => {
|
2026-01-19 18:09:37 +08:00
|
|
|
setCurrentItem(item);
|
|
|
|
|
setCurrentOption(option);
|
|
|
|
|
setOpenDetailDrawer(false);
|
|
|
|
|
setOpenAddItemModal(true);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-03-16 19:30:45 +08:00
|
|
|
|
2026-02-24 10:33:10 +08:00
|
|
|
<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;
|