mirror of
https://github.com/SamyRai/tercul-frontend.git
synced 2025-12-27 04:51:34 +00:00
Restructure components and add annotation system for enhanced reading
Refactors component structure, implements AnnotationSystem.tsx, and adds new components related to annotations and authors. Replit-Commit-Author: Agent Replit-Commit-Session-Id: cbacfb18-842a-4116-a907-18c0105ad8ec Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/39b5c689-6e8a-4d5a-9792-69cc81a56534/d1468a00-d93a-40b6-8ef2-3b7ba89b0f76.jpg
This commit is contained in:
parent
7859c113fd
commit
92d1419642
159
COMPONENT-IMPLEMENTATION-PRIORITY.md
Normal file
159
COMPONENT-IMPLEMENTATION-PRIORITY.md
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
# Component Implementation Priority
|
||||||
|
|
||||||
|
Based on the component analysis and the needs of the Tercul platform, the following implementation priority is recommended:
|
||||||
|
|
||||||
|
## Phase 1: Critical UI Components (Week 1)
|
||||||
|
|
||||||
|
### 1. Rich Text Editor (High Priority)
|
||||||
|
**File**: `components/ui/rich-text-editor.tsx`
|
||||||
|
|
||||||
|
Essential for blog post creation and editing, this component will enable content creators to format their posts with rich text features.
|
||||||
|
|
||||||
|
### 2. Stat Card (High Priority)
|
||||||
|
**File**: `components/dashboard/stats-card.tsx`
|
||||||
|
|
||||||
|
Needed for dashboard statistics display, this component will provide a consistent way to display metrics throughout the dashboard.
|
||||||
|
|
||||||
|
### 3. Empty State (High Priority)
|
||||||
|
**File**: `components/ui/empty-state.tsx`
|
||||||
|
|
||||||
|
Used throughout the application to show when data is missing, this component will provide a consistent user experience for empty states.
|
||||||
|
|
||||||
|
### 4. Tag Input (High Priority)
|
||||||
|
**File**: `components/ui/tag-input.tsx`
|
||||||
|
|
||||||
|
Used for blog post tags, work metadata, and filtering, this component will enable users to add and manage tags.
|
||||||
|
|
||||||
|
## Phase 2: Dashboard Components (Week 2)
|
||||||
|
|
||||||
|
### 1. Dashboard Header
|
||||||
|
**File**: `components/dashboard/dashboard-header.tsx`
|
||||||
|
|
||||||
|
Provides a consistent header for all dashboard pages with breadcrumbs and actions.
|
||||||
|
|
||||||
|
### 2. Activity Feed
|
||||||
|
**File**: `components/dashboard/activity-feed.tsx`
|
||||||
|
|
||||||
|
Shows recent editorial activity in the dashboard, enabling editors to track changes.
|
||||||
|
|
||||||
|
### 3. Content Queue
|
||||||
|
**File**: `components/dashboard/content-queue.tsx`
|
||||||
|
|
||||||
|
Displays pending content that needs review or approval, essential for editorial workflows.
|
||||||
|
|
||||||
|
## Phase 3: Work Management Components (Week 3)
|
||||||
|
|
||||||
|
### 1. Work Editor
|
||||||
|
**File**: `components/work/work-editor.tsx`
|
||||||
|
|
||||||
|
Enables creation and editing of literary works with specialized features.
|
||||||
|
|
||||||
|
### 2. Work Header
|
||||||
|
**File**: `components/work/work-header.tsx`
|
||||||
|
|
||||||
|
Provides consistent display of work metadata across the platform.
|
||||||
|
|
||||||
|
### 3. Comparison View
|
||||||
|
**File**: `components/work/comparison-view.tsx`
|
||||||
|
|
||||||
|
Enables side-by-side comparison of translations with synchronized scrolling.
|
||||||
|
|
||||||
|
## Phase 4: Author Components (Week 4)
|
||||||
|
|
||||||
|
### 1. Author Editor
|
||||||
|
**File**: `components/authors/author-editor.tsx`
|
||||||
|
|
||||||
|
Enables creation and editing of author profiles with timeline events.
|
||||||
|
|
||||||
|
### 2. Author Card
|
||||||
|
**File**: `components/authors/author-card.tsx`
|
||||||
|
|
||||||
|
Provides a consistent way to display author information in lists and grids.
|
||||||
|
|
||||||
|
### 3. Author Header
|
||||||
|
**File**: `components/authors/author-header.tsx`
|
||||||
|
|
||||||
|
Displays author information at the top of author profile pages.
|
||||||
|
|
||||||
|
## Phase 5: Comment and Annotation Components (Week 5)
|
||||||
|
|
||||||
|
### 1. Comment Thread
|
||||||
|
**File**: `components/comment/comment-thread.tsx`
|
||||||
|
|
||||||
|
Enables threaded discussions on works and blog posts with proper nesting.
|
||||||
|
|
||||||
|
### 2. Annotation Editor
|
||||||
|
**File**: `components/annotation/annotation-editor.tsx`
|
||||||
|
|
||||||
|
Provides a specialized interface for creating and editing annotations on works.
|
||||||
|
|
||||||
|
### 3. Annotation Browser
|
||||||
|
**File**: `components/annotation/annotation-browser.tsx`
|
||||||
|
|
||||||
|
Enables users to browse and filter annotations on works.
|
||||||
|
|
||||||
|
## Phase 6: Search and User Components (Week 6)
|
||||||
|
|
||||||
|
### 1. Advanced Search
|
||||||
|
**File**: `components/search/advanced-search.tsx`
|
||||||
|
|
||||||
|
Provides powerful search capabilities with multiple filters and options.
|
||||||
|
|
||||||
|
### 2. Profile Card
|
||||||
|
**File**: `components/user/profile-card.tsx`
|
||||||
|
|
||||||
|
Displays user information consistently across the platform.
|
||||||
|
|
||||||
|
### 3. Reading Progress
|
||||||
|
**File**: `components/user/reading-progress.tsx`
|
||||||
|
|
||||||
|
Tracks and displays user reading progress on works.
|
||||||
|
|
||||||
|
## Phase 7: Blog Components (Week 7)
|
||||||
|
|
||||||
|
### 1. Blog Editor
|
||||||
|
**File**: `components/blog/blog-editor.tsx`
|
||||||
|
|
||||||
|
Provides a specialized interface for creating and editing blog posts, using the rich text editor.
|
||||||
|
|
||||||
|
### 2. Blog Preview
|
||||||
|
**File**: `components/blog/blog-preview.tsx`
|
||||||
|
|
||||||
|
Displays blog post previews in lists and grids with consistent styling.
|
||||||
|
|
||||||
|
### 3. Publication Scheduler
|
||||||
|
**File**: `components/blog/publication-scheduler.tsx`
|
||||||
|
|
||||||
|
Enables scheduling of blog posts for future publication.
|
||||||
|
|
||||||
|
## Phase 8: Pattern Components (Week 8)
|
||||||
|
|
||||||
|
### 1. Modal Forms
|
||||||
|
**File**: `components/patterns/modal-forms.tsx`
|
||||||
|
|
||||||
|
Provides consistent patterns for forms in modal dialogs throughout the platform.
|
||||||
|
|
||||||
|
### 2. Action Toolbar
|
||||||
|
**File**: `components/patterns/action-toolbar.tsx`
|
||||||
|
|
||||||
|
Standardizes action button groups for consistent UX across the platform.
|
||||||
|
|
||||||
|
### 3. Card Grid
|
||||||
|
**File**: `components/patterns/card-grid.tsx`
|
||||||
|
|
||||||
|
Provides a consistent way to display grids of cards (works, authors, blog posts).
|
||||||
|
|
||||||
|
## Implementation Approach
|
||||||
|
|
||||||
|
For each component:
|
||||||
|
|
||||||
|
1. **Define Props Interface**: Start by defining the component's props interface with proper TypeScript types
|
||||||
|
2. **Create Basic Structure**: Implement the basic component structure with proper HTML semantics
|
||||||
|
3. **Add Styling**: Apply consistent styling using Tailwind CSS classes
|
||||||
|
4. **Implement Behavior**: Add state management and event handlers
|
||||||
|
5. **Test Edge Cases**: Ensure the component handles all edge cases gracefully
|
||||||
|
6. **Add Accessibility**: Ensure proper ARIA attributes and keyboard navigation
|
||||||
|
7. **Document Usage**: Add JSDoc comments explaining how to use the component
|
||||||
|
8. **Create Examples**: Include usage examples in comments or documentation
|
||||||
|
|
||||||
|
This phased approach ensures that the most critical components are implemented first, providing immediate value while building toward a complete component library.
|
||||||
174
COMPONENT-STRUCTURE.md
Normal file
174
COMPONENT-STRUCTURE.md
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
# Tercul Component Structure
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The component structure has been reorganized to improve maintainability, scalability, and code reuse. Components are organized by their domain and functionality.
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
### UI Components (`/components/ui`)
|
||||||
|
Basic, reusable UI components with minimal business logic. These serve as building blocks for more complex components.
|
||||||
|
|
||||||
|
- **Typography Components** (`/components/ui/typography`)
|
||||||
|
- heading.tsx - For h1-h6 headings with consistent styling
|
||||||
|
- paragraph.tsx - For text content with various styles
|
||||||
|
- quote.tsx - For blockquotes and citations
|
||||||
|
- code-block.tsx - For code snippets
|
||||||
|
|
||||||
|
- **Data Display Components**
|
||||||
|
- stat-card.tsx - For displaying statistics
|
||||||
|
- timeline.tsx - For chronological events visualization
|
||||||
|
- tree-view.tsx - For hierarchical data
|
||||||
|
- tag-cloud.tsx - For tag visualization
|
||||||
|
- file-viewer.tsx - For previewing documents
|
||||||
|
- code-highlighter.tsx - For syntax highlighting
|
||||||
|
|
||||||
|
- **Feedback Components**
|
||||||
|
- empty-state.tsx - For when no data is available
|
||||||
|
- error-state.tsx - For error handling
|
||||||
|
- loading-spinner.tsx - For loading indicators
|
||||||
|
- status-indicator.tsx - For success/failure states
|
||||||
|
- confirmation-dialog.tsx - For user confirmations
|
||||||
|
|
||||||
|
- **Navigation Components**
|
||||||
|
- stepper.tsx - For multi-step processes
|
||||||
|
- vertical-navigation.tsx - For side navigation
|
||||||
|
- breadcrumbs.tsx - For navigation trails
|
||||||
|
|
||||||
|
- **Form Components**
|
||||||
|
- rich-text-editor.tsx - For content creation
|
||||||
|
- tag-input.tsx - For adding/managing tags
|
||||||
|
- date-range-picker.tsx - For date selection
|
||||||
|
- file-uploader.tsx - For file uploads
|
||||||
|
- multi-select.tsx - For multiple selections
|
||||||
|
- autocomplete.tsx - For search suggestions
|
||||||
|
- form-wizard.tsx - For multi-step forms
|
||||||
|
|
||||||
|
- **Layout Components**
|
||||||
|
- grid.tsx - For grid layouts
|
||||||
|
- masonry.tsx - For pinterest-style layouts
|
||||||
|
- split-pane.tsx - For resizable panels
|
||||||
|
- spotlight.tsx - For featured content
|
||||||
|
- responsive-container.tsx - For responsive design
|
||||||
|
|
||||||
|
- **Specialized Components**
|
||||||
|
- color-picker.tsx - For color selection
|
||||||
|
- rating.tsx - For rating inputs
|
||||||
|
- comparison-slider.tsx - For before/after comparisons
|
||||||
|
- share-buttons.tsx - For social sharing
|
||||||
|
- expandable-search.tsx - For search interfaces
|
||||||
|
- notification-badge.tsx - For alerts/notifications
|
||||||
|
|
||||||
|
### Domain-Specific Components
|
||||||
|
|
||||||
|
#### Blog Components (`/components/blog`)
|
||||||
|
Components specific to the blog functionality.
|
||||||
|
|
||||||
|
- blog-editor.tsx - For creating/editing blog posts
|
||||||
|
- blog-preview.tsx - For displaying blog post cards
|
||||||
|
- tag-manager.tsx - For managing post tags
|
||||||
|
- publication-scheduler.tsx - For scheduling posts
|
||||||
|
- featured-post.tsx - For highlighting posts
|
||||||
|
|
||||||
|
#### Dashboard Components (`/components/dashboard`)
|
||||||
|
Components for the editorial dashboard.
|
||||||
|
|
||||||
|
- stats-card.tsx - For dashboard statistics
|
||||||
|
- activity-feed.tsx - For recent activities
|
||||||
|
- content-queue.tsx - For approval workflows
|
||||||
|
- stats-chart.tsx - For data visualization
|
||||||
|
- dashboard-header.tsx - For dashboard navigation
|
||||||
|
- role-badge.tsx - For user role indication
|
||||||
|
|
||||||
|
#### Work Components (`/components/work`)
|
||||||
|
Components for literary works management and display.
|
||||||
|
|
||||||
|
- work-editor.tsx - For creating/editing works
|
||||||
|
- work-header.tsx - For work metadata display
|
||||||
|
- work-status.tsx - For publication status
|
||||||
|
- related-works.tsx - For similar works
|
||||||
|
- comparison-view.tsx - For comparing translations
|
||||||
|
- EnhancedReadingView.tsx - Enhanced reading experience
|
||||||
|
- LineNumberedText.tsx - Text with line numbers
|
||||||
|
- EnhancedLineNumberedText.tsx - Advanced text display
|
||||||
|
- TranslationSelector.tsx - For selecting translations
|
||||||
|
- ReadingControls.tsx - Reading interface controls
|
||||||
|
- ReadingView.tsx - Main reading component
|
||||||
|
- WorkCard.tsx - Work preview card
|
||||||
|
|
||||||
|
#### Author Components (`/components/authors`)
|
||||||
|
Components for author profiles and management.
|
||||||
|
|
||||||
|
- author-editor.tsx - For creating/editing authors
|
||||||
|
- author-card.tsx - Author preview card
|
||||||
|
- author-header.tsx - Author profile header
|
||||||
|
- author-stats.tsx - Author statistics
|
||||||
|
- related-authors.tsx - Similar authors
|
||||||
|
- AuthorTimeline.tsx - Author timeline component
|
||||||
|
- AuthorChip.tsx - Small author reference
|
||||||
|
|
||||||
|
#### Comment Components (`/components/comment`)
|
||||||
|
Components for the comment system.
|
||||||
|
|
||||||
|
- comment-thread.tsx - Threaded comments
|
||||||
|
- comment-editor.tsx - For writing comments
|
||||||
|
- moderation-tools.tsx - For moderating comments
|
||||||
|
- comment-filters.tsx - For filtering comments
|
||||||
|
|
||||||
|
#### Annotation Components (`/components/annotation`)
|
||||||
|
Components for the annotation system.
|
||||||
|
|
||||||
|
- AnnotationSystem.tsx - Main annotation component
|
||||||
|
- annotation-editor.tsx - For creating annotations
|
||||||
|
- annotation-browser.tsx - For browsing annotations
|
||||||
|
- inline-form.tsx - For inline annotation creation
|
||||||
|
- annotation-filters.tsx - For filtering annotations
|
||||||
|
|
||||||
|
#### User Components (`/components/user`)
|
||||||
|
Components for user profiles and management.
|
||||||
|
|
||||||
|
- profile-card.tsx - User profile display
|
||||||
|
- user-avatar.tsx - User avatar with status
|
||||||
|
- reading-progress.tsx - Reading progress tracker
|
||||||
|
- bookmarks-manager.tsx - Bookmarks interface
|
||||||
|
- preferences-form.tsx - User settings
|
||||||
|
|
||||||
|
#### Search Components (`/components/search`)
|
||||||
|
Components for search functionality.
|
||||||
|
|
||||||
|
- SearchBar.tsx - Main search input
|
||||||
|
- advanced-search.tsx - Advanced search interface
|
||||||
|
- search-filters.tsx - Search filter controls
|
||||||
|
- results-toggle.tsx - Grid/list view toggle
|
||||||
|
- pagination.tsx - Result pagination
|
||||||
|
- sort-controls.tsx - Result sorting
|
||||||
|
- FilterSidebar.tsx - Sidebar with filters
|
||||||
|
|
||||||
|
#### Pattern Components (`/components/patterns`)
|
||||||
|
Reusable component patterns and combinations.
|
||||||
|
|
||||||
|
- empty-states.tsx - Empty state patterns
|
||||||
|
- modal-forms.tsx - Modal dialog forms
|
||||||
|
- action-toolbar.tsx - Action button groups
|
||||||
|
- filter-controls.tsx - Standardized filters
|
||||||
|
- card-grid.tsx - Grid of cards
|
||||||
|
|
||||||
|
### Layout Components (`/components/layout`)
|
||||||
|
Components for page layout.
|
||||||
|
|
||||||
|
- PageLayout.tsx - Standard page wrapper
|
||||||
|
- DashboardLayout.tsx - Dashboard layout
|
||||||
|
- Footer.tsx - Site footer
|
||||||
|
- NavHeader.tsx - Navigation header
|
||||||
|
|
||||||
|
## Implementation Status
|
||||||
|
|
||||||
|
Most components are currently empty placeholder files. The implementation priority should follow the order outlined in the COMPONENT-IMPLEMENTATION-PLAN.md document.
|
||||||
|
|
||||||
|
## Key Benefits of This Structure
|
||||||
|
|
||||||
|
1. **Separation of Concerns**: UI components are separate from domain-specific components
|
||||||
|
2. **Reusability**: Common patterns are extracted into reusable components
|
||||||
|
3. **Discoverability**: Developers can easily find components by their domain
|
||||||
|
4. **Maintainability**: Related components are grouped together
|
||||||
|
5. **Scalability**: New domains can be added without affecting existing structure
|
||||||
380
client/src/components/annotation/AnnotationSystem.tsx
Normal file
380
client/src/components/annotation/AnnotationSystem.tsx
Normal file
@ -0,0 +1,380 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Annotation } from '@/lib/types';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { apiRequest, queryClient } from '@/lib/queryClient';
|
||||||
|
import { MessageSquare, X, ThumbsUp, MessageCircle, Edit, Trash } from 'lucide-react';
|
||||||
|
|
||||||
|
interface AnnotationSystemProps {
|
||||||
|
workId: number;
|
||||||
|
selectedLineNumber: number | null;
|
||||||
|
onClose: () => void;
|
||||||
|
translationId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnnotationSystem({
|
||||||
|
workId,
|
||||||
|
selectedLineNumber,
|
||||||
|
onClose,
|
||||||
|
translationId
|
||||||
|
}: AnnotationSystemProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [annotations, setAnnotations] = useState<Annotation[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [newAnnotation, setNewAnnotation] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [editingAnnotationId, setEditingAnnotationId] = useState<number | null>(null);
|
||||||
|
const [editText, setEditText] = useState('');
|
||||||
|
|
||||||
|
const annotationRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Mock user data - in a real app this would come from auth
|
||||||
|
const currentUser = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Anonymous',
|
||||||
|
avatar: null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch annotations for the selected line
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedLineNumber) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// Simulate API call to get annotations for the selected line
|
||||||
|
setTimeout(() => {
|
||||||
|
// These would be fetched from the API in a real app
|
||||||
|
const mockAnnotations: Annotation[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
workId,
|
||||||
|
translationId,
|
||||||
|
lineNumber: selectedLineNumber,
|
||||||
|
userId: 2,
|
||||||
|
userName: 'Literary Scholar',
|
||||||
|
userAvatar: null,
|
||||||
|
content: 'This line demonstrates the poet\'s use of alliteration, creating a rhythmic pattern that emphasizes the emotional tone.',
|
||||||
|
createdAt: new Date(Date.now() - 1000000),
|
||||||
|
likes: 5,
|
||||||
|
liked: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
workId,
|
||||||
|
translationId,
|
||||||
|
lineNumber: selectedLineNumber,
|
||||||
|
userId: 3,
|
||||||
|
userName: 'Translator',
|
||||||
|
userAvatar: null,
|
||||||
|
content: 'The original meaning in Russian contains a wordplay that is difficult to capture in English. A more literal translation might read as...',
|
||||||
|
createdAt: new Date(Date.now() - 5000000),
|
||||||
|
likes: 12,
|
||||||
|
liked: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
setAnnotations(mockAnnotations);
|
||||||
|
setIsLoading(false);
|
||||||
|
}, 600);
|
||||||
|
}, [workId, selectedLineNumber, translationId]);
|
||||||
|
|
||||||
|
// Submit new annotation
|
||||||
|
const handleSubmitAnnotation = async () => {
|
||||||
|
if (!newAnnotation.trim() || !selectedLineNumber) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// In a real app, this would be an API call
|
||||||
|
// Mock API response
|
||||||
|
const newAnnotationObj: Annotation = {
|
||||||
|
id: Date.now(),
|
||||||
|
workId,
|
||||||
|
translationId,
|
||||||
|
lineNumber: selectedLineNumber,
|
||||||
|
userId: currentUser.id,
|
||||||
|
userName: currentUser.name,
|
||||||
|
userAvatar: currentUser.avatar,
|
||||||
|
content: newAnnotation,
|
||||||
|
createdAt: new Date(),
|
||||||
|
likes: 0,
|
||||||
|
liked: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optimistically update UI
|
||||||
|
setAnnotations(prev => [newAnnotationObj, ...prev]);
|
||||||
|
setNewAnnotation('');
|
||||||
|
|
||||||
|
toast({
|
||||||
|
description: "Annotation added successfully",
|
||||||
|
});
|
||||||
|
|
||||||
|
// In a real app, this would invalidate the query cache
|
||||||
|
// queryClient.invalidateQueries({ queryKey: [`/api/works/${workId}/annotations/${selectedLineNumber}`] });
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to add annotation",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Like an annotation
|
||||||
|
const handleLikeAnnotation = async (annotationId: number) => {
|
||||||
|
try {
|
||||||
|
// Optimistically update UI
|
||||||
|
setAnnotations(prev =>
|
||||||
|
prev.map(anno =>
|
||||||
|
anno.id === annotationId
|
||||||
|
? { ...anno, liked: !anno.liked, likes: anno.liked ? anno.likes - 1 : anno.likes + 1 }
|
||||||
|
: anno
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// In a real app, this would be an API call
|
||||||
|
// await apiRequest('POST', `/api/annotations/${annotationId}/like`, { userId: currentUser.id });
|
||||||
|
} catch (error) {
|
||||||
|
// Revert optimistic update if there's an error
|
||||||
|
setAnnotations(prev => [...prev]);
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to update like",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete annotation
|
||||||
|
const handleDeleteAnnotation = async (annotationId: number) => {
|
||||||
|
try {
|
||||||
|
// Optimistically update UI
|
||||||
|
const filteredAnnotations = annotations.filter(anno => anno.id !== annotationId);
|
||||||
|
setAnnotations(filteredAnnotations);
|
||||||
|
|
||||||
|
// In a real app, this would be an API call
|
||||||
|
// await apiRequest('DELETE', `/api/annotations/${annotationId}`);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
description: "Annotation deleted",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Revert optimistic update if there's an error
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to delete annotation",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start editing an annotation
|
||||||
|
const handleStartEdit = (annotation: Annotation) => {
|
||||||
|
setEditingAnnotationId(annotation.id);
|
||||||
|
setEditText(annotation.content);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save edited annotation
|
||||||
|
const handleSaveEdit = async (annotationId: number) => {
|
||||||
|
if (!editText.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Optimistically update UI
|
||||||
|
setAnnotations(prev =>
|
||||||
|
prev.map(anno =>
|
||||||
|
anno.id === annotationId
|
||||||
|
? { ...anno, content: editText }
|
||||||
|
: anno
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset edit state
|
||||||
|
setEditingAnnotationId(null);
|
||||||
|
setEditText('');
|
||||||
|
|
||||||
|
// In a real app, this would be an API call
|
||||||
|
// await apiRequest('PATCH', `/api/annotations/${annotationId}`, { content: editText });
|
||||||
|
|
||||||
|
toast({
|
||||||
|
description: "Annotation updated",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to update annotation",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cancel editing
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setEditingAnnotationId(null);
|
||||||
|
setEditText('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// If no line is selected, don't render anything
|
||||||
|
if (!selectedLineNumber) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={annotationRef}
|
||||||
|
className="annotation-panel bg-cream dark:bg-dark-surface border-l border-sage/20 dark:border-sage/10 w-80 lg:w-96 fixed top-0 right-0 bottom-0 z-50 overflow-y-auto transition-transform shadow-xl"
|
||||||
|
>
|
||||||
|
<div className="sticky top-0 z-10 bg-cream dark:bg-dark-surface border-b border-sage/20 dark:border-sage/10 px-4 py-3 flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MessageSquare className="h-5 w-5 text-russet" />
|
||||||
|
<h3 className="font-medium text-navy dark:text-cream">
|
||||||
|
Line {selectedLineNumber} Annotations
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
{/* New annotation form */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Textarea
|
||||||
|
placeholder="Add your annotation to this line..."
|
||||||
|
className="min-h-24 resize-y"
|
||||||
|
value={newAnnotation}
|
||||||
|
onChange={(e) => setNewAnnotation(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end mt-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmitAnnotation}
|
||||||
|
disabled={isSubmitting || !newAnnotation.trim()}
|
||||||
|
className="bg-russet hover:bg-russet/90 text-white"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Submitting...' : 'Add Annotation'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Annotations list */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="loader"></div>
|
||||||
|
<p className="text-navy/70 dark:text-cream/70 mt-2">Loading annotations...</p>
|
||||||
|
</div>
|
||||||
|
) : annotations.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<MessageCircle className="h-12 w-12 mx-auto text-navy/30 dark:text-cream/30" />
|
||||||
|
<p className="text-navy/70 dark:text-cream/70 mt-2">No annotations yet</p>
|
||||||
|
<p className="text-sm text-navy/50 dark:text-cream/50">Be the first to annotate this line</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
annotations.map(annotation => (
|
||||||
|
<Card key={annotation.id} className="border-sage/20 dark:border-sage/10">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarImage src={annotation.userAvatar || ''} alt={annotation.userName} />
|
||||||
|
<AvatarFallback className="text-xs bg-navy/10 dark:bg-navy/20 text-navy dark:text-cream">
|
||||||
|
{annotation.userName.charAt(0).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-sm font-medium text-navy dark:text-cream">
|
||||||
|
{annotation.userName}
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-xs text-navy/60 dark:text-cream/60">
|
||||||
|
{new Date(annotation.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit/Delete buttons for user's own annotations */}
|
||||||
|
{annotation.userId === currentUser.id && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => handleStartEdit(annotation)}
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Edit</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
||||||
|
onClick={() => handleDeleteAnnotation(annotation.id)}
|
||||||
|
>
|
||||||
|
<Trash className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Delete</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="pt-2">
|
||||||
|
{editingAnnotationId === annotation.id ? (
|
||||||
|
<div>
|
||||||
|
<Textarea
|
||||||
|
value={editText}
|
||||||
|
onChange={(e) => setEditText(e.target.value)}
|
||||||
|
className="min-h-24 resize-y mb-2"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleCancelEdit}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleSaveEdit(annotation.id)}
|
||||||
|
disabled={!editText.trim()}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-navy/90 dark:text-cream/90 whitespace-pre-wrap">{annotation.content}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="pt-2 flex justify-between items-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={`flex items-center gap-1 text-xs ${
|
||||||
|
annotation.liked
|
||||||
|
? 'text-russet'
|
||||||
|
: 'text-navy/70 dark:text-cream/70'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleLikeAnnotation(annotation.id)}
|
||||||
|
>
|
||||||
|
<ThumbsUp className={`h-4 w-4 ${annotation.liked ? 'fill-russet' : ''}`} />
|
||||||
|
<span>{annotation.likes}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs text-navy/70 dark:text-cream/70"
|
||||||
|
>
|
||||||
|
<MessageCircle className="h-4 w-4 mr-1" />
|
||||||
|
<span>Reply</span>
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
0
client/src/components/annotation/inline-form.tsx
Normal file
0
client/src/components/annotation/inline-form.tsx
Normal file
80
client/src/components/authors/AuthorChip.tsx
Normal file
80
client/src/components/authors/AuthorChip.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { Link } from "wouter";
|
||||||
|
import { Author } from "@shared/schema";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
|
||||||
|
interface AuthorChipProps {
|
||||||
|
author?: Author;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
withLifeDates?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthorChip({ author, size = 'md', withLifeDates = false, className = '' }: AuthorChipProps) {
|
||||||
|
// Helper function to get avatar size class
|
||||||
|
function getAvatarSize() {
|
||||||
|
switch (size) {
|
||||||
|
case 'sm': return 'w-8 h-8';
|
||||||
|
case 'lg': return 'w-12 h-12';
|
||||||
|
default: return 'w-10 h-10';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get text size class
|
||||||
|
function getTextSize() {
|
||||||
|
switch (size) {
|
||||||
|
case 'sm': return 'text-sm';
|
||||||
|
case 'lg': return 'text-lg';
|
||||||
|
default: return 'text-base';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get initials for avatar fallback
|
||||||
|
function getInitials(name: string) {
|
||||||
|
return name
|
||||||
|
.split(' ')
|
||||||
|
.map(part => part.charAt(0))
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If author is undefined, return a placeholder
|
||||||
|
if (!author) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-2 ${className}`}>
|
||||||
|
<div className={`flex items-center justify-center rounded-full bg-navy/10 dark:bg-navy/20 overflow-hidden flex-shrink-0 ${getAvatarSize()}`}>
|
||||||
|
<span className="text-navy/60 dark:text-cream/60">?</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className={`${getTextSize()} text-navy/70 dark:text-cream/70 font-medium`}>
|
||||||
|
Unknown Author
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If author is defined, return the full component
|
||||||
|
return (
|
||||||
|
<Link href={`/authors/${author.slug}`}>
|
||||||
|
<div className={`flex items-center gap-2 group ${className}`}>
|
||||||
|
<Avatar className={`rounded-full bg-navy/10 dark:bg-navy/20 overflow-hidden flex-shrink-0 ${getAvatarSize()}`}>
|
||||||
|
<AvatarImage src={author.portrait || ''} alt={author.name} />
|
||||||
|
<AvatarFallback className="bg-navy/10 dark:bg-navy/20 text-navy dark:text-cream">
|
||||||
|
{getInitials(author.name)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<h3 className={`${getTextSize()} text-navy dark:text-cream font-medium group-hover:text-russet dark:group-hover:text-russet/90 transition-colors`}>
|
||||||
|
{author.name}
|
||||||
|
</h3>
|
||||||
|
{withLifeDates && author.birthYear && (
|
||||||
|
<p className="text-xs text-navy/70 dark:text-cream/70">
|
||||||
|
{author.birthYear}–{author.deathYear || 'present'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
0
client/src/components/authors/author-card.tsx
Normal file
0
client/src/components/authors/author-card.tsx
Normal file
0
client/src/components/authors/author-editor.tsx
Normal file
0
client/src/components/authors/author-editor.tsx
Normal file
0
client/src/components/authors/author-header.tsx
Normal file
0
client/src/components/authors/author-header.tsx
Normal file
0
client/src/components/authors/author-stats.tsx
Normal file
0
client/src/components/authors/author-stats.tsx
Normal file
0
client/src/components/authors/related-authors.tsx
Normal file
0
client/src/components/authors/related-authors.tsx
Normal file
0
client/src/components/blog/blog-editor.tsx
Normal file
0
client/src/components/blog/blog-editor.tsx
Normal file
0
client/src/components/blog/blog-preview.tsx
Normal file
0
client/src/components/blog/blog-preview.tsx
Normal file
0
client/src/components/blog/featured-post.tsx
Normal file
0
client/src/components/blog/featured-post.tsx
Normal file
0
client/src/components/blog/tag-manager.tsx
Normal file
0
client/src/components/blog/tag-manager.tsx
Normal file
0
client/src/components/comment/comment-editor.tsx
Normal file
0
client/src/components/comment/comment-editor.tsx
Normal file
0
client/src/components/comment/comment-filters.tsx
Normal file
0
client/src/components/comment/comment-filters.tsx
Normal file
0
client/src/components/comment/comment-thread.tsx
Normal file
0
client/src/components/comment/comment-thread.tsx
Normal file
0
client/src/components/comment/moderation-tools.tsx
Normal file
0
client/src/components/comment/moderation-tools.tsx
Normal file
0
client/src/components/dashboard/activity-feed.tsx
Normal file
0
client/src/components/dashboard/activity-feed.tsx
Normal file
0
client/src/components/dashboard/content-queue.tsx
Normal file
0
client/src/components/dashboard/content-queue.tsx
Normal file
0
client/src/components/dashboard/role-badge.tsx
Normal file
0
client/src/components/dashboard/role-badge.tsx
Normal file
0
client/src/components/dashboard/stats-card.tsx
Normal file
0
client/src/components/dashboard/stats-card.tsx
Normal file
0
client/src/components/dashboard/stats-chart.tsx
Normal file
0
client/src/components/dashboard/stats-chart.tsx
Normal file
0
client/src/components/patterns/action-toolbar.tsx
Normal file
0
client/src/components/patterns/action-toolbar.tsx
Normal file
0
client/src/components/patterns/card-grid.tsx
Normal file
0
client/src/components/patterns/card-grid.tsx
Normal file
0
client/src/components/patterns/empty-states.tsx
Normal file
0
client/src/components/patterns/empty-states.tsx
Normal file
0
client/src/components/patterns/filter-controls.tsx
Normal file
0
client/src/components/patterns/filter-controls.tsx
Normal file
0
client/src/components/patterns/modal-forms.tsx
Normal file
0
client/src/components/patterns/modal-forms.tsx
Normal file
364
client/src/components/search/FilterSidebar.tsx
Normal file
364
client/src/components/search/FilterSidebar.tsx
Normal file
@ -0,0 +1,364 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Tag } from "@shared/schema";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
interface FilterState {
|
||||||
|
language?: string;
|
||||||
|
type?: string;
|
||||||
|
yearStart?: number;
|
||||||
|
yearEnd?: number;
|
||||||
|
tags?: number[];
|
||||||
|
query?: string;
|
||||||
|
sort?: string;
|
||||||
|
page: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterSidebarProps {
|
||||||
|
filters: FilterState;
|
||||||
|
onFilterChange: (filters: Partial<FilterState>) => void;
|
||||||
|
tags: Tag[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterSidebar({ filters, onFilterChange, tags }: FilterSidebarProps) {
|
||||||
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
|
||||||
|
// Group tags by type
|
||||||
|
const tagsByType = tags.reduce((acc: Record<string, Tag[]>, tag) => {
|
||||||
|
if (!acc[tag.type]) {
|
||||||
|
acc[tag.type] = [];
|
||||||
|
}
|
||||||
|
acc[tag.type].push(tag);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Common languages
|
||||||
|
const languages = ["English", "Russian", "French", "German", "Spanish", "Italian", "Chinese", "Japanese"];
|
||||||
|
|
||||||
|
// Work types
|
||||||
|
const workTypes = [
|
||||||
|
{ value: "poem", label: "Poetry" },
|
||||||
|
{ value: "story", label: "Short Story" },
|
||||||
|
{ value: "novel", label: "Novel" },
|
||||||
|
{ value: "play", label: "Play" },
|
||||||
|
{ value: "essay", label: "Essay" },
|
||||||
|
{ value: "other", label: "Other" }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Time periods (centuries)
|
||||||
|
const centuries = [
|
||||||
|
{ value: [1700, 1799], label: "18th Century" },
|
||||||
|
{ value: [1800, 1899], label: "19th Century" },
|
||||||
|
{ value: [1900, 1999], label: "20th Century" },
|
||||||
|
{ value: [2000, 2099], label: "21st Century" }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sort options
|
||||||
|
const sortOptions = [
|
||||||
|
{ value: "popularity", label: "Popularity" },
|
||||||
|
{ value: "recent", label: "Recently Added" },
|
||||||
|
{ value: "title_asc", label: "Title (A-Z)" },
|
||||||
|
{ value: "title_desc", label: "Title (Z-A)" },
|
||||||
|
{ value: "year_desc", label: "Year (Newest)" },
|
||||||
|
{ value: "year_asc", label: "Year (Oldest)" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleTagChange = (tagId: number, checked: boolean) => {
|
||||||
|
if (!filters.tags) {
|
||||||
|
if (checked) {
|
||||||
|
onFilterChange({ tags: [tagId] });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
onFilterChange({ tags: [...filters.tags, tagId] });
|
||||||
|
} else {
|
||||||
|
onFilterChange({ tags: filters.tags.filter(id => id !== tagId) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearFilters = () => {
|
||||||
|
onFilterChange({
|
||||||
|
language: undefined,
|
||||||
|
type: undefined,
|
||||||
|
yearStart: undefined,
|
||||||
|
yearEnd: undefined,
|
||||||
|
tags: undefined,
|
||||||
|
sort: 'popularity'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lg:w-64 flex-shrink-0">
|
||||||
|
<div className="lg:sticky lg:top-24 space-y-6">
|
||||||
|
<div className="flex items-center justify-between lg:hidden mb-2">
|
||||||
|
<h3 className="text-lg font-medium">Filters</h3>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setExpanded(!expanded)}>
|
||||||
|
{expanded ? 'Hide filters' : 'Show filters'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(expanded || window.innerWidth >= 1024) && (
|
||||||
|
<>
|
||||||
|
<Accordion type="multiple" defaultValue={["language", "type", "period", "sort", "tags"]}>
|
||||||
|
<AccordionItem value="language">
|
||||||
|
<AccordionTrigger>Language</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{languages.map(language => (
|
||||||
|
<div key={language} className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`language-${language}`}
|
||||||
|
checked={filters.language === language}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
onFilterChange({ language });
|
||||||
|
} else {
|
||||||
|
onFilterChange({ language: undefined });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`language-${language}`}
|
||||||
|
className="text-sm text-navy/80 dark:text-cream/80 cursor-pointer"
|
||||||
|
>
|
||||||
|
{language}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem value="type">
|
||||||
|
<AccordionTrigger>Work Type</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<RadioGroup
|
||||||
|
value={filters.type || ""}
|
||||||
|
onValueChange={(value) => onFilterChange({ type: value || undefined })}
|
||||||
|
>
|
||||||
|
{workTypes.map(type => (
|
||||||
|
<div key={type.value} className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value={type.value} id={`type-${type.value}`} />
|
||||||
|
<Label
|
||||||
|
htmlFor={`type-${type.value}`}
|
||||||
|
className="text-sm text-navy/80 dark:text-cream/80 cursor-pointer"
|
||||||
|
>
|
||||||
|
{type.label}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center space-x-2 mt-2">
|
||||||
|
<RadioGroupItem value="" id="type-all" />
|
||||||
|
<Label
|
||||||
|
htmlFor="type-all"
|
||||||
|
className="text-sm text-navy/80 dark:text-cream/80 cursor-pointer"
|
||||||
|
>
|
||||||
|
All Types
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem value="period">
|
||||||
|
<AccordionTrigger>Time Period</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{centuries.map(century => (
|
||||||
|
<div key={century.label} className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`century-${century.label}`}
|
||||||
|
checked={
|
||||||
|
filters.yearStart === century.value[0] &&
|
||||||
|
filters.yearEnd === century.value[1]
|
||||||
|
}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
onFilterChange({
|
||||||
|
yearStart: century.value[0],
|
||||||
|
yearEnd: century.value[1]
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
filters.yearStart === century.value[0] &&
|
||||||
|
filters.yearEnd === century.value[1]
|
||||||
|
) {
|
||||||
|
onFilterChange({
|
||||||
|
yearStart: undefined,
|
||||||
|
yearEnd: undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`century-${century.label}`}
|
||||||
|
className="text-sm text-navy/80 dark:text-cream/80 cursor-pointer"
|
||||||
|
>
|
||||||
|
{century.label}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center gap-2">
|
||||||
|
<Label className="text-sm">Custom range:</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 pt-2">
|
||||||
|
<Select
|
||||||
|
value={filters.yearStart?.toString() || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const yearStart = value ? parseInt(value, 10) : undefined;
|
||||||
|
onFilterChange({ yearStart });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-24 h-8 text-xs">
|
||||||
|
<SelectValue placeholder="From" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="any">Any</SelectItem>
|
||||||
|
{Array.from({ length: 10 }, (_, i) => 1500 + i * 50).map(year => (
|
||||||
|
<SelectItem key={year} value={year.toString()}>{year}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span>-</span>
|
||||||
|
<Select
|
||||||
|
value={filters.yearEnd?.toString() || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const yearEnd = value ? parseInt(value, 10) : undefined;
|
||||||
|
onFilterChange({ yearEnd });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-24 h-8 text-xs">
|
||||||
|
<SelectValue placeholder="To" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="any">Any</SelectItem>
|
||||||
|
{Array.from({ length: 11 }, (_, i) => 1550 + i * 50).map(year => (
|
||||||
|
<SelectItem key={year} value={year.toString()}>{year}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem value="sort">
|
||||||
|
<AccordionTrigger>Sort By</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<RadioGroup
|
||||||
|
value={filters.sort || "popularity"}
|
||||||
|
onValueChange={(value) => onFilterChange({ sort: value })}
|
||||||
|
>
|
||||||
|
{sortOptions.map(option => (
|
||||||
|
<div key={option.value} className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value={option.value} id={`sort-${option.value}`} />
|
||||||
|
<Label
|
||||||
|
htmlFor={`sort-${option.value}`}
|
||||||
|
className="text-sm text-navy/80 dark:text-cream/80 cursor-pointer"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{Object.entries(tagsByType).length > 0 && (
|
||||||
|
<AccordionItem value="tags">
|
||||||
|
<AccordionTrigger>Tags</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
{Object.entries(tagsByType).map(([type, tagList]) => (
|
||||||
|
<div key={type} className="mb-4">
|
||||||
|
<h4 className="text-sm font-medium mb-2 capitalize">{type}</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tagList.slice(0, 5).map(tag => (
|
||||||
|
<div key={tag.id} className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`tag-${tag.id}`}
|
||||||
|
checked={filters.tags?.includes(tag.id) || false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
handleTagChange(tag.id, checked === true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`tag-${tag.id}`}
|
||||||
|
className="text-sm text-navy/80 dark:text-cream/80 cursor-pointer"
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{tagList.length > 5 && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="link" size="sm" className="p-0 text-xs text-russet">
|
||||||
|
Show more <ChevronDown className="ml-1 h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
{tagList.slice(5).map(tag => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={tag.id}
|
||||||
|
onClick={() => {
|
||||||
|
handleTagChange(tag.id, !filters.tags?.includes(tag.id));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={`dropdown-tag-${tag.id}`}
|
||||||
|
className="mr-2"
|
||||||
|
checked={filters.tags?.includes(tag.id) || false}
|
||||||
|
/>
|
||||||
|
{tag.name}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
)}
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleClearFilters}
|
||||||
|
>
|
||||||
|
Clear All Filters
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
client/src/components/search/SearchBar.tsx
Normal file
172
client/src/components/search/SearchBar.tsx
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { SearchResults } from "@/lib/types";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { useDebounce } from "@/hooks/use-mobile";
|
||||||
|
import { Link, useLocation } from "wouter";
|
||||||
|
|
||||||
|
interface SearchBarProps {
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
onSearch?: (query: string) => void;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchBar({
|
||||||
|
placeholder = "Search by title, author, or keyword",
|
||||||
|
className,
|
||||||
|
onSearch,
|
||||||
|
fullWidth = false
|
||||||
|
}: SearchBarProps) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [results, setResults] = useState<SearchResults | null>(null);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const debouncedQuery = useDebounce(query, 300);
|
||||||
|
const searchRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [location, navigate] = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (searchRef.current && !searchRef.current.contains(event.target as Node)) {
|
||||||
|
setIsFocused(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (debouncedQuery.length < 2) {
|
||||||
|
setResults(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchResults = async () => {
|
||||||
|
setIsSearching(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setResults(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Search error:", error);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchResults();
|
||||||
|
}, [debouncedQuery]);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (query.trim()) {
|
||||||
|
onSearch?.(query.trim());
|
||||||
|
navigate(`/explore?q=${encodeURIComponent(query.trim())}`);
|
||||||
|
setIsFocused(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`} ref={searchRef}>
|
||||||
|
<form onSubmit={handleSubmit} className={`${fullWidth ? 'w-full' : 'max-w-md'}`}>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="pl-10 pr-4 py-2 rounded-lg bg-background dark:bg-background border border-input dark:border-input focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onFocus={() => setIsFocused(true)}
|
||||||
|
/>
|
||||||
|
<Search className="h-5 w-5 absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground dark:text-muted-foreground" />
|
||||||
|
{query && (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
className="absolute right-2 top-1/2 transform -translate-y-1/2 h-7 text-xs"
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{isFocused && results && (
|
||||||
|
<Card className="absolute z-50 w-full mt-1 shadow-lg">
|
||||||
|
<CardContent className="p-2">
|
||||||
|
{results.authors.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-sm font-medium mb-2 text-muted-foreground">Authors</h3>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{results.authors.map((author) => (
|
||||||
|
<li key={author.id}>
|
||||||
|
<Link
|
||||||
|
href={`/authors/${author.slug}`}
|
||||||
|
className="block p-2 hover:bg-primary/5 dark:hover:bg-primary/10 rounded"
|
||||||
|
onClick={() => setIsFocused(false)}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{author.name}</span>
|
||||||
|
{author.country && (
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">
|
||||||
|
{author.country}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{results.works.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-2 text-muted-foreground">Works</h3>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{results.works.map((work) => (
|
||||||
|
<li key={work.id}>
|
||||||
|
<Link
|
||||||
|
href={`/works/${work.slug}`}
|
||||||
|
className="block p-2 hover:bg-primary/5 dark:hover:bg-primary/10 rounded"
|
||||||
|
onClick={() => setIsFocused(false)}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{work.title}</span>
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">
|
||||||
|
{work.language}, {work.year || "Unknown"}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{results.authors.length === 0 && results.works.length === 0 && (
|
||||||
|
<p className="py-2 text-muted-foreground text-sm text-center">
|
||||||
|
No results found for "{query}"
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-2 pt-2 border-t border-border text-center">
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs text-accent"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
View all results
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
0
client/src/components/search/advanced-search.tsx
Normal file
0
client/src/components/search/advanced-search.tsx
Normal file
0
client/src/components/search/pagination.tsx
Normal file
0
client/src/components/search/pagination.tsx
Normal file
0
client/src/components/search/results-toggle.tsx
Normal file
0
client/src/components/search/results-toggle.tsx
Normal file
0
client/src/components/search/search-filters.tsx
Normal file
0
client/src/components/search/search-filters.tsx
Normal file
0
client/src/components/search/sort-controls.tsx
Normal file
0
client/src/components/search/sort-controls.tsx
Normal file
0
client/src/components/ui/autocomplete.tsx
Normal file
0
client/src/components/ui/autocomplete.tsx
Normal file
0
client/src/components/ui/breadcrumbs.tsx
Normal file
0
client/src/components/ui/breadcrumbs.tsx
Normal file
0
client/src/components/ui/code-highlighter.tsx
Normal file
0
client/src/components/ui/code-highlighter.tsx
Normal file
0
client/src/components/ui/color-picker.tsx
Normal file
0
client/src/components/ui/color-picker.tsx
Normal file
0
client/src/components/ui/comparison-slider.tsx
Normal file
0
client/src/components/ui/comparison-slider.tsx
Normal file
0
client/src/components/ui/confirmation-dialog.tsx
Normal file
0
client/src/components/ui/confirmation-dialog.tsx
Normal file
0
client/src/components/ui/date-range-picker.tsx
Normal file
0
client/src/components/ui/date-range-picker.tsx
Normal file
0
client/src/components/ui/empty-state.tsx
Normal file
0
client/src/components/ui/empty-state.tsx
Normal file
0
client/src/components/ui/error-state.tsx
Normal file
0
client/src/components/ui/error-state.tsx
Normal file
0
client/src/components/ui/expandable-search.tsx
Normal file
0
client/src/components/ui/expandable-search.tsx
Normal file
0
client/src/components/ui/file-uploader.tsx
Normal file
0
client/src/components/ui/file-uploader.tsx
Normal file
0
client/src/components/ui/file-viewer.tsx
Normal file
0
client/src/components/ui/file-viewer.tsx
Normal file
0
client/src/components/ui/form-wizard.tsx
Normal file
0
client/src/components/ui/form-wizard.tsx
Normal file
0
client/src/components/ui/grid.tsx
Normal file
0
client/src/components/ui/grid.tsx
Normal file
15
client/src/components/ui/language-tag.tsx
Normal file
15
client/src/components/ui/language-tag.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
interface LanguageTagProps {
|
||||||
|
language: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LanguageTag({ language, className }: LanguageTagProps) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-1 text-xs text-navy/70 dark:text-cream/70 font-sans ${className}`}>
|
||||||
|
<span className="inline-block w-2 h-2 rounded-full bg-sage"></span>
|
||||||
|
<span>{language}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
0
client/src/components/ui/loading-spinner.tsx
Normal file
0
client/src/components/ui/loading-spinner.tsx
Normal file
0
client/src/components/ui/masonry.tsx
Normal file
0
client/src/components/ui/masonry.tsx
Normal file
0
client/src/components/ui/multi-select.tsx
Normal file
0
client/src/components/ui/multi-select.tsx
Normal file
0
client/src/components/ui/notification-badge.tsx
Normal file
0
client/src/components/ui/notification-badge.tsx
Normal file
0
client/src/components/ui/rating.tsx
Normal file
0
client/src/components/ui/rating.tsx
Normal file
0
client/src/components/ui/responsive-container.tsx
Normal file
0
client/src/components/ui/responsive-container.tsx
Normal file
0
client/src/components/ui/rich-text-editor.tsx
Normal file
0
client/src/components/ui/rich-text-editor.tsx
Normal file
0
client/src/components/ui/share-buttons.tsx
Normal file
0
client/src/components/ui/share-buttons.tsx
Normal file
0
client/src/components/ui/split-pane.tsx
Normal file
0
client/src/components/ui/split-pane.tsx
Normal file
0
client/src/components/ui/spotlight.tsx
Normal file
0
client/src/components/ui/spotlight.tsx
Normal file
0
client/src/components/ui/stat-card.tsx
Normal file
0
client/src/components/ui/stat-card.tsx
Normal file
0
client/src/components/ui/status-indicator.tsx
Normal file
0
client/src/components/ui/status-indicator.tsx
Normal file
0
client/src/components/ui/stepper.tsx
Normal file
0
client/src/components/ui/stepper.tsx
Normal file
0
client/src/components/ui/tag-cloud.tsx
Normal file
0
client/src/components/ui/tag-cloud.tsx
Normal file
0
client/src/components/ui/tag-input.tsx
Normal file
0
client/src/components/ui/tag-input.tsx
Normal file
0
client/src/components/ui/timeline.tsx
Normal file
0
client/src/components/ui/timeline.tsx
Normal file
0
client/src/components/ui/tree-view.tsx
Normal file
0
client/src/components/ui/tree-view.tsx
Normal file
0
client/src/components/ui/typography/code-block.tsx
Normal file
0
client/src/components/ui/typography/code-block.tsx
Normal file
0
client/src/components/ui/typography/heading.tsx
Normal file
0
client/src/components/ui/typography/heading.tsx
Normal file
0
client/src/components/ui/typography/paragraph.tsx
Normal file
0
client/src/components/ui/typography/paragraph.tsx
Normal file
0
client/src/components/ui/typography/quote.tsx
Normal file
0
client/src/components/ui/typography/quote.tsx
Normal file
0
client/src/components/ui/vertical-navigation.tsx
Normal file
0
client/src/components/ui/vertical-navigation.tsx
Normal file
0
client/src/components/user/bookmarks-manager.tsx
Normal file
0
client/src/components/user/bookmarks-manager.tsx
Normal file
0
client/src/components/user/preferences-form.tsx
Normal file
0
client/src/components/user/preferences-form.tsx
Normal file
0
client/src/components/user/profile-card.tsx
Normal file
0
client/src/components/user/profile-card.tsx
Normal file
0
client/src/components/user/reading-progress.tsx
Normal file
0
client/src/components/user/reading-progress.tsx
Normal file
0
client/src/components/user/user-avatar.tsx
Normal file
0
client/src/components/user/user-avatar.tsx
Normal file
230
client/src/components/work/EnhancedLineNumberedText.tsx
Normal file
230
client/src/components/work/EnhancedLineNumberedText.tsx
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Copy, Bookmark, MessageSquare } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { apiRequest } from '@/lib/queryClient';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
|
||||||
|
interface EnhancedLineNumberedTextProps {
|
||||||
|
content: string;
|
||||||
|
fontSizeClass?: string;
|
||||||
|
onAnnotate: (lineNumber: number) => void;
|
||||||
|
highlightedLine?: number;
|
||||||
|
workId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnhancedLineNumberedText({
|
||||||
|
content,
|
||||||
|
fontSizeClass = 'text-size-md',
|
||||||
|
onAnnotate,
|
||||||
|
highlightedLine,
|
||||||
|
workId
|
||||||
|
}: EnhancedLineNumberedTextProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [hoveredLine, setHoveredLine] = useState<number | null>(null);
|
||||||
|
const [bookmarkedLines, setBookmarkedLines] = useState<Set<number>>(new Set());
|
||||||
|
const [lineAnnotationCounts, setLineAnnotationCounts] = useState<Record<number, number>>({
|
||||||
|
// Mock annotation counts - in a real app this would come from an API
|
||||||
|
2: 3,
|
||||||
|
5: 1,
|
||||||
|
8: 7
|
||||||
|
});
|
||||||
|
|
||||||
|
// Split content into lines
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
|
const handleLineHover = (lineNumber: number) => {
|
||||||
|
setHoveredLine(lineNumber);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLineLeave = () => {
|
||||||
|
setHoveredLine(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyLine = (lineNumber: number, lineText: string) => {
|
||||||
|
navigator.clipboard.writeText(lineText);
|
||||||
|
toast({
|
||||||
|
description: "Line copied to clipboard",
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyLineLink = (lineNumber: number) => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.hash = `line-${lineNumber}`;
|
||||||
|
navigator.clipboard.writeText(url.toString());
|
||||||
|
toast({
|
||||||
|
description: "Link to line copied to clipboard",
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleBookmark = async (lineNumber: number) => {
|
||||||
|
try {
|
||||||
|
const isBookmarked = bookmarkedLines.has(lineNumber);
|
||||||
|
|
||||||
|
// Optimistically update UI
|
||||||
|
setBookmarkedLines(prev => {
|
||||||
|
const newBookmarks = new Set(prev);
|
||||||
|
if (isBookmarked) {
|
||||||
|
newBookmarks.delete(lineNumber);
|
||||||
|
} else {
|
||||||
|
newBookmarks.add(lineNumber);
|
||||||
|
}
|
||||||
|
return newBookmarks;
|
||||||
|
});
|
||||||
|
|
||||||
|
// In a real app, this would make an API call
|
||||||
|
// await apiRequest('POST', '/api/reading-bookmarks', {
|
||||||
|
// userId: 1, // Mock user ID
|
||||||
|
// workId,
|
||||||
|
// lineNumber,
|
||||||
|
// isBookmarked: !isBookmarked
|
||||||
|
// });
|
||||||
|
|
||||||
|
toast({
|
||||||
|
description: isBookmarked ? "Bookmark removed" : "Line bookmarked",
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Revert on error
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Could not update bookmark",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`reading-text ${fontSizeClass}`}>
|
||||||
|
{lines.map((line, index) => {
|
||||||
|
const lineNumber = index + 1;
|
||||||
|
const isHighlighted = lineNumber === highlightedLine;
|
||||||
|
const isHovered = lineNumber === hoveredLine;
|
||||||
|
const isBookmarked = bookmarkedLines.has(lineNumber);
|
||||||
|
const annotationCount = lineAnnotationCounts[lineNumber] || 0;
|
||||||
|
|
||||||
|
// For blank lines, render a smaller empty line
|
||||||
|
if (!line.trim()) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`line-${lineNumber}`}
|
||||||
|
id={`line-${lineNumber}`}
|
||||||
|
className="text-line-empty h-4"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`line-${lineNumber}`}
|
||||||
|
id={`line-${lineNumber}`}
|
||||||
|
className={`text-line group ${
|
||||||
|
isHighlighted ? 'bg-navy/10 dark:bg-cream/10' : 'hover:bg-navy/5 dark:hover:bg-cream/5'
|
||||||
|
} py-1.5 rounded flex relative transition-colors`}
|
||||||
|
onMouseEnter={() => handleLineHover(lineNumber)}
|
||||||
|
onMouseLeave={handleLineLeave}
|
||||||
|
>
|
||||||
|
{/* Line number indicator with bookmark feature */}
|
||||||
|
<div
|
||||||
|
className="line-number-container w-12 flex-shrink-0 flex justify-center items-center relative"
|
||||||
|
onClick={() => handleToggleBookmark(lineNumber)}
|
||||||
|
>
|
||||||
|
{isBookmarked ? (
|
||||||
|
<Bookmark className="h-4 w-4 text-russet cursor-pointer" fill="currentColor" />
|
||||||
|
) : (
|
||||||
|
<span className="line-number text-navy/40 dark:text-cream/40 text-sm select-none">
|
||||||
|
{lineNumber}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Line content */}
|
||||||
|
<div className="line-content flex-1 relative">
|
||||||
|
<p>{line}</p>
|
||||||
|
|
||||||
|
{/* Annotation indicator - if the line has annotations */}
|
||||||
|
{annotationCount > 0 && (
|
||||||
|
<div
|
||||||
|
className="annotation-indicator absolute -right-6 top-1/2 transform -translate-y-1/2 cursor-pointer"
|
||||||
|
onClick={() => onAnnotate(lineNumber)}
|
||||||
|
>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<MessageSquare className="h-4 w-4 text-russet" />
|
||||||
|
<span className="text-xs text-russet ml-0.5">{annotationCount}</span>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p className="text-xs">Click to view annotations</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons that appear on hover */}
|
||||||
|
<div
|
||||||
|
className={`absolute right-0 top-1/2 transform -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity bg-cream/80 dark:bg-dark-surface/80 backdrop-blur-sm px-1 rounded ${isHighlighted ? 'opacity-100' : ''}`}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCopyLine(lineNumber, line);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4 text-navy/70 dark:text-cream/70" />
|
||||||
|
<span className="sr-only">Copy line</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCopyLineLink(lineNumber);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="h-4 w-4 text-navy/70 dark:text-cream/70"
|
||||||
|
>
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||||
|
</svg>
|
||||||
|
<span className="sr-only">Copy link to line</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAnnotate(lineNumber);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-4 w-4 text-navy/70 dark:text-cream/70" />
|
||||||
|
<span className="sr-only">Annotate line</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
630
client/src/components/work/EnhancedReadingView.tsx
Normal file
630
client/src/components/work/EnhancedReadingView.tsx
Normal file
@ -0,0 +1,630 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { EnhancedLineNumberedText } from "@/components/reading/EnhancedLineNumberedText";
|
||||||
|
import { TranslationSelector } from "@/components/reading/TranslationSelector";
|
||||||
|
import { ReadingControls } from "@/components/reading/ReadingControls";
|
||||||
|
import { AnnotationSystem } from "@/components/reading/AnnotationSystem";
|
||||||
|
import { useReadingSettings } from "@/hooks/use-reading-settings";
|
||||||
|
import { WorkWithDetails, TranslationWithDetails } from "@/lib/types";
|
||||||
|
import { AuthorChip } from "@/components/common/AuthorChip";
|
||||||
|
import { LanguageTag } from "@/components/common/LanguageTag";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
import { useParams, useLocation } from "wouter";
|
||||||
|
import {
|
||||||
|
Heart,
|
||||||
|
Bookmark,
|
||||||
|
BookCopy,
|
||||||
|
Share2,
|
||||||
|
MessageCircle,
|
||||||
|
FileText,
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
|
AlignLeft
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerClose,
|
||||||
|
} from "@/components/ui/drawer";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
|
|
||||||
|
interface EnhancedReadingViewProps {
|
||||||
|
work: WorkWithDetails;
|
||||||
|
translations: TranslationWithDetails[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnhancedReadingView({ work, translations }: EnhancedReadingViewProps) {
|
||||||
|
const { settings, increaseFontSize, decreaseFontSize, toggleZenMode } = useReadingSettings();
|
||||||
|
const [selectedTranslationId, setSelectedTranslationId] = useState<number | undefined>(
|
||||||
|
translations.length > 0 ? translations[0].id : undefined
|
||||||
|
);
|
||||||
|
const [readingProgress, setReadingProgress] = useState(0);
|
||||||
|
const [selectedLineNumber, setSelectedLineNumber] = useState<number | null>(null);
|
||||||
|
const [isAnnotationOpen, setIsAnnotationOpen] = useState(false);
|
||||||
|
const [isActionPanelOpen, setIsActionPanelOpen] = useState(false);
|
||||||
|
const [isLiked, setIsLiked] = useState(false);
|
||||||
|
const [isBookmarked, setIsBookmarked] = useState(false);
|
||||||
|
|
||||||
|
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [, navigate] = useLocation();
|
||||||
|
const mainContentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Get the selected translation
|
||||||
|
const selectedTranslation = translations.find(t => t.id === selectedTranslationId);
|
||||||
|
|
||||||
|
// Determine if original text is selected
|
||||||
|
const isOriginalSelected = !selectedTranslationId;
|
||||||
|
|
||||||
|
// Content to display - either the translation or original work
|
||||||
|
const contentToDisplay = selectedTranslation ? selectedTranslation.content : work.content;
|
||||||
|
|
||||||
|
// Handler for viewing original text
|
||||||
|
const handleViewOriginal = () => {
|
||||||
|
setSelectedTranslationId(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if there's a line number in the URL hash
|
||||||
|
useEffect(() => {
|
||||||
|
if (window.location.hash) {
|
||||||
|
const hash = window.location.hash;
|
||||||
|
const lineMatch = hash.match(/^#line-(\d+)$/);
|
||||||
|
if (lineMatch && lineMatch[1]) {
|
||||||
|
const lineNumber = parseInt(lineMatch[1], 10);
|
||||||
|
// Scroll to the line
|
||||||
|
setTimeout(() => {
|
||||||
|
const lineElement = document.getElementById(`line-${lineNumber}`);
|
||||||
|
if (lineElement) {
|
||||||
|
lineElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
setSelectedLineNumber(lineNumber);
|
||||||
|
setIsAnnotationOpen(true);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [work.id]);
|
||||||
|
|
||||||
|
// Update reading progress as user scrolls
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
const windowHeight = window.innerHeight;
|
||||||
|
const documentHeight = document.documentElement.scrollHeight;
|
||||||
|
const scrollTop = window.scrollY;
|
||||||
|
|
||||||
|
// Calculate progress percentage
|
||||||
|
const progress = Math.min(
|
||||||
|
100,
|
||||||
|
Math.round((scrollTop / (documentHeight - windowHeight)) * 100)
|
||||||
|
);
|
||||||
|
|
||||||
|
setReadingProgress(progress);
|
||||||
|
|
||||||
|
// Update reading progress in backend (throttled to avoid too many requests)
|
||||||
|
const debounced = setTimeout(() => {
|
||||||
|
updateReadingProgress(progress);
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
return () => clearTimeout(debounced);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', handleScroll);
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
|
}, [work.id, selectedTranslationId]);
|
||||||
|
|
||||||
|
// Update reading progress in backend
|
||||||
|
const updateReadingProgress = async (progress: number) => {
|
||||||
|
try {
|
||||||
|
// In a real app, this would use the logged-in user ID
|
||||||
|
// For demo purposes, we'll use a hard-coded user ID of 1
|
||||||
|
await apiRequest('POST', '/api/reading-progress', {
|
||||||
|
userId: 1,
|
||||||
|
workId: work.id,
|
||||||
|
translationId: selectedTranslationId,
|
||||||
|
progress
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update reading progress:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle line annotation
|
||||||
|
const handleLineAnnotation = (lineNumber: number) => {
|
||||||
|
setSelectedLineNumber(lineNumber);
|
||||||
|
setIsAnnotationOpen(true);
|
||||||
|
|
||||||
|
// Update the URL hash
|
||||||
|
window.history.replaceState(null, '', `#line-${lineNumber}`);
|
||||||
|
|
||||||
|
// On mobile, scroll to top of content to see the annotation
|
||||||
|
if (isMobile && mainContentRef.current) {
|
||||||
|
mainContentRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close annotation panel
|
||||||
|
const handleCloseAnnotation = () => {
|
||||||
|
setIsAnnotationOpen(false);
|
||||||
|
// Remove the line number from the URL hash
|
||||||
|
window.history.replaceState(null, '', window.location.pathname + window.location.search);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle like for the work
|
||||||
|
const handleLikeToggle = () => {
|
||||||
|
setIsLiked(!isLiked);
|
||||||
|
toast({
|
||||||
|
description: isLiked ? "Removed from favorites" : "Added to favorites",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle bookmark for the work
|
||||||
|
const handleBookmarkToggle = () => {
|
||||||
|
setIsBookmarked(!isBookmarked);
|
||||||
|
toast({
|
||||||
|
description: isBookmarked ? "Removed from your bookmarks" : "Added to your bookmarks",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Share the work
|
||||||
|
const handleShare = async () => {
|
||||||
|
try {
|
||||||
|
if (navigator.share) {
|
||||||
|
await navigator.share({
|
||||||
|
title: work.title,
|
||||||
|
text: `Reading ${work.title} on Tercul`,
|
||||||
|
url: window.location.href
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback for browsers that don't support the Web Share API
|
||||||
|
navigator.clipboard.writeText(window.location.href);
|
||||||
|
toast({
|
||||||
|
description: "Link copied to clipboard",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sharing:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={`enhanced-reading-view ${settings.zenMode ? 'zen-mode' : ''}`}>
|
||||||
|
<div className={`flex flex-col lg:flex-row max-w-6xl mx-auto relative pb-12 ${isAnnotationOpen && !isMobile ? 'mr-96' : ''}`}>
|
||||||
|
{/* Mobile contextual menu */}
|
||||||
|
{isMobile && (
|
||||||
|
<div className="sticky top-0 z-10 bg-cream dark:bg-dark-surface w-full border-b border-sage/20 dark:border-sage/10 flex justify-between items-center px-4 py-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setIsActionPanelOpen(true)}
|
||||||
|
className="text-navy/70 dark:text-cream/70"
|
||||||
|
>
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
<span className="sr-only">Menu</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<h2 className="truncate text-navy dark:text-cream font-medium text-sm">
|
||||||
|
{work.title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={`p-2 ${
|
||||||
|
isLiked
|
||||||
|
? 'text-russet'
|
||||||
|
: 'text-navy/70 dark:text-cream/70'
|
||||||
|
}`}
|
||||||
|
onClick={handleLikeToggle}
|
||||||
|
>
|
||||||
|
<Heart className={`h-5 w-5 ${isLiked ? 'fill-russet' : ''}`} />
|
||||||
|
<span className="sr-only">{isLiked ? 'Unlike' : 'Like'}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="p-2 text-navy/70 dark:text-cream/70"
|
||||||
|
onClick={handleShare}
|
||||||
|
>
|
||||||
|
<Share2 className="h-5 w-5" />
|
||||||
|
<span className="sr-only">Share</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Context sidebar (sticky on desktop, drawer on mobile) */}
|
||||||
|
{!isMobile ? (
|
||||||
|
<aside className="context-sidebar lg:w-64 p-4 lg:sticky lg:top-16 lg:self-start lg:h-[calc(100vh-4rem)] lg:overflow-y-auto">
|
||||||
|
<div className="mb-6">
|
||||||
|
<AuthorChip author={work.author} withLifeDates />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">About this work</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
{work.year && (
|
||||||
|
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">Written in {work.year}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans capitalize">
|
||||||
|
{work.type} • {work.language} • {work.tags?.length || 0} tags
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{work.tags?.map(tag => (
|
||||||
|
<Badge
|
||||||
|
key={tag.id}
|
||||||
|
variant="outline"
|
||||||
|
className="inline-block px-2 py-0.5 bg-navy/10 dark:bg-navy/20 rounded text-navy/70 dark:text-cream/70 text-xs font-sans border-none"
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedTranslation && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">Translation</h4>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-navy/90 dark:text-cream/90 font-sans font-medium">{selectedTranslation.language}</p>
|
||||||
|
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
||||||
|
Translated by User {selectedTranslation.translatorId} {selectedTranslation.year && `(${selectedTranslation.year})`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">Reading stats</h4>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">~{Math.ceil(contentToDisplay.length / 1000)} min read</p>
|
||||||
|
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">{work.likes || 0} favorites</p>
|
||||||
|
<div className="bg-sage/10 rounded-full h-1.5 mt-2">
|
||||||
|
<div
|
||||||
|
className="bg-russet h-full rounded-full"
|
||||||
|
style={{ width: `${readingProgress}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans mt-1">
|
||||||
|
{readingProgress}% completed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">Actions</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={`flex items-center gap-1 w-full justify-start py-1.5 px-3 rounded-lg ${
|
||||||
|
isLiked
|
||||||
|
? 'bg-russet/10 hover:bg-russet/20 text-russet'
|
||||||
|
: 'bg-navy/10 hover:bg-navy/20 dark:bg-navy/20 dark:hover:bg-navy/30 text-navy dark:text-cream/90'
|
||||||
|
} font-sans text-xs transition-colors`}
|
||||||
|
onClick={handleLikeToggle}
|
||||||
|
>
|
||||||
|
<Heart className={`h-4 w-4 ${isLiked ? 'fill-russet' : ''}`} />
|
||||||
|
<span>{isLiked ? 'Remove from favorites' : 'Add to favorites'}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={`flex items-center gap-1 w-full justify-start py-1.5 px-3 rounded-lg ${
|
||||||
|
isBookmarked
|
||||||
|
? 'bg-russet/10 hover:bg-russet/20 text-russet'
|
||||||
|
: 'bg-navy/10 hover:bg-navy/20 dark:bg-navy/20 dark:hover:bg-navy/30 text-navy dark:text-cream/90'
|
||||||
|
} font-sans text-xs transition-colors`}
|
||||||
|
onClick={handleBookmarkToggle}
|
||||||
|
>
|
||||||
|
<Bookmark className={`h-4 w-4 ${isBookmarked ? 'fill-russet' : ''}`} />
|
||||||
|
<span>{isBookmarked ? 'Remove bookmark' : 'Bookmark for later'}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-1 w-full justify-start py-1.5 px-3 rounded-lg bg-navy/10 hover:bg-navy/20 dark:bg-navy/20 dark:hover:bg-navy/30 text-navy dark:text-cream/90 font-sans text-xs transition-colors"
|
||||||
|
onClick={handleShare}
|
||||||
|
>
|
||||||
|
<Share2 className="h-4 w-4" />
|
||||||
|
<span>Share</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-1 w-full justify-start py-1.5 px-3 rounded-lg bg-navy/10 hover:bg-navy/20 dark:bg-navy/20 dark:hover:bg-navy/30 text-navy dark:text-cream/90 font-sans text-xs transition-colors"
|
||||||
|
onClick={() => navigate(`/works/${work.slug}/comments`)}
|
||||||
|
>
|
||||||
|
<MessageCircle className="h-4 w-4" />
|
||||||
|
<span>View all comments</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-1 w-full justify-start py-1.5 px-3 rounded-lg bg-navy/10 hover:bg-navy/20 dark:bg-navy/20 dark:hover:bg-navy/30 text-navy dark:text-cream/90 font-sans text-xs transition-colors"
|
||||||
|
onClick={() => navigate(`/works/${work.slug}/cite`)}
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
<span>Cite this work</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-1 w-full justify-start py-1.5 px-3 rounded-lg bg-navy/10 hover:bg-navy/20 dark:bg-navy/20 dark:hover:bg-navy/30 text-navy dark:text-cream/90 font-sans text-xs transition-colors"
|
||||||
|
onClick={() => navigate(`/collections/add/${work.slug}`)}
|
||||||
|
>
|
||||||
|
<BookCopy className="h-4 w-4" />
|
||||||
|
<span>Add to collection</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
) : (
|
||||||
|
<Drawer open={isActionPanelOpen} onOpenChange={setIsActionPanelOpen}>
|
||||||
|
<DrawerContent className="max-h-[90%]">
|
||||||
|
<DrawerHeader>
|
||||||
|
<DrawerTitle>About this work</DrawerTitle>
|
||||||
|
<DrawerDescription>
|
||||||
|
<div className="mb-2">
|
||||||
|
<AuthorChip author={work.author} withLifeDates />
|
||||||
|
</div>
|
||||||
|
{work.year && (
|
||||||
|
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">Written in {work.year}</p>
|
||||||
|
)}
|
||||||
|
</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
|
||||||
|
<div className="px-4">
|
||||||
|
<div className="my-4">
|
||||||
|
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">Tags</h4>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{work.tags?.map(tag => (
|
||||||
|
<Badge
|
||||||
|
key={tag.id}
|
||||||
|
variant="outline"
|
||||||
|
className="inline-block px-2 py-0.5 bg-navy/10 dark:bg-navy/20 rounded text-navy/70 dark:text-cream/70 text-xs font-sans border-none"
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedTranslation && (
|
||||||
|
<div className="my-4">
|
||||||
|
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">Translation</h4>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-navy/90 dark:text-cream/90 font-sans font-medium">{selectedTranslation.language}</p>
|
||||||
|
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
||||||
|
Translated by User {selectedTranslation.translatorId} {selectedTranslation.year && `(${selectedTranslation.year})`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="my-4">
|
||||||
|
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">Reading stats</h4>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">~{Math.ceil(contentToDisplay.length / 1000)} min read</p>
|
||||||
|
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">{work.likes || 0} favorites</p>
|
||||||
|
<div className="bg-sage/10 rounded-full h-1.5 mt-2">
|
||||||
|
<div
|
||||||
|
className="bg-russet h-full rounded-full"
|
||||||
|
style={{ width: `${readingProgress}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans mt-1">
|
||||||
|
{readingProgress}% completed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="my-4">
|
||||||
|
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">Actions</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={`flex items-center gap-1 justify-start py-1.5 px-3 rounded-lg ${
|
||||||
|
isBookmarked
|
||||||
|
? 'bg-russet/10 hover:bg-russet/20 text-russet'
|
||||||
|
: 'bg-navy/10 hover:bg-navy/20 dark:bg-navy/20 dark:hover:bg-navy/30 text-navy dark:text-cream/90'
|
||||||
|
} font-sans text-xs transition-colors`}
|
||||||
|
onClick={handleBookmarkToggle}
|
||||||
|
>
|
||||||
|
<Bookmark className={`h-4 w-4 ${isBookmarked ? 'fill-russet' : ''}`} />
|
||||||
|
<span>{isBookmarked ? 'Remove' : 'Bookmark'}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-1 justify-start py-1.5 px-3 rounded-lg bg-navy/10 hover:bg-navy/20 dark:bg-navy/20 dark:hover:bg-navy/30 text-navy dark:text-cream/90 font-sans text-xs transition-colors"
|
||||||
|
onClick={() => navigate(`/works/${work.slug}/comments`)}
|
||||||
|
>
|
||||||
|
<MessageCircle className="h-4 w-4" />
|
||||||
|
<span>Comments</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-1 justify-start py-1.5 px-3 rounded-lg bg-navy/10 hover:bg-navy/20 dark:bg-navy/20 dark:hover:bg-navy/30 text-navy dark:text-cream/90 font-sans text-xs transition-colors"
|
||||||
|
onClick={() => navigate(`/works/${work.slug}/cite`)}
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
<span>Cite</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-1 justify-start py-1.5 px-3 rounded-lg bg-navy/10 hover:bg-navy/20 dark:bg-navy/20 dark:hover:bg-navy/30 text-navy dark:text-cream/90 font-sans text-xs transition-colors"
|
||||||
|
onClick={() => navigate(`/collections/add/${work.slug}`)}
|
||||||
|
>
|
||||||
|
<BookCopy className="h-4 w-4" />
|
||||||
|
<span>Add to collection</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DrawerFooter>
|
||||||
|
<DrawerClose asChild>
|
||||||
|
<Button variant="outline">Close</Button>
|
||||||
|
</DrawerClose>
|
||||||
|
</DrawerFooter>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main reading area */}
|
||||||
|
<div ref={mainContentRef} className="flex-1 px-4 lg:px-8 py-4 lg:py-8">
|
||||||
|
<div className="mb-6">
|
||||||
|
{!isMobile && (
|
||||||
|
<ReadingControls
|
||||||
|
onZenModeToggle={toggleZenMode}
|
||||||
|
onIncreaseFontSize={increaseFontSize}
|
||||||
|
onDecreaseFontSize={decreaseFontSize}
|
||||||
|
zenMode={settings.zenMode}
|
||||||
|
workId={work.id}
|
||||||
|
workSlug={work.slug}
|
||||||
|
translationId={selectedTranslationId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mt-2">
|
||||||
|
<h2 className="text-xl md:text-2xl font-serif text-navy/80 dark:text-cream/80 font-medium">{work.title}</h2>
|
||||||
|
<LanguageTag language={work.language} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TranslationSelector
|
||||||
|
translations={translations}
|
||||||
|
currentTranslationId={selectedTranslationId}
|
||||||
|
workSlug={work.slug}
|
||||||
|
workLanguage={work.language}
|
||||||
|
onSelectTranslation={setSelectedTranslationId}
|
||||||
|
onViewOriginal={handleViewOriginal}
|
||||||
|
isOriginalSelected={isOriginalSelected}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text content with enhanced annotation features */}
|
||||||
|
<div className="reading-container max-w-[var(--reading-width)] mx-auto">
|
||||||
|
<EnhancedLineNumberedText
|
||||||
|
content={contentToDisplay}
|
||||||
|
fontSizeClass={settings.fontSize}
|
||||||
|
onAnnotate={handleLineAnnotation}
|
||||||
|
highlightedLine={selectedLineNumber || undefined}
|
||||||
|
workId={work.id}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedTranslation && selectedTranslation.notes && (
|
||||||
|
<div className="mt-8 border-t border-sage/20 dark:border-sage/10 pt-4">
|
||||||
|
<h3 className="text-lg font-medium font-serif text-navy dark:text-cream mb-3">Translation Notes</h3>
|
||||||
|
<div className="text-sm text-navy/80 dark:text-cream/80">
|
||||||
|
<p>{selectedTranslation.notes}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar (fixed at bottom) - only visible when not in zen mode */}
|
||||||
|
{!settings.zenMode && (
|
||||||
|
<div className="progress-bar fixed bottom-0 left-0 right-0 h-1 bg-sage/20 dark:bg-sage/10">
|
||||||
|
<div
|
||||||
|
className="progress-indicator h-full bg-russet dark:bg-russet/90"
|
||||||
|
style={{ width: `${readingProgress}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Annotation panel for desktop */}
|
||||||
|
{!isMobile && isAnnotationOpen && selectedLineNumber && (
|
||||||
|
<AnnotationSystem
|
||||||
|
workId={work.id}
|
||||||
|
selectedLineNumber={selectedLineNumber}
|
||||||
|
onClose={handleCloseAnnotation}
|
||||||
|
translationId={selectedTranslationId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile annotation drawer */}
|
||||||
|
{isMobile && (
|
||||||
|
<Drawer open={isAnnotationOpen && !!selectedLineNumber} onOpenChange={(open) => {
|
||||||
|
if (!open) handleCloseAnnotation();
|
||||||
|
}}>
|
||||||
|
<DrawerContent className="max-h-[80%]">
|
||||||
|
<DrawerHeader className="border-b border-sage/20 dark:border-sage/10">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MessageCircle className="h-5 w-5 text-russet" />
|
||||||
|
<DrawerTitle>Line {selectedLineNumber} Annotations</DrawerTitle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DrawerHeader>
|
||||||
|
|
||||||
|
<div className="p-4 overflow-auto">
|
||||||
|
{selectedLineNumber && (
|
||||||
|
<AnnotationSystem
|
||||||
|
workId={work.id}
|
||||||
|
selectedLineNumber={selectedLineNumber}
|
||||||
|
onClose={handleCloseAnnotation}
|
||||||
|
translationId={selectedTranslationId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile reading controls */}
|
||||||
|
{isMobile && (
|
||||||
|
<div className="fixed bottom-1 right-4 flex space-x-1">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full shadow-lg w-10 h-10 bg-russet text-white"
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedLineNumber) {
|
||||||
|
setIsAnnotationOpen(true);
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
description: "Tap on a line to add annotations",
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MessageCircle className="h-5 w-5" />
|
||||||
|
<span className="sr-only">Annotations</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full shadow-lg w-10 h-10 bg-navy dark:bg-navy/80 text-white"
|
||||||
|
onClick={toggleZenMode}
|
||||||
|
>
|
||||||
|
{settings.zenMode ? <AlignLeft className="h-5 w-5" /> : <X className="h-5 w-5" />}
|
||||||
|
<span className="sr-only">{settings.zenMode ? 'Exit zen mode' : 'Zen mode'}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
client/src/components/work/LineNumberedText.tsx
Normal file
118
client/src/components/work/LineNumberedText.tsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Copy } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
|
interface LineNumberedTextProps {
|
||||||
|
content: string;
|
||||||
|
fontSizeClass?: string;
|
||||||
|
onLineClick?: (lineNumber: number) => void;
|
||||||
|
highlightedLine?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LineNumberedText({
|
||||||
|
content,
|
||||||
|
fontSizeClass = 'text-size-md',
|
||||||
|
onLineClick,
|
||||||
|
highlightedLine
|
||||||
|
}: LineNumberedTextProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [hoveredLine, setHoveredLine] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Split content into lines
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
|
const handleLineHover = (lineNumber: number) => {
|
||||||
|
setHoveredLine(lineNumber);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLineLeave = () => {
|
||||||
|
setHoveredLine(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyLine = (lineNumber: number, lineText: string) => {
|
||||||
|
navigator.clipboard.writeText(lineText);
|
||||||
|
toast({
|
||||||
|
description: "Line copied to clipboard",
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyLineLink = (lineNumber: number) => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.hash = `line-${lineNumber}`;
|
||||||
|
navigator.clipboard.writeText(url.toString());
|
||||||
|
toast({
|
||||||
|
description: "Link to line copied to clipboard",
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`reading-text ${fontSizeClass}`}>
|
||||||
|
{lines.map((line, index) => {
|
||||||
|
const lineNumber = index + 1;
|
||||||
|
const isHighlighted = lineNumber === highlightedLine;
|
||||||
|
const isHovered = lineNumber === hoveredLine;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`line-${lineNumber}`}
|
||||||
|
id={`line-${lineNumber}`}
|
||||||
|
className={`text-line group ${
|
||||||
|
isHighlighted ? 'bg-navy/10 dark:bg-cream/10' : 'hover:bg-navy/5 dark:hover:bg-cream/5'
|
||||||
|
} py-1 rounded flex`}
|
||||||
|
onMouseEnter={() => handleLineHover(lineNumber)}
|
||||||
|
onMouseLeave={handleLineLeave}
|
||||||
|
onClick={() => onLineClick?.(lineNumber)}
|
||||||
|
>
|
||||||
|
<span className="line-number">{lineNumber}</span>
|
||||||
|
<p className="flex-1">{line}</p>
|
||||||
|
|
||||||
|
{/* Copy buttons that appear on hover */}
|
||||||
|
{isHovered && (
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity ml-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCopyLine(lineNumber, line);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Copy line</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCopyLineLink(lineNumber);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="h-4 w-4"
|
||||||
|
>
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||||
|
</svg>
|
||||||
|
<span className="sr-only">Copy link to line</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
client/src/components/work/ReadingControls.tsx
Normal file
165
client/src/components/work/ReadingControls.tsx
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
|
Plus,
|
||||||
|
Minus,
|
||||||
|
Heart,
|
||||||
|
Share2,
|
||||||
|
MessageSquare,
|
||||||
|
BookOpen
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Link } from "wouter";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
interface ReadingControlsProps {
|
||||||
|
onZenModeToggle: () => void;
|
||||||
|
onIncreaseFontSize: () => void;
|
||||||
|
onDecreaseFontSize: () => void;
|
||||||
|
zenMode: boolean;
|
||||||
|
workId: number;
|
||||||
|
workSlug: string;
|
||||||
|
translationId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReadingControls({
|
||||||
|
onZenModeToggle,
|
||||||
|
onIncreaseFontSize,
|
||||||
|
onDecreaseFontSize,
|
||||||
|
zenMode,
|
||||||
|
workId,
|
||||||
|
workSlug,
|
||||||
|
translationId
|
||||||
|
}: ReadingControlsProps) {
|
||||||
|
const [isLiked, setIsLiked] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const handleLikeToggle = async () => {
|
||||||
|
try {
|
||||||
|
// In a real app, this would be an API call to like/unlike the work
|
||||||
|
setIsLiked(!isLiked);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
description: isLiked ? "Removed from favorites" : "Added to favorites",
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Could not update favorite status",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShare = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.share({
|
||||||
|
title: "Tercul - Literary Work",
|
||||||
|
url: window.location.href
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback for browsers that don't support the Web Share API
|
||||||
|
navigator.clipboard.writeText(window.location.href);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
description: "Link copied to clipboard",
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold font-serif text-navy dark:text-cream">
|
||||||
|
{/* Work title would go here */}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="btn-feedback p-2 rounded-full text-navy/70 dark:text-cream/70 hover:bg-navy/10 dark:hover:bg-cream/10"
|
||||||
|
onClick={onZenModeToggle}
|
||||||
|
>
|
||||||
|
{zenMode ? (
|
||||||
|
<Minimize2 className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<Maximize2 className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
<span className="sr-only">{zenMode ? 'Exit zen mode' : 'Zen mode'}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="btn-feedback p-2 rounded-full text-navy/70 dark:text-cream/70 hover:bg-navy/10 dark:hover:bg-cream/10"
|
||||||
|
onClick={onIncreaseFontSize}
|
||||||
|
>
|
||||||
|
<Plus className="h-5 w-5" />
|
||||||
|
<span className="sr-only">Increase font</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="btn-feedback p-2 rounded-full text-navy/70 dark:text-cream/70 hover:bg-navy/10 dark:hover:bg-cream/10"
|
||||||
|
onClick={onDecreaseFontSize}
|
||||||
|
>
|
||||||
|
<Minus className="h-5 w-5" />
|
||||||
|
<span className="sr-only">Decrease font</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="h-6 w-px bg-sage/20 dark:bg-sage/10 mx-1"></div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={`btn-feedback p-2 rounded-full ${
|
||||||
|
isLiked
|
||||||
|
? 'text-russet'
|
||||||
|
: 'text-navy/70 dark:text-cream/70 hover:bg-navy/10 dark:hover:bg-cream/10'
|
||||||
|
}`}
|
||||||
|
onClick={handleLikeToggle}
|
||||||
|
>
|
||||||
|
<Heart className={`h-5 w-5 ${isLiked ? 'fill-russet' : ''}`} />
|
||||||
|
<span className="sr-only">{isLiked ? 'Remove from favorites' : 'Add to favorites'}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="btn-feedback p-2 rounded-full text-navy/70 dark:text-cream/70 hover:bg-navy/10 dark:hover:bg-cream/10"
|
||||||
|
onClick={handleShare}
|
||||||
|
>
|
||||||
|
<Share2 className="h-5 w-5" />
|
||||||
|
<span className="sr-only">Share</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Link href={`/works/${workSlug}/comments${translationId ? `?translation=${translationId}` : ''}`}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="btn-feedback p-2 rounded-full text-navy/70 dark:text-cream/70 hover:bg-navy/10 dark:hover:bg-cream/10"
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-5 w-5" />
|
||||||
|
<span className="sr-only">Comments</span>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href={`/works/${workSlug}/citation${translationId ? `?translation=${translationId}` : ''}`}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="btn-feedback p-2 rounded-full text-navy/70 dark:text-cream/70 hover:bg-navy/10 dark:hover:bg-cream/10"
|
||||||
|
>
|
||||||
|
<BookOpen className="h-5 w-5" />
|
||||||
|
<span className="sr-only">Cite</span>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
255
client/src/components/work/ReadingView.tsx
Normal file
255
client/src/components/work/ReadingView.tsx
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { LineNumberedText } from "@/components/reading/LineNumberedText";
|
||||||
|
import { TranslationSelector } from "@/components/reading/TranslationSelector";
|
||||||
|
import { ReadingControls } from "@/components/reading/ReadingControls";
|
||||||
|
import { useReadingSettings } from "@/hooks/use-reading-settings";
|
||||||
|
import { WorkWithDetails, TranslationWithDetails } from "@/lib/types";
|
||||||
|
import { AuthorChip } from "@/components/common/AuthorChip";
|
||||||
|
import { LanguageTag } from "@/components/common/LanguageTag";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
|
||||||
|
interface ReadingViewProps {
|
||||||
|
work: WorkWithDetails;
|
||||||
|
translations: TranslationWithDetails[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReadingView({ work, translations }: ReadingViewProps) {
|
||||||
|
const { settings, increaseFontSize, decreaseFontSize, toggleZenMode } = useReadingSettings();
|
||||||
|
const [selectedTranslationId, setSelectedTranslationId] = useState<number | undefined>(
|
||||||
|
translations.length > 0 ? translations[0].id : undefined
|
||||||
|
);
|
||||||
|
const [readingProgress, setReadingProgress] = useState(0);
|
||||||
|
|
||||||
|
// Get the selected translation
|
||||||
|
const selectedTranslation = translations.find(t => t.id === selectedTranslationId);
|
||||||
|
|
||||||
|
// Content to display - either the translation or original work
|
||||||
|
const contentToDisplay = selectedTranslation ? selectedTranslation.content : work.content;
|
||||||
|
|
||||||
|
// Update reading progress as user scrolls
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
const windowHeight = window.innerHeight;
|
||||||
|
const documentHeight = document.documentElement.scrollHeight;
|
||||||
|
const scrollTop = window.scrollY;
|
||||||
|
|
||||||
|
// Calculate progress percentage
|
||||||
|
const progress = Math.min(
|
||||||
|
100,
|
||||||
|
Math.round((scrollTop / (documentHeight - windowHeight)) * 100)
|
||||||
|
);
|
||||||
|
|
||||||
|
setReadingProgress(progress);
|
||||||
|
|
||||||
|
// Update reading progress in backend (throttled to avoid too many requests)
|
||||||
|
const debounced = setTimeout(() => {
|
||||||
|
updateReadingProgress(progress);
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
return () => clearTimeout(debounced);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', handleScroll);
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
|
}, [work.id, selectedTranslationId]);
|
||||||
|
|
||||||
|
// Update reading progress in backend
|
||||||
|
const updateReadingProgress = async (progress: number) => {
|
||||||
|
try {
|
||||||
|
// In a real app, this would use the logged-in user ID
|
||||||
|
// For demo purposes, we'll use a hard-coded user ID of 1
|
||||||
|
await apiRequest('POST', '/api/reading-progress', {
|
||||||
|
userId: 1,
|
||||||
|
workId: work.id,
|
||||||
|
translationId: selectedTranslationId,
|
||||||
|
progress
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update reading progress:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={`reading-view ${settings.zenMode ? 'zen-mode' : ''}`}>
|
||||||
|
<div className="flex flex-col lg:flex-row max-w-[var(--content-width)] mx-auto">
|
||||||
|
{/* Context sidebar (sticky on desktop) */}
|
||||||
|
<aside className="context-sidebar lg:w-64 p-4 lg:sticky lg:top-16 lg:self-start lg:h-[calc(100vh-4rem)] lg:overflow-y-auto">
|
||||||
|
<div className="mb-6">
|
||||||
|
<AuthorChip author={work.author} withLifeDates />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">About this work</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
{work.year && (
|
||||||
|
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">Written in {work.year}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans capitalize">
|
||||||
|
{work.type} • {work.tags.map(tag => tag.name).join(' • ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{work.tags.map(tag => (
|
||||||
|
<Badge
|
||||||
|
key={tag.id}
|
||||||
|
variant="outline"
|
||||||
|
className="inline-block px-2 py-0.5 bg-navy/10 dark:bg-navy/20 rounded text-navy/70 dark:text-cream/70 text-xs font-sans border-none"
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedTranslation && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">Translation</h4>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-navy/90 dark:text-cream/90 font-sans font-medium">{selectedTranslation.language}</p>
|
||||||
|
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
||||||
|
Translated by User {selectedTranslation.translatorId} {selectedTranslation.year && `(${selectedTranslation.year})`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">Reading stats</h4>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">~{Math.ceil(contentToDisplay.length / 1000)} min read</p>
|
||||||
|
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">{work.likes || 0} favorites</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">Actions</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="btn-feedback flex items-center gap-1 py-1.5 px-3 rounded-lg bg-russet/10 hover:bg-russet/20 dark:bg-russet/20 dark:hover:bg-russet/30 text-russet dark:text-russet/90 font-sans text-xs transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>Favorite</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="btn-feedback flex items-center gap-1 py-1.5 px-3 rounded-lg bg-navy/10 hover:bg-navy/20 dark:bg-navy/20 dark:hover:bg-navy/30 text-navy dark:text-cream/90 font-sans text-xs transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||||
|
</svg>
|
||||||
|
<span>Share</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="btn-feedback flex items-center gap-1 py-1.5 px-3 rounded-lg bg-navy/10 hover:bg-navy/20 dark:bg-navy/20 dark:hover:bg-navy/30 text-navy dark:text-cream/90 font-sans text-xs transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
|
||||||
|
</svg>
|
||||||
|
<span>Comment</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="btn-feedback flex items-center gap-1 py-1.5 px-3 rounded-lg bg-navy/10 hover:bg-navy/20 dark:bg-navy/20 dark:hover:bg-navy/30 text-navy dark:text-cream/90 font-sans text-xs transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<span>Cite</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main reading area */}
|
||||||
|
<div className="flex-1 px-4 lg:px-8 py-6 lg:py-8">
|
||||||
|
<div className="mb-6">
|
||||||
|
<ReadingControls
|
||||||
|
onZenModeToggle={toggleZenMode}
|
||||||
|
onIncreaseFontSize={increaseFontSize}
|
||||||
|
onDecreaseFontSize={decreaseFontSize}
|
||||||
|
zenMode={settings.zenMode}
|
||||||
|
workId={work.id}
|
||||||
|
workSlug={work.slug}
|
||||||
|
translationId={selectedTranslationId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mt-2">
|
||||||
|
<h2 className="text-xl font-serif text-navy/80 dark:text-cream/80">{work.title}</h2>
|
||||||
|
<LanguageTag language={work.language} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{translations.length > 0 && (
|
||||||
|
<TranslationSelector
|
||||||
|
translations={translations}
|
||||||
|
currentTranslationId={selectedTranslationId}
|
||||||
|
workSlug={work.slug}
|
||||||
|
onSelectTranslation={setSelectedTranslationId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text content */}
|
||||||
|
<div className="reading-container max-w-[var(--reading-width)] mx-auto">
|
||||||
|
<LineNumberedText
|
||||||
|
content={contentToDisplay}
|
||||||
|
fontSizeClass={settings.fontSize}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedTranslation && selectedTranslation.notes && (
|
||||||
|
<div className="mt-8 border-t border-sage/20 dark:border-sage/10 pt-4">
|
||||||
|
<h3 className="text-lg font-medium font-serif text-navy dark:text-cream mb-3">Translation Notes</h3>
|
||||||
|
<div className="text-sm text-navy/80 dark:text-cream/80">
|
||||||
|
<p>{selectedTranslation.notes}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar (fixed at bottom) */}
|
||||||
|
<div className="progress-bar fixed bottom-0 left-0 right-0 h-1 bg-sage/20 dark:bg-sage/10">
|
||||||
|
<div
|
||||||
|
className="progress-indicator h-full bg-russet dark:bg-russet/90"
|
||||||
|
style={{ width: `${readingProgress}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
300
client/src/components/work/TranslationSelector.tsx
Normal file
300
client/src/components/work/TranslationSelector.tsx
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
import { TranslationWithDetails } from "@/lib/types";
|
||||||
|
import { Translation } from "@shared/schema";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Link } from "wouter";
|
||||||
|
import { Languages, ChevronDown, Globe, BookOpen, BookMarked } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
SelectGroup,
|
||||||
|
SelectLabel
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from "@/components/ui/tabs";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
|
||||||
|
interface TranslationSelectorProps {
|
||||||
|
translations: TranslationWithDetails[];
|
||||||
|
currentTranslationId?: number;
|
||||||
|
workSlug: string;
|
||||||
|
workLanguage: string;
|
||||||
|
onSelectTranslation: (translationId: number) => void;
|
||||||
|
onViewOriginal: () => void;
|
||||||
|
isOriginalSelected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Language display names for common languages
|
||||||
|
const languageNames: Record<string, string> = {
|
||||||
|
'en': 'English',
|
||||||
|
'fr': 'French (Français)',
|
||||||
|
'es': 'Spanish (Español)',
|
||||||
|
'de': 'German (Deutsch)',
|
||||||
|
'it': 'Italian (Italiano)',
|
||||||
|
'pt': 'Portuguese (Português)',
|
||||||
|
'ru': 'Russian (Русский)',
|
||||||
|
'zh': 'Chinese (中文)',
|
||||||
|
'ja': 'Japanese (日本語)',
|
||||||
|
'ko': 'Korean (한국어)',
|
||||||
|
'ar': 'Arabic (العربية)',
|
||||||
|
'hi': 'Hindi (हिन्दी)',
|
||||||
|
'bn': 'Bengali (বাংলা)',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to get display name
|
||||||
|
const getLanguageDisplayName = (code: string): string => {
|
||||||
|
return languageNames[code.toLowerCase()] || code;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TranslationSelector({
|
||||||
|
translations,
|
||||||
|
currentTranslationId,
|
||||||
|
workSlug,
|
||||||
|
workLanguage,
|
||||||
|
onSelectTranslation,
|
||||||
|
onViewOriginal,
|
||||||
|
isOriginalSelected
|
||||||
|
}: TranslationSelectorProps) {
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
|
|
||||||
|
// Group translations by language
|
||||||
|
const translationsByLanguage = translations.reduce<Record<string, TranslationWithDetails[]>>((acc, translation) => {
|
||||||
|
if (!acc[translation.language]) {
|
||||||
|
acc[translation.language] = [];
|
||||||
|
}
|
||||||
|
acc[translation.language].push(translation);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Get current translation
|
||||||
|
const currentTranslation = translations.find(t => t.id === currentTranslationId);
|
||||||
|
|
||||||
|
// Count languages for display
|
||||||
|
const languageCount = Object.keys(translationsByLanguage).length;
|
||||||
|
|
||||||
|
// Format date/year for display
|
||||||
|
const formatYear = (year?: number) => {
|
||||||
|
if (!year) return '';
|
||||||
|
return year.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h3 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2 flex items-center gap-1">
|
||||||
|
<Languages className="h-3.5 w-3.5" />
|
||||||
|
<span>Language Options</span>
|
||||||
|
<Badge variant="outline" className="ml-1 text-xs py-0 px-1.5 h-4 bg-navy/5 dark:bg-navy/20 border-none">
|
||||||
|
{languageCount + 1} languages
|
||||||
|
</Badge>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{/* Original language button */}
|
||||||
|
<Button
|
||||||
|
variant={isOriginalSelected ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={`py-1 px-3 rounded-lg flex items-center gap-1 ${
|
||||||
|
isOriginalSelected
|
||||||
|
? "bg-russet hover:bg-russet/90 text-white"
|
||||||
|
: "border-navy/20 dark:border-cream/20 hover:bg-navy/5 dark:hover:bg-cream/5"
|
||||||
|
}`}
|
||||||
|
onClick={onViewOriginal}
|
||||||
|
>
|
||||||
|
<BookOpen className="h-3.5 w-3.5" />
|
||||||
|
<span>Original ({getLanguageDisplayName(workLanguage)})</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Simple mobile-friendly dropdown for all translations */}
|
||||||
|
<Select
|
||||||
|
value={currentTranslationId?.toString()}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value) {
|
||||||
|
onSelectTranslation(parseInt(value, 10));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="w-auto border-navy/20 dark:border-cream/20 rounded-lg h-8 px-3"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Select translation" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel>Translations</SelectLabel>
|
||||||
|
{translations.map(translation => (
|
||||||
|
<SelectItem
|
||||||
|
key={translation.id}
|
||||||
|
value={translation.id.toString()}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{getLanguageDisplayName(translation.language)} {translation.year ? `(${translation.year})` : ''}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Advanced translation selector with Popover */}
|
||||||
|
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="py-1 px-3 rounded-lg border-navy/20 dark:border-cream/20 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Globe className="h-3.5 w-3.5" />
|
||||||
|
<span>All Translations</span>
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 ml-1 opacity-70" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80 p-0" align="start">
|
||||||
|
<Tabs defaultValue="languages" className="w-full">
|
||||||
|
<TabsList className="grid grid-cols-2 w-full">
|
||||||
|
<TabsTrigger value="languages">By Language</TabsTrigger>
|
||||||
|
<TabsTrigger value="translators">By Translator</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="languages" className="p-0">
|
||||||
|
<ScrollArea className="h-60">
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{Object.entries(translationsByLanguage).map(([language, languageTranslations]) => (
|
||||||
|
<div key={language} className="space-y-2">
|
||||||
|
<h4 className="text-sm font-medium">{getLanguageDisplayName(language)}</h4>
|
||||||
|
<div className="ml-4 space-y-1.5">
|
||||||
|
{languageTranslations.map(translation => (
|
||||||
|
<Button
|
||||||
|
key={translation.id}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={`w-full justify-start text-left py-1 h-auto ${
|
||||||
|
translation.id === currentTranslationId
|
||||||
|
? "bg-russet/10 text-russet font-medium"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
onSelectTranslation(translation.id);
|
||||||
|
setIsPopoverOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<span>
|
||||||
|
{translation.translator?.displayName || `Translator ${translation.translatorId}`}
|
||||||
|
{translation.year ? ` (${translation.year})` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="translators" className="p-0">
|
||||||
|
<ScrollArea className="h-60">
|
||||||
|
<div className="p-4 space-y-2">
|
||||||
|
{translations.map(translation => (
|
||||||
|
<Button
|
||||||
|
key={translation.id}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={`w-full justify-start text-left h-auto ${
|
||||||
|
translation.id === currentTranslationId
|
||||||
|
? "bg-russet/10 text-russet font-medium"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
onSelectTranslation(translation.id);
|
||||||
|
setIsPopoverOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="font-medium">
|
||||||
|
{translation.translator?.displayName || `Translator ${translation.translatorId}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs flex gap-2 mt-0.5">
|
||||||
|
<span>{getLanguageDisplayName(translation.language)}</span>
|
||||||
|
{translation.year && <span>• {translation.year}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="p-2 border-t border-sage/20 dark:border-sage/10">
|
||||||
|
<Link href={`/works/${workSlug}/compare${currentTranslationId ? `/${currentTranslationId}` : ''}`}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full flex items-center gap-1 border-navy/20 dark:border-cream/20"
|
||||||
|
>
|
||||||
|
<BookMarked className="h-4 w-4" />
|
||||||
|
<span>Compare Translations</span>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{/* Quick language pills for most common languages */}
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||||
|
{Object.keys(translationsByLanguage).slice(0, 5).map(language => {
|
||||||
|
const languageTranslations = translationsByLanguage[language];
|
||||||
|
const isLanguageSelected = languageTranslations.some(t => t.id === currentTranslationId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={language}
|
||||||
|
variant={isLanguageSelected ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={`py-0.5 px-2 h-7 rounded-full text-xs ${
|
||||||
|
isLanguageSelected
|
||||||
|
? "bg-russet hover:bg-russet/90 text-white"
|
||||||
|
: "border-navy/20 dark:border-cream/20"
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
// Select the first translation for this language
|
||||||
|
if (languageTranslations.length > 0) {
|
||||||
|
onSelectTranslation(languageTranslations[0].id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getLanguageDisplayName(language)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{Object.keys(translationsByLanguage).length > 5 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="py-0.5 px-2 h-7 rounded-full text-xs border-navy/20 dark:border-cream/20"
|
||||||
|
onClick={() => setIsPopoverOpen(true)}
|
||||||
|
>
|
||||||
|
+{Object.keys(translationsByLanguage).length - 5} more
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
client/src/components/work/WorkCard.tsx
Normal file
149
client/src/components/work/WorkCard.tsx
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import { Link } from "wouter";
|
||||||
|
import { WorkWithAuthor } from "@/lib/types";
|
||||||
|
import { Heart } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { LanguageTag } from "@/components/common/LanguageTag";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
interface WorkCardProps {
|
||||||
|
work: WorkWithAuthor;
|
||||||
|
compact?: boolean;
|
||||||
|
grid?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkCard({ work, compact = false, grid = false }: WorkCardProps) {
|
||||||
|
const [likeCount, setLikeCount] = useState<number>(work.likes || 0);
|
||||||
|
const [isLiked, setIsLiked] = useState<boolean>(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const handleLike = async (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!isLiked) {
|
||||||
|
await apiRequest('POST', '/api/likes', {
|
||||||
|
userId: 1, // For demo, we'll use hardcoded user ID
|
||||||
|
entityType: 'work',
|
||||||
|
entityId: work.id
|
||||||
|
});
|
||||||
|
setLikeCount(likeCount + 1);
|
||||||
|
setIsLiked(true);
|
||||||
|
} else {
|
||||||
|
// In a real app, we would delete the specific like
|
||||||
|
setLikeCount(likeCount - 1);
|
||||||
|
setIsLiked(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Could not like this work. Please try again.",
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (grid) {
|
||||||
|
return (
|
||||||
|
<Link href={`/works/${work.slug}`}>
|
||||||
|
<div className="card group bg-cream dark:bg-dark-surface p-4 rounded-lg shadow-sm border border-sage/10 dark:border-sage/5 flex flex-col h-full transition-shadow hover:shadow-md">
|
||||||
|
<div className="mb-3">
|
||||||
|
<h3 className="font-serif text-lg font-semibold mb-1 group-hover:text-russet dark:group-hover:text-russet/90 transition-colors">
|
||||||
|
{work.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-navy/70 dark:text-cream/70">{work.author?.name || 'Unknown Author'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
|
{work.tags && work.tags.length > 0 ? (
|
||||||
|
work.tags.slice(0, 3).map((tag) => (
|
||||||
|
<Badge key={tag.id} variant="outline" className="bg-navy/10 dark:bg-navy/20 text-navy/70 dark:text-cream/70 text-xs border-none">
|
||||||
|
{tag.name}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="bg-navy/10 dark:bg-navy/20 text-navy/70 dark:text-cream/70 text-xs border-none">
|
||||||
|
General
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-navy/80 dark:text-cream/80 mb-3 line-clamp-3 flex-1">
|
||||||
|
{work.description}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between mt-auto pt-3 border-t border-sage/10 dark:border-sage/5">
|
||||||
|
<LanguageTag language={`${work.language}, ${work.year || 'Unknown'}`} />
|
||||||
|
<span className="text-xs text-navy/60 dark:text-cream/60">
|
||||||
|
{/* Assuming translations count would be available */}
|
||||||
|
{Math.floor(Math.random() * 10) + 1} translations
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card bg-cream dark:bg-dark-surface p-4 rounded-lg shadow-sm border border-sage/10 dark:border-sage/5 flex flex-col sm:flex-row sm:items-center gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-serif text-lg font-semibold mb-1">
|
||||||
|
<Link href={`/works/${work.slug}`} className="text-navy dark:text-cream hover:text-russet dark:hover:text-russet/90 transition-colors">
|
||||||
|
{work.title}
|
||||||
|
</Link>
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mb-2">
|
||||||
|
<span className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
||||||
|
{work.type === 'poem' ? 'Poem' :
|
||||||
|
work.type === 'story' ? 'Short story' :
|
||||||
|
work.type === 'novel' ? 'Novel' :
|
||||||
|
work.type === 'play' ? 'Play' :
|
||||||
|
work.type === 'essay' ? 'Essay' : 'Work'}, {work.year}
|
||||||
|
</span>
|
||||||
|
<LanguageTag language={work.language} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!compact && (
|
||||||
|
<p className="text-navy/80 dark:text-cream/80 text-sm mb-2 line-clamp-2">
|
||||||
|
{work.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{work.tags && work.tags.length > 0 ? (
|
||||||
|
work.tags.map((tag) => (
|
||||||
|
<Badge key={tag.id} variant="outline" className="inline-block px-2 py-0.5 bg-navy/10 dark:bg-navy/20 rounded text-navy/70 dark:text-cream/70 text-xs font-sans border-none">
|
||||||
|
{tag.name}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="inline-block px-2 py-0.5 bg-navy/10 dark:bg-navy/20 rounded text-navy/70 dark:text-cream/70 text-xs font-sans border-none">
|
||||||
|
General
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center sm:flex-col gap-3 sm:gap-2 self-end sm:self-center">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
||||||
|
{/* Assuming translations count would be available */}
|
||||||
|
{Math.floor(Math.random() * 10) + 1} translations
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className={`btn-feedback flex items-center gap-1 py-1 px-2.5 rounded-full ${
|
||||||
|
isLiked
|
||||||
|
? 'bg-russet/20 hover:bg-russet/30 dark:bg-russet/30 dark:hover:bg-russet/40 text-russet dark:text-russet/90'
|
||||||
|
: 'bg-russet/10 hover:bg-russet/20 dark:bg-russet/20 dark:hover:bg-russet/30 text-russet dark:text-russet/90'
|
||||||
|
} font-sans text-xs transition-colors`}
|
||||||
|
onClick={handleLike}
|
||||||
|
>
|
||||||
|
<Heart className={`h-3.5 w-3.5 ${isLiked ? 'fill-russet' : ''}`} />
|
||||||
|
<span>{likeCount}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
0
client/src/components/work/comparison-view.tsx
Normal file
0
client/src/components/work/comparison-view.tsx
Normal file
0
client/src/components/work/related-works.tsx
Normal file
0
client/src/components/work/related-works.tsx
Normal file
0
client/src/components/work/work-editor.tsx
Normal file
0
client/src/components/work/work-editor.tsx
Normal file
0
client/src/components/work/work-header.tsx
Normal file
0
client/src/components/work/work-header.tsx
Normal file
0
client/src/components/work/work-status.tsx
Normal file
0
client/src/components/work/work-status.tsx
Normal file
Loading…
Reference in New Issue
Block a user