Story Collections Feature
This commit is contained in:
@@ -10,10 +10,20 @@ import Button from '../ui/Button';
|
||||
interface StoryCardProps {
|
||||
story: Story;
|
||||
viewMode: 'grid' | 'list';
|
||||
onUpdate: () => void;
|
||||
onUpdate?: () => void;
|
||||
showSelection?: boolean;
|
||||
isSelected?: boolean;
|
||||
onSelect?: () => void;
|
||||
}
|
||||
|
||||
export default function StoryCard({ story, viewMode, onUpdate }: StoryCardProps) {
|
||||
export default function StoryCard({
|
||||
story,
|
||||
viewMode,
|
||||
onUpdate,
|
||||
showSelection = false,
|
||||
isSelected = false,
|
||||
onSelect
|
||||
}: StoryCardProps) {
|
||||
const [rating, setRating] = useState(story.rating || 0);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
|
||||
@@ -24,7 +34,7 @@ export default function StoryCard({ story, viewMode, onUpdate }: StoryCardProps)
|
||||
setUpdating(true);
|
||||
await storyApi.updateRating(story.id, newRating);
|
||||
setRating(newRating);
|
||||
onUpdate();
|
||||
onUpdate?.();
|
||||
} catch (error) {
|
||||
console.error('Failed to update rating:', error);
|
||||
} finally {
|
||||
|
||||
131
frontend/src/components/stories/StoryMultiSelect.tsx
Normal file
131
frontend/src/components/stories/StoryMultiSelect.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Story } from '../../types/api';
|
||||
import StoryCard from './StoryCard';
|
||||
import StorySelectionToolbar from './StorySelectionToolbar';
|
||||
|
||||
interface StoryMultiSelectProps {
|
||||
stories: Story[];
|
||||
viewMode: 'grid' | 'list';
|
||||
onUpdate?: () => void;
|
||||
allowMultiSelect?: boolean;
|
||||
}
|
||||
|
||||
export default function StoryMultiSelect({
|
||||
stories,
|
||||
viewMode,
|
||||
onUpdate,
|
||||
allowMultiSelect = true
|
||||
}: StoryMultiSelectProps) {
|
||||
const [selectedStoryIds, setSelectedStoryIds] = useState<string[]>([]);
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
|
||||
const handleStorySelect = (storyId: string) => {
|
||||
setSelectedStoryIds(prev => {
|
||||
if (prev.includes(storyId)) {
|
||||
const newSelection = prev.filter(id => id !== storyId);
|
||||
if (newSelection.length === 0) {
|
||||
setIsSelectionMode(false);
|
||||
}
|
||||
return newSelection;
|
||||
} else {
|
||||
if (!isSelectionMode) {
|
||||
setIsSelectionMode(true);
|
||||
}
|
||||
return [...prev, storyId];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedStoryIds.length === stories.length) {
|
||||
setSelectedStoryIds([]);
|
||||
setIsSelectionMode(false);
|
||||
} else {
|
||||
setSelectedStoryIds(stories.map(story => story.id));
|
||||
setIsSelectionMode(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearSelection = () => {
|
||||
setSelectedStoryIds([]);
|
||||
setIsSelectionMode(false);
|
||||
};
|
||||
|
||||
const handleBatchOperation = (operation: string) => {
|
||||
// This will trigger the appropriate action based on the operation
|
||||
console.log(`Batch operation: ${operation} on stories:`, selectedStoryIds);
|
||||
// After operation, clear selection
|
||||
setSelectedStoryIds([]);
|
||||
setIsSelectionMode(false);
|
||||
onUpdate?.();
|
||||
};
|
||||
|
||||
if (stories.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-20">
|
||||
<div className="theme-text text-lg mb-4">
|
||||
No stories found
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Selection Toolbar */}
|
||||
{allowMultiSelect && (
|
||||
<StorySelectionToolbar
|
||||
selectedCount={selectedStoryIds.length}
|
||||
totalCount={stories.length}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onSelectAll={handleSelectAll}
|
||||
onClearSelection={handleClearSelection}
|
||||
onBatchOperation={handleBatchOperation}
|
||||
selectedStoryIds={selectedStoryIds}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Stories Grid/List */}
|
||||
<div className={
|
||||
viewMode === 'grid'
|
||||
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6'
|
||||
: 'space-y-4'
|
||||
}>
|
||||
{stories.map((story) => (
|
||||
<div key={story.id} className="relative">
|
||||
{/* Selection Checkbox */}
|
||||
{allowMultiSelect && (isSelectionMode || selectedStoryIds.includes(story.id)) && (
|
||||
<div className="absolute top-2 left-2 z-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedStoryIds.includes(story.id)}
|
||||
onChange={() => handleStorySelect(story.id)}
|
||||
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 bg-white shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Story Card */}
|
||||
<div
|
||||
className={`transition-all duration-200 ${
|
||||
selectedStoryIds.includes(story.id) ? 'ring-2 ring-blue-500 ring-opacity-50' : ''
|
||||
}`}
|
||||
onDoubleClick={() => allowMultiSelect && handleStorySelect(story.id)}
|
||||
>
|
||||
<StoryCard
|
||||
story={story}
|
||||
viewMode={viewMode}
|
||||
onUpdate={onUpdate}
|
||||
showSelection={isSelectionMode}
|
||||
isSelected={selectedStoryIds.includes(story.id)}
|
||||
onSelect={() => handleStorySelect(story.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
251
frontend/src/components/stories/StorySelectionToolbar.tsx
Normal file
251
frontend/src/components/stories/StorySelectionToolbar.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { collectionApi } from '../../lib/api';
|
||||
import { Collection } from '../../types/api';
|
||||
import Button from '../ui/Button';
|
||||
import LoadingSpinner from '../ui/LoadingSpinner';
|
||||
|
||||
interface StorySelectionToolbarProps {
|
||||
selectedCount: number;
|
||||
totalCount: number;
|
||||
isSelectionMode: boolean;
|
||||
onSelectAll: () => void;
|
||||
onClearSelection: () => void;
|
||||
onBatchOperation: (operation: string) => void;
|
||||
selectedStoryIds: string[];
|
||||
}
|
||||
|
||||
export default function StorySelectionToolbar({
|
||||
selectedCount,
|
||||
totalCount,
|
||||
isSelectionMode,
|
||||
onSelectAll,
|
||||
onClearSelection,
|
||||
onBatchOperation,
|
||||
selectedStoryIds
|
||||
}: StorySelectionToolbarProps) {
|
||||
const [showAddToCollection, setShowAddToCollection] = useState(false);
|
||||
const [collections, setCollections] = useState<Collection[]>([]);
|
||||
const [loadingCollections, setLoadingCollections] = useState(false);
|
||||
const [addingToCollection, setAddingToCollection] = useState(false);
|
||||
const [newCollectionName, setNewCollectionName] = useState('');
|
||||
const [showCreateNew, setShowCreateNew] = useState(false);
|
||||
|
||||
const loadCollections = async () => {
|
||||
try {
|
||||
setLoadingCollections(true);
|
||||
const result = await collectionApi.getCollections({
|
||||
page: 0,
|
||||
limit: 50,
|
||||
archived: false,
|
||||
});
|
||||
setCollections(result.results || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load collections:', error);
|
||||
} finally {
|
||||
setLoadingCollections(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowAddToCollection = async () => {
|
||||
setShowAddToCollection(true);
|
||||
await loadCollections();
|
||||
};
|
||||
|
||||
const handleAddToExistingCollection = async (collectionId: string) => {
|
||||
try {
|
||||
setAddingToCollection(true);
|
||||
await collectionApi.addStoriesToCollection(collectionId, selectedStoryIds);
|
||||
setShowAddToCollection(false);
|
||||
onBatchOperation('addToCollection');
|
||||
} catch (error) {
|
||||
console.error('Failed to add stories to collection:', error);
|
||||
} finally {
|
||||
setAddingToCollection(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNewCollection = async () => {
|
||||
if (!newCollectionName.trim()) return;
|
||||
|
||||
try {
|
||||
setAddingToCollection(true);
|
||||
const collection = await collectionApi.createCollection({
|
||||
name: newCollectionName.trim(),
|
||||
storyIds: selectedStoryIds,
|
||||
});
|
||||
setShowAddToCollection(false);
|
||||
setNewCollectionName('');
|
||||
setShowCreateNew(false);
|
||||
onBatchOperation('createCollection');
|
||||
} catch (error) {
|
||||
console.error('Failed to create collection:', error);
|
||||
} finally {
|
||||
setAddingToCollection(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isSelectionMode && selectedCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
{/* Selection Info */}
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="font-medium text-blue-900 dark:text-blue-100">
|
||||
{selectedCount} of {totalCount} stories selected
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={selectedCount === totalCount ? onClearSelection : onSelectAll}
|
||||
className="text-blue-600 hover:text-blue-800 dark:text-blue-400"
|
||||
>
|
||||
{selectedCount === totalCount ? 'Deselect All' : 'Select All'}
|
||||
</Button>
|
||||
|
||||
{selectedCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClearSelection}
|
||||
className="text-blue-600 hover:text-blue-800 dark:text-blue-400"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Batch Actions */}
|
||||
{selectedCount > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleShowAddToCollection}
|
||||
disabled={addingToCollection}
|
||||
>
|
||||
Add to Collection
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add to Collection Modal */}
|
||||
{showAddToCollection && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="theme-card max-w-lg w-full max-h-[80vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b theme-border">
|
||||
<h2 className="text-xl font-semibold theme-header">
|
||||
Add {selectedCount} Stories to Collection
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAddToCollection(false);
|
||||
setShowCreateNew(false);
|
||||
setNewCollectionName('');
|
||||
}}
|
||||
disabled={addingToCollection}
|
||||
className="text-gray-500 hover:text-gray-700 disabled:opacity-50"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{loadingCollections ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<LoadingSpinner size="md" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Create New Collection */}
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => setShowCreateNew(!showCreateNew)}
|
||||
className="w-full p-3 border-2 border-dashed theme-border rounded-lg theme-text hover:border-gray-400 transition-colors"
|
||||
>
|
||||
+ Create New Collection
|
||||
</button>
|
||||
|
||||
{showCreateNew && (
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newCollectionName}
|
||||
onChange={(e) => setNewCollectionName(e.target.value)}
|
||||
placeholder="Enter collection name"
|
||||
className="w-full px-3 py-2 border theme-border rounded-lg theme-card theme-text focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCreateNewCollection();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleCreateNewCollection}
|
||||
disabled={!newCollectionName.trim() || addingToCollection}
|
||||
>
|
||||
{addingToCollection ? <LoadingSpinner size="sm" /> : 'Create'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowCreateNew(false);
|
||||
setNewCollectionName('');
|
||||
}}
|
||||
disabled={addingToCollection}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Existing Collections */}
|
||||
{collections.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium theme-text">Add to Existing Collection:</h3>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{collections.map((collection) => (
|
||||
<button
|
||||
key={collection.id}
|
||||
onClick={() => handleAddToExistingCollection(collection.id)}
|
||||
disabled={addingToCollection}
|
||||
className="w-full p-3 text-left theme-card hover:border-gray-400 border theme-border rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<div className="font-medium theme-text">{collection.name}</div>
|
||||
<div className="text-sm theme-text opacity-70">
|
||||
{collection.storyCount} stories
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{collections.length === 0 && !loadingCollections && (
|
||||
<div className="text-center py-8 theme-text opacity-70">
|
||||
No collections found. Create a new one above.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user