mirror of
https://github.com/SamyRai/tercul-frontend.git
synced 2025-12-27 04:51:34 +00:00
Enhance reading experience with annotations, controls, and mobile support
Refactors WorkReading page to use EnhancedReadingView with annotation system, reading controls, translation selection, and mobile-friendly UI. 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/26a2d04a-2208-438a-9cf2-8cd6f3d3fc29.jpg
This commit is contained in:
parent
83af0535b1
commit
04619aa3cb
380
client/src/components/reading/AnnotationSystem.tsx
Normal file
380
client/src/components/reading/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>
|
||||
);
|
||||
}
|
||||
230
client/src/components/reading/EnhancedLineNumberedText.tsx
Normal file
230
client/src/components/reading/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>
|
||||
);
|
||||
}
|
||||
621
client/src/components/reading/EnhancedReadingView.tsx
Normal file
621
client/src/components/reading/EnhancedReadingView.tsx
Normal file
@ -0,0 +1,621 @@
|
||||
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);
|
||||
|
||||
// Content to display - either the translation or original work
|
||||
const contentToDisplay = selectedTranslation ? selectedTranslation.content : work.content;
|
||||
|
||||
// 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} 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>
|
||||
|
||||
{translations.length > 0 && (
|
||||
<TranslationSelector
|
||||
translations={translations}
|
||||
currentTranslationId={selectedTranslationId}
|
||||
workSlug={work.slug}
|
||||
onSelectTranslation={setSelectedTranslationId}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
27
client/src/hooks/use-media-query.ts
Normal file
27
client/src/hooks/use-media-query.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia(query);
|
||||
|
||||
// Set initial value
|
||||
setMatches(mediaQuery.matches);
|
||||
|
||||
// Define callback function to handle changes
|
||||
const handleChange = (event: MediaQueryListEvent) => {
|
||||
setMatches(event.matches);
|
||||
};
|
||||
|
||||
// Add event listener for changes
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
|
||||
// Cleanup function to remove event listener
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleChange);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
@ -2,10 +2,11 @@ import { PageLayout } from "@/components/layout/PageLayout";
|
||||
import { useParams } from "wouter";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { WorkWithDetails, TranslationWithDetails } from "@/lib/types";
|
||||
import { ReadingView } from "@/components/reading/ReadingView";
|
||||
import { EnhancedReadingView } from "@/components/reading/EnhancedReadingView";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Link } from "wouter";
|
||||
import { BookOpen } from "lucide-react";
|
||||
|
||||
export default function WorkReading() {
|
||||
const { slug } = useParams();
|
||||
@ -55,9 +56,12 @@ export default function WorkReading() {
|
||||
<div className="max-w-[var(--content-width)] mx-auto px-4 py-16 text-center">
|
||||
<h1 className="text-2xl font-bold mb-4">Work not found</h1>
|
||||
<p className="mb-6">The literary work you're looking for could not be found.</p>
|
||||
<Link href="/explore">
|
||||
<Button>Explore Works</Button>
|
||||
</Link>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<BookOpen className="h-16 w-16 text-russet/30" />
|
||||
<Link href="/explore">
|
||||
<Button className="bg-russet hover:bg-russet/90">Explore Works</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
@ -65,7 +69,7 @@ export default function WorkReading() {
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<ReadingView
|
||||
<EnhancedReadingView
|
||||
work={work}
|
||||
translations={translations || []}
|
||||
/>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user