tercul-frontend/client/src/pages/works/SimpleWorkReading.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

1377 lines
40 KiB
TypeScript

import { useQuery } from "@tanstack/react-query";
// Icons
import {
ArrowLeft,
ArrowRight,
BookMarked,
Bookmark,
BookOpen,
Copy,
Heart,
Languages,
LineChart,
Link as LinkIcon,
MessageCircle,
Moon,
Share2,
Sparkles,
Sun,
Waves,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useLocation, useParams } from "wouter";
import { AuthorChip } from "@/components/common/AuthorChip";
import { PageLayout } from "@/components/layout/PageLayout";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
// UI Components
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useMediaQuery } from "@/hooks/use-media-query";
import { toast } from "@/hooks/use-toast";
import type { TranslationWithDetails, WorkWithDetails } from "@/lib/types";
// Define types for linguistic analysis
interface PartOfSpeech {
term: string;
tag: string;
color: string;
explanation: string;
}
interface Sentiment {
score: number; // -1 to 1
label: string; // positive, negative, neutral
intensity: string; // mild, moderate, strong
}
interface SyllableData {
count: number;
breakdown: string[];
}
interface RhymeInfo {
word: string;
lineNumber: number;
rhymeGroup: string;
lineText: string;
}
interface LinguisticAnalysis {
partOfSpeech: Record<number, PartOfSpeech[]>; // Line number to POS tags
entityRecognition: Record<number, string[]>; // Line number to entities (names, places)
syllableCount: Record<string, SyllableData>; // Word to syllable count
sentiment: Record<number, Sentiment>; // Line number to sentiment
rhymes: RhymeInfo[]; // Identified rhymes
meter: Record<number, string[]>; // Line number to meter pattern
themeLexicon: Record<string, string[]>; // Theme to related words found
readabilityScore: number; // Overall text readability (0-100)
}
export default function SimpleWorkReading() {
const { slug } = useParams();
const [, navigate] = useLocation();
const _isMobile = useMediaQuery("(max-width: 768px)");
// Main content states
const [activePage, setActivePage] = useState(1);
const [selectedTranslationId, setSelectedTranslationId] = useState<
number | undefined
>(undefined);
const [secondaryTranslationId, setSecondaryTranslationId] = useState<
number | undefined
>(undefined);
const [isBookmarked, setIsBookmarked] = useState(false);
const [isLiked, setIsLiked] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(false);
const [fontSize, setFontSize] = useState(16);
const [activeTab, setActiveTab] = useState("text");
const [viewMode, setViewMode] = useState<
"traditional" | "enhanced" | "parallel"
>("traditional");
const [selectedLineNumber, setSelectedLineNumber] = useState<number | null>(
null,
);
const [highlightMode, setHighlightMode] = useState<
"none" | "partOfSpeech" | "sentiment" | "meter" | "themes"
>("none");
const [linguisticAnalysis, setLinguisticAnalysis] =
useState<LinguisticAnalysis | null>(null);
const [isAnalysisLoading, setIsAnalysisLoading] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
// Queries
const {
data: work,
isLoading: workLoading,
error: workError,
} = useQuery<WorkWithDetails>({
queryKey: [`/api/works/${slug}`],
});
const { data: translations, isLoading: translationsLoading } = useQuery<
TranslationWithDetails[]
>({
queryKey: [`/api/works/${slug}/translations`],
enabled: !!work,
});
// Page validation effect
useEffect(() => {
if (work) {
const content = getSelectedContent();
const lines = contentToLines(content);
const totalPages = Math.ceil(lines.length / 20);
const safePage = Math.min(
Math.max(1, activePage),
Math.max(1, totalPages),
);
if (safePage !== activePage) {
setActivePage(safePage);
}
}
}, [work, activePage, contentToLines, getSelectedContent]);
// Effect to generate linguistic analysis when needed
useEffect(() => {
if (work && activeTab === "analysis" && !linguisticAnalysis) {
setIsAnalysisLoading(true);
// In a real implementation, this would be an API call to Claude 3 or another LLM
setTimeout(() => {
generateLinguisticAnalysis(getSelectedContent());
setIsAnalysisLoading(false);
}, 1000);
}
}, [
work,
activeTab,
linguisticAnalysis,
generateLinguisticAnalysis,
getSelectedContent,
]);
// Get the secondary translation content (for parallel view)
function getSecondaryContent() {
if (!work || !secondaryTranslationId) return "";
const translation = translations?.find(
(t) => t.id === secondaryTranslationId,
);
return translation?.content || "";
}
// Generate demo linguistic analysis for the content
function generateLinguisticAnalysis(content: string) {
const lines = content.split("\n");
// Part of speech examples for lines
const partOfSpeech: Record<number, PartOfSpeech[]> = {};
const entityRecognition: Record<number, string[]> = {};
const sentiment: Record<number, Sentiment> = {};
const meter: Record<number, string[]> = {};
// Generate sample data for part of speech, entities, sentiment and meter
lines.forEach((line, index) => {
if (line.trim().length === 0) return;
const lineNumber = index + 1;
// Create sample POS data
const words = line.split(" ").filter((w) => w.trim().length > 0);
partOfSpeech[lineNumber] = words.map((word) => {
const tags = [
"NOUN",
"VERB",
"ADJ",
"ADV",
"PRON",
"DET",
"ADP",
"CONJ",
"PRT",
];
const colors = [
"#8884d8",
"#83a6ed",
"#8dd1e1",
"#82ca9d",
"#a4de6c",
"#d0ed57",
"#ffc658",
"#ff8042",
"#ff6361",
];
const explanations = [
"Noun",
"Verb",
"Adjective",
"Adverb",
"Pronoun",
"Determiner",
"Adposition",
"Conjunction",
"Particle",
];
const randomIndex = Math.floor(Math.random() * tags.length);
return {
term: word.replace(/[.,;!?]/g, ""),
tag: tags[randomIndex],
color: colors[randomIndex],
explanation: explanations[randomIndex],
};
});
// Create sample entity recognition
if (Math.random() > 0.7) {
const _entities = [
"PERSON",
"LOCATION",
"ORGANIZATION",
"TIME",
"DATE",
];
entityRecognition[lineNumber] = [
words[Math.floor(Math.random() * words.length)],
];
}
// Create sample sentiment analysis
const sentimentScore = Math.random() * 2 - 1; // -1 to 1
sentiment[lineNumber] = {
score: sentimentScore,
label:
sentimentScore > 0.3
? "positive"
: sentimentScore < -0.3
? "negative"
: "neutral",
intensity:
Math.abs(sentimentScore) > 0.7
? "strong"
: Math.abs(sentimentScore) > 0.4
? "moderate"
: "mild",
};
// Create sample meter pattern
meter[lineNumber] = Array(words.length)
.fill("")
.map(() => (Math.random() > 0.5 ? "/" : "\\"));
});
// Create syllable count data
const syllableCount: Record<string, SyllableData> = {};
const uniqueWords = new Set<string>();
lines.forEach((line) => {
line.split(" ").forEach((word) => {
const cleanWord = word.replace(/[.,;!?]/g, "").toLowerCase();
if (cleanWord.length > 0) uniqueWords.add(cleanWord);
});
});
Array.from(uniqueWords).forEach((word) => {
const count = Math.ceil(word.length / 3);
const breakdown = [];
for (let i = 0; i < word.length; i += 3) {
breakdown.push(word.substring(i, Math.min(i + 3, word.length)));
}
syllableCount[word] = { count, breakdown };
});
// Create sample rhyme data
const rhymeGroups = ["A", "B", "C", "D", "E"];
const rhymes: RhymeInfo[] = [];
lines.forEach((line, index) => {
if (line.trim().length === 0 || Math.random() > 0.3) return;
const words = line.split(" ");
if (words.length === 0) return;
const lastWord = words[words.length - 1].replace(/[.,;!?]/g, "");
if (lastWord.length === 0) return;
rhymes.push({
word: lastWord,
lineNumber: index + 1,
rhymeGroup: rhymeGroups[Math.floor(Math.random() * rhymeGroups.length)],
lineText: line,
});
});
// Create theme lexicon data
const themes = ["love", "nature", "time", "death", "art"];
const themeLexicon: Record<string, string[]> = {};
const allWords = lines
.join(" ")
.split(" ")
.map((w) => w.replace(/[.,;!?]/g, "").toLowerCase())
.filter((w) => w.length > 0);
themes.forEach((theme) => {
themeLexicon[theme] = allWords
.filter(() => Math.random() > 0.95)
.slice(0, 10);
});
// Set the linguistic analysis data
setLinguisticAnalysis({
partOfSpeech,
entityRecognition,
syllableCount,
sentiment,
rhymes,
meter,
themeLexicon,
readabilityScore: Math.floor(Math.random() * 40) + 60, // 60-100
});
};
// Get the selected translation content
function getSelectedContent() {
if (!work) return "";
if (!selectedTranslationId) return work.content;
const translation = translations?.find(
(t) => t.id === selectedTranslationId,
);
return translation?.content || work.content;
}
// Split content into lines and pages for display
function contentToLines(content: string) {
return content.split("\n").filter((line) => line.length > 0);
}
function getPagedContent(content: string, linesPerPage = 20) {
const lines = contentToLines(content);
const totalPages = Math.ceil(lines.length / linesPerPage);
// Make sure active page is in bounds
const safePage = Math.min(Math.max(1, activePage), Math.max(1, totalPages));
const startIdx = (safePage - 1) * linesPerPage;
const endIdx = startIdx + linesPerPage;
return {
lines: lines.slice(startIdx, endIdx),
page: safePage,
totalPages,
startLineNumber: startIdx + 1,
};
};
// Toggle bookmark status
function handleBookmarkToggle() {
setIsBookmarked(!isBookmarked);
toast({
description: isBookmarked
? "Removed from your bookmarks"
: "Added to your bookmarks",
duration: 3000,
});
}
// Toggle like status
function handleLikeToggle() {
setIsLiked(!isLiked);
toast({
description: isLiked
? "Removed from your favorites"
: "Added to your favorites",
duration: 3000,
});
}
// Share the work
async function handleShare() {
try {
if (navigator.share) {
await navigator.share({
title: work?.title || "Literary Work",
text: `Reading ${work?.title || "a work"} on Tercul`,
url: window.location.href,
});
} else {
navigator.clipboard.writeText(window.location.href);
toast({
description: "Link copied to clipboard",
duration: 3000,
});
}
} catch (error) {
console.error("Error sharing:", error);
}
}
// Handle navigation between pages
function handleNextPage() {
if (!work) return;
const { totalPages } = getPagedContent(getSelectedContent());
if (activePage < totalPages) {
setActivePage(activePage + 1);
// Scroll to top of content area
if (contentRef.current) {
contentRef.current.scrollIntoView({ behavior: "smooth" });
}
}
}
function handlePreviousPage() {
if (activePage > 1) {
setActivePage(activePage - 1);
// Scroll to top of content area
if (contentRef.current) {
contentRef.current.scrollIntoView({ behavior: "smooth" });
}
}
}
// Loading state
if (workLoading) {
return (
<PageLayout>
<div className="max-w-full mx-auto px-4 py-8">
<div className="flex items-center space-x-4 mb-6">
<Skeleton className="h-12 w-12 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</div>
<div className="flex flex-col lg:flex-row gap-8">
<div className="lg:w-64 flex-shrink-0">
<Skeleton className="h-64 w-full rounded-lg" />
</div>
<div className="flex-1">
<Skeleton className="h-8 w-3/4 mb-4" />
<div className="space-y-3">
{Array.from({ length: 15 }).map((_, i) => (
<Skeleton key={i} className="h-6 w-full" />
))}
</div>
</div>
</div>
</div>
</PageLayout>
);
}
// Error state
if (workError || !work) {
return (
<PageLayout>
<div className="max-w-[var(--content-width)] mx-auto px-4 py-16 text-center">
<h1 className="text-2xl font-bold mb-4 text-primary">
Work not found
</h1>
<p className="mb-6 text-muted-foreground">
The literary work you're looking for could not be found.
</p>
<div className="flex flex-col items-center gap-4">
<BookOpen className="h-16 w-16 text-primary/30" />
<Button
onClick={() => navigate("/explore")}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
Explore Works
</Button>
</div>
</div>
</PageLayout>
);
}
// Get content for current view and page
const mainContent = getPagedContent(getSelectedContent());
// Get the selected translation details
const selectedTranslation = translations?.find(
(t) => t.id === selectedTranslationId,
);
// Calculate reading time estimation
const wordCount = getSelectedContent().split(/\s+/).length;
const readingTimeMinutes = Math.ceil(wordCount / 200); // Average reading speed
return (
<PageLayout>
<div className="max-w-[var(--content-width)] mx-auto pt-4 px-4">
{/* Work header */}
<div className="mb-8">
<h1 className="text-3xl font-serif font-bold text-primary mb-2">
{work.title}
</h1>
<div className="flex items-center gap-4 flex-wrap mb-4">
<AuthorChip author={work.author} withLifeDates />
{work.year && (
<span className="text-muted-foreground">
Published: {work.year}
</span>
)}
<span className="text-muted-foreground">
Reading time: ~{readingTimeMinutes} min
</span>
</div>
<div className="flex flex-wrap gap-2 mb-4">
{work.tags?.map((tag) => (
<Badge
key={tag.id}
variant="outline"
className="bg-primary/5 hover:bg-primary/10 text-primary/80"
>
{tag.name}
</Badge>
))}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className={`flex items-center gap-1 ${isLiked ? "text-accent border-accent" : ""}`}
onClick={handleLikeToggle}
>
<Heart className={`h-4 w-4 ${isLiked ? "fill-accent" : ""}`} />
{isLiked ? "Unlike" : "Like"}
</Button>
<Button
variant="outline"
size="sm"
className={`flex items-center gap-1 ${isBookmarked ? "text-accent border-accent" : ""}`}
onClick={handleBookmarkToggle}
>
<Bookmark
className={`h-4 w-4 ${isBookmarked ? "fill-accent" : ""}`}
/>
Bookmark
</Button>
<Button
variant="outline"
size="sm"
className="flex items-center gap-1"
onClick={handleShare}
>
<Share2 className="h-4 w-4" />
Share
</Button>
</div>
</div>
{/* Translation selector */}
{translations && translations.length > 0 && (
<div className="mb-6">
<h3 className="text-md font-medium mb-2">Translations</h3>
<div className="flex flex-wrap gap-2">
<Button
variant={!selectedTranslationId ? "default" : "outline"}
size="sm"
onClick={() => setSelectedTranslationId(undefined)}
>
<Languages className="mr-2 h-4 w-4" />
Original ({work.language})
</Button>
{translations.map((translation) => (
<Button
key={translation.id}
variant={
selectedTranslationId === translation.id
? "default"
: "outline"
}
size="sm"
onClick={() => setSelectedTranslationId(translation.id)}
>
<Languages className="mr-2 h-4 w-4" />
{translation.language}
</Button>
))}
</div>
</div>
)}
{/* Reading content */}
<div
className="reading-content mb-8 prose dark:prose-invert max-w-none"
ref={contentRef}
>
{/* Reading settings */}
<div className="mb-6 flex flex-wrap justify-between items-center gap-4">
<div className="space-y-2">
<h3 className="text-sm font-medium">Reading Settings</h3>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-sm">Font Size:</span>
<div className="w-32">
<Slider
value={[fontSize]}
min={12}
max={24}
step={1}
onValueChange={(values) => setFontSize(values[0])}
/>
</div>
<span className="text-sm">{fontSize}px</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm">Dark Mode:</span>
<Switch
checked={isDarkMode}
onCheckedChange={setIsDarkMode}
/>
{isDarkMode ? (
<Moon className="h-4 w-4 text-muted-foreground" />
) : (
<Sun className="h-4 w-4 text-muted-foreground" />
)}
</div>
<div className="flex items-center gap-2">
<span className="text-sm">View Mode:</span>
<div className="flex border rounded-md">
<Button
variant={viewMode === "traditional" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("traditional")}
className="rounded-r-none"
>
Traditional
</Button>
<Button
variant={viewMode === "enhanced" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("enhanced")}
className="rounded-none border-x"
>
Enhanced
</Button>
<Button
variant={viewMode === "parallel" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("parallel")}
className="rounded-l-none"
>
Parallel
</Button>
</div>
</div>
</div>
</div>
</div>
{/* Main tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="mb-6">
<TabsTrigger value="text">Text</TabsTrigger>
<TabsTrigger value="annotations">Annotations</TabsTrigger>
<TabsTrigger value="analysis">Analysis</TabsTrigger>
{translations && translations.length > 0 && (
<TabsTrigger value="compare">Compare</TabsTrigger>
)}
</TabsList>
{/* Text tab content */}
<TabsContent value="text">
<Card className="p-6">
{viewMode === "traditional" && (
<div style={{ fontSize: `${fontSize}px` }}>
{mainContent.lines.map((line, i) => (
<div key={i} className="mb-3">
{line}
</div>
))}
</div>
)}
{viewMode === "enhanced" && (
<div style={{ fontSize: `${fontSize}px` }}>
{mainContent.lines.map((line, i) => {
const lineNumber = mainContent.startLineNumber + i;
return (
<div
key={i}
className="flex group hover:bg-muted/20 rounded transition-colors mb-2 py-1"
onClick={() => setSelectedLineNumber(lineNumber)}
>
<div className="text-muted-foreground w-10 flex-shrink-0 text-right pr-3 select-none">
{lineNumber}
</div>
<div className="flex-1 relative">
{line}
{/* Line actions - visible on hover */}
<div className="absolute right-0 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity bg-card/80 backdrop-blur-sm rounded flex items-center">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
>
<Copy className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Copy line</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
>
<LinkIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Copy link to line</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
>
<MessageCircle className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Add annotation</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
);
})}
</div>
)}
{viewMode === "parallel" &&
secondaryTranslationId === undefined && (
<div className="text-center py-6">
<h3 className="text-lg font-medium mb-4">
Parallel View
</h3>
<p className="text-muted-foreground mb-6">
Select a second translation to enable parallel view
</p>
{translations && translations.length > 0 ? (
<div className="flex flex-wrap justify-center gap-2 max-w-md mx-auto">
{translations
.filter((t) => t.id !== selectedTranslationId)
.map((translation) => (
<Button
key={translation.id}
variant="outline"
size="sm"
onClick={() =>
setSecondaryTranslationId(translation.id)
}
>
<Languages className="mr-2 h-4 w-4" />
{translation.language}
</Button>
))}
</div>
) : (
<div className="text-center">
<p className="text-muted-foreground mb-4">
No translations available for this work.
</p>
<p className="text-sm text-muted-foreground">
Try another work or check back later as we add more
translations.
</p>
</div>
)}
</div>
)}
{viewMode === "parallel" &&
secondaryTranslationId !== undefined && (
<div style={{ fontSize: `${fontSize}px` }}>
<div className="flex justify-between items-center mb-4">
<div className="flex-1">
<h3 className="text-lg font-medium">
{selectedTranslationId
? `${selectedTranslation?.language} Translation`
: `Original (${work.language})`}
</h3>
</div>
<div className="w-8"></div>
<div className="flex-1">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium">
{translations?.find(
(t) => t.id === secondaryTranslationId,
)?.language || "Translation"}
</h3>
<Button
variant="ghost"
size="sm"
onClick={() =>
setSecondaryTranslationId(undefined)
}
>
Clear
</Button>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
{/* First column */}
<div>
{mainContent.lines.map((line, i) => (
<div
key={i}
className="mb-3 py-1 px-2 hover:bg-muted/20 rounded transition-colors"
>
{line}
</div>
))}
</div>
{/* Second column */}
<div>
{getPagedContent(getSecondaryContent()).lines.map(
(line, i) => (
<div
key={i}
className="mb-3 py-1 px-2 hover:bg-muted/20 rounded transition-colors"
>
{line}
</div>
),
)}
</div>
</div>
</div>
)}
</Card>
</TabsContent>
{/* Annotations tab content */}
<TabsContent value="annotations">
<Card className="p-6">
{selectedLineNumber ? (
<div>
<div className="bg-muted/30 p-3 rounded mb-4">
<h3 className="text-sm font-medium mb-2">
Line {selectedLineNumber}
</h3>
<p className="italic">
{getSelectedContent().split("\n")[
selectedLineNumber - 1
] || ""}
</p>
</div>
<h3 className="text-lg font-medium mb-4">Annotations</h3>
<p className="text-center text-muted-foreground py-4">
No annotations for this line yet.
</p>
<div className="mt-6">
<h4 className="text-sm font-medium mb-2">
Add your annotation
</h4>
<textarea
className="w-full min-h-[100px] p-3 border rounded-md"
placeholder="Share your thoughts or insights about this line..."
/>
<div className="flex justify-end mt-2">
<Button>Add Annotation</Button>
</div>
</div>
</div>
) : (
<div className="text-center py-8">
<MessageCircle className="mx-auto h-12 w-12 text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium mb-2">
Line Annotations
</h3>
<p className="text-muted-foreground">
Select a line from the text to view or add annotations.
</p>
</div>
)}
</Card>
</TabsContent>
{/* Analysis tab content */}
<TabsContent value="analysis">
{isAnalysisLoading ? (
<Card className="p-6">
<div className="text-center py-12">
<div className="inline-block h-12 w-12 border-4 border-primary/30 border-t-primary rounded-full animate-spin mb-4"></div>
<p className="text-muted-foreground">
Analyzing text patterns...
</p>
</div>
</Card>
) : linguisticAnalysis ? (
<div className="space-y-6">
{/* Analysis tools */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Analysis Tools</CardTitle>
<CardDescription>
Select different views to explore the linguistic
patterns
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
<Button
variant={
highlightMode === "partOfSpeech"
? "default"
: "outline"
}
size="sm"
onClick={() =>
setHighlightMode(
highlightMode === "partOfSpeech"
? "none"
: "partOfSpeech",
)
}
>
<BookMarked className="mr-2 h-4 w-4" />
Parts of Speech
</Button>
<Button
variant={
highlightMode === "sentiment"
? "default"
: "outline"
}
size="sm"
onClick={() =>
setHighlightMode(
highlightMode === "sentiment"
? "none"
: "sentiment",
)
}
>
<LineChart className="mr-2 h-4 w-4" />
Sentiment
</Button>
<Button
variant={
highlightMode === "meter" ? "default" : "outline"
}
size="sm"
onClick={() =>
setHighlightMode(
highlightMode === "meter" ? "none" : "meter",
)
}
>
<Waves className="mr-2 h-4 w-4" />
Meter
</Button>
<Button
variant={
highlightMode === "themes" ? "default" : "outline"
}
size="sm"
onClick={() =>
setHighlightMode(
highlightMode === "themes" ? "none" : "themes",
)
}
>
<Sparkles className="mr-2 h-4 w-4" />
Themes
</Button>
</div>
</CardContent>
</Card>
{/* Analysis summary */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-lg">Readability</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div>
<div className="flex justify-between text-sm">
<span>Score</span>
<span className="font-medium">
{linguisticAnalysis.readabilityScore}/100
</span>
</div>
<div className="h-2 bg-muted rounded-full mt-1">
<div
className="h-full bg-primary rounded-full"
style={{
width: `${linguisticAnalysis.readabilityScore}%`,
}}
/>
</div>
</div>
<p className="text-sm text-muted-foreground">
{linguisticAnalysis.readabilityScore > 80
? "Very accessible text with simple language and structure"
: linguisticAnalysis.readabilityScore > 60
? "Moderately complex text suitable for general readers"
: "Complex text with sophisticated vocabulary and structure"}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-lg">Structure</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">
Lines
</p>
<p className="text-2xl font-medium">
{contentToLines(getSelectedContent()).length}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">
Words
</p>
<p className="text-2xl font-medium">{wordCount}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-lg">Themes</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-1">
{Object.keys(linguisticAnalysis.themeLexicon).map(
(theme) => (
<Badge
key={theme}
variant="outline"
className="capitalize"
>
{theme}
</Badge>
),
)}
</div>
</CardContent>
</Card>
</div>
{/* Text with analysis overlay */}
<Card className="p-6">
<div style={{ fontSize: `${fontSize}px` }}>
{mainContent.lines.map((line, i) => {
const lineNumber = mainContent.startLineNumber + i;
let highlightInfo = null;
// Handle different highlight modes
if (
highlightMode === "partOfSpeech" &&
linguisticAnalysis.partOfSpeech[lineNumber]
) {
highlightInfo = (
<div className="text-xs text-muted-foreground mt-1 flex gap-1 flex-wrap">
{linguisticAnalysis.partOfSpeech[lineNumber].map(
(pos, j) => (
<Badge
key={j}
variant="outline"
className="border-[0.5px]"
style={{ borderColor: pos.color }}
>
{pos.term}: {pos.tag}
</Badge>
),
)}
</div>
);
} else if (
highlightMode === "sentiment" &&
linguisticAnalysis.sentiment[lineNumber]
) {
const sentiment =
linguisticAnalysis.sentiment[lineNumber];
const sentimentColor =
sentiment.label === "positive"
? "border-green-500"
: sentiment.label === "negative"
? "border-red-500"
: "border-gray-400";
highlightInfo = (
<div className="text-xs text-muted-foreground mt-1">
<Badge
variant="outline"
className={sentimentColor}
>
{sentiment.label.charAt(0).toUpperCase() +
sentiment.label.slice(1)}
({sentiment.intensity})
</Badge>
</div>
);
} else if (
highlightMode === "meter" &&
linguisticAnalysis.meter[lineNumber]
) {
highlightInfo = (
<div className="text-xs font-mono mt-1">
{linguisticAnalysis.meter[lineNumber].join(" ")}
</div>
);
}
return (
<div
key={i}
className="mb-4 p-2 rounded hover:bg-muted/30 transition-colors"
>
<div className="flex">
<div className="w-10 text-right pr-3 text-muted-foreground select-none">
{lineNumber}
</div>
<div className="flex-1">
<div>{line}</div>
{highlightInfo}
</div>
</div>
</div>
);
})}
</div>
</Card>
</div>
) : (
<Card className="p-6">
<div className="text-center py-8">
<p>Click to analyze the text</p>
<Button
className="mt-4"
onClick={() =>
generateLinguisticAnalysis(getSelectedContent())
}
>
Analyze Text
</Button>
</div>
</Card>
)}
</TabsContent>
{/* Compare tab content */}
{translations && translations.length > 0 && (
<TabsContent value="compare">
<Card className="p-6">
<h3 className="text-lg font-medium mb-4">
Compare Translations
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Original version card */}
<Card>
<CardHeader className="pb-2">
<CardTitle>Original</CardTitle>
<CardDescription>
{work.language} {work.year || "Unknown year"}
</CardDescription>
</CardHeader>
<CardContent className="h-48 overflow-y-auto">
<div className="space-y-1 text-sm">
{contentToLines(work.content)
.slice(0, 10)
.map((line, i) => (
<div
key={i}
className="text-muted-foreground hover:text-foreground transition-colors"
>
{line}
</div>
))}
{contentToLines(work.content).length > 10 && (
<div className="italic text-muted-foreground">
... {contentToLines(work.content).length - 10}{" "}
more lines
</div>
)}
</div>
</CardContent>
<CardFooter>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => {
setSelectedTranslationId(undefined);
setActiveTab("text");
}}
>
Read Original
</Button>
</CardFooter>
</Card>
{/* Translation cards */}
{translations.map((translation) => (
<Card key={translation.id}>
<CardHeader className="pb-2">
<CardTitle>
{translation.language} Translation
</CardTitle>
<CardDescription>
{translation.translator?.displayName ||
`Translator #${translation.translatorId}`}
{translation.year && `${translation.year}`}
</CardDescription>
</CardHeader>
<CardContent className="h-48 overflow-y-auto">
<div className="space-y-1 text-sm">
{contentToLines(translation.content)
.slice(0, 10)
.map((line, i) => (
<div
key={i}
className="text-muted-foreground hover:text-foreground transition-colors"
>
{line}
</div>
))}
{contentToLines(translation.content).length >
10 && (
<div className="italic text-muted-foreground">
...{" "}
{contentToLines(translation.content).length -
10}{" "}
more lines
</div>
)}
</div>
</CardContent>
<CardFooter className="flex flex-col gap-2">
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => {
setSelectedTranslationId(translation.id);
setActiveTab("text");
}}
>
Read Translation
</Button>
<Button
variant="ghost"
size="sm"
className="w-full"
onClick={() => {
if (viewMode !== "parallel")
setViewMode("parallel");
setSelectedTranslationId(undefined);
setSecondaryTranslationId(translation.id);
setActiveTab("text");
}}
>
Compare with Original
</Button>
</CardFooter>
</Card>
))}
</div>
</Card>
</TabsContent>
)}
</Tabs>
</div>
{/* Pagination controls */}
{mainContent.totalPages > 1 && (
<div className="flex items-center justify-between mt-8 border-t pt-4 mb-10">
<Button
variant="outline"
onClick={handlePreviousPage}
disabled={activePage === 1}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Previous
</Button>
<div className="text-sm text-muted-foreground">
Page {activePage} of {mainContent.totalPages}
</div>
<Button
variant="outline"
onClick={handleNextPage}
disabled={activePage === mainContent.totalPages}
>
Next
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
)}
</div>
</PageLayout>
);
}