feat: 添加评论、反应、离线编辑及主题定制功能
All checks were successful
test/timeline-frontend/pipeline/head This commit looks good
All checks were successful
test/timeline-frontend/pipeline/head This commit looks good
- 实现评论系统,包括评论输入、列表展示和集成指南 - 添加反应功能组件(ReactionBar、ReactionButton、ReactionPicker) - 实现离线编辑支持,包括同步状态管理和冲突解决 - 添加主题定制功能,支持多种配色方案和主题预览 - 新增多视图布局选项(时间线、分组、砌体视图) - 实现个人资料编辑器,支持头像、简介和自定义字段编辑 - 添加统计页面,展示存储使用情况和上传趋势 - 新增相册管理功能,支持相册创建、编辑和照片管理 - 实现响应式设计和加载骨架屏组件 - 扩展国际化支持,新增孟加拉语、波斯语、印尼语、日语、葡萄牙语等语言 - 添加错误边界组件和离线指示器 - 更新配置文件、路由和依赖项 - 新增完整的文档、测试用例和集成指南
This commit is contained in:
43
src/pages/account/settings/components/theme.tsx
Normal file
43
src/pages/account/settings/components/theme.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Theme Settings Component
|
||||
* Feature: personal-user-enhancements
|
||||
* Requirements: 7.1, 7.2, 7.3, 7.4
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card, Space, Typography } from 'antd';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { ThemeSelector, ColorSchemePicker, ThemePreview } from '@/components/ThemeCustomizer';
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
|
||||
const ThemeView: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<Card title={intl.formatMessage({ id: 'theme.settings.mode.title' })} bordered={false}>
|
||||
<Paragraph type="secondary">
|
||||
{intl.formatMessage({ id: 'theme.settings.mode.description' })}
|
||||
</Paragraph>
|
||||
<ThemeSelector />
|
||||
</Card>
|
||||
|
||||
<Card title={intl.formatMessage({ id: 'theme.settings.colorScheme.title' })} bordered={false}>
|
||||
<Paragraph type="secondary">
|
||||
{intl.formatMessage({ id: 'theme.settings.colorScheme.description' })}
|
||||
</Paragraph>
|
||||
<ColorSchemePicker />
|
||||
</Card>
|
||||
|
||||
<Card title={intl.formatMessage({ id: 'theme.settings.preview.title' })} bordered={false}>
|
||||
<Paragraph type="secondary">
|
||||
{intl.formatMessage({ id: 'theme.settings.preview.description' })}
|
||||
</Paragraph>
|
||||
<ThemePreview />
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeView;
|
||||
@@ -5,8 +5,9 @@ import BaseView from './components/base';
|
||||
import BindingView from './components/binding';
|
||||
import NotificationView from './components/notification';
|
||||
import SecurityView from './components/security';
|
||||
import ThemeView from './components/theme';
|
||||
import useStyles from './style.style';
|
||||
type SettingsStateKeys = 'base' | 'security' | 'binding' | 'notification';
|
||||
type SettingsStateKeys = 'base' | 'security' | 'binding' | 'notification' | 'theme';
|
||||
type SettingsState = {
|
||||
mode: 'inline' | 'horizontal';
|
||||
selectKey: SettingsStateKeys;
|
||||
@@ -18,6 +19,7 @@ const Settings: React.FC = () => {
|
||||
security: '安全设置',
|
||||
binding: '账号绑定',
|
||||
notification: '新消息通知',
|
||||
theme: '主题外观',
|
||||
};
|
||||
const [initConfig, setInitConfig] = useState<SettingsState>({
|
||||
mode: 'inline',
|
||||
@@ -66,6 +68,8 @@ const Settings: React.FC = () => {
|
||||
return <BindingView />;
|
||||
case 'notification':
|
||||
return <NotificationView />;
|
||||
case 'theme':
|
||||
return <ThemeView />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
31
src/pages/account/statistics/components/StatsOverview.less
Normal file
31
src/pages/account/statistics/components/StatsOverview.less
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Statistics Overview Styles
|
||||
* Feature: personal-user-enhancements
|
||||
*/
|
||||
|
||||
.overview {
|
||||
margin-bottom: 16px;
|
||||
|
||||
:global {
|
||||
.ant-card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-statistic-title {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ant-statistic-content {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
64
src/pages/account/statistics/components/StatsOverview.tsx
Normal file
64
src/pages/account/statistics/components/StatsOverview.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Statistics Overview Component
|
||||
* Feature: personal-user-enhancements
|
||||
* Requirements: 3.8
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card, Row, Col, Statistic } from 'antd';
|
||||
import { PictureOutlined, FileTextOutlined, DatabaseOutlined } from '@ant-design/icons';
|
||||
import styles from './StatsOverview.less';
|
||||
|
||||
interface StatsOverviewProps {
|
||||
stats: API.UserStatistics;
|
||||
}
|
||||
|
||||
const StatsOverview: React.FC<StatsOverviewProps> = ({ stats }) => {
|
||||
// Format storage bytes to human-readable format
|
||||
const formatStorage = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.overview}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="Total Photos"
|
||||
value={stats.totalPhotos}
|
||||
prefix={<PictureOutlined />}
|
||||
valueStyle={{ color: '#3f8600' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="Total Stories"
|
||||
value={stats.totalStories}
|
||||
prefix={<FileTextOutlined />}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="Storage Used"
|
||||
value={formatStorage(stats.totalStorageBytes)}
|
||||
prefix={<DatabaseOutlined />}
|
||||
valueStyle={{ color: '#cf1322' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatsOverview;
|
||||
89
src/pages/account/statistics/components/StorageBreakdown.tsx
Normal file
89
src/pages/account/statistics/components/StorageBreakdown.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Storage Breakdown Pie Chart Component
|
||||
* Feature: personal-user-enhancements
|
||||
* Requirements: 3.8
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Card } from 'antd';
|
||||
import { Pie } from '@antv/plots';
|
||||
|
||||
interface StorageBreakdownProps {
|
||||
storageBreakdown: API.StorageBreakdown;
|
||||
}
|
||||
|
||||
const StorageBreakdown: React.FC<StorageBreakdownProps> = ({ storageBreakdown }) => {
|
||||
// Format storage bytes to human-readable format
|
||||
const formatStorage = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
// Transform data for the chart
|
||||
const chartData = useMemo(() => {
|
||||
return [
|
||||
{ type: 'Photos', value: storageBreakdown.photos },
|
||||
{ type: 'Videos', value: storageBreakdown.videos },
|
||||
{ type: 'Documents', value: storageBreakdown.documents },
|
||||
{ type: 'Other', value: storageBreakdown.other },
|
||||
].filter((item) => item.value > 0); // Only show non-zero values
|
||||
}, [storageBreakdown]);
|
||||
|
||||
const config = {
|
||||
data: chartData,
|
||||
angleField: 'value',
|
||||
colorField: 'type',
|
||||
radius: 0.8,
|
||||
innerRadius: 0.6,
|
||||
label: {
|
||||
type: 'spider',
|
||||
labelHeight: 28,
|
||||
content: '{name}\n{percentage}',
|
||||
},
|
||||
legend: {
|
||||
position: 'bottom' as const,
|
||||
},
|
||||
color: ['#3f8600', '#1890ff', '#faad14', '#cf1322'],
|
||||
statistic: {
|
||||
title: {
|
||||
offsetY: -8,
|
||||
content: 'Total',
|
||||
},
|
||||
content: {
|
||||
offsetY: 4,
|
||||
style: {
|
||||
fontSize: '20px',
|
||||
},
|
||||
customHtml: () => {
|
||||
const total = chartData.reduce((sum, item) => sum + item.value, 0);
|
||||
return formatStorage(total);
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
formatter: (datum: any) => {
|
||||
return {
|
||||
name: datum.type,
|
||||
value: formatStorage(datum.value),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title="Storage Breakdown" style={{ marginTop: 16 }}>
|
||||
{chartData.length > 0 ? (
|
||||
<Pie {...config} />
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0', color: '#999' }}>
|
||||
No storage data available
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default StorageBreakdown;
|
||||
77
src/pages/account/statistics/components/UploadChart.tsx
Normal file
77
src/pages/account/statistics/components/UploadChart.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Upload Trends Chart Component
|
||||
* Feature: personal-user-enhancements
|
||||
* Requirements: 3.8
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Card } from 'antd';
|
||||
import { Column } from '@antv/plots';
|
||||
|
||||
interface UploadChartProps {
|
||||
uploadTrends: API.UploadTrend[];
|
||||
}
|
||||
|
||||
const UploadChart: React.FC<UploadChartProps> = ({ uploadTrends }) => {
|
||||
// Transform data for the chart
|
||||
const chartData = useMemo(() => {
|
||||
const data: Array<{ period: string; count: number; type: string }> = [];
|
||||
|
||||
uploadTrends.forEach((trend) => {
|
||||
data.push({
|
||||
period: trend.period,
|
||||
count: trend.photoCount,
|
||||
type: 'Photos',
|
||||
});
|
||||
data.push({
|
||||
period: trend.period,
|
||||
count: trend.storyCount,
|
||||
type: 'Stories',
|
||||
});
|
||||
});
|
||||
|
||||
return data;
|
||||
}, [uploadTrends]);
|
||||
|
||||
const config = {
|
||||
data: chartData,
|
||||
xField: 'period',
|
||||
yField: 'count',
|
||||
seriesField: 'type',
|
||||
isGroup: true,
|
||||
columnStyle: {
|
||||
radius: [4, 4, 0, 0],
|
||||
},
|
||||
label: {
|
||||
position: 'top' as const,
|
||||
style: {
|
||||
fill: '#000000',
|
||||
opacity: 0.6,
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
label: {
|
||||
autoRotate: true,
|
||||
autoHide: true,
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
position: 'top-right' as const,
|
||||
},
|
||||
color: ['#3f8600', '#1890ff'],
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title="Upload Trends" style={{ marginTop: 16 }}>
|
||||
{chartData.length > 0 ? (
|
||||
<Column {...config} />
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0', color: '#999' }}>
|
||||
No upload data available
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadChart;
|
||||
17
src/pages/account/statistics/index.less
Normal file
17
src/pages/account/statistics/index.less
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Statistics Dashboard Styles
|
||||
* Feature: personal-user-enhancements
|
||||
*/
|
||||
|
||||
.dashboard {
|
||||
.charts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
69
src/pages/account/statistics/index.tsx
Normal file
69
src/pages/account/statistics/index.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Statistics Dashboard Page
|
||||
* Feature: personal-user-enhancements
|
||||
* Requirements: 3.8
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { PageContainer } from '@ant-design/pro-components';
|
||||
import { Spin, message, Button } from 'antd';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
import { useModel } from '@umijs/max';
|
||||
import StatsOverview from './components/StatsOverview';
|
||||
import UploadChart from './components/UploadChart';
|
||||
import StorageBreakdown from './components/StorageBreakdown';
|
||||
import styles from './index.less';
|
||||
|
||||
const StatsDashboard: React.FC = () => {
|
||||
const { stats, loading, fetchStatistics, refreshStatistics } = useModel('statistics');
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatistics().catch((error) => {
|
||||
message.error('Failed to load statistics');
|
||||
console.error('Error loading statistics:', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
try {
|
||||
await refreshStatistics();
|
||||
message.success('Statistics refreshed');
|
||||
} catch (error) {
|
||||
message.error('Failed to refresh statistics');
|
||||
console.error('Error refreshing statistics:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="Personal Statistics"
|
||||
subTitle="View your content analytics and insights"
|
||||
extra={[
|
||||
<Button
|
||||
key="refresh"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleRefresh}
|
||||
loading={loading}
|
||||
>
|
||||
Refresh
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<div className={styles.dashboard}>
|
||||
{stats && (
|
||||
<>
|
||||
<StatsOverview stats={stats} />
|
||||
<div className={styles.charts}>
|
||||
<UploadChart uploadTrends={stats.uploadTrends} />
|
||||
<StorageBreakdown storageBreakdown={stats.storageBreakdown} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Spin>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatsDashboard;
|
||||
136
src/pages/albums/[id].tsx
Normal file
136
src/pages/albums/[id].tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Album Detail Page
|
||||
* Feature: personal-user-enhancements
|
||||
* Requirements: 2.1, 2.4, 2.5
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { PageContainer } from '@ant-design/pro-components';
|
||||
import { Button, Spin, Empty, message, Modal, Dropdown, Typography } from 'antd';
|
||||
import {
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
MoreOutlined,
|
||||
ArrowLeftOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useModel, history, useParams } from '@umijs/max';
|
||||
import PhotoGrid from './components/PhotoGrid';
|
||||
import styles from './index.less';
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
|
||||
const AlbumDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { currentAlbum, loading, fetchAlbumById, deleteAlbum } = useModel('albums');
|
||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchAlbumById(id).catch((error) => {
|
||||
message.error('Failed to load album');
|
||||
console.error('Error loading album:', error);
|
||||
});
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const handleEdit = () => {
|
||||
history.push(`/albums/${id}/edit`);
|
||||
};
|
||||
|
||||
const handleAddPhotos = () => {
|
||||
history.push(`/albums/${id}/add-photos`);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteAlbum(id!);
|
||||
message.success('Album deleted successfully');
|
||||
history.push('/albums');
|
||||
} catch (error) {
|
||||
message.error('Failed to delete album');
|
||||
console.error('Error deleting album:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
history.push('/albums');
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Edit Album',
|
||||
icon: <EditOutlined />,
|
||||
onClick: handleEdit,
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete Album',
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: () => setDeleteModalVisible(true),
|
||||
},
|
||||
];
|
||||
|
||||
if (loading || !currentAlbum) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Spin spinning={true} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title={currentAlbum.name}
|
||||
subTitle={
|
||||
<Paragraph style={{ marginBottom: 0, color: 'rgba(0, 0, 0, 0.45)' }}>
|
||||
{currentAlbum.photoCount} {currentAlbum.photoCount === 1 ? 'photo' : 'photos'}
|
||||
</Paragraph>
|
||||
}
|
||||
extra={[
|
||||
<Button key="back" icon={<ArrowLeftOutlined />} onClick={handleBack}>
|
||||
Back
|
||||
</Button>,
|
||||
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={handleAddPhotos}>
|
||||
Add Photos
|
||||
</Button>,
|
||||
<Dropdown key="more" menu={{ items: menuItems }} placement="bottomRight">
|
||||
<Button icon={<MoreOutlined />} />
|
||||
</Dropdown>,
|
||||
]}
|
||||
>
|
||||
{currentAlbum.description && (
|
||||
<Paragraph style={{ marginBottom: 24 }}>{currentAlbum.description}</Paragraph>
|
||||
)}
|
||||
|
||||
{currentAlbum.photos.length === 0 ? (
|
||||
<Empty
|
||||
description="No photos in this album"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddPhotos}>
|
||||
Add Photos
|
||||
</Button>
|
||||
</Empty>
|
||||
) : (
|
||||
<PhotoGrid albumId={id!} photos={currentAlbum.photos} />
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title="Delete Album"
|
||||
open={deleteModalVisible}
|
||||
onOk={handleDelete}
|
||||
onCancel={() => setDeleteModalVisible(false)}
|
||||
okText="Delete"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<p>Are you sure you want to delete this album?</p>
|
||||
<p>The photos will not be deleted, only the album.</p>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlbumDetail;
|
||||
669
src/pages/albums/__tests__/albums.property.test.ts
Normal file
669
src/pages/albums/__tests__/albums.property.test.ts
Normal file
@@ -0,0 +1,669 @@
|
||||
/**
|
||||
* Property Tests for Album Management Frontend
|
||||
* Feature: personal-user-enhancements
|
||||
*
|
||||
* These tests verify correctness properties for Album Management functionality.
|
||||
*/
|
||||
|
||||
import * as fc from 'fast-check';
|
||||
import {
|
||||
albumArb,
|
||||
createAlbumDTOArb,
|
||||
albumNameArb,
|
||||
albumDescriptionArb,
|
||||
photoIdArb,
|
||||
photoIdsArrayArb,
|
||||
albumIdArb,
|
||||
} from '@/utils/test/generators';
|
||||
import { hasNoDuplicates, haveSameElements } from '@/utils/test/helpers';
|
||||
|
||||
describe('Album Management Property Tests', () => {
|
||||
/**
|
||||
* Feature: personal-user-enhancements
|
||||
* Property 4: Album creation with required fields
|
||||
*
|
||||
* For any valid album name (non-empty string), creating an album should result
|
||||
* in a retrievable album with that name and optional description.
|
||||
*
|
||||
* Validates: Requirements 2.1
|
||||
*/
|
||||
describe('Property 4: Album creation with required fields', () => {
|
||||
it('should create album with valid name', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
albumNameArb(),
|
||||
albumDescriptionArb(),
|
||||
(name, description) => {
|
||||
// Create album DTO
|
||||
const createDTO: API.CreateAlbumDTO = {
|
||||
name,
|
||||
description,
|
||||
};
|
||||
|
||||
// Simulate album creation response
|
||||
const createdAlbum: API.Album = {
|
||||
id: 'test-album-id',
|
||||
userId: 'test-user-id',
|
||||
name: createDTO.name,
|
||||
description: createDTO.description,
|
||||
photoCount: 0,
|
||||
photos: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Verify album has the correct name and description
|
||||
return (
|
||||
createdAlbum.name === name &&
|
||||
createdAlbum.description === description &&
|
||||
createdAlbum.photoCount === 0 &&
|
||||
createdAlbum.photos.length === 0
|
||||
);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should create album with name only (no description)', () => {
|
||||
fc.assert(
|
||||
fc.property(albumNameArb(), (name) => {
|
||||
// Create album DTO without description
|
||||
const createDTO: API.CreateAlbumDTO = {
|
||||
name,
|
||||
};
|
||||
|
||||
// Simulate album creation response
|
||||
const createdAlbum: API.Album = {
|
||||
id: 'test-album-id',
|
||||
userId: 'test-user-id',
|
||||
name: createDTO.name,
|
||||
description: undefined,
|
||||
photoCount: 0,
|
||||
photos: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Verify album has the correct name and no description
|
||||
return (
|
||||
createdAlbum.name === name &&
|
||||
createdAlbum.description === undefined &&
|
||||
createdAlbum.photoCount === 0
|
||||
);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve album name exactly as provided', () => {
|
||||
fc.assert(
|
||||
fc.property(albumNameArb(), (name) => {
|
||||
// Create album
|
||||
const createDTO: API.CreateAlbumDTO = { name };
|
||||
|
||||
const createdAlbum: API.Album = {
|
||||
id: 'test-album-id',
|
||||
userId: 'test-user-id',
|
||||
name: createDTO.name,
|
||||
photoCount: 0,
|
||||
photos: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Verify name is preserved exactly (no trimming, case changes, etc.)
|
||||
return createdAlbum.name === name;
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should initialize album with zero photos', () => {
|
||||
fc.assert(
|
||||
fc.property(createAlbumDTOArb(), (createDTO) => {
|
||||
// Create album
|
||||
const createdAlbum: API.Album = {
|
||||
id: 'test-album-id',
|
||||
userId: 'test-user-id',
|
||||
name: createDTO.name,
|
||||
description: createDTO.description,
|
||||
coverPhotoId: createDTO.coverPhotoId,
|
||||
photoCount: 0,
|
||||
photos: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Verify album starts with no photos
|
||||
return createdAlbum.photoCount === 0 && createdAlbum.photos.length === 0;
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate unique album ID for each creation', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(albumNameArb(), { minLength: 2, maxLength: 10 }),
|
||||
(names) => {
|
||||
// Create multiple albums
|
||||
const createdAlbums = names.map((name, index) => ({
|
||||
id: `album-${index}`,
|
||||
userId: 'test-user-id',
|
||||
name,
|
||||
photoCount: 0,
|
||||
photos: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
|
||||
// Verify all album IDs are unique
|
||||
const ids = createdAlbums.map((a) => a.id);
|
||||
return hasNoDuplicates(ids);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: personal-user-enhancements
|
||||
* Property 7: Photo reordering persistence
|
||||
*
|
||||
* For any album and any valid reordering of its photos, after applying the
|
||||
* reorder operation, retrieving the album should return photos in the new order.
|
||||
*
|
||||
* Validates: Requirements 2.4
|
||||
*/
|
||||
describe('Property 7: Photo reordering persistence', () => {
|
||||
it('should persist photo order after reordering', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
albumArb(),
|
||||
(album) => {
|
||||
// Skip if album has no photos
|
||||
if (album.photos.length === 0) return true;
|
||||
|
||||
// Create a new order by reversing the current order
|
||||
const originalOrder = album.photos.map((p) => p.photoId);
|
||||
const newOrder = [...originalOrder].reverse();
|
||||
|
||||
// Simulate reorder operation
|
||||
const reorderedPhotos = newOrder.map((photoId, index) => {
|
||||
const photo = album.photos.find((p) => p.photoId === photoId)!;
|
||||
return {
|
||||
...photo,
|
||||
order: index,
|
||||
};
|
||||
});
|
||||
|
||||
const updatedAlbum: API.Album = {
|
||||
...album,
|
||||
photos: reorderedPhotos,
|
||||
};
|
||||
|
||||
// Verify photos are in the new order
|
||||
const resultOrder = updatedAlbum.photos.map((p) => p.photoId);
|
||||
return JSON.stringify(resultOrder) === JSON.stringify(newOrder);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should maintain all photos after reordering (no loss)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
albumArb(),
|
||||
(album) => {
|
||||
// Skip if album has no photos
|
||||
if (album.photos.length === 0) return true;
|
||||
|
||||
// Create a shuffled order
|
||||
const originalPhotoIds = album.photos.map((p) => p.photoId);
|
||||
const shuffledOrder = [...originalPhotoIds].sort(() => Math.random() - 0.5);
|
||||
|
||||
// Simulate reorder operation
|
||||
const reorderedPhotos = shuffledOrder.map((photoId, index) => {
|
||||
const photo = album.photos.find((p) => p.photoId === photoId)!;
|
||||
return {
|
||||
...photo,
|
||||
order: index,
|
||||
};
|
||||
});
|
||||
|
||||
const updatedAlbum: API.Album = {
|
||||
...album,
|
||||
photos: reorderedPhotos,
|
||||
};
|
||||
|
||||
// Verify all photos are still present (same set of IDs)
|
||||
const originalIds = album.photos.map((p) => p.photoId);
|
||||
const reorderedIds = updatedAlbum.photos.map((p) => p.photoId);
|
||||
|
||||
return haveSameElements(originalIds, reorderedIds);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should update order field to match position', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
albumArb(),
|
||||
(album) => {
|
||||
// Skip if album has no photos
|
||||
if (album.photos.length === 0) return true;
|
||||
|
||||
// Create a new order
|
||||
const newOrder = album.photos.map((p) => p.photoId).reverse();
|
||||
|
||||
// Simulate reorder operation with explicit order values
|
||||
const reorderedPhotos = newOrder.map((photoId, index) => {
|
||||
const photo = album.photos.find((p) => p.photoId === photoId)!;
|
||||
return {
|
||||
...photo,
|
||||
order: index, // Order should match array index
|
||||
};
|
||||
});
|
||||
|
||||
// Verify order field matches position in array
|
||||
return reorderedPhotos.every((photo, index) => photo.order === index);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle reordering with single photo (no-op)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
albumIdArb(),
|
||||
photoIdArb(),
|
||||
(albumId, photoId) => {
|
||||
// Create album with single photo
|
||||
const album: API.Album = {
|
||||
id: albumId,
|
||||
userId: 'test-user-id',
|
||||
name: 'Test Album',
|
||||
photoCount: 1,
|
||||
photos: [
|
||||
{
|
||||
id: 'photo-entry-1',
|
||||
photoId,
|
||||
albumId,
|
||||
order: 0,
|
||||
photoUrl: 'https://example.com/photo.jpg',
|
||||
thumbnailUrl: 'https://example.com/thumb.jpg',
|
||||
addedAt: new Date(),
|
||||
},
|
||||
],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// "Reorder" with same order
|
||||
const newOrder = [photoId];
|
||||
|
||||
// Verify album remains unchanged
|
||||
const resultOrder = album.photos.map((p) => p.photoId);
|
||||
return JSON.stringify(resultOrder) === JSON.stringify(newOrder);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle reordering empty album (no-op)', () => {
|
||||
fc.assert(
|
||||
fc.property(albumIdArb(), (albumId) => {
|
||||
// Create empty album
|
||||
const album: API.Album = {
|
||||
id: albumId,
|
||||
userId: 'test-user-id',
|
||||
name: 'Empty Album',
|
||||
photoCount: 0,
|
||||
photos: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Attempt to reorder with empty array
|
||||
const newOrder: string[] = [];
|
||||
|
||||
// Verify album remains empty
|
||||
return album.photos.length === 0 && newOrder.length === 0;
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve photo metadata during reordering', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
albumArb(),
|
||||
(album) => {
|
||||
// Skip if album has no photos
|
||||
if (album.photos.length === 0) return true;
|
||||
|
||||
// Store original photo metadata
|
||||
const originalMetadata = album.photos.map((p) => ({
|
||||
photoId: p.photoId,
|
||||
photoUrl: p.photoUrl,
|
||||
thumbnailUrl: p.thumbnailUrl,
|
||||
addedAt: p.addedAt,
|
||||
}));
|
||||
|
||||
// Create a new order
|
||||
const newOrder = album.photos.map((p) => p.photoId).reverse();
|
||||
|
||||
// Simulate reorder operation
|
||||
const reorderedPhotos = newOrder.map((photoId, index) => {
|
||||
const photo = album.photos.find((p) => p.photoId === photoId)!;
|
||||
return {
|
||||
...photo,
|
||||
order: index,
|
||||
};
|
||||
});
|
||||
|
||||
// Verify all metadata is preserved (only order changed)
|
||||
return reorderedPhotos.every((photo) => {
|
||||
const original = originalMetadata.find((m) => m.photoId === photo.photoId);
|
||||
if (!original) return false;
|
||||
|
||||
// Handle NaN dates (both should be NaN or both should be valid)
|
||||
const photoTime = photo.addedAt.getTime();
|
||||
const originalTime = original.addedAt.getTime();
|
||||
const datesMatch = (isNaN(photoTime) && isNaN(originalTime)) || photoTime === originalTime;
|
||||
|
||||
return (
|
||||
photo.photoUrl === original.photoUrl &&
|
||||
photo.thumbnailUrl === original.thumbnailUrl &&
|
||||
datesMatch
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: personal-user-enhancements
|
||||
* Property 9: Cover photo assignment
|
||||
*
|
||||
* For any album and any photo in that album, setting that photo as the cover
|
||||
* should result in the album's cover photo ID matching that photo's ID.
|
||||
*
|
||||
* Validates: Requirements 2.6
|
||||
*/
|
||||
describe('Property 9: Cover photo assignment', () => {
|
||||
it('should set cover photo ID when photo is in album', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
albumArb(),
|
||||
(album) => {
|
||||
// Skip if album has no photos
|
||||
if (album.photos.length === 0) return true;
|
||||
|
||||
// Select a random photo from the album
|
||||
const photoIndex = Math.floor(Math.random() * album.photos.length);
|
||||
const selectedPhoto = album.photos[photoIndex];
|
||||
|
||||
// Simulate setting cover photo
|
||||
const updatedAlbum: API.Album = {
|
||||
...album,
|
||||
coverPhotoId: selectedPhoto.photoId,
|
||||
coverPhotoUrl: selectedPhoto.photoUrl,
|
||||
};
|
||||
|
||||
// Verify cover photo ID matches selected photo
|
||||
return (
|
||||
updatedAlbum.coverPhotoId === selectedPhoto.photoId &&
|
||||
updatedAlbum.coverPhotoUrl === selectedPhoto.photoUrl
|
||||
);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should update cover photo when changed', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
albumArb(),
|
||||
(album) => {
|
||||
// Skip if album has less than 2 photos
|
||||
if (album.photos.length < 2) return true;
|
||||
|
||||
// Set initial cover photo
|
||||
const firstPhoto = album.photos[0];
|
||||
const albumWithCover: API.Album = {
|
||||
...album,
|
||||
coverPhotoId: firstPhoto.photoId,
|
||||
coverPhotoUrl: firstPhoto.photoUrl,
|
||||
};
|
||||
|
||||
// Change to different cover photo
|
||||
const secondPhoto = album.photos[1];
|
||||
const updatedAlbum: API.Album = {
|
||||
...albumWithCover,
|
||||
coverPhotoId: secondPhoto.photoId,
|
||||
coverPhotoUrl: secondPhoto.photoUrl,
|
||||
};
|
||||
|
||||
// Verify cover photo was updated
|
||||
return (
|
||||
updatedAlbum.coverPhotoId === secondPhoto.photoId &&
|
||||
updatedAlbum.coverPhotoId !== firstPhoto.photoId
|
||||
);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow clearing cover photo', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
albumArb(),
|
||||
(album) => {
|
||||
// Skip if album has no photos
|
||||
if (album.photos.length === 0) return true;
|
||||
|
||||
// Set initial cover photo
|
||||
const photo = album.photos[0];
|
||||
const albumWithCover: API.Album = {
|
||||
...album,
|
||||
coverPhotoId: photo.photoId,
|
||||
coverPhotoUrl: photo.photoUrl,
|
||||
};
|
||||
|
||||
// Clear cover photo
|
||||
const updatedAlbum: API.Album = {
|
||||
...albumWithCover,
|
||||
coverPhotoId: undefined,
|
||||
coverPhotoUrl: undefined,
|
||||
};
|
||||
|
||||
// Verify cover photo was cleared
|
||||
return (
|
||||
updatedAlbum.coverPhotoId === undefined &&
|
||||
updatedAlbum.coverPhotoUrl === undefined
|
||||
);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve cover photo URL with cover photo ID', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
albumArb(),
|
||||
(album) => {
|
||||
// Skip if album has no photos
|
||||
if (album.photos.length === 0) return true;
|
||||
|
||||
// Select a photo and set as cover
|
||||
const photo = album.photos[0];
|
||||
const updatedAlbum: API.Album = {
|
||||
...album,
|
||||
coverPhotoId: photo.photoId,
|
||||
coverPhotoUrl: photo.photoUrl,
|
||||
};
|
||||
|
||||
// Verify both ID and URL are set and match the photo
|
||||
return (
|
||||
updatedAlbum.coverPhotoId === photo.photoId &&
|
||||
updatedAlbum.coverPhotoUrl === photo.photoUrl
|
||||
);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should not affect other album properties when setting cover', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
albumArb(),
|
||||
(album) => {
|
||||
// Skip if album has no photos
|
||||
if (album.photos.length === 0) return true;
|
||||
|
||||
// Store original properties
|
||||
const originalName = album.name;
|
||||
const originalDescription = album.description;
|
||||
const originalPhotoCount = album.photoCount;
|
||||
const originalPhotosLength = album.photos.length;
|
||||
|
||||
// Set cover photo
|
||||
const photo = album.photos[0];
|
||||
const updatedAlbum: API.Album = {
|
||||
...album,
|
||||
coverPhotoId: photo.photoId,
|
||||
coverPhotoUrl: photo.photoUrl,
|
||||
};
|
||||
|
||||
// Verify other properties unchanged
|
||||
return (
|
||||
updatedAlbum.name === originalName &&
|
||||
updatedAlbum.description === originalDescription &&
|
||||
updatedAlbum.photoCount === originalPhotoCount &&
|
||||
updatedAlbum.photos.length === originalPhotosLength
|
||||
);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle setting same photo as cover multiple times (idempotent)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
albumArb(),
|
||||
(album) => {
|
||||
// Skip if album has no photos
|
||||
if (album.photos.length === 0) return true;
|
||||
|
||||
// Set cover photo
|
||||
const photo = album.photos[0];
|
||||
const firstUpdate: API.Album = {
|
||||
...album,
|
||||
coverPhotoId: photo.photoId,
|
||||
coverPhotoUrl: photo.photoUrl,
|
||||
};
|
||||
|
||||
// Set same cover photo again
|
||||
const secondUpdate: API.Album = {
|
||||
...firstUpdate,
|
||||
coverPhotoId: photo.photoId,
|
||||
coverPhotoUrl: photo.photoUrl,
|
||||
};
|
||||
|
||||
// Verify result is the same (idempotent operation)
|
||||
return (
|
||||
firstUpdate.coverPhotoId === secondUpdate.coverPhotoId &&
|
||||
firstUpdate.coverPhotoUrl === secondUpdate.coverPhotoUrl
|
||||
);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Additional property tests for album operations
|
||||
*/
|
||||
describe('Additional Album Properties', () => {
|
||||
it('should maintain photo count consistency with photos array', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
albumArb(),
|
||||
(album) => {
|
||||
// Photo count should always match photos array length
|
||||
return album.photoCount === album.photos.length;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should have unique photo IDs within an album', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
albumArb(),
|
||||
(album) => {
|
||||
// All photo IDs should be unique
|
||||
const photoIds = album.photos.map((p) => p.photoId);
|
||||
return hasNoDuplicates(photoIds);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve album ID across operations', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
albumArb(),
|
||||
albumNameArb(),
|
||||
(album, newName) => {
|
||||
// Simulate update operation
|
||||
const updatedAlbum: API.Album = {
|
||||
...album,
|
||||
name: newName,
|
||||
};
|
||||
|
||||
// Album ID should never change
|
||||
return updatedAlbum.id === album.id;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should maintain chronological order of addedAt timestamps', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
albumArb(),
|
||||
(album) => {
|
||||
// Skip if album has less than 2 photos
|
||||
if (album.photos.length < 2) return true;
|
||||
|
||||
// Photos should be ordered by their order field, not addedAt
|
||||
// But addedAt should be valid timestamps
|
||||
return album.photos.every((photo) => photo.addedAt instanceof Date);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
56
src/pages/albums/add-photos/[id].tsx
Normal file
56
src/pages/albums/add-photos/[id].tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Add Photos to Album Page
|
||||
* Feature: personal-user-enhancements
|
||||
* Requirements: 2.2
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { PageContainer } from '@ant-design/pro-components';
|
||||
import { Card, Spin, message } from 'antd';
|
||||
import { history, useParams, useModel } from '@umijs/max';
|
||||
import PhotoSelector from '../components/PhotoSelector';
|
||||
|
||||
const AddPhotosToAlbum: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { currentAlbum, loading, fetchAlbumById } = useModel('albums');
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchAlbumById(id).catch((error) => {
|
||||
message.error('Failed to load album');
|
||||
console.error('Error loading album:', error);
|
||||
});
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const handleSuccess = () => {
|
||||
history.push(`/albums/${id}`);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
history.push(`/albums/${id}`);
|
||||
};
|
||||
|
||||
if (loading || !currentAlbum) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Spin spinning={true} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer title={`Add Photos to ${currentAlbum.name}`}>
|
||||
<Card>
|
||||
<PhotoSelector
|
||||
albumId={id!}
|
||||
existingPhotoIds={currentAlbum.photos.map((p) => p.photoId)}
|
||||
onSuccess={handleSuccess}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddPhotosToAlbum;
|
||||
63
src/pages/albums/components/AlbumCard.tsx
Normal file
63
src/pages/albums/components/AlbumCard.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Album Card Component
|
||||
* Feature: personal-user-enhancements
|
||||
* Requirements: 2.1, 2.5
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card, Typography } from 'antd';
|
||||
import { history } from '@umijs/max';
|
||||
import { PictureOutlined } from '@ant-design/icons';
|
||||
import styles from '../index.less';
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
|
||||
interface AlbumCardProps {
|
||||
album: API.Album;
|
||||
}
|
||||
|
||||
const AlbumCard: React.FC<AlbumCardProps> = ({ album }) => {
|
||||
const handleClick = () => {
|
||||
history.push(`/albums/${album.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
hoverable
|
||||
className={styles.albumCard}
|
||||
onClick={handleClick}
|
||||
cover={
|
||||
album.coverPhotoUrl ? (
|
||||
<img
|
||||
alt={album.name}
|
||||
src={album.coverPhotoUrl}
|
||||
className={styles.albumThumbnail}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.albumThumbnail}>
|
||||
<PictureOutlined style={{ fontSize: 48, color: 'rgba(255, 255, 255, 0.8)' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className={styles.albumInfo}>
|
||||
<div className={styles.albumName}>{album.name}</div>
|
||||
{album.description && (
|
||||
<Paragraph
|
||||
className={styles.albumDescription}
|
||||
ellipsis={{ rows: 2 }}
|
||||
>
|
||||
{album.description}
|
||||
</Paragraph>
|
||||
)}
|
||||
<div className={styles.albumMeta}>
|
||||
<span className={styles.albumCount}>
|
||||
<PictureOutlined /> {album.photoCount} {album.photoCount === 1 ? 'photo' : 'photos'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlbumCard;
|
||||
94
src/pages/albums/components/AlbumEditor.tsx
Normal file
94
src/pages/albums/components/AlbumEditor.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Album Editor Component
|
||||
* Feature: personal-user-enhancements
|
||||
* Requirements: 2.1, 2.6
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Form, Input, Button, message } from 'antd';
|
||||
import { useModel } from '@umijs/max';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface AlbumEditorProps {
|
||||
album?: API.Album;
|
||||
onSuccess: (albumId: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const AlbumEditor: React.FC<AlbumEditorProps> = ({ album, onSuccess, onCancel }) => {
|
||||
const { createAlbum, updateAlbum } = useModel('albums');
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const isEditMode = !!album;
|
||||
|
||||
const handleSubmit = async (values: API.CreateAlbumDTO) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (isEditMode) {
|
||||
const updated = await updateAlbum(album.id, values);
|
||||
message.success('Album updated successfully');
|
||||
onSuccess(updated.id);
|
||||
} else {
|
||||
const created = await createAlbum(values);
|
||||
message.success('Album created successfully');
|
||||
onSuccess(created.id);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(isEditMode ? 'Failed to update album' : 'Failed to create album');
|
||||
console.error('Error saving album:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
initialValues={
|
||||
album
|
||||
? {
|
||||
name: album.name,
|
||||
description: album.description,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="Album Name"
|
||||
rules={[
|
||||
{ required: true, message: 'Please enter album name' },
|
||||
{ max: 100, message: 'Album name cannot exceed 100 characters' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="Enter album name" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="Description"
|
||||
rules={[{ max: 500, message: 'Description cannot exceed 500 characters' }]}
|
||||
>
|
||||
<TextArea
|
||||
rows={4}
|
||||
placeholder="Enter album description (optional)"
|
||||
showCount
|
||||
maxLength={500}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={loading} style={{ marginRight: 8 }}>
|
||||
{isEditMode ? 'Update Album' : 'Create Album'}
|
||||
</Button>
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlbumEditor;
|
||||
200
src/pages/albums/components/PhotoGrid.tsx
Normal file
200
src/pages/albums/components/PhotoGrid.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Photo Grid Component with Drag-and-Drop Reordering
|
||||
* Feature: personal-user-enhancements
|
||||
* Requirements: 2.4
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { message, Button, Modal } from 'antd';
|
||||
import { DeleteOutlined, StarOutlined, StarFilled } from '@ant-design/icons';
|
||||
import { useModel } from '@umijs/max';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
rectSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import styles from '../index.less';
|
||||
|
||||
interface PhotoGridProps {
|
||||
albumId: string;
|
||||
photos: API.AlbumPhoto[];
|
||||
}
|
||||
|
||||
interface SortablePhotoProps {
|
||||
photo: API.AlbumPhoto;
|
||||
isCover: boolean;
|
||||
onSetCover: (photoId: string) => void;
|
||||
onRemove: (photoId: string) => void;
|
||||
}
|
||||
|
||||
const SortablePhoto: React.FC<SortablePhotoProps> = ({ photo, isCover, onSetCover, onRemove }) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: photo.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={styles.photoItem}
|
||||
>
|
||||
<img src={photo.thumbnailUrl} alt="" />
|
||||
<div className={styles.photoActions}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={isCover ? <StarFilled /> : <StarOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSetCover(photo.photoId);
|
||||
}}
|
||||
title={isCover ? 'Cover photo' : 'Set as cover'}
|
||||
/>
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(photo.photoId);
|
||||
}}
|
||||
title="Remove from album"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PhotoGrid: React.FC<PhotoGridProps> = ({ albumId, photos }) => {
|
||||
const { currentAlbum, reorderPhotos, removePhotosFromAlbum, setAlbumCover } = useModel('albums');
|
||||
const [localPhotos, setLocalPhotos] = useState(photos);
|
||||
const [removeModalVisible, setRemoveModalVisible] = useState(false);
|
||||
const [photoToRemove, setPhotoToRemove] = useState<string | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = localPhotos.findIndex((p) => p.id === active.id);
|
||||
const newIndex = localPhotos.findIndex((p) => p.id === over.id);
|
||||
|
||||
const newPhotos = arrayMove(localPhotos, oldIndex, newIndex);
|
||||
setLocalPhotos(newPhotos);
|
||||
|
||||
// Optimistic update
|
||||
try {
|
||||
const photoOrder = newPhotos.map((p) => p.photoId);
|
||||
await reorderPhotos(albumId, photoOrder);
|
||||
message.success('Photos reordered successfully');
|
||||
} catch (error) {
|
||||
// Rollback on failure
|
||||
setLocalPhotos(localPhotos);
|
||||
message.error('Failed to reorder photos');
|
||||
console.error('Error reordering photos:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetCover = async (photoId: string) => {
|
||||
try {
|
||||
await setAlbumCover(albumId, photoId);
|
||||
message.success('Cover photo updated');
|
||||
} catch (error) {
|
||||
message.error('Failed to set cover photo');
|
||||
console.error('Error setting cover photo:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveClick = (photoId: string) => {
|
||||
setPhotoToRemove(photoId);
|
||||
setRemoveModalVisible(true);
|
||||
};
|
||||
|
||||
const handleRemoveConfirm = async () => {
|
||||
if (!photoToRemove) return;
|
||||
|
||||
try {
|
||||
await removePhotosFromAlbum(albumId, [photoToRemove]);
|
||||
setLocalPhotos(localPhotos.filter((p) => p.photoId !== photoToRemove));
|
||||
message.success('Photo removed from album');
|
||||
setRemoveModalVisible(false);
|
||||
setPhotoToRemove(null);
|
||||
} catch (error) {
|
||||
message.error('Failed to remove photo');
|
||||
console.error('Error removing photo:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={localPhotos.map((p) => p.id)} strategy={rectSortingStrategy}>
|
||||
<div className={styles.photoGrid}>
|
||||
{localPhotos.map((photo) => (
|
||||
<SortablePhoto
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
isCover={photo.photoId === currentAlbum?.coverPhotoId}
|
||||
onSetCover={handleSetCover}
|
||||
onRemove={handleRemoveClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<Modal
|
||||
title="Remove Photo"
|
||||
open={removeModalVisible}
|
||||
onOk={handleRemoveConfirm}
|
||||
onCancel={() => {
|
||||
setRemoveModalVisible(false);
|
||||
setPhotoToRemove(null);
|
||||
}}
|
||||
okText="Remove"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<p>Are you sure you want to remove this photo from the album?</p>
|
||||
<p>The photo will not be deleted, only removed from this album.</p>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhotoGrid;
|
||||
169
src/pages/albums/components/PhotoSelector.tsx
Normal file
169
src/pages/albums/components/PhotoSelector.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Photo Selector Component with Multi-Select
|
||||
* Feature: personal-user-enhancements
|
||||
* Requirements: 2.2
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button, message, Spin, Empty, Checkbox } from 'antd';
|
||||
import { useModel, request } from '@umijs/max';
|
||||
import styles from '../index.less';
|
||||
|
||||
interface PhotoSelectorProps {
|
||||
albumId: string;
|
||||
existingPhotoIds: string[];
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface Photo {
|
||||
id: string;
|
||||
url: string;
|
||||
thumbnailUrl: string;
|
||||
}
|
||||
|
||||
const PhotoSelector: React.FC<PhotoSelectorProps> = ({
|
||||
albumId,
|
||||
existingPhotoIds,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { addPhotosToAlbum } = useModel('albums');
|
||||
const [photos, setPhotos] = useState<Photo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [selectedPhotoIds, setSelectedPhotoIds] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserPhotos();
|
||||
}, []);
|
||||
|
||||
const fetchUserPhotos = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch user's photos from gallery
|
||||
const response = await request('/api/v1/gallery/photos', {
|
||||
method: 'GET',
|
||||
params: { pageSize: 100 },
|
||||
});
|
||||
|
||||
// Filter out photos already in the album
|
||||
const availablePhotos = response.items?.filter(
|
||||
(photo: Photo) => !existingPhotoIds.includes(photo.id)
|
||||
) || [];
|
||||
|
||||
setPhotos(availablePhotos);
|
||||
} catch (error) {
|
||||
message.error('Failed to load photos');
|
||||
console.error('Error loading photos:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhotoToggle = (photoId: string) => {
|
||||
setSelectedPhotoIds((prev) =>
|
||||
prev.includes(photoId)
|
||||
? prev.filter((id) => id !== photoId)
|
||||
: [...prev, photoId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedPhotoIds.length === photos.length) {
|
||||
setSelectedPhotoIds([]);
|
||||
} else {
|
||||
setSelectedPhotoIds(photos.map((p) => p.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (selectedPhotoIds.length === 0) {
|
||||
message.warning('Please select at least one photo');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await addPhotosToAlbum(albumId, selectedPhotoIds);
|
||||
message.success(`${selectedPhotoIds.length} photo(s) added to album`);
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
message.error('Failed to add photos to album');
|
||||
console.error('Error adding photos:', error);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Spin spinning={true} />;
|
||||
}
|
||||
|
||||
if (photos.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
description="No photos available to add"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
>
|
||||
<Button onClick={onCancel}>Back to Album</Button>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<Checkbox
|
||||
checked={selectedPhotoIds.length === photos.length}
|
||||
indeterminate={selectedPhotoIds.length > 0 && selectedPhotoIds.length < photos.length}
|
||||
onChange={handleSelectAll}
|
||||
>
|
||||
Select All ({selectedPhotoIds.length} selected)
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div>
|
||||
<Button onClick={onCancel} style={{ marginRight: 8 }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
disabled={selectedPhotoIds.length === 0}
|
||||
>
|
||||
Add {selectedPhotoIds.length > 0 ? `${selectedPhotoIds.length} ` : ''}Photo(s)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.photoGrid}>
|
||||
{photos.map((photo) => {
|
||||
const isSelected = selectedPhotoIds.includes(photo.id);
|
||||
return (
|
||||
<div
|
||||
key={photo.id}
|
||||
className={`${styles.photoItem} ${isSelected ? styles.photoItemSelected : ''}`}
|
||||
onClick={() => handlePhotoToggle(photo.id)}
|
||||
>
|
||||
<img src={photo.thumbnailUrl} alt="" />
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
left: 8,
|
||||
zIndex: 1,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhotoSelector;
|
||||
31
src/pages/albums/create.tsx
Normal file
31
src/pages/albums/create.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Create Album Page
|
||||
* Feature: personal-user-enhancements
|
||||
* Requirements: 2.1
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { PageContainer } from '@ant-design/pro-components';
|
||||
import { Card } from 'antd';
|
||||
import { history } from '@umijs/max';
|
||||
import AlbumEditor from './components/AlbumEditor';
|
||||
|
||||
const CreateAlbum: React.FC = () => {
|
||||
const handleSuccess = (albumId: string) => {
|
||||
history.push(`/albums/${albumId}`);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
history.push('/albums');
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer title="Create Album">
|
||||
<Card>
|
||||
<AlbumEditor onSuccess={handleSuccess} onCancel={handleCancel} />
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateAlbum;
|
||||
55
src/pages/albums/edit/[id].tsx
Normal file
55
src/pages/albums/edit/[id].tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Edit Album Page
|
||||
* Feature: personal-user-enhancements
|
||||
* Requirements: 2.1
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { PageContainer } from '@ant-design/pro-components';
|
||||
import { Card, Spin, message } from 'antd';
|
||||
import { history, useParams, useModel } from '@umijs/max';
|
||||
import AlbumEditor from '../components/AlbumEditor';
|
||||
|
||||
const EditAlbum: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { currentAlbum, loading, fetchAlbumById } = useModel('albums');
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchAlbumById(id).catch((error) => {
|
||||
message.error('Failed to load album');
|
||||
console.error('Error loading album:', error);
|
||||
});
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const handleSuccess = (albumId: string) => {
|
||||
history.push(`/albums/${albumId}`);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
history.push(`/albums/${id}`);
|
||||
};
|
||||
|
||||
if (loading || !currentAlbum) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Spin spinning={true} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer title="Edit Album">
|
||||
<Card>
|
||||
<AlbumEditor
|
||||
album={currentAlbum}
|
||||
onSuccess={handleSuccess}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditAlbum;
|
||||
106
src/pages/albums/index.less
Normal file
106
src/pages/albums/index.less
Normal file
@@ -0,0 +1,106 @@
|
||||
.albumCard {
|
||||
height: 100%;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.albumThumbnail {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.albumInfo {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.albumName {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.albumDescription {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.albumMeta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.albumCount {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.photoGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.photoItem {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.photoItemDragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.photoItemSelected {
|
||||
border: 3px solid #1890ff;
|
||||
}
|
||||
|
||||
.photoActions {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
.photoItem:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.emptyPhotos {
|
||||
text-align: center;
|
||||
padding: 48px 0;
|
||||
}
|
||||
63
src/pages/albums/index.tsx
Normal file
63
src/pages/albums/index.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Album List Page
|
||||
* Feature: personal-user-enhancements
|
||||
* Requirements: 2.1, 2.5
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { PageContainer } from '@ant-design/pro-components';
|
||||
import { Button, Row, Col, Spin, Empty, message } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { useModel, history } from '@umijs/max';
|
||||
import AlbumCard from './components/AlbumCard';
|
||||
import styles from './index.less';
|
||||
|
||||
const AlbumList: React.FC = () => {
|
||||
const { albums, loading, fetchAlbums } = useModel('albums');
|
||||
|
||||
useEffect(() => {
|
||||
fetchAlbums().catch((error) => {
|
||||
message.error('Failed to load albums');
|
||||
console.error('Error loading albums:', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCreateAlbum = () => {
|
||||
history.push('/albums/create');
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="Albums"
|
||||
subTitle="Organize your photos into albums"
|
||||
extra={[
|
||||
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={handleCreateAlbum}>
|
||||
Create Album
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
{albums.length === 0 && !loading ? (
|
||||
<Empty
|
||||
description="No albums yet"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateAlbum}>
|
||||
Create Your First Album
|
||||
</Button>
|
||||
</Empty>
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{albums.map((album) => (
|
||||
<Col xs={24} sm={12} md={8} lg={6} key={album.id}>
|
||||
<AlbumCard album={album} />
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
</Spin>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlbumList;
|
||||
109
src/pages/collections/[id].tsx
Normal file
109
src/pages/collections/[id].tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Smart Collection View Page
|
||||
* Feature: personal-user-enhancements
|
||||
* Requirements: 1.6
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { PageContainer } from '@ant-design/pro-components';
|
||||
import { Card, Row, Col, Spin, Empty, Pagination, message, Button } from 'antd';
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||
import { useModel, history, useParams } from '@umijs/max';
|
||||
import styles from './view.less';
|
||||
|
||||
const SmartCollectionView: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { currentCollection, collectionContent, loading, fetchCollectionContent } =
|
||||
useModel('collections');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(20);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchCollectionContent(id, { page, pageSize }).catch((error) => {
|
||||
message.error('Failed to load collection content');
|
||||
console.error('Error loading collection content:', error);
|
||||
});
|
||||
}
|
||||
}, [id, page, pageSize]);
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
history.push('/collections');
|
||||
};
|
||||
|
||||
const renderContentItem = (item: API.ContentItem) => {
|
||||
return (
|
||||
<Col xs={24} sm={12} md={8} lg={6} key={item.id}>
|
||||
<Card
|
||||
hoverable
|
||||
cover={
|
||||
<img
|
||||
alt={item.type}
|
||||
src={item.thumbnailUrl}
|
||||
style={{ width: '100%', height: 200, objectFit: 'cover' }}
|
||||
/>
|
||||
}
|
||||
onClick={() => {
|
||||
if (item.type === 'story') {
|
||||
history.push(`/timeline/${item.id}`);
|
||||
} else if (item.type === 'photo') {
|
||||
// Navigate to photo detail or gallery
|
||||
history.push(`/gallery?photo=${item.id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Card.Meta
|
||||
title={item.type === 'story' ? 'Story' : 'Photo'}
|
||||
description={new Date(item.createdAt).toLocaleDateString()}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title={currentCollection?.name || 'Collection'}
|
||||
subTitle={
|
||||
currentCollection
|
||||
? `${currentCollection.type} collection • ${currentCollection.contentCount} items`
|
||||
: ''
|
||||
}
|
||||
extra={[
|
||||
<Button key="back" icon={<ArrowLeftOutlined />} onClick={handleBack}>
|
||||
Back to Collections
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
{collectionContent && collectionContent.items.length > 0 ? (
|
||||
<>
|
||||
<Row gutter={[16, 16]} className={styles.contentGrid}>
|
||||
{collectionContent.items.map(renderContentItem)}
|
||||
</Row>
|
||||
{collectionContent.total > pageSize && (
|
||||
<div className={styles.pagination}>
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={collectionContent.total}
|
||||
onChange={handlePageChange}
|
||||
showSizeChanger={false}
|
||||
showTotal={(total) => `Total ${total} items`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
!loading && <Empty description="No content in this collection" />
|
||||
)}
|
||||
</Spin>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SmartCollectionView;
|
||||
497
src/pages/collections/__tests__/collections.property.test.ts
Normal file
497
src/pages/collections/__tests__/collections.property.test.ts
Normal file
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* Property Tests for Smart Collections Frontend
|
||||
* Feature: personal-user-enhancements
|
||||
*
|
||||
* These tests verify correctness properties for Smart Collections functionality.
|
||||
*/
|
||||
|
||||
import * as fc from 'fast-check';
|
||||
import {
|
||||
smartCollectionArb,
|
||||
contentItemArb,
|
||||
contentItemsArrayArb,
|
||||
collectionContentArb,
|
||||
dateArb,
|
||||
locationArb,
|
||||
userIdArb,
|
||||
} from '@/utils/test/generators';
|
||||
import {
|
||||
matchesCollectionCriteria,
|
||||
matchesDateCriteria,
|
||||
matchesLocationCriteria,
|
||||
matchesPersonCriteria,
|
||||
isSortedNewestFirst,
|
||||
} from '@/utils/test/helpers';
|
||||
|
||||
describe('Smart Collections Property Tests', () => {
|
||||
/**
|
||||
* Feature: personal-user-enhancements
|
||||
* Property 1: Metadata-based collection assignment
|
||||
*
|
||||
* For any content item (photo or story) with metadata (date, location, or person),
|
||||
* when uploaded to the system, it should appear in all smart collections whose
|
||||
* criteria match that metadata.
|
||||
*
|
||||
* Validates: Requirements 1.1, 1.2, 1.3
|
||||
*/
|
||||
describe('Property 1: Metadata-based collection assignment', () => {
|
||||
it('should match content with date metadata to date-based collections', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
contentItemArb(),
|
||||
dateArb(),
|
||||
(item, date) => {
|
||||
// Set up content item with date metadata
|
||||
const itemWithDate = {
|
||||
...item,
|
||||
metadata: {
|
||||
...item.metadata,
|
||||
date: new Date(date.year, date.month - 1, date.day),
|
||||
},
|
||||
};
|
||||
|
||||
// Create date collection with matching criteria
|
||||
const collection: API.SmartCollection = {
|
||||
id: 'test-collection',
|
||||
userId: 'test-user',
|
||||
type: 'date',
|
||||
name: `${date.year}`,
|
||||
criteria: { year: date.year },
|
||||
contentCount: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Verify the item matches the collection
|
||||
const matches = matchesDateCriteria(itemWithDate, collection.criteria);
|
||||
return matches === true;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should match content with location metadata to location-based collections', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
contentItemArb(),
|
||||
locationArb(),
|
||||
(item, location) => {
|
||||
// Set up content item with location metadata
|
||||
const itemWithLocation = {
|
||||
...item,
|
||||
metadata: {
|
||||
...item.metadata,
|
||||
location: {
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Create location collection with matching criteria (small radius)
|
||||
const collection: API.SmartCollection = {
|
||||
id: 'test-collection',
|
||||
userId: 'test-user',
|
||||
type: 'location',
|
||||
name: 'Test Location',
|
||||
criteria: {
|
||||
location: {
|
||||
name: 'Test Location',
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
radius: 100, // 100 meters - should match exact location
|
||||
},
|
||||
},
|
||||
contentCount: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Verify the item matches the collection
|
||||
const matches = matchesLocationCriteria(itemWithLocation, collection.criteria);
|
||||
return matches === true;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should match content with person metadata to person-based collections', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
contentItemArb(),
|
||||
userIdArb(),
|
||||
(item, personId) => {
|
||||
// Set up content item with person metadata
|
||||
const itemWithPerson = {
|
||||
...item,
|
||||
metadata: {
|
||||
...item.metadata,
|
||||
personId,
|
||||
},
|
||||
};
|
||||
|
||||
// Create person collection with matching criteria
|
||||
const collection: API.SmartCollection = {
|
||||
id: 'test-collection',
|
||||
userId: 'test-user',
|
||||
type: 'person',
|
||||
name: 'Test Person',
|
||||
criteria: {
|
||||
personId,
|
||||
personName: 'Test Person',
|
||||
},
|
||||
contentCount: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Verify the item matches the collection
|
||||
const matches = matchesPersonCriteria(itemWithPerson, collection.criteria);
|
||||
return matches === true;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should appear in all matching collections when content has multiple metadata types', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
contentItemArb(),
|
||||
dateArb(),
|
||||
locationArb(),
|
||||
userIdArb(),
|
||||
(item, date, location, personId) => {
|
||||
// Set up content item with all metadata types
|
||||
const itemWithAllMetadata = {
|
||||
...item,
|
||||
metadata: {
|
||||
date: new Date(date.year, date.month - 1, date.day),
|
||||
location: {
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
},
|
||||
personId,
|
||||
},
|
||||
};
|
||||
|
||||
// Create collections for each metadata type
|
||||
const dateCollection: API.SmartCollection = {
|
||||
id: 'date-collection',
|
||||
userId: 'test-user',
|
||||
type: 'date',
|
||||
name: `${date.year}`,
|
||||
criteria: { year: date.year },
|
||||
contentCount: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const locationCollection: API.SmartCollection = {
|
||||
id: 'location-collection',
|
||||
userId: 'test-user',
|
||||
type: 'location',
|
||||
name: 'Test Location',
|
||||
criteria: {
|
||||
location: {
|
||||
name: 'Test Location',
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
radius: 100,
|
||||
},
|
||||
},
|
||||
contentCount: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const personCollection: API.SmartCollection = {
|
||||
id: 'person-collection',
|
||||
userId: 'test-user',
|
||||
type: 'person',
|
||||
name: 'Test Person',
|
||||
criteria: {
|
||||
personId,
|
||||
personName: 'Test Person',
|
||||
},
|
||||
contentCount: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Verify the item matches all collections
|
||||
const matchesDate = matchesCollectionCriteria(itemWithAllMetadata, dateCollection);
|
||||
const matchesLocation = matchesCollectionCriteria(
|
||||
itemWithAllMetadata,
|
||||
locationCollection,
|
||||
);
|
||||
const matchesPerson = matchesCollectionCriteria(itemWithAllMetadata, personCollection);
|
||||
|
||||
return matchesDate && matchesLocation && matchesPerson;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should not match content without required metadata', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
contentItemArb(),
|
||||
dateArb(),
|
||||
(item, date) => {
|
||||
// Set up content item WITHOUT date metadata
|
||||
const itemWithoutDate = {
|
||||
...item,
|
||||
metadata: {
|
||||
...item.metadata,
|
||||
date: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
// Create date collection
|
||||
const collection: API.SmartCollection = {
|
||||
id: 'test-collection',
|
||||
userId: 'test-user',
|
||||
type: 'date',
|
||||
name: `${date.year}`,
|
||||
criteria: { year: date.year },
|
||||
contentCount: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Verify the item does NOT match the collection
|
||||
const matches = matchesDateCriteria(itemWithoutDate, collection.criteria);
|
||||
return matches === false;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: personal-user-enhancements
|
||||
* Property 3: Collection content matches criteria
|
||||
*
|
||||
* For any smart collection, all content items returned when viewing that collection
|
||||
* should match the collection's criteria and be sorted in chronological order
|
||||
* (newest first).
|
||||
*
|
||||
* Validates: Requirements 1.6
|
||||
*/
|
||||
describe('Property 3: Collection content matches criteria', () => {
|
||||
it('should return only content that matches collection criteria', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
smartCollectionArb(),
|
||||
contentItemsArrayArb(5, 20),
|
||||
(collection, items) => {
|
||||
// Filter items to only those that match the collection criteria
|
||||
const matchingItems = items.filter((item) =>
|
||||
matchesCollectionCriteria(item, collection),
|
||||
);
|
||||
|
||||
// Simulate collection content response
|
||||
const collectionContent: API.CollectionContent = {
|
||||
collectionId: collection.id,
|
||||
items: matchingItems,
|
||||
total: matchingItems.length,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
};
|
||||
|
||||
// Verify all returned items match the criteria
|
||||
const allMatch = collectionContent.items.every((item) =>
|
||||
matchesCollectionCriteria(item, collection),
|
||||
);
|
||||
|
||||
return allMatch;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should return content sorted in chronological order (newest first)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
collectionContentArb(),
|
||||
(collectionContent) => {
|
||||
// Sort items by date (newest first)
|
||||
const sortedItems = [...collectionContent.items].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
|
||||
// Create properly sorted collection content
|
||||
const sortedContent: API.CollectionContent = {
|
||||
...collectionContent,
|
||||
items: sortedItems,
|
||||
};
|
||||
|
||||
// Verify items are sorted newest first
|
||||
const dates = sortedContent.items.map((item) => new Date(item.createdAt));
|
||||
return isSortedNewestFirst(dates);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should maintain chronological order across pagination', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
contentItemsArrayArb(30, 50),
|
||||
fc.integer({ min: 10, max: 20 }),
|
||||
(items, pageSize) => {
|
||||
// Sort all items newest first
|
||||
const sortedItems = [...items].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
|
||||
// Simulate first page
|
||||
const page1Items = sortedItems.slice(0, pageSize);
|
||||
// Simulate second page
|
||||
const page2Items = sortedItems.slice(pageSize, pageSize * 2);
|
||||
|
||||
// Verify both pages are sorted
|
||||
const page1Dates = page1Items.map((item) => new Date(item.createdAt));
|
||||
const page2Dates = page2Items.map((item) => new Date(item.createdAt));
|
||||
|
||||
const page1Sorted = isSortedNewestFirst(page1Dates);
|
||||
const page2Sorted = page2Items.length === 0 || isSortedNewestFirst(page2Dates);
|
||||
|
||||
// Verify last item of page 1 is newer than or equal to first item of page 2
|
||||
const crossPageOrder =
|
||||
page2Items.length === 0 ||
|
||||
new Date(page1Items[page1Items.length - 1].createdAt).getTime() >=
|
||||
new Date(page2Items[0].createdAt).getTime();
|
||||
|
||||
return page1Sorted && page2Sorted && crossPageOrder;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should return correct total count matching criteria', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
smartCollectionArb(),
|
||||
contentItemsArrayArb(10, 30),
|
||||
(collection, items) => {
|
||||
// Filter items to only those that match
|
||||
const matchingItems = items.filter((item) =>
|
||||
matchesCollectionCriteria(item, collection),
|
||||
);
|
||||
|
||||
// Simulate collection content with pagination
|
||||
const pageSize = 10;
|
||||
const page1Items = matchingItems.slice(0, pageSize);
|
||||
|
||||
const collectionContent: API.CollectionContent = {
|
||||
collectionId: collection.id,
|
||||
items: page1Items,
|
||||
total: matchingItems.length, // Total should be all matching items
|
||||
page: 1,
|
||||
pageSize,
|
||||
};
|
||||
|
||||
// Verify total count matches actual matching items
|
||||
return collectionContent.total === matchingItems.length;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty collections correctly', () => {
|
||||
fc.assert(
|
||||
fc.property(smartCollectionArb(), (collection) => {
|
||||
// Create empty collection content
|
||||
const emptyContent: API.CollectionContent = {
|
||||
collectionId: collection.id,
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
};
|
||||
|
||||
// Verify empty collection properties
|
||||
return (
|
||||
emptyContent.items.length === 0 &&
|
||||
emptyContent.total === 0 &&
|
||||
emptyContent.page === 1
|
||||
);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle date collections with year, month, and day specificity', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
contentItemArb(),
|
||||
dateArb(),
|
||||
(item, date) => {
|
||||
// Set up content item with specific date
|
||||
const itemWithDate = {
|
||||
...item,
|
||||
metadata: {
|
||||
...item.metadata,
|
||||
date: new Date(date.year, date.month - 1, date.day),
|
||||
},
|
||||
};
|
||||
|
||||
// Test year-only collection
|
||||
const yearCollection: API.SmartCollection = {
|
||||
id: 'year-collection',
|
||||
userId: 'test-user',
|
||||
type: 'date',
|
||||
name: `${date.year}`,
|
||||
criteria: { year: date.year },
|
||||
contentCount: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Test year-month collection
|
||||
const monthCollection: API.SmartCollection = {
|
||||
id: 'month-collection',
|
||||
userId: 'test-user',
|
||||
type: 'date',
|
||||
name: `${date.year}-${date.month}`,
|
||||
criteria: { year: date.year, month: date.month },
|
||||
contentCount: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Test year-month-day collection
|
||||
const dayCollection: API.SmartCollection = {
|
||||
id: 'day-collection',
|
||||
userId: 'test-user',
|
||||
type: 'date',
|
||||
name: `${date.year}-${date.month}-${date.day}`,
|
||||
criteria: { year: date.year, month: date.month, day: date.day },
|
||||
contentCount: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// All three collections should match the item
|
||||
const matchesYear = matchesCollectionCriteria(itemWithDate, yearCollection);
|
||||
const matchesMonth = matchesCollectionCriteria(itemWithDate, monthCollection);
|
||||
const matchesDay = matchesCollectionCriteria(itemWithDate, dayCollection);
|
||||
|
||||
return matchesYear && matchesMonth && matchesDay;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
80
src/pages/collections/components/CollectionCard.tsx
Normal file
80
src/pages/collections/components/CollectionCard.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Collection Card Component
|
||||
* Feature: personal-user-enhancements
|
||||
* Requirements: 1.6
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card, Tag } from 'antd';
|
||||
import { history } from '@umijs/max';
|
||||
import { CalendarOutlined, EnvironmentOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import styles from '../index.less';
|
||||
|
||||
interface CollectionCardProps {
|
||||
collection: API.SmartCollection;
|
||||
}
|
||||
|
||||
const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
|
||||
const getIcon = () => {
|
||||
switch (collection.type) {
|
||||
case 'date':
|
||||
return <CalendarOutlined />;
|
||||
case 'location':
|
||||
return <EnvironmentOutlined />;
|
||||
case 'person':
|
||||
return <UserOutlined />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeColor = () => {
|
||||
switch (collection.type) {
|
||||
case 'date':
|
||||
return 'blue';
|
||||
case 'location':
|
||||
return 'green';
|
||||
case 'person':
|
||||
return 'purple';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
history.push(`/collections/${collection.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
hoverable
|
||||
className={styles.collectionCard}
|
||||
onClick={handleClick}
|
||||
cover={
|
||||
collection.thumbnailUrl ? (
|
||||
<img
|
||||
alt={collection.name}
|
||||
src={collection.thumbnailUrl}
|
||||
className={styles.collectionThumbnail}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.collectionThumbnail} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className={styles.collectionInfo}>
|
||||
<div className={styles.collectionName}>{collection.name}</div>
|
||||
<div className={styles.collectionMeta}>
|
||||
<Tag icon={getIcon()} color={getTypeColor()} className={styles.collectionType}>
|
||||
{collection.type}
|
||||
</Tag>
|
||||
<span className={styles.collectionCount}>
|
||||
{collection.contentCount} {collection.contentCount === 1 ? 'item' : 'items'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionCard;
|
||||
42
src/pages/collections/index.less
Normal file
42
src/pages/collections/index.less
Normal file
@@ -0,0 +1,42 @@
|
||||
.collectionCard {
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.collectionThumbnail {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.collectionInfo {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.collectionName {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.collectionMeta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.collectionType {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.collectionCount {
|
||||
font-weight: 500;
|
||||
}
|
||||
46
src/pages/collections/index.tsx
Normal file
46
src/pages/collections/index.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Smart Collections List Page
|
||||
* Feature: personal-user-enhancements
|
||||
* Requirements: 1.6
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { PageContainer } from '@ant-design/pro-components';
|
||||
import { Card, Row, Col, Spin, Empty, message } from 'antd';
|
||||
import { useModel } from '@umijs/max';
|
||||
import CollectionCard from './components/CollectionCard';
|
||||
import styles from './index.less';
|
||||
|
||||
const SmartCollectionList: React.FC = () => {
|
||||
const { collections, loading, fetchCollections } = useModel('collections');
|
||||
|
||||
useEffect(() => {
|
||||
fetchCollections().catch((error) => {
|
||||
message.error('Failed to load collections');
|
||||
console.error('Error loading collections:', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="Smart Collections"
|
||||
subTitle="Automatically organized content by date, location, and person"
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
{collections.length === 0 && !loading ? (
|
||||
<Empty description="No collections found" />
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{collections.map((collection) => (
|
||||
<Col xs={24} sm={12} md={8} lg={6} key={collection.id}>
|
||||
<CollectionCard collection={collection} />
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
</Spin>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SmartCollectionList;
|
||||
10
src/pages/collections/view.less
Normal file
10
src/pages/collections/view.less
Normal file
@@ -0,0 +1,10 @@
|
||||
.contentGrid {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 32px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
@@ -1,17 +1,9 @@
|
||||
// src/pages/gallery\components\GridView.tsx
|
||||
import { ImageItem } from '@/pages/gallery/typings';
|
||||
import { formatDuration } from '@/utils/timelineUtils';
|
||||
import { getAuthization } from '@/utils/userUtils';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
EyeOutlined,
|
||||
MoreOutlined,
|
||||
PlayCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button, Checkbox, Dropdown, Menu, Spin } from 'antd';
|
||||
import { Spin } from 'antd';
|
||||
import React, { FC, useCallback, useEffect, useState } from 'react';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import PhotoCard from './PhotoCard';
|
||||
import '../index.css';
|
||||
|
||||
interface GridViewProps {
|
||||
@@ -74,54 +66,6 @@ const GridView: FC<GridViewProps> = ({
|
||||
|
||||
const imageSize = getImageSize();
|
||||
|
||||
const getImageMenu = useCallback(
|
||||
(item: ImageItem) => (
|
||||
<Menu>
|
||||
<Menu.Item
|
||||
key="preview"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => {
|
||||
const index = imageList.findIndex((img) => img.instanceId === item.instanceId);
|
||||
onPreview(index);
|
||||
}}
|
||||
>
|
||||
预览
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key="download"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => onDownload(item.instanceId, item.imageName)}
|
||||
>
|
||||
下载
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
key="delete"
|
||||
icon={<DeleteOutlined />}
|
||||
danger
|
||||
onClick={() => onDelete(item.instanceId, item.imageName)}
|
||||
>
|
||||
删除
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
),
|
||||
[imageList, onPreview, onDownload, onDelete],
|
||||
);
|
||||
|
||||
// 根据视图模式确定图像 URL
|
||||
const getImageUrl = (item: ImageItem, isHighRes?: boolean) => {
|
||||
// 如果是视频,使用封面图
|
||||
if (item.thumbnailInstanceId) {
|
||||
return `/file/image-low-res/${item.thumbnailInstanceId}?Authorization=${getAuthization()}`;
|
||||
}
|
||||
// 小图模式使用低分辨率图像,除非明确要求高清
|
||||
if (viewMode === 'small' && !isHighRes) {
|
||||
return `/file/image-low-res/${item.instanceId}?Authorization=${getAuthization()}`;
|
||||
}
|
||||
// 其他模式使用原图
|
||||
return `/file/image/${item.instanceId}?Authorization=${getAuthization()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={viewMode === 'small' ? 'small-grid-view' : 'large-grid-view'}
|
||||
@@ -134,63 +78,20 @@ const GridView: FC<GridViewProps> = ({
|
||||
} : {}}
|
||||
>
|
||||
{imageList.map((item: ImageItem, index: number) => (
|
||||
<div
|
||||
key={item.instanceId}
|
||||
className="image-card"
|
||||
style={isMobile ? { width: '100%', margin: 0 } : {}}
|
||||
>
|
||||
{batchMode && (
|
||||
<Checkbox
|
||||
className="image-checkbox"
|
||||
checked={selectedRowKeys.includes(item.instanceId)}
|
||||
onChange={(e) => onSelect(item.instanceId, e.target.checked)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="image-wrapper"
|
||||
style={{
|
||||
width: isMobile ? '100%' : imageSize.width,
|
||||
height: imageSize.height,
|
||||
backgroundImage: `url(${getImageUrl(item, false)})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
}}
|
||||
onClick={() => !batchMode && onPreview(index)}
|
||||
>
|
||||
{(item.duration || item.thumbnailInstanceId) && (
|
||||
<PlayCircleOutlined style={{ fontSize: isMobile ? '24px' : '32px', color: 'rgba(255,255,255,0.8)' }} />
|
||||
)}
|
||||
{item.duration && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
right: 4,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
color: '#fff',
|
||||
padding: '2px 4px',
|
||||
borderRadius: 2,
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
{formatDuration(item.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="image-info">
|
||||
<div className="image-title" title={item.imageName}>
|
||||
{item.imageName}
|
||||
</div>
|
||||
<Dropdown overlay={getImageMenu(item)} trigger={['click']}>
|
||||
<Button type="text" icon={<MoreOutlined />} size={isMobile ? "small" : "middle"} />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<PhotoCard
|
||||
key={item.instanceId}
|
||||
item={item}
|
||||
index={index}
|
||||
viewMode={viewMode}
|
||||
batchMode={batchMode}
|
||||
isSelected={selectedRowKeys.includes(item.instanceId)}
|
||||
imageSize={imageSize}
|
||||
isMobile={isMobile}
|
||||
onPreview={onPreview}
|
||||
onSelect={onSelect}
|
||||
onDownload={onDownload}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
{loadingMore && (
|
||||
<div style={{ gridColumn: '1 / -1', textAlign: 'center', padding: '20px' }}>
|
||||
|
||||
183
src/pages/gallery/components/PhotoCard.tsx
Normal file
183
src/pages/gallery/components/PhotoCard.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* PhotoCard Component
|
||||
* Feature: personal-user-enhancements
|
||||
*
|
||||
* Individual photo card with reactions support
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button, Checkbox, Dropdown, Menu } from 'antd';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
EyeOutlined,
|
||||
MoreOutlined,
|
||||
PlayCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { ReactionBar } from '@/components/Reactions';
|
||||
import useReactions from '@/hooks/useReactions';
|
||||
import { ImageItem } from '@/pages/gallery/typings';
|
||||
import { formatDuration } from '@/utils/timelineUtils';
|
||||
import { getAuthization } from '@/utils/userUtils';
|
||||
|
||||
interface PhotoCardProps {
|
||||
item: ImageItem;
|
||||
index: number;
|
||||
viewMode: 'small' | 'large';
|
||||
batchMode: boolean;
|
||||
isSelected: boolean;
|
||||
imageSize: { width: number | string; height: number };
|
||||
isMobile: boolean;
|
||||
onPreview: (index: number) => void;
|
||||
onSelect: (instanceId: string, checked: boolean) => void;
|
||||
onDownload: (instanceId: string, imageName: string) => void;
|
||||
onDelete: (instanceId: string, imageName: string) => void;
|
||||
}
|
||||
|
||||
const PhotoCard: React.FC<PhotoCardProps> = ({
|
||||
item,
|
||||
index,
|
||||
viewMode,
|
||||
batchMode,
|
||||
isSelected,
|
||||
imageSize,
|
||||
isMobile,
|
||||
onPreview,
|
||||
onSelect,
|
||||
onDownload,
|
||||
onDelete,
|
||||
}) => {
|
||||
// Initialize reactions for this photo
|
||||
const {
|
||||
reactions,
|
||||
addReaction,
|
||||
updateReaction,
|
||||
removeReaction,
|
||||
actionLoading: reactionLoading,
|
||||
} = useReactions('photo', item.instanceId, {
|
||||
autoFetch: true,
|
||||
autoSubscribe: true,
|
||||
});
|
||||
|
||||
// 根据视图模式确定图像 URL
|
||||
const getImageUrl = (isHighRes?: boolean) => {
|
||||
// 如果是视频,使用封面图
|
||||
if (item.thumbnailInstanceId) {
|
||||
return `/file/image-low-res/${item.thumbnailInstanceId}?Authorization=${getAuthization()}`;
|
||||
}
|
||||
// 小图模式使用低分辨率图像,除非明确要求高清
|
||||
if (viewMode === 'small' && !isHighRes) {
|
||||
return `/file/image-low-res/${item.instanceId}?Authorization=${getAuthization()}`;
|
||||
}
|
||||
// 其他模式使用原图
|
||||
return `/file/image/${item.instanceId}?Authorization=${getAuthization()}`;
|
||||
};
|
||||
|
||||
const getImageMenu = () => (
|
||||
<Menu>
|
||||
<Menu.Item
|
||||
key="preview"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => onPreview(index)}
|
||||
>
|
||||
预览
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key="download"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => onDownload(item.instanceId, item.imageName)}
|
||||
>
|
||||
下载
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
key="delete"
|
||||
icon={<DeleteOutlined />}
|
||||
danger
|
||||
onClick={() => onDelete(item.instanceId, item.imageName)}
|
||||
>
|
||||
删除
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.instanceId}
|
||||
className="image-card"
|
||||
style={isMobile ? { width: '100%', margin: 0 } : {}}
|
||||
>
|
||||
{batchMode && (
|
||||
<Checkbox
|
||||
className="image-checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => onSelect(item.instanceId, e.target.checked)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="image-wrapper"
|
||||
style={{
|
||||
width: isMobile ? '100%' : imageSize.width,
|
||||
height: imageSize.height,
|
||||
backgroundImage: `url(${getImageUrl(false)})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
}}
|
||||
onClick={() => !batchMode && onPreview(index)}
|
||||
>
|
||||
{(item.duration || item.thumbnailInstanceId) && (
|
||||
<PlayCircleOutlined style={{ fontSize: isMobile ? '24px' : '32px', color: 'rgba(255,255,255,0.8)' }} />
|
||||
)}
|
||||
{item.duration && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
right: 4,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
color: '#fff',
|
||||
padding: '2px 4px',
|
||||
borderRadius: 2,
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
{formatDuration(item.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="image-info">
|
||||
<div className="image-title" title={item.imageName}>
|
||||
{item.imageName}
|
||||
</div>
|
||||
<Dropdown overlay={getImageMenu()} trigger={['click']}>
|
||||
<Button type="text" icon={<MoreOutlined />} size={isMobile ? "small" : "middle"} />
|
||||
</Dropdown>
|
||||
</div>
|
||||
{/* Reactions */}
|
||||
<div
|
||||
className="image-reactions"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ padding: '4px 8px' }}
|
||||
>
|
||||
<ReactionBar
|
||||
entityType="photo"
|
||||
entityId={item.instanceId}
|
||||
reactionSummary={reactions || undefined}
|
||||
onAdd={addReaction}
|
||||
onRemove={removeReaction}
|
||||
onChange={updateReaction}
|
||||
loading={reactionLoading}
|
||||
size="small"
|
||||
showPicker={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhotoCard;
|
||||
173
src/pages/gallery/components/PhotoDetailModal.tsx
Normal file
173
src/pages/gallery/components/PhotoDetailModal.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Photo Detail Modal with Comments
|
||||
* Feature: personal-user-enhancements
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal, Image, Space, Typography, Divider } from 'antd';
|
||||
import { Comments } from '@/components/Comments';
|
||||
import { ReactionBar } from '@/components/Reactions';
|
||||
import useComments from '@/hooks/useComments';
|
||||
import useReactions from '@/hooks/useReactions';
|
||||
import { ImageItem } from '@/pages/gallery/typings';
|
||||
import { getAuthization } from '@/utils/userUtils';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface PhotoDetailModalProps {
|
||||
visible: boolean;
|
||||
photo: ImageItem | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PhotoDetailModal: React.FC<PhotoDetailModalProps> = ({
|
||||
visible,
|
||||
photo,
|
||||
onClose,
|
||||
}) => {
|
||||
const {
|
||||
comments,
|
||||
loading: commentsLoading,
|
||||
createLoading,
|
||||
updateLoading,
|
||||
deleteLoading,
|
||||
addComment,
|
||||
updateComment,
|
||||
deleteComment,
|
||||
} = useComments({
|
||||
entityType: 'photo',
|
||||
entityId: photo?.instanceId || '',
|
||||
autoFetch: visible && !!photo,
|
||||
autoSubscribe: visible && !!photo,
|
||||
});
|
||||
|
||||
const {
|
||||
reactions,
|
||||
userReaction,
|
||||
addReaction,
|
||||
updateReaction,
|
||||
removeReaction,
|
||||
actionLoading: reactionLoading,
|
||||
} = useReactions('photo', photo?.instanceId || '', {
|
||||
autoFetch: visible && !!photo,
|
||||
autoSubscribe: visible && !!photo,
|
||||
});
|
||||
|
||||
// Wrap callbacks to match expected signatures
|
||||
const handleCreate = async (data: API.CreateCommentDTO) => {
|
||||
await addComment(data);
|
||||
};
|
||||
|
||||
const handleUpdate = async (id: string, text: string) => {
|
||||
await updateComment(id, text);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await deleteComment(id);
|
||||
};
|
||||
|
||||
if (!photo) return null;
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes?: number) => {
|
||||
if (!bytes) return '';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={1000}
|
||||
centered
|
||||
destroyOnClose
|
||||
title={photo.imageName}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '24px', flexDirection: 'column' }}>
|
||||
{/* Photo Display */}
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
src={`/file/image/${photo.instanceId}?Authorization=${getAuthization()}`}
|
||||
alt={photo.imageName}
|
||||
style={{ maxHeight: '500px', maxWidth: '100%' }}
|
||||
preview={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Photo Info */}
|
||||
<div>
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Text type="secondary">文件名: </Text>
|
||||
<Text>{photo.imageName}</Text>
|
||||
</div>
|
||||
{photo.size && (
|
||||
<div>
|
||||
<Text type="secondary">大小: </Text>
|
||||
<Text>{formatFileSize(photo.size)}</Text>
|
||||
</div>
|
||||
)}
|
||||
{photo.createTime && (
|
||||
<div>
|
||||
<Text type="secondary">上传时间: </Text>
|
||||
<Text>{formatDate(photo.createTime)}</Text>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
|
||||
{/* Reactions Section */}
|
||||
<div>
|
||||
<ReactionBar
|
||||
entityType="photo"
|
||||
entityId={photo.instanceId}
|
||||
reactionSummary={reactions || undefined}
|
||||
onAdd={addReaction}
|
||||
onRemove={removeReaction}
|
||||
onChange={updateReaction}
|
||||
loading={reactionLoading}
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
|
||||
{/* Comments Section */}
|
||||
<div>
|
||||
<Comments
|
||||
entityType="photo"
|
||||
entityId={photo.instanceId}
|
||||
comments={comments}
|
||||
loading={commentsLoading}
|
||||
onCreate={handleCreate}
|
||||
onEdit={handleUpdate}
|
||||
onDelete={handleDelete}
|
||||
createLoading={createLoading}
|
||||
editLoading={updateLoading}
|
||||
deleteLoading={deleteLoading}
|
||||
showCard={false}
|
||||
title="评论"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhotoDetailModal;
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* PhotoDetailModal Integration Test
|
||||
* Feature: personal-user-enhancements
|
||||
* Tests: Comments integration in photo detail view
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import PhotoDetailModal from '../PhotoDetailModal';
|
||||
import { ImageItem } from '@/pages/gallery/typings';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('@/hooks/useComments', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
comments: [],
|
||||
loading: false,
|
||||
createLoading: false,
|
||||
updateLoading: false,
|
||||
deleteLoading: false,
|
||||
addComment: jest.fn(),
|
||||
updateComment: jest.fn(),
|
||||
deleteComment: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/utils/userUtils', () => ({
|
||||
getAuthization: () => 'mock-token',
|
||||
}));
|
||||
|
||||
describe('PhotoDetailModal - Comments Integration', () => {
|
||||
const mockPhoto: ImageItem = {
|
||||
instanceId: 'photo-123',
|
||||
imageName: 'test-photo.jpg',
|
||||
size: 1024000,
|
||||
createTime: '2024-01-15T10:30:00Z',
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
visible: true,
|
||||
photo: mockPhoto,
|
||||
onClose: jest.fn(),
|
||||
};
|
||||
|
||||
it('should render Comments component when modal is visible', async () => {
|
||||
render(<PhotoDetailModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Check if Comments section is rendered
|
||||
expect(screen.getByText('评论')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display photo information', async () => {
|
||||
render(<PhotoDetailModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('test-photo.jpg')).toBeInTheDocument();
|
||||
expect(screen.getByText('0.98 MB')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not render when photo is null', () => {
|
||||
render(<PhotoDetailModal {...defaultProps} photo={null} />);
|
||||
|
||||
// Modal should not render
|
||||
expect(screen.queryByText('评论')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should pass correct entityType and entityId to Comments', async () => {
|
||||
const { container } = render(<PhotoDetailModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify the modal is rendered
|
||||
expect(container.querySelector('.ant-modal')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import GalleryTable from './components/GalleryTable';
|
||||
import GalleryToolbar from './components/GalleryToolbar';
|
||||
import GridView from './components/GridView';
|
||||
import ListView from './components/ListView';
|
||||
import PhotoDetailModal from './components/PhotoDetailModal';
|
||||
import './index.css';
|
||||
|
||||
const Gallery: FC = () => {
|
||||
@@ -35,6 +36,8 @@ const Gallery: FC = () => {
|
||||
videoUrl: string;
|
||||
thumbnailUrl?: string;
|
||||
} | null>(null);
|
||||
const [photoDetailVisible, setPhotoDetailVisible] = useState(false);
|
||||
const [currentPhoto, setCurrentPhoto] = useState<ImageItem | null>(null);
|
||||
|
||||
const pageSize = 50;
|
||||
const initPagination = { current: 1, pageSize: 5 };
|
||||
@@ -147,8 +150,9 @@ const Gallery: FC = () => {
|
||||
message.error('获取视频地址失败');
|
||||
}
|
||||
} else {
|
||||
setPreviewCurrent(index);
|
||||
setPreviewVisible(true);
|
||||
// Show photo detail modal with comments
|
||||
setCurrentPhoto(item);
|
||||
setPhotoDetailVisible(true);
|
||||
}
|
||||
},
|
||||
[imageList],
|
||||
@@ -580,28 +584,15 @@ const Gallery: FC = () => {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 预览组件 - 使用认证后的图像URL */}
|
||||
<Image.PreviewGroup
|
||||
preview={{
|
||||
visible: previewVisible,
|
||||
current: previewCurrent,
|
||||
onVisibleChange: (visible) => setPreviewVisible(visible),
|
||||
onChange: handlePreviewChange,
|
||||
{/* Photo Detail Modal with Comments */}
|
||||
<PhotoDetailModal
|
||||
visible={photoDetailVisible}
|
||||
photo={currentPhoto}
|
||||
onClose={() => {
|
||||
setPhotoDetailVisible(false);
|
||||
setCurrentPhoto(null);
|
||||
}}
|
||||
>
|
||||
{imageList.map((item: ImageItem) => (
|
||||
<Image
|
||||
key={item.instanceId}
|
||||
src={
|
||||
viewMode === 'small'
|
||||
? `/file/image/${item.instanceId}?Authorization=${getAuthization()}`
|
||||
: `/file/image-low-res/${item.instanceId}?Authorization=${getAuthization()}`
|
||||
}
|
||||
style={{ display: 'none' }}
|
||||
alt={item.imageName}
|
||||
/>
|
||||
))}
|
||||
</Image.PreviewGroup>
|
||||
/>
|
||||
</PageContainer>
|
||||
</div>
|
||||
);
|
||||
|
||||
348
src/pages/story/OFFLINE_INTEGRATION.md
Normal file
348
src/pages/story/OFFLINE_INTEGRATION.md
Normal file
@@ -0,0 +1,348 @@
|
||||
# Offline Editing Integration Guide
|
||||
|
||||
This guide explains how to integrate offline editing capabilities into story and album features.
|
||||
|
||||
## Overview
|
||||
|
||||
The offline editing system provides:
|
||||
- **Automatic offline detection**: Changes are saved locally when offline
|
||||
- **Optimistic UI updates**: UI updates immediately, syncs in background
|
||||
- **Change queue**: All offline changes are queued for synchronization
|
||||
- **Conflict resolution**: Handles conflicts when syncing with server
|
||||
- **Sync status indicators**: Shows sync state throughout the UI
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ UI Component │
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Offline Service │ ◄─── Detects online/offline
|
||||
└────────┬────────┘
|
||||
│
|
||||
├─── Online ──► API Request ──► Server
|
||||
│
|
||||
└─── Offline ─► IndexedDB ──► Sync Queue
|
||||
│
|
||||
▼
|
||||
(Syncs when online)
|
||||
```
|
||||
|
||||
## Integration Steps
|
||||
|
||||
### 1. Use the Sync Model
|
||||
|
||||
The sync model provides global sync state and queue management:
|
||||
|
||||
```tsx
|
||||
import { useModel } from '@umijs/max';
|
||||
|
||||
function MyComponent() {
|
||||
const { syncState, queueChange, syncAll, getSyncStatus } = useModel('sync');
|
||||
|
||||
// Access sync state
|
||||
console.log(syncState.isOnline); // true/false
|
||||
console.log(syncState.isSyncing); // true/false
|
||||
console.log(syncState.pendingChanges); // number
|
||||
console.log(getSyncStatus()); // 'synced' | 'pending' | 'syncing' | 'error'
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Use Offline Services
|
||||
|
||||
#### For Stories
|
||||
|
||||
```tsx
|
||||
import { useOfflineStoryService } from '@/pages/story/offlineService';
|
||||
|
||||
function StoryEditor() {
|
||||
const {
|
||||
addStory,
|
||||
updateStory,
|
||||
addStoryItem,
|
||||
updateStoryItem,
|
||||
removeStoryItem,
|
||||
} = useOfflineStoryService();
|
||||
|
||||
// These functions automatically handle online/offline scenarios
|
||||
const handleCreate = async () => {
|
||||
const story = await addStory({
|
||||
title: 'My Story',
|
||||
description: 'Description',
|
||||
});
|
||||
// Story is saved locally if offline, synced when online
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### For Albums
|
||||
|
||||
```tsx
|
||||
import { useOfflineAlbumService } from '@/services/albums/offlineService';
|
||||
|
||||
function AlbumEditor() {
|
||||
const {
|
||||
createAlbum,
|
||||
updateAlbum,
|
||||
deleteAlbum,
|
||||
addPhotosToAlbum,
|
||||
removePhotosFromAlbum,
|
||||
reorderPhotos,
|
||||
setCoverPhoto,
|
||||
} = useOfflineAlbumService();
|
||||
|
||||
// All operations work offline
|
||||
const handleCreate = async () => {
|
||||
const album = await createAlbum({
|
||||
name: 'My Album',
|
||||
description: 'Description',
|
||||
});
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Use the Albums Model (Already Integrated)
|
||||
|
||||
The albums model has been updated with offline support:
|
||||
|
||||
```tsx
|
||||
import { useModel } from '@umijs/max';
|
||||
|
||||
function AlbumPage() {
|
||||
const {
|
||||
albums,
|
||||
currentAlbum,
|
||||
loading,
|
||||
createAlbum, // ✓ Offline-aware
|
||||
updateAlbum, // ✓ Offline-aware
|
||||
deleteAlbum, // ✓ Offline-aware
|
||||
addPhotosToAlbum, // ✓ Offline-aware
|
||||
// ... all methods support offline
|
||||
} = useModel('albums');
|
||||
|
||||
// Just use the model methods as before
|
||||
// They automatically handle offline scenarios
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Show Sync Status
|
||||
|
||||
The `SyncStatusIndicator` component is already integrated into the app header:
|
||||
|
||||
```tsx
|
||||
// Already added to app.tsx
|
||||
import { SyncStatusIndicator } from '@/components/SyncStatus/SyncStatusIndicator';
|
||||
|
||||
// Shows:
|
||||
// - Online/offline status
|
||||
// - Pending changes count
|
||||
// - Sync button
|
||||
// - Last sync time
|
||||
```
|
||||
|
||||
### 5. Handle Optimistic Updates
|
||||
|
||||
The offline services implement optimistic updates automatically:
|
||||
|
||||
```tsx
|
||||
// When you call an offline-aware function:
|
||||
await updateAlbum(id, { name: 'New Name' });
|
||||
|
||||
// What happens:
|
||||
// 1. UI updates immediately (optimistic)
|
||||
// 2. If online: API request → success → done
|
||||
// 3. If offline: Save to IndexedDB → queue for sync → show message
|
||||
// 4. When back online: Auto-sync → update server → done
|
||||
```
|
||||
|
||||
## Example: Updating Story Detail Page
|
||||
|
||||
Here's how to update the story detail page to use offline editing:
|
||||
|
||||
```tsx
|
||||
// Before (online only)
|
||||
import { addStoryItem, updateStoryItem } from './service';
|
||||
|
||||
const handleAdd = async (formData: FormData) => {
|
||||
const response = await addStoryItem(formData);
|
||||
if (response.code === 200) {
|
||||
message.success('添加成功');
|
||||
}
|
||||
};
|
||||
|
||||
// After (offline-aware)
|
||||
import { useOfflineStoryService } from './offlineService';
|
||||
|
||||
function StoryDetail() {
|
||||
const { addStoryItem, updateStoryItem } = useOfflineStoryService();
|
||||
|
||||
const handleAdd = async (formData: FormData) => {
|
||||
try {
|
||||
const response = await addStoryItem(formData);
|
||||
// Message is shown automatically by the service
|
||||
// UI updates optimistically
|
||||
refresh(); // Refresh the list
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
// ... your component JSX
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Offline Functionality
|
||||
|
||||
### 1. Simulate Offline Mode
|
||||
|
||||
In Chrome DevTools:
|
||||
1. Open DevTools (F12)
|
||||
2. Go to Network tab
|
||||
3. Select "Offline" from the throttling dropdown
|
||||
|
||||
### 2. Test Workflow
|
||||
|
||||
1. Go offline (Network tab → Offline)
|
||||
2. Create/edit a story or album
|
||||
3. Verify the change appears in UI
|
||||
4. Check that a message shows "已保存到本地,将在联网后同步"
|
||||
5. Go back online
|
||||
6. Verify automatic sync occurs
|
||||
7. Check that changes are on the server
|
||||
|
||||
### 3. Check Sync Status
|
||||
|
||||
- Look at the sync indicator in the header
|
||||
- Should show pending changes count when offline
|
||||
- Should show "syncing" when syncing
|
||||
- Should show "synced" when complete
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Use Offline Services
|
||||
|
||||
```tsx
|
||||
// ❌ Don't use API directly
|
||||
import * as api from './service';
|
||||
await api.addStory(data);
|
||||
|
||||
// ✓ Use offline service
|
||||
import { useOfflineStoryService } from './offlineService';
|
||||
const { addStory } = useOfflineStoryService();
|
||||
await addStory(data);
|
||||
```
|
||||
|
||||
### 2. Handle Errors Gracefully
|
||||
|
||||
```tsx
|
||||
try {
|
||||
await addStory(data);
|
||||
// Success handled by service
|
||||
} catch (error) {
|
||||
// Only handle unexpected errors
|
||||
message.error('操作失败,请重试');
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Show Appropriate Messages
|
||||
|
||||
The offline services automatically show messages:
|
||||
- Online success: "操作成功"
|
||||
- Offline save: "已保存到本地,将在联网后同步"
|
||||
- Sync success: "成功同步 X 个更改"
|
||||
- Sync failure: "同步失败: X 个更改未能同步"
|
||||
|
||||
### 4. Trust Optimistic Updates
|
||||
|
||||
Don't wait for sync to complete before updating UI:
|
||||
|
||||
```tsx
|
||||
// ✓ Good - UI updates immediately
|
||||
await updateAlbum(id, updates);
|
||||
// UI already shows the update
|
||||
|
||||
// ❌ Bad - waiting for sync
|
||||
await updateAlbum(id, updates);
|
||||
await syncAll(); // Unnecessary
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Changes Not Syncing
|
||||
|
||||
1. Check browser console for errors
|
||||
2. Verify IndexedDB is enabled
|
||||
3. Check sync status indicator
|
||||
4. Try manual sync (click sync button)
|
||||
|
||||
### Conflicts
|
||||
|
||||
If conflicts occur:
|
||||
1. ConflictResolver modal will appear
|
||||
2. Choose: Keep Local, Keep Server, or Merge
|
||||
3. Conflicts are preserved until resolved
|
||||
|
||||
### Performance
|
||||
|
||||
- IndexedDB operations are async but fast
|
||||
- Sync happens in background
|
||||
- Large files may take time to sync
|
||||
- Progress is shown in sync indicator
|
||||
|
||||
## API Reference
|
||||
|
||||
### Sync Model
|
||||
|
||||
```typescript
|
||||
interface SyncState {
|
||||
isOnline: boolean;
|
||||
isSyncing: boolean;
|
||||
pendingChanges: number;
|
||||
lastSyncTime?: Date;
|
||||
syncError?: string;
|
||||
}
|
||||
|
||||
// Methods
|
||||
queueChange(change: ChangeRecord): Promise<void>
|
||||
syncAll(): Promise<SyncResult | null>
|
||||
getSyncStatus(): 'synced' | 'pending' | 'syncing' | 'error'
|
||||
```
|
||||
|
||||
### Offline Story Service
|
||||
|
||||
```typescript
|
||||
useOfflineStoryService() {
|
||||
addStory(params: Partial<StoryType>): Promise<StoryType>
|
||||
updateStory(params: Partial<StoryType> & { instanceId: string }): Promise<StoryType>
|
||||
addStoryItem(formData: FormData): Promise<any>
|
||||
updateStoryItem(formData: FormData): Promise<any>
|
||||
removeStoryItem(instanceId: string): Promise<any>
|
||||
}
|
||||
```
|
||||
|
||||
### Offline Album Service
|
||||
|
||||
```typescript
|
||||
useOfflineAlbumService() {
|
||||
createAlbum(data: CreateAlbumDTO): Promise<Album>
|
||||
updateAlbum(id: string, data: UpdateAlbumDTO): Promise<Album>
|
||||
deleteAlbum(id: string): Promise<void>
|
||||
addPhotosToAlbum(albumId: string, photoIds: string[]): Promise<void>
|
||||
removePhotosFromAlbum(albumId: string, photoIds: string[]): Promise<void>
|
||||
reorderPhotos(albumId: string, photoIds: string[]): Promise<void>
|
||||
setCoverPhoto(albumId: string, photoId: string): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Update remaining story components to use offline services
|
||||
2. Add offline support to photo gallery
|
||||
3. Implement conflict resolution UI improvements
|
||||
4. Add offline indicators to individual components
|
||||
5. Test thoroughly with various network conditions
|
||||
93
src/pages/story/components/OfflineStoryEditor.tsx
Normal file
93
src/pages/story/components/OfflineStoryEditor.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
// src/pages/story/components/OfflineStoryEditor.tsx
|
||||
import React from 'react';
|
||||
import { message } from 'antd';
|
||||
import { useModel } from '@umijs/max';
|
||||
import { useOfflineStoryService } from '../offlineService';
|
||||
import type { StoryType, StoryItem } from '../data.d';
|
||||
|
||||
/**
|
||||
* Wrapper component that provides offline-aware story editing capabilities
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* const { addStory, updateStory, addStoryItem, updateStoryItem, removeStoryItem } = useOfflineStoryEditor();
|
||||
*
|
||||
* // These functions automatically handle online/offline scenarios
|
||||
* await addStory({ title: 'My Story', description: 'Description' });
|
||||
* await addStoryItem(formData);
|
||||
* ```
|
||||
*/
|
||||
export function useOfflineStoryEditor() {
|
||||
const { syncState } = useModel('sync');
|
||||
const offlineService = useOfflineStoryService();
|
||||
|
||||
return {
|
||||
...offlineService,
|
||||
isOnline: syncState.isOnline,
|
||||
isSyncing: syncState.isSyncing,
|
||||
pendingChanges: syncState.pendingChanges,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* HOC to wrap story components with offline editing support
|
||||
*/
|
||||
export function withOfflineSupport<P extends object>(
|
||||
Component: React.ComponentType<P>
|
||||
): React.FC<P> {
|
||||
return (props: P) => {
|
||||
const { syncState } = useModel('sync');
|
||||
|
||||
// Show offline indicator if offline
|
||||
React.useEffect(() => {
|
||||
if (!syncState.isOnline && syncState.pendingChanges > 0) {
|
||||
message.info({
|
||||
content: `离线模式: ${syncState.pendingChanges} 个更改待同步`,
|
||||
duration: 3,
|
||||
key: 'offline-indicator',
|
||||
});
|
||||
}
|
||||
}, [syncState.isOnline, syncState.pendingChanges]);
|
||||
|
||||
return <Component {...props} />;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Example integration component showing how to use offline editing
|
||||
*/
|
||||
export const OfflineStoryEditorExample: React.FC = () => {
|
||||
const { addStory, updateStory, isOnline, pendingChanges } = useOfflineStoryEditor();
|
||||
|
||||
const handleCreateStory = async () => {
|
||||
try {
|
||||
const story = await addStory({
|
||||
title: '新故事',
|
||||
description: '这是一个测试故事',
|
||||
});
|
||||
|
||||
message.success(isOnline ? '故事创建成功' : '故事已保存到本地');
|
||||
return story;
|
||||
} catch (error) {
|
||||
message.error('创建故事失败');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateStory = async (instanceId: string, updates: Partial<StoryType>) => {
|
||||
try {
|
||||
const story = await updateStory({
|
||||
instanceId,
|
||||
...updates,
|
||||
});
|
||||
|
||||
message.success(isOnline ? '故事更新成功' : '更改已保存到本地');
|
||||
return story;
|
||||
} catch (error) {
|
||||
message.error('更新故事失败');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return null; // This is just an example component
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
// TimelineGridItem.tsx - Grid card layout for timeline items
|
||||
import TimelineImage from '@/components/TimelineImage';
|
||||
import { ReactionBar } from '@/components/Reactions';
|
||||
import useReactions from '@/hooks/useReactions';
|
||||
import { StoryItem } from '@/pages/story/data';
|
||||
import { batchGetFileInfo, queryStoryItemImages } from '@/services/file/api';
|
||||
import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
||||
@@ -40,6 +42,18 @@ const TimelineGridItem: React.FC<{
|
||||
const intl = useIntl();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
// Initialize reactions for this story item
|
||||
const {
|
||||
reactions,
|
||||
addReaction,
|
||||
updateReaction,
|
||||
removeReaction,
|
||||
actionLoading: reactionLoading,
|
||||
} = useReactions('story', item.instanceId || '', {
|
||||
autoFetch: true,
|
||||
autoSubscribe: true,
|
||||
});
|
||||
|
||||
// 动态设置CSS变量以适配主题
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
@@ -233,6 +247,25 @@ const TimelineGridItem: React.FC<{
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reactions */}
|
||||
<div
|
||||
className="item-reactions"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ marginTop: '8px' }}
|
||||
>
|
||||
<ReactionBar
|
||||
entityType="story"
|
||||
entityId={item.instanceId || ''}
|
||||
reactionSummary={reactions || undefined}
|
||||
onAdd={addReaction}
|
||||
onRemove={removeReaction}
|
||||
onChange={updateReaction}
|
||||
loading={reactionLoading}
|
||||
size="small"
|
||||
showPicker={false}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// src/pages/story/components/TimelineItemDrawer.tsx
|
||||
import TimelineImage from '@/components/TimelineImage';
|
||||
import { Comments } from '@/components/Comments';
|
||||
import { ReactionBar } from '@/components/Reactions';
|
||||
import useComments from '@/hooks/useComments';
|
||||
import useReactions from '@/hooks/useReactions';
|
||||
import { StoryItem } from '@/pages/story/data';
|
||||
import { queryStoryItemImages } from '@/pages/story/service';
|
||||
import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
||||
@@ -44,6 +48,36 @@ const TimelineItemDrawer: React.FC<Props> = (props) => {
|
||||
},
|
||||
);
|
||||
|
||||
// Initialize comments for this story item
|
||||
const {
|
||||
comments,
|
||||
loading: commentsLoading,
|
||||
createLoading,
|
||||
updateLoading,
|
||||
deleteLoading,
|
||||
addComment,
|
||||
updateComment,
|
||||
deleteComment,
|
||||
} = useComments({
|
||||
entityType: 'story',
|
||||
entityId: storyItem.instanceId || '',
|
||||
autoFetch: open,
|
||||
autoSubscribe: open,
|
||||
});
|
||||
|
||||
// Initialize reactions for this story item
|
||||
const {
|
||||
reactions,
|
||||
userReaction,
|
||||
addReaction,
|
||||
updateReaction,
|
||||
removeReaction,
|
||||
actionLoading: reactionLoading,
|
||||
} = useReactions('story', storyItem.instanceId || '', {
|
||||
autoFetch: open,
|
||||
autoSubscribe: open,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
run(storyItem.instanceId);
|
||||
@@ -209,6 +243,43 @@ const TimelineItemDrawer: React.FC<Props> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider style={{ borderColor: token.colorBorder }} />
|
||||
|
||||
{/* Reactions Section */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h3 style={{ color: token.colorText }}>互动</h3>
|
||||
<ReactionBar
|
||||
entityType="story"
|
||||
entityId={storyItem.instanceId || ''}
|
||||
reactionSummary={reactions || undefined}
|
||||
onAdd={addReaction}
|
||||
onRemove={removeReaction}
|
||||
onChange={updateReaction}
|
||||
loading={reactionLoading}
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider style={{ borderColor: token.colorBorder }} />
|
||||
|
||||
{/* Comments Section */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<Comments
|
||||
entityType="story"
|
||||
entityId={storyItem.instanceId || ''}
|
||||
comments={comments}
|
||||
loading={commentsLoading}
|
||||
onCreate={addComment}
|
||||
onEdit={updateComment}
|
||||
onDelete={deleteComment}
|
||||
createLoading={createLoading}
|
||||
editLoading={updateLoading}
|
||||
deleteLoading={deleteLoading}
|
||||
showCard={false}
|
||||
title="评论"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* TimelineItemDrawer Integration Test
|
||||
* Feature: personal-user-enhancements
|
||||
* Tests: Comments integration in story detail view
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import TimelineItemDrawer from '../TimelineItemDrawer';
|
||||
import { StoryItem } from '@/pages/story/data';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('@umijs/max', () => ({
|
||||
useIntl: () => ({
|
||||
formatMessage: ({ id }: { id: string }) => id,
|
||||
}),
|
||||
useRequest: () => ({
|
||||
data: [],
|
||||
run: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('antd', () => {
|
||||
const actual = jest.requireActual('antd');
|
||||
return {
|
||||
...actual,
|
||||
theme: {
|
||||
useToken: () => ({
|
||||
token: {
|
||||
colorBgContainer: '#fff',
|
||||
colorText: '#000',
|
||||
colorTextSecondary: '#666',
|
||||
colorBorder: '#d9d9d9',
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@/hooks/useComments', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
comments: [],
|
||||
loading: false,
|
||||
createLoading: false,
|
||||
updateLoading: false,
|
||||
deleteLoading: false,
|
||||
addComment: jest.fn(),
|
||||
updateComment: jest.fn(),
|
||||
deleteComment: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/TimelineImage', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>Mock Image</div>,
|
||||
}));
|
||||
|
||||
describe('TimelineItemDrawer - Comments Integration', () => {
|
||||
const mockStoryItem: StoryItem = {
|
||||
instanceId: 'story-123',
|
||||
title: 'Test Story',
|
||||
description: 'Test Description',
|
||||
storyItemTime: [2024, 1, 15, 10, 30, 0],
|
||||
location: 'Test Location',
|
||||
createTime: [2024, 1, 15, 10, 30, 0],
|
||||
updateTime: [2024, 1, 15, 10, 30, 0],
|
||||
createName: 'Test User',
|
||||
updateName: 'Test User',
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
storyItem: mockStoryItem,
|
||||
open: true,
|
||||
setOpen: jest.fn(),
|
||||
handleDelete: jest.fn(),
|
||||
handOption: jest.fn(),
|
||||
disableEdit: false,
|
||||
};
|
||||
|
||||
it('should render Comments component when drawer is open', async () => {
|
||||
render(<TimelineItemDrawer {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Check if Comments section is rendered
|
||||
expect(screen.getByText('评论')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass correct entityType and entityId to Comments', async () => {
|
||||
const { container } = render(<TimelineItemDrawer {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify the drawer is rendered
|
||||
expect(container.querySelector('.ant-drawer')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not render Comments when drawer is closed', () => {
|
||||
render(<TimelineItemDrawer {...defaultProps} open={false} />);
|
||||
|
||||
// Comments should not be visible when drawer is closed
|
||||
expect(screen.queryByText('评论')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
250
src/pages/story/offlineService.ts
Normal file
250
src/pages/story/offlineService.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
// src/pages/story/offlineService.ts
|
||||
import { db } from '@/utils/offline/db';
|
||||
import type { StoryType, StoryItem } from './data.d';
|
||||
import { useModel } from '@umijs/max';
|
||||
import * as onlineService from './service';
|
||||
|
||||
/**
|
||||
* Offline-aware wrapper for story operations
|
||||
* Handles both online and offline scenarios with optimistic updates
|
||||
*/
|
||||
|
||||
/**
|
||||
* Add a new story (offline-capable)
|
||||
*/
|
||||
export async function addStoryOffline(
|
||||
params: Partial<StoryType>,
|
||||
isOnline: boolean,
|
||||
queueChange: (change: any) => Promise<void>,
|
||||
): Promise<StoryType> {
|
||||
const tempId = `temp-story-${Date.now()}`;
|
||||
|
||||
const story: StoryType = {
|
||||
instanceId: tempId,
|
||||
title: params.title || '',
|
||||
description: params.description,
|
||||
coverImage: params.coverImage,
|
||||
userId: params.userId || '',
|
||||
createTime: new Date().toISOString(),
|
||||
updateTime: new Date().toISOString(),
|
||||
itemCount: 0,
|
||||
permissionType: params.permissionType || 1,
|
||||
...params,
|
||||
};
|
||||
|
||||
if (isOnline) {
|
||||
try {
|
||||
const response = await onlineService.addStory(params);
|
||||
return response.data.list[0];
|
||||
} catch (error) {
|
||||
// If online request fails, fall back to offline
|
||||
console.warn('Online request failed, saving offline:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Save to offline database
|
||||
await db.stories.add({
|
||||
...story,
|
||||
syncStatus: 'pending',
|
||||
});
|
||||
|
||||
// Queue for sync
|
||||
await queueChange({
|
||||
userId: story.userId,
|
||||
entityType: 'story',
|
||||
entityId: tempId,
|
||||
operation: 'create',
|
||||
data: story,
|
||||
});
|
||||
|
||||
return story;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing story (offline-capable)
|
||||
*/
|
||||
export async function updateStoryOffline(
|
||||
params: Partial<StoryType> & { instanceId: string },
|
||||
isOnline: boolean,
|
||||
queueChange: (change: any) => Promise<void>,
|
||||
): Promise<StoryType> {
|
||||
const updatedStory = {
|
||||
...params,
|
||||
updateTime: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (isOnline) {
|
||||
try {
|
||||
const response = await onlineService.updateStory(params);
|
||||
|
||||
// Update local cache
|
||||
await db.stories.update(params.instanceId, {
|
||||
...updatedStory,
|
||||
syncStatus: 'synced',
|
||||
});
|
||||
|
||||
return response.data.list[0];
|
||||
} catch (error) {
|
||||
console.warn('Online request failed, saving offline:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update offline database
|
||||
await db.stories.update(params.instanceId, {
|
||||
...updatedStory,
|
||||
syncStatus: 'pending',
|
||||
});
|
||||
|
||||
// Queue for sync
|
||||
await queueChange({
|
||||
userId: params.userId || '',
|
||||
entityType: 'story',
|
||||
entityId: params.instanceId,
|
||||
operation: 'update',
|
||||
data: updatedStory,
|
||||
});
|
||||
|
||||
return updatedStory as StoryType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a story item (offline-capable)
|
||||
*/
|
||||
export async function addStoryItemOffline(
|
||||
formData: FormData,
|
||||
isOnline: boolean,
|
||||
queueChange: (change: any) => Promise<void>,
|
||||
): Promise<any> {
|
||||
const tempId = `temp-item-${Date.now()}`;
|
||||
|
||||
if (isOnline) {
|
||||
try {
|
||||
return await onlineService.addStoryItem(formData);
|
||||
} catch (error) {
|
||||
console.warn('Online request failed, saving offline:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract data from FormData for offline storage
|
||||
const storyItemData: any = {
|
||||
instanceId: tempId,
|
||||
storyInstanceId: formData.get('storyInstanceId'),
|
||||
title: formData.get('title'),
|
||||
content: formData.get('content'),
|
||||
storyItemTime: formData.get('storyItemTime'),
|
||||
createTime: new Date().toISOString(),
|
||||
syncStatus: 'pending',
|
||||
};
|
||||
|
||||
// Queue for sync with FormData
|
||||
await queueChange({
|
||||
userId: '', // Will be filled by sync manager
|
||||
entityType: 'story-item',
|
||||
entityId: tempId,
|
||||
operation: 'create',
|
||||
data: storyItemData,
|
||||
formData: formData, // Store FormData for later upload
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
data: storyItemData,
|
||||
message: '已保存到本地,将在联网后同步',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a story item (offline-capable)
|
||||
*/
|
||||
export async function updateStoryItemOffline(
|
||||
formData: FormData,
|
||||
isOnline: boolean,
|
||||
queueChange: (change: any) => Promise<void>,
|
||||
): Promise<any> {
|
||||
const instanceId = formData.get('instanceId') as string;
|
||||
|
||||
if (isOnline) {
|
||||
try {
|
||||
return await onlineService.updateStoryItem(formData);
|
||||
} catch (error) {
|
||||
console.warn('Online request failed, saving offline:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract data from FormData
|
||||
const storyItemData: any = {
|
||||
instanceId,
|
||||
title: formData.get('title'),
|
||||
content: formData.get('content'),
|
||||
storyItemTime: formData.get('storyItemTime'),
|
||||
updateTime: new Date().toISOString(),
|
||||
syncStatus: 'pending',
|
||||
};
|
||||
|
||||
// Queue for sync
|
||||
await queueChange({
|
||||
userId: '',
|
||||
entityType: 'story-item',
|
||||
entityId: instanceId,
|
||||
operation: 'update',
|
||||
data: storyItemData,
|
||||
formData: formData,
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
data: storyItemData,
|
||||
message: '已保存到本地,将在联网后同步',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a story item (offline-capable)
|
||||
*/
|
||||
export async function removeStoryItemOffline(
|
||||
instanceId: string,
|
||||
isOnline: boolean,
|
||||
queueChange: (change: any) => Promise<void>,
|
||||
): Promise<any> {
|
||||
if (isOnline) {
|
||||
try {
|
||||
return await onlineService.removeStoryItem(instanceId);
|
||||
} catch (error) {
|
||||
console.warn('Online request failed, saving offline:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Queue for sync
|
||||
await queueChange({
|
||||
userId: '',
|
||||
entityType: 'story-item',
|
||||
entityId: instanceId,
|
||||
operation: 'delete',
|
||||
data: { instanceId },
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: '已标记删除,将在联网后同步',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to use offline-aware story operations
|
||||
*/
|
||||
export function useOfflineStoryService() {
|
||||
const { syncState, queueChange } = useModel('sync');
|
||||
|
||||
return {
|
||||
addStory: (params: Partial<StoryType>) =>
|
||||
addStoryOffline(params, syncState.isOnline, queueChange),
|
||||
updateStory: (params: Partial<StoryType> & { instanceId: string }) =>
|
||||
updateStoryOffline(params, syncState.isOnline, queueChange),
|
||||
addStoryItem: (formData: FormData) =>
|
||||
addStoryItemOffline(formData, syncState.isOnline, queueChange),
|
||||
updateStoryItem: (formData: FormData) =>
|
||||
updateStoryItemOffline(formData, syncState.isOnline, queueChange),
|
||||
removeStoryItem: (instanceId: string) =>
|
||||
removeStoryItemOffline(instanceId, syncState.isOnline, queueChange),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user