Story Collections Feature

This commit is contained in:
Stefan Hardegger
2025-07-25 14:15:23 +02:00
parent 9dd8855914
commit 312093ae2e
42 changed files with 5398 additions and 45 deletions

View File

@@ -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 {

View 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>
);
}

View 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>
)}
</>
);
}