diff --git a/frontend/src/components/step-library/StepCard.tsx b/frontend/src/components/step-library/StepCard.tsx new file mode 100644 index 00000000..5345ac24 --- /dev/null +++ b/frontend/src/components/step-library/StepCard.tsx @@ -0,0 +1,144 @@ +import { Star, User, Calendar, TrendingUp, Eye, Plus, HelpCircle, Zap, CheckCircle } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { StepListItem } from '@/types/step' + +interface StepCardProps { + step: StepListItem + onPreview: (step: StepListItem) => void + onInsert: (step: StepListItem) => void +} + +const stepTypeIcons = { + decision: HelpCircle, + action: Zap, + solution: CheckCircle +} + +const stepTypeColors = { + decision: 'bg-blue-500/20 text-blue-600 dark:text-blue-400 border-blue-500/30', + action: 'bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 border-yellow-500/30', + solution: 'bg-green-500/20 text-green-600 dark:text-green-400 border-green-500/30' +} + +export function StepCard({ step, onPreview, onInsert }: StepCardProps) { + const Icon = stepTypeIcons[step.step_type as keyof typeof stepTypeIcons] || HelpCircle + const hasRating = step.rating_count > 0 + const visibleTags = step.tags.slice(0, 3) + const remainingTags = step.tags.length - 3 + + return ( +
+ {/* Header */} +
+
+
+ {/* Step Type Badge */} + + + {step.step_type} + + + {/* Featured Badge */} + {step.is_featured && ( + + Featured + + )} +
+ + {/* Title */} +

{step.title}

+
+
+ + {/* Metadata */} +
+ {/* Category */} + {step.category_name && ( +
+ 📁 + {step.category_name} +
+ )} + + {/* Rating */} +
+ + {hasRating ? ( + + {step.rating_average.toFixed(1)} ({step.rating_count} {step.rating_count === 1 ? 'rating' : 'ratings'}) + + ) : ( + Not rated + )} +
+ + {/* Usage Count */} +
+ + Used {step.usage_count} {step.usage_count === 1 ? 'time' : 'times'} +
+ + {/* Author & Date */} +
+
+ + {step.author_name || 'Unknown'} +
+
+ + {new Date(step.created_at).toLocaleDateString()} +
+
+
+ + {/* Tags */} + {step.tags.length > 0 && ( +
+ {visibleTags.map(tag => ( + + {tag} + + ))} + {remainingTags > 0 && ( + + +{remainingTags} more + + )} +
+ )} + + {/* Actions */} +
+ + +
+
+ ) +} diff --git a/frontend/src/components/step-library/StepDetailModal.tsx b/frontend/src/components/step-library/StepDetailModal.tsx new file mode 100644 index 00000000..39934ae7 --- /dev/null +++ b/frontend/src/components/step-library/StepDetailModal.tsx @@ -0,0 +1,326 @@ +import { useState, useEffect } from 'react' +import { X, Star, Copy, Check, HelpCircle, Zap, CheckCircle, User, Calendar } from 'lucide-react' +import { cn } from '@/lib/utils' +import { MarkdownContent } from '@/components/ui/MarkdownContent' +import { stepsApi } from '@/api' +import type { Step, Review } from '@/types/step' + +interface StepDetailModalProps { + stepId: string + onClose: () => void + onInsert: (step: Step) => void +} + +const stepTypeIcons = { + decision: HelpCircle, + action: Zap, + solution: CheckCircle +} + +const stepTypeColors = { + decision: 'bg-blue-500/20 text-blue-600 dark:text-blue-400 border-blue-500/30', + action: 'bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 border-yellow-500/30', + solution: 'bg-green-500/20 text-green-600 dark:text-green-400 border-green-500/30' +} + +export function StepDetailModal({ stepId, onClose, onInsert }: StepDetailModalProps) { + const [step, setStep] = useState(null) + const [reviews, setReviews] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [copiedCommandIndex, setCopiedCommandIndex] = useState(null) + + useEffect(() => { + const loadStepDetails = async () => { + setIsLoading(true) + setError(null) + try { + const [stepData, reviewsData] = await Promise.all([ + stepsApi.get(stepId), + stepsApi.getReviews(stepId) + ]) + setStep(stepData) + setReviews(reviewsData) + } catch (err) { + console.error('Failed to load step details:', err) + setError('Failed to load step details') + } finally { + setIsLoading(false) + } + } + + loadStepDetails() + }, [stepId]) + + const handleCopyCommand = async (command: string, index: number) => { + await navigator.clipboard.writeText(command) + setCopiedCommandIndex(index) + setTimeout(() => setCopiedCommandIndex(null), 2000) + } + + const handleInsert = () => { + if (step) { + onInsert(step) + } + } + + const Icon = step ? stepTypeIcons[step.step_type as keyof typeof stepTypeIcons] : HelpCircle + const hasRating = step && step.rating_count > 0 + const topReviews = reviews.filter(r => r.review_text).slice(0, 3) + + return ( +
+
+ {/* Header */} +
+ {isLoading ? ( +
+ ) : error ? ( +

{error}

+ ) : step ? ( +
+
+ + + {step.step_type} + + {step.category_name && ( + 📁 {step.category_name} + )} + {step.is_featured && ( + + Featured + + )} + {step.is_verified && ( + + ✓ Verified + + )} +
+

{step.title}

+
+ ) : null} + +
+ + {/* Body - Scrollable */} +
+ {isLoading ? ( +
+
+
+
+
+ ) : error ? ( +

{error}

+ ) : step ? ( +
+ {/* Rating */} + {hasRating && ( +
+

Rating

+
+
+ {[1, 2, 3, 4, 5].map(i => ( + + ))} +
+ + {step.rating_average.toFixed(1)} ({step.rating_count} {step.rating_count === 1 ? 'rating' : 'ratings'}) + +
+
+ )} + + {/* Tags */} + {step.tags.length > 0 && ( +
+

Tags

+
+ {step.tags.map(tag => ( + + {tag} + + ))} +
+
+ )} + + {/* Instructions */} +
+

Instructions

+
+ +
+
+ + {/* Help Text */} + {step.content.help_text && ( +
+

Help Text

+
+ +
+
+ )} + + {/* Commands */} + {step.content.commands && step.content.commands.length > 0 && ( +
+

Commands

+
+ {step.content.commands.map((cmd, index) => ( +
+
+ {cmd.label} + +
+
+                          {cmd.command}
+                        
+
+ ))} +
+
+ )} + + {/* Reviews */} + {topReviews.length > 0 && ( +
+
+

Reviews

+ {reviews.length > 3 && ( + + )} +
+
+ {topReviews.map(review => ( +
+
+
+ + {review.user_name || 'Anonymous'} + {review.verified_use && ( + + ✓ Verified Use + + )} +
+
+ {[1, 2, 3, 4, 5].map(i => ( + + ))} +
+
+

{review.review_text}

+
+ + {new Date(review.created_at).toLocaleDateString()} +
+
+ ))} +
+
+ )} + + {/* Metadata */} +
+
+
+ Author: + {step.author_name || 'Unknown'} +
+
+ Usage Count: + {step.usage_count} +
+
+ Created: + {new Date(step.created_at).toLocaleDateString()} +
+
+ Visibility: + {step.visibility} +
+
+
+
+ ) : null} +
+ + {/* Footer - Actions */} +
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/step-library/StepLibraryBrowser.tsx b/frontend/src/components/step-library/StepLibraryBrowser.tsx new file mode 100644 index 00000000..f45ec12c --- /dev/null +++ b/frontend/src/components/step-library/StepLibraryBrowser.tsx @@ -0,0 +1,364 @@ +import { useState, useEffect, useMemo } from 'react' +import { Search, ChevronDown, ChevronUp, Loader2 } from 'lucide-react' +import { cn } from '@/lib/utils' +import { stepsApi, stepCategoriesApi } from '@/api' +import { StepCard } from './StepCard' +import { StepDetailModal } from './StepDetailModal' +import type { Step, StepListItem, StepCategory, PopularTag, StepListParams } from '@/types/step' + +interface StepLibraryBrowserProps { + onInsert: (step: Step) => void + onCreateNew?: () => void + showCreateButton?: boolean +} + +export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = false }: StepLibraryBrowserProps) { + // State + const [steps, setSteps] = useState([]) + const [categories, setCategories] = useState([]) + const [popularTags, setPopularTags] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + // Filters + const [searchQuery, setSearchQuery] = useState('') + const [selectedCategoryId, setSelectedCategoryId] = useState() + const [selectedStepType, setSelectedStepType] = useState<'decision' | 'action' | 'solution' | undefined>() + const [minRating, setMinRating] = useState() + const [sortBy, setSortBy] = useState<'recent' | 'popular' | 'highest_rated' | 'most_used'>('recent') + const [selectedTag, setSelectedTag] = useState() + + // UI state + const [previewStepId, setPreviewStepId] = useState(null) + const [collapsedSections, setCollapsedSections] = useState>({}) + + // Load initial data + useEffect(() => { + const loadInitialData = async () => { + setIsLoading(true) + setError(null) + try { + const [categoriesData, tagsData] = await Promise.all([ + stepCategoriesApi.list(), + stepsApi.getPopularTags() + ]) + setCategories(categoriesData.filter(c => c.is_active)) + setPopularTags(tagsData.slice(0, 10)) // Show top 10 tags + } catch (err) { + console.error('Failed to load initial data:', err) + setError('Failed to load categories and tags') + } finally { + setIsLoading(false) + } + } + + loadInitialData() + }, []) + + // Load steps when filters change + useEffect(() => { + const loadSteps = async () => { + setIsLoading(true) + setError(null) + try { + const params: StepListParams = { + category_id: selectedCategoryId, + step_type: selectedStepType, + min_rating: minRating, + sort_by: sortBy, + tags: selectedTag ? [selectedTag] : undefined + } + + let stepsData: StepListItem[] + if (searchQuery.trim()) { + stepsData = await stepsApi.search(searchQuery) + } else { + stepsData = await stepsApi.list(params) + } + + setSteps(stepsData) + } catch (err) { + console.error('Failed to load steps:', err) + setError('Failed to load steps') + } finally { + setIsLoading(false) + } + } + + loadSteps() + }, [searchQuery, selectedCategoryId, selectedStepType, minRating, sortBy, selectedTag]) + + // Group steps by visibility + const groupedSteps = useMemo(() => { + return { + private: steps.filter(s => s.visibility === 'private'), + team: steps.filter(s => s.visibility === 'team'), + public: steps.filter(s => s.visibility === 'public') + } + }, [steps]) + + const toggleSection = (section: string) => { + setCollapsedSections(prev => ({ ...prev, [section]: !prev[section] })) + } + + const handlePreview = (step: StepListItem) => { + setPreviewStepId(step.id) + } + + const handleInsertFromPreview = (step: Step) => { + setPreviewStepId(null) + onInsert(step) + } + + const handleInsertFromCard = (stepItem: StepListItem) => { + // Need to fetch full step details for insert + stepsApi.get(stepItem.id).then(onInsert) + } + + const handleTagClick = (tag: string) => { + setSelectedTag(selectedTag === tag ? undefined : tag) + } + + const clearFilters = () => { + setSearchQuery('') + setSelectedCategoryId(undefined) + setSelectedStepType(undefined) + setMinRating(undefined) + setSelectedTag(undefined) + } + + const hasActiveFilters = searchQuery || selectedCategoryId || selectedStepType || minRating || selectedTag + + return ( +
+ {/* Header - Filters */} +
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="w-full rounded-md border border-input bg-background py-2 pl-10 pr-4 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+ + {/* Filter Row */} +
+ {/* Category Filter */} + + + {/* Type Filter */} + + + {/* Min Rating Filter */} + + + {/* Sort By */} + +
+ + {/* Popular Tags */} + {popularTags.length > 0 && ( +
+
Popular Tags:
+
+ {popularTags.map(tag => ( + + ))} +
+
+ )} + + {/* Clear Filters */} + {hasActiveFilters && ( + + )} +
+ + {/* Body - Step List */} +
+ {isLoading ? ( +
+ +
+ ) : error ? ( +
+ {error} +
+ ) : steps.length === 0 ? ( +
+

No steps found

+

+ {hasActiveFilters ? 'Try adjusting your filters' : 'Create your first step to get started!'} +

+
+ ) : ( +
+ {/* My Steps */} + {groupedSteps.private.length > 0 && ( +
+ + {!collapsedSections.private && ( +
+ {groupedSteps.private.map(step => ( + + ))} +
+ )} +
+ )} + + {/* Team Steps */} + {groupedSteps.team.length > 0 && ( +
+ + {!collapsedSections.team && ( +
+ {groupedSteps.team.map(step => ( + + ))} +
+ )} +
+ )} + + {/* Community Steps */} + {groupedSteps.public.length > 0 && ( +
+ + {!collapsedSections.public && ( +
+ {groupedSteps.public.map(step => ( + + ))} +
+ )} +
+ )} +
+ )} +
+ + {/* Footer - Optional Create Button */} + {showCreateButton && onCreateNew && ( +
+ +
+ )} + + {/* Preview Modal */} + {previewStepId && ( + setPreviewStepId(null)} + onInsert={handleInsertFromPreview} + /> + )} +
+ ) +}