tercul-frontend/client/src/components/reading/EnhancedReadingView.tsx
google-labs-jules[bot] 1dcd8f076c
feat: Fix TypeScript errors and improve type safety (#6)
This commit addresses 275 TypeScript compilation errors and improves type safety, code quality, and maintainability across the frontend codebase.

The following issues have been resolved:
- Standardized `translationId` to `number`
- Fixed missing properties on annotation types
- Resolved `tags` type mismatch
- Corrected `country` type mismatch
- Addressed date vs. string mismatches
- Fixed variable hoisting issues
- Improved server-side type safety
- Added missing null/undefined checks
- Fixed arithmetic operations on non-numbers
- Resolved `RefObject` type issues

Note: I was unable to verify the frontend changes due to local setup issues with the development server. The server would not start, and I was unable to run the Playwright tests.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-11-27 18:48:47 +01:00

716 lines
23 KiB
TypeScript

import {
AlignLeft,
BookCopy,
Bookmark,
FileText,
Heart,
Menu,
MessageCircle,
Share2,
X,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useLocation } from "wouter";
import { AuthorChip } from "@/components/common/AuthorChip";
import { LanguageTag } from "@/components/common/LanguageTag";
import { AnnotationSystem } from "@/components/reading/AnnotationSystem";
import { EnhancedLineNumberedText } from "@/components/reading/EnhancedLineNumberedText";
import { ReadingControls } from "@/components/reading/ReadingControls";
import { TranslationSelector } from "@/components/reading/TranslationSelector";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useReadingSettings } from "@/hooks/use-reading-settings";
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
import type { TranslationWithDetails, WorkWithDetails } from "@/lib/types";
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?.[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);
}
}
}, []);
// Update reading progress in backend
const updateReadingProgress = useCallback(
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
? Number(selectedTranslationId)
: undefined,
progress,
});
} catch (error) {
console.error("Failed to update reading progress:", error);
}
},
[work.id, selectedTranslationId],
);
// 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);
}, [updateReadingProgress]);
// 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?.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>
);
}