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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user