mirror of
https://github.com/SamyRai/tercul-frontend.git
synced 2025-12-27 03:41:34 +00:00
- Updated `Search.tsx` to align `tags` type with schema (string[]). - Fixed `useQuery` usage in `Search.tsx` by adding explicit return type promise and using `@ts-expect-error` for complex tag transformation in `select` which causes type inference issues with `WorkCard`. - Removed unused variables in `Submit.tsx`, `AuthorProfile.tsx`, `Authors.tsx`, `BlogDetail.tsx`, `NewWorkReading.tsx`, `SimpleWorkReading.tsx`, `WorkReading.tsx`. - Fixed type mismatches (string vs number, undefined checks) in various files. - Fixed server-side import path in `server/routes/blog.ts` and `server/routes/userProfile.ts`. - Updated `server/routes/userProfile.ts` to use correct GraphQL generated members. - Updated `Profile.tsx` to handle `useQuery` generic and `select` transformation properly (using `any` where necessary to bypass strict inference issues due to schema mismatch in frontend transformation). - Successfully built the application.
1810 lines
53 KiB
TypeScript
1810 lines
53 KiB
TypeScript
import { useQuery } from "@tanstack/react-query";
|
|
// Icons
|
|
import {
|
|
AlignJustify,
|
|
ArrowLeft,
|
|
ArrowRight,
|
|
BarChart4,
|
|
BookMarked,
|
|
Bookmark,
|
|
BookOpen,
|
|
Columns,
|
|
Copy,
|
|
Eye,
|
|
Heart,
|
|
Languages,
|
|
LayoutGrid,
|
|
LineChart,
|
|
Link,
|
|
Menu,
|
|
MessageCircle,
|
|
MoveHorizontal,
|
|
Pencil,
|
|
Settings,
|
|
Share2,
|
|
SlidersHorizontal,
|
|
Sparkles,
|
|
Volume2,
|
|
Waves,
|
|
X,
|
|
} from "lucide-react";
|
|
import { useCallback, 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 {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Drawer,
|
|
DrawerClose,
|
|
DrawerContent,
|
|
DrawerDescription,
|
|
DrawerFooter,
|
|
DrawerHeader,
|
|
DrawerTitle,
|
|
DrawerTrigger,
|
|
} from "@/components/ui/drawer";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { Slider } from "@/components/ui/slider";
|
|
import { Switch } from "@/components/ui/switch";
|
|
// UI Components
|
|
import { Tabs, 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";
|
|
|
|
// Type definitions 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 RhymeInfo {
|
|
word: string;
|
|
lineNumber: number;
|
|
rhymeGroup: string;
|
|
lineText: string;
|
|
}
|
|
|
|
interface SyllableData {
|
|
count: number;
|
|
breakdown: 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
|
|
complexTerms: string[]; // Words that might be difficult
|
|
themeLexicon: Record<string, string[]>; // Theme to related words found
|
|
readabilityScore: number; // Overall text readability (0-100)
|
|
}
|
|
|
|
export default function NewWorkReading() {
|
|
const { slug } = useParams();
|
|
const [, navigate] = useLocation();
|
|
const isMobile = useMediaQuery("(max-width: 768px)");
|
|
|
|
// Main content states
|
|
const [activeTab, setActiveTab] = useState<string>("read");
|
|
const [currentView, setCurrentView] = useState<
|
|
"traditional" | "enhanced" | "parallel"
|
|
>("enhanced");
|
|
const [activePage, setActivePage] = useState(1);
|
|
const [fontSize, setFontSize] = useState(18);
|
|
const [lineHeight, setLineHeight] = useState(1.8);
|
|
const [isDarkMode, setIsDarkMode] = useState(false);
|
|
const [isFullWidth, setIsFullWidth] = useState(false);
|
|
const [zenMode, setZenMode] = useState(false);
|
|
const [sidebarOpen, setSidebarOpen] = useState(!isMobile);
|
|
const [selectedLanguageFilter, setSelectedLanguageFilter] = useState<
|
|
string | null
|
|
>(null);
|
|
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 [readingProgress, _setReadingProgress] = useState(0);
|
|
const [_selectedLineNumber, setSelectedLineNumber] = useState<number | null>(
|
|
null,
|
|
);
|
|
|
|
// Linguistic analysis states
|
|
const [highlightMode, setHighlightMode] = useState<
|
|
| "none"
|
|
| "partOfSpeech"
|
|
| "entities"
|
|
| "sentiment"
|
|
| "syllables"
|
|
| "meter"
|
|
| "themes"
|
|
| "complexity"
|
|
>("none");
|
|
const [showPhoneticTranscription, setShowPhoneticTranscription] =
|
|
useState(false);
|
|
const [_showRhymePatterns, _setShowRhymePatterns] = useState(false);
|
|
const [_showDefinitions, _setShowDefinitions] = useState(false);
|
|
const [_autoPlay, _setAutoPlay] = useState(false);
|
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Queries
|
|
const {
|
|
data: work,
|
|
isLoading: workLoading,
|
|
error: workError,
|
|
} = useQuery<WorkWithDetails>({
|
|
queryKey: [`/api/works/${slug}`],
|
|
});
|
|
|
|
const { data: translations } = useQuery<
|
|
TranslationWithDetails[]
|
|
>({
|
|
queryKey: [`/api/works/${slug}/translations`],
|
|
enabled: !!work,
|
|
});
|
|
|
|
// Simulated linguistic analysis data (would be fetched from an API)
|
|
const [linguisticAnalysis, setLinguisticAnalysis] =
|
|
useState<LinguisticAnalysis | null>(null);
|
|
const [isAnalysisLoading, setIsAnalysisLoading] = useState(false);
|
|
|
|
// Generate demo linguistic analysis
|
|
const generateLinguisticAnalysis = useCallback((content: string) => {
|
|
const lines = content.split("\n");
|
|
|
|
// Part of speech examples for the first 10 lines
|
|
const partOfSpeech: Record<number, PartOfSpeech[]> = {};
|
|
const entityRecognition: Record<number, string[]> = {};
|
|
const sentiment: Record<number, Sentiment> = {};
|
|
const meter: Record<number, string[]> = {};
|
|
|
|
// Sample data - in a real app, this would come from NLP API
|
|
lines.forEach((line, index) => {
|
|
if (line.trim().length === 0) return;
|
|
|
|
const lineNumber = index + 1;
|
|
|
|
// Create example POS data (randomly)
|
|
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 example entity recognition
|
|
if (Math.random() > 0.7) {
|
|
// const _entities = [
|
|
// "PERSON",
|
|
// "LOCATION",
|
|
// "ORGANIZATION",
|
|
// "TIME",
|
|
// "DATE",
|
|
// ];
|
|
entityRecognition[lineNumber] = [
|
|
words[Math.floor(Math.random() * words.length)],
|
|
];
|
|
}
|
|
|
|
// Create example 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 example meter pattern
|
|
// const meterPatterns = ["iambic", "trochaic", "anapestic", "dactylic"];
|
|
// const _randomPattern =
|
|
// meterPatterns[Math.floor(Math.random() * meterPatterns.length)];
|
|
meter[lineNumber] = Array(words.length)
|
|
.fill("")
|
|
.map(() => (Math.random() > 0.5 ? "/" : "\\"));
|
|
});
|
|
|
|
// Create example syllable 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 example rhymes
|
|
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,
|
|
});
|
|
});
|
|
|
|
// Complex terms
|
|
const allWords = lines
|
|
.join(" ")
|
|
.split(" ")
|
|
.map((w) => w.replace(/[.,;!?]/g, "").toLowerCase())
|
|
.filter((w) => w.length > 0);
|
|
|
|
const complexTerms = Array.from(
|
|
new Set(allWords.filter((w) => w.length > 8 || Math.random() > 0.9)),
|
|
);
|
|
|
|
// Theme lexicon
|
|
const themes = ["love", "nature", "time", "death", "art"];
|
|
const themeLexicon: Record<string, string[]> = {};
|
|
|
|
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,
|
|
complexTerms,
|
|
themeLexicon,
|
|
readabilityScore: Math.floor(Math.random() * 40) + 60, // 60-100
|
|
});
|
|
}, []);
|
|
|
|
// Generate simulated linguistic data when work loads
|
|
useEffect(() => {
|
|
if (work && activeTab === "analysis" && !linguisticAnalysis) {
|
|
setIsAnalysisLoading(true);
|
|
// In a real implementation, this would be an API call
|
|
setTimeout(() => {
|
|
generateLinguisticAnalysis(work.content);
|
|
setIsAnalysisLoading(false);
|
|
}, 1500);
|
|
}
|
|
}, [work, activeTab, linguisticAnalysis, generateLinguisticAnalysis]);
|
|
|
|
// Get the selected translation content
|
|
const getSelectedContent = useCallback(() => {
|
|
if (!work) return "";
|
|
if (!selectedTranslationId) return work.content;
|
|
|
|
const translation = translations?.find(
|
|
(t) => t.id === String(selectedTranslationId),
|
|
);
|
|
return translation?.content || work.content;
|
|
}, [work, selectedTranslationId, translations]);
|
|
|
|
// Get the secondary translation content (for parallel view)
|
|
const getSecondaryContent = useCallback(() => {
|
|
if (!work || !secondaryTranslationId) return "";
|
|
|
|
const translation = translations?.find(
|
|
(t) => t.id === String(secondaryTranslationId),
|
|
);
|
|
return translation?.content || "";
|
|
}, [work, secondaryTranslationId, translations]);
|
|
|
|
// Split content into lines and pages for display
|
|
const contentToLines = useCallback((content: string) => {
|
|
return content.split("\n").filter((line) => line.length > 0);
|
|
}, []);
|
|
|
|
const getPagedContent = useCallback((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,
|
|
};
|
|
}, [activePage, contentToLines]);
|
|
|
|
// Add a separate effect to handle page bounds
|
|
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);
|
|
}
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [work, activePage, getSelectedContent]);
|
|
|
|
// Toggle bookmark status
|
|
const handleBookmarkToggle = () => {
|
|
setIsBookmarked(!isBookmarked);
|
|
toast({
|
|
description: isBookmarked
|
|
? "Removed from your bookmarks"
|
|
: "Added to your bookmarks",
|
|
duration: 3000,
|
|
});
|
|
};
|
|
|
|
// Toggle like status
|
|
const handleLikeToggle = () => {
|
|
setIsLiked(!isLiked);
|
|
toast({
|
|
description: isLiked
|
|
? "Removed from your favorites"
|
|
: "Added to your favorites",
|
|
duration: 3000,
|
|
});
|
|
};
|
|
|
|
// Share the work
|
|
const handleShare = async () => {
|
|
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
|
|
const 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" });
|
|
}
|
|
}
|
|
};
|
|
|
|
const handlePreviousPage = () => {
|
|
if (activePage > 1) {
|
|
setActivePage(activePage - 1);
|
|
// Scroll to top of content area
|
|
if (contentRef.current) {
|
|
contentRef.current.scrollIntoView({ behavior: "smooth" });
|
|
}
|
|
}
|
|
};
|
|
|
|
// Handle line selection for annotation or analysis
|
|
const handleLineClick = (lineNumber: number) => {
|
|
setSelectedLineNumber(lineNumber);
|
|
// In a real implementation, this would:
|
|
// 1. Open an annotation sidebar or modal
|
|
// 2. Fetch any existing annotations for this line
|
|
// 3. Allow adding new annotations
|
|
};
|
|
|
|
// 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());
|
|
const secondaryContent = getPagedContent(getSecondaryContent());
|
|
|
|
// Get the selected translation details
|
|
const selectedTranslation = translations?.find(
|
|
(t) => t.id === String(selectedTranslationId),
|
|
);
|
|
const secondaryTranslation = translations?.find(
|
|
(t) => t.id === String(secondaryTranslationId),
|
|
);
|
|
|
|
// Calculate reading time estimation
|
|
const wordCount = getSelectedContent().split(/\s+/).length;
|
|
const readingTimeMinutes = Math.ceil(wordCount / 200); // Average reading speed
|
|
|
|
// Calculate unique language options from translations
|
|
const languageOptions = translations
|
|
? Array.from(new Set(translations.map((t) => t.language)))
|
|
: [];
|
|
|
|
return (
|
|
<PageLayout>
|
|
<div
|
|
className={`reading-experience relative ${zenMode ? "zen-mode" : ""}`}
|
|
>
|
|
{/* Reading progress bar - fixed at the top */}
|
|
<div className="sticky top-0 z-20 w-full h-1 bg-muted">
|
|
<div
|
|
className="h-full bg-primary transition-all duration-300"
|
|
style={{ width: `${readingProgress}%` }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Main content area with flexible layout */}
|
|
<div
|
|
className={`flex ${isFullWidth ? "max-w-full" : "max-w-[var(--content-width)]"} mx-auto transition-all duration-300`}
|
|
>
|
|
{/* Left sidebar - collapsible on mobile */}
|
|
<aside
|
|
className={`
|
|
context-sidebar
|
|
${sidebarOpen ? "w-64 lg:w-72" : "w-0 overflow-hidden"}
|
|
bg-card lg:bg-transparent
|
|
flex-shrink-0 transition-all duration-300
|
|
${!zenMode ? "lg:sticky lg:top-16 lg:self-start lg:h-[calc(100vh-4rem)] lg:overflow-y-auto" : "hidden"}
|
|
`}
|
|
>
|
|
<div className="p-4 space-y-6">
|
|
{/* Work metadata */}
|
|
<div>
|
|
<h1 className="text-2xl font-serif font-bold text-primary mb-2">
|
|
{work.title}
|
|
</h1>
|
|
<div className="mb-4">
|
|
<AuthorChip author={work.author} withLifeDates />
|
|
</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="text-sm text-muted-foreground space-y-1">
|
|
<p>Original language: {work.language}</p>
|
|
{work.year && <p>Published: {work.year}</p>}
|
|
<p>Type: {work.type || "Unknown"}</p>
|
|
<p>Reading time: ~{readingTimeMinutes} min</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Translation selector */}
|
|
<div>
|
|
<h3 className="text-sm font-medium mb-2 text-foreground">
|
|
Translations
|
|
</h3>
|
|
<div className="space-y-2">
|
|
<Button
|
|
variant={!selectedTranslationId ? "default" : "outline"}
|
|
size="sm"
|
|
className="w-full justify-start"
|
|
onClick={() => setSelectedTranslationId(undefined)}
|
|
>
|
|
<Languages className="mr-2 h-4 w-4" />
|
|
Original ({work.language})
|
|
</Button>
|
|
|
|
{translations && translations.length > 0 ? (
|
|
translations.map((translation) => (
|
|
<Button
|
|
key={translation.id}
|
|
variant={
|
|
selectedTranslationId === Number(translation.id)
|
|
? "default"
|
|
: "outline"
|
|
}
|
|
size="sm"
|
|
className="w-full justify-start"
|
|
onClick={() =>
|
|
setSelectedTranslationId(Number(translation.id))
|
|
}
|
|
>
|
|
<Languages className="mr-2 h-4 w-4" />
|
|
{translation.language}
|
|
</Button>
|
|
))
|
|
) : (
|
|
<p className="text-xs text-muted-foreground italic">
|
|
No translations available
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Reading actions */}
|
|
<div>
|
|
<h3 className="text-sm font-medium mb-2 text-foreground">
|
|
Actions
|
|
</h3>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className={`flex items-center justify-center gap-1 ${isLiked ? "text-accent border-accent" : ""}`}
|
|
onClick={handleLikeToggle}
|
|
>
|
|
<Heart
|
|
className={`h-4 w-4 ${isLiked ? "fill-accent" : ""}`}
|
|
/>
|
|
<span className="sr-only lg:not-sr-only">
|
|
{isLiked ? "Unlike" : "Like"}
|
|
</span>
|
|
</Button>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className={`flex items-center justify-center gap-1 ${isBookmarked ? "text-accent border-accent" : ""}`}
|
|
onClick={handleBookmarkToggle}
|
|
>
|
|
<Bookmark
|
|
className={`h-4 w-4 ${isBookmarked ? "fill-accent" : ""}`}
|
|
/>
|
|
<span className="sr-only lg:not-sr-only">Bookmark</span>
|
|
</Button>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="flex items-center justify-center gap-1"
|
|
onClick={handleShare}
|
|
>
|
|
<Share2 className="h-4 w-4" />
|
|
<span className="sr-only lg:not-sr-only">Share</span>
|
|
</Button>
|
|
|
|
<Dialog>
|
|
<DialogTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="flex items-center justify-center gap-1"
|
|
>
|
|
<MessageCircle className="h-4 w-4" />
|
|
<span className="sr-only lg:not-sr-only">Comments</span>
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Work Comments</DialogTitle>
|
|
<DialogDescription>
|
|
View and add comments about the entire work.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="h-96 overflow-y-auto">
|
|
<p className="text-center text-muted-foreground py-8">
|
|
No comments yet
|
|
</p>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Reading settings */}
|
|
<Drawer>
|
|
<DrawerTrigger asChild>
|
|
<Button variant="outline" className="w-full">
|
|
<Settings className="mr-2 h-4 w-4" />
|
|
Reading Settings
|
|
</Button>
|
|
</DrawerTrigger>
|
|
<DrawerContent>
|
|
<div className="mx-auto w-full max-w-lg">
|
|
<DrawerHeader>
|
|
<DrawerTitle>Reading Settings</DrawerTitle>
|
|
<DrawerDescription>
|
|
Customize your reading experience
|
|
</DrawerDescription>
|
|
</DrawerHeader>
|
|
<div className="p-4 space-y-6">
|
|
{/* Font size control */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium">Font Size</span>
|
|
<span className="text-sm text-muted-foreground">
|
|
{fontSize}px
|
|
</span>
|
|
</div>
|
|
<Slider
|
|
value={[fontSize]}
|
|
min={14}
|
|
max={28}
|
|
step={1}
|
|
onValueChange={(values) => setFontSize(values[0])}
|
|
/>
|
|
</div>
|
|
|
|
{/* Line height control */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium">
|
|
Line Spacing
|
|
</span>
|
|
<span className="text-sm text-muted-foreground">
|
|
{lineHeight.toFixed(1)}
|
|
</span>
|
|
</div>
|
|
<Slider
|
|
value={[lineHeight * 10]}
|
|
min={10}
|
|
max={30}
|
|
step={1}
|
|
onValueChange={(values) =>
|
|
setLineHeight(values[0] / 10)
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{/* View mode selector */}
|
|
<div className="space-y-2">
|
|
<span className="text-sm font-medium">View Mode</span>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<Button
|
|
variant={
|
|
currentView === "traditional"
|
|
? "default"
|
|
: "outline"
|
|
}
|
|
size="sm"
|
|
onClick={() => setCurrentView("traditional")}
|
|
>
|
|
<AlignJustify className="mr-2 h-4 w-4" />
|
|
Traditional
|
|
</Button>
|
|
|
|
<Button
|
|
variant={
|
|
currentView === "enhanced" ? "default" : "outline"
|
|
}
|
|
size="sm"
|
|
onClick={() => setCurrentView("enhanced")}
|
|
>
|
|
<LayoutGrid className="mr-2 h-4 w-4" />
|
|
Enhanced
|
|
</Button>
|
|
|
|
<Button
|
|
variant={
|
|
currentView === "parallel" ? "default" : "outline"
|
|
}
|
|
size="sm"
|
|
onClick={() => setCurrentView("parallel")}
|
|
>
|
|
<Columns className="mr-2 h-4 w-4" />
|
|
Parallel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Other settings options */}
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-0.5">
|
|
<label className="text-sm font-medium">
|
|
Dark Mode
|
|
</label>
|
|
<p className="text-xs text-muted-foreground">
|
|
Reduce eye strain in low light
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
checked={isDarkMode}
|
|
onCheckedChange={setIsDarkMode}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-0.5">
|
|
<label className="text-sm font-medium">
|
|
Full Width
|
|
</label>
|
|
<p className="text-xs text-muted-foreground">
|
|
Maximize reading space
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
checked={isFullWidth}
|
|
onCheckedChange={setIsFullWidth}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-0.5">
|
|
<label className="text-sm font-medium">
|
|
Zen Mode
|
|
</label>
|
|
<p className="text-xs text-muted-foreground">
|
|
Hide all UI elements
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
checked={zenMode}
|
|
onCheckedChange={setZenMode}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<DrawerFooter>
|
|
<Button>Save Settings</Button>
|
|
<DrawerClose asChild>
|
|
<Button variant="outline">Close</Button>
|
|
</DrawerClose>
|
|
</DrawerFooter>
|
|
</div>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Main reading area */}
|
|
<main className="flex-1 py-6 px-4" ref={contentRef}>
|
|
{/* Mobile header with controls */}
|
|
{!zenMode && (
|
|
<div className="flex items-center justify-between mb-6 lg:hidden">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
|
>
|
|
<Menu className="h-5 w-5" />
|
|
</Button>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Drawer>
|
|
<DrawerTrigger asChild>
|
|
<Button variant="outline" size="sm">
|
|
<SlidersHorizontal className="mr-2 h-4 w-4" />
|
|
Settings
|
|
</Button>
|
|
</DrawerTrigger>
|
|
<DrawerContent>
|
|
{/* Same content as the desktop settings drawer */}
|
|
</DrawerContent>
|
|
</Drawer>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setZenMode(true)}
|
|
>
|
|
<Eye className="h-5 w-5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Reading tabs - only visible when not in zen mode */}
|
|
{!zenMode && (
|
|
<div className="mb-6">
|
|
<Tabs
|
|
defaultValue="read"
|
|
value={activeTab}
|
|
onValueChange={setActiveTab}
|
|
className="w-full"
|
|
>
|
|
<TabsList className="grid w-full grid-cols-3">
|
|
<TabsTrigger value="read">Read</TabsTrigger>
|
|
<TabsTrigger value="analysis">Analysis</TabsTrigger>
|
|
<TabsTrigger value="compare">Compare</TabsTrigger>
|
|
</TabsList>
|
|
</Tabs>
|
|
</div>
|
|
)}
|
|
|
|
{/* Content based on active tab */}
|
|
{activeTab === "read" && (
|
|
<div>
|
|
{/* Reading header with work title and translation info */}
|
|
{!zenMode && (
|
|
<div className="mb-6">
|
|
<h1 className="text-2xl lg:text-3xl font-serif font-bold text-primary mb-1">
|
|
{work.title}
|
|
</h1>
|
|
{selectedTranslation ? (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<span>
|
|
Translation in {selectedTranslation.language}
|
|
</span>
|
|
{selectedTranslation.year && (
|
|
<>
|
|
<span>•</span>
|
|
<span>{selectedTranslation.year}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<span>Original in {work.language}</span>
|
|
{work.year && (
|
|
<>
|
|
<span>•</span>
|
|
<span>{work.year}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Reading content */}
|
|
<div
|
|
className="reading-content"
|
|
style={{
|
|
fontSize: `${fontSize}px`,
|
|
lineHeight: lineHeight,
|
|
}}
|
|
>
|
|
{/* Traditional view - simple text display */}
|
|
{currentView === "traditional" && (
|
|
<div className="mx-auto prose dark:prose-invert">
|
|
{mainContent.lines.map((line, i) => (
|
|
<div key={i} className="mb-1">
|
|
{line}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Enhanced view - line numbers and interaction */}
|
|
{currentView === "enhanced" && (
|
|
<div className="mx-auto">
|
|
{mainContent.lines.map((line, i) => {
|
|
const lineNumber = mainContent.startLineNumber + i;
|
|
|
|
return (
|
|
<div
|
|
key={i}
|
|
className="flex group hover:bg-muted/30 rounded p-1 transition-colors"
|
|
onClick={() => handleLineClick(lineNumber)}
|
|
>
|
|
{/* Line number */}
|
|
<div className="text-muted-foreground w-12 text-right pr-4 select-none">
|
|
{lineNumber}
|
|
</div>
|
|
|
|
{/* Line content */}
|
|
<div className="flex-1 relative">
|
|
{line}
|
|
|
|
{/* Line actions - visible on hover */}
|
|
<div className="absolute right-0 top-1/2 transform -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity bg-background/80 backdrop-blur-sm rounded-md 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"
|
|
>
|
|
<Link 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>Annotate</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Parallel view - show two texts side by side */}
|
|
{currentView === "parallel" && (
|
|
<div>
|
|
{!secondaryTranslationId ? (
|
|
<div className="text-center py-8">
|
|
<h3 className="text-lg font-medium mb-2">
|
|
Parallel View
|
|
</h3>
|
|
<p className="text-muted-foreground mb-4">
|
|
Select a second translation to enable parallel view
|
|
</p>
|
|
|
|
<div className="space-y-2 max-w-md mx-auto">
|
|
{translations && translations.length > 0 ? (
|
|
translations
|
|
.filter((t) => t.id !== String(selectedTranslationId))
|
|
.map((translation) => (
|
|
<Button
|
|
key={translation.id}
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full justify-start"
|
|
onClick={() =>
|
|
setSecondaryTranslationId(Number(translation.id))
|
|
}
|
|
>
|
|
<Languages className="mr-2 h-4 w-4" />
|
|
{translation.language}
|
|
</Button>
|
|
))
|
|
) : (
|
|
<p className="text-xs text-muted-foreground italic">
|
|
No other translations available
|
|
</p>
|
|
)}
|
|
|
|
{selectedTranslationId && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full justify-start"
|
|
onClick={() =>
|
|
setSecondaryTranslationId(undefined)
|
|
}
|
|
>
|
|
<Languages className="mr-2 h-4 w-4" />
|
|
Original ({work.language})
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-2 gap-8">
|
|
{/* First column */}
|
|
<div>
|
|
<h4 className="text-sm font-medium mb-2">
|
|
{selectedTranslationId
|
|
? `${selectedTranslation?.language} Translation`
|
|
: `Original (${work.language})`}
|
|
</h4>
|
|
<div>
|
|
{mainContent.lines.map((line, i) => (
|
|
<div
|
|
key={i}
|
|
className="mb-1 hover:bg-muted/30 p-1 rounded transition-colors"
|
|
>
|
|
{line}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Second column */}
|
|
<div>
|
|
<h4 className="text-sm font-medium mb-2 flex items-center justify-between">
|
|
<span>
|
|
{secondaryTranslation
|
|
? `${secondaryTranslation.language} Translation`
|
|
: `Original (${work.language})`}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6"
|
|
onClick={() =>
|
|
setSecondaryTranslationId(undefined)
|
|
}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</h4>
|
|
<div>
|
|
{secondaryContent.lines.map((line, i) => (
|
|
<div
|
|
key={i}
|
|
className="mb-1 hover:bg-muted/30 p-1 rounded transition-colors"
|
|
>
|
|
{line}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Pagination controls */}
|
|
{mainContent.totalPages > 1 && !zenMode && (
|
|
<div className="flex items-center justify-between mt-8 border-t pt-4">
|
|
<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>
|
|
)}
|
|
|
|
{/* Linguistic Analysis Tab */}
|
|
{activeTab === "analysis" && (
|
|
<div>
|
|
<div className="mb-6">
|
|
<h2 className="text-2xl font-serif font-medium text-primary mb-4">
|
|
Linguistic Analysis
|
|
</h2>
|
|
|
|
{isAnalysisLoading ? (
|
|
<div className="space-y-4 text-center py-12">
|
|
<p className="text-muted-foreground">
|
|
Analyzing text patterns...
|
|
</p>
|
|
<div className="mx-auto w-24 h-24 rounded-full border-4 border-t-primary border-r-primary border-b-muted border-l-muted animate-spin" />
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Analysis controls */}
|
|
<div className="mb-6">
|
|
<h3 className="text-sm font-medium mb-3">
|
|
Analysis Tools
|
|
</h3>
|
|
<div className="grid grid-cols-2 sm: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 === "entities"
|
|
? "default"
|
|
: "outline"
|
|
}
|
|
size="sm"
|
|
onClick={() =>
|
|
setHighlightMode(
|
|
highlightMode === "entities"
|
|
? "none"
|
|
: "entities",
|
|
)
|
|
}
|
|
>
|
|
<Pencil className="mr-2 h-4 w-4" />
|
|
Named Entities
|
|
</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 === "syllables"
|
|
? "default"
|
|
: "outline"
|
|
}
|
|
size="sm"
|
|
onClick={() =>
|
|
setHighlightMode(
|
|
highlightMode === "syllables"
|
|
? "none"
|
|
: "syllables",
|
|
)
|
|
}
|
|
>
|
|
<MoveHorizontal className="mr-2 h-4 w-4" />
|
|
Syllables
|
|
</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>
|
|
|
|
<Button
|
|
variant={
|
|
highlightMode === "complexity"
|
|
? "default"
|
|
: "outline"
|
|
}
|
|
size="sm"
|
|
onClick={() =>
|
|
setHighlightMode(
|
|
highlightMode === "complexity"
|
|
? "none"
|
|
: "complexity",
|
|
)
|
|
}
|
|
>
|
|
<BarChart4 className="mr-2 h-4 w-4" />
|
|
Complexity
|
|
</Button>
|
|
|
|
<Button
|
|
variant={
|
|
showPhoneticTranscription ? "default" : "outline"
|
|
}
|
|
size="sm"
|
|
onClick={() =>
|
|
setShowPhoneticTranscription(
|
|
!showPhoneticTranscription,
|
|
)
|
|
}
|
|
>
|
|
<Volume2 className="mr-2 h-4 w-4" />
|
|
Phonetics
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Analysis details */}
|
|
{linguisticAnalysis && (
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-6">
|
|
<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>
|
|
<p className="text-sm text-muted-foreground">
|
|
Syllables
|
|
</p>
|
|
<p className="text-2xl font-medium">
|
|
{Object.values(
|
|
linguisticAnalysis.syllableCount,
|
|
).reduce(
|
|
(sum, data) => sum + data.count,
|
|
0,
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">
|
|
Characters
|
|
</p>
|
|
<p className="text-2xl font-medium">
|
|
{getSelectedContent().length}
|
|
</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 content with analysis overlay */}
|
|
<div className="analysis-text-content mt-8">
|
|
{mainContent.lines.map((line, i) => {
|
|
const lineNumber = mainContent.startLineNumber + i;
|
|
|
|
// Handle different highlight modes
|
|
const lineDisplay = line;
|
|
let highlightInfo = null;
|
|
|
|
if (linguisticAnalysis) {
|
|
// POS highlighting
|
|
if (
|
|
highlightMode === "partOfSpeech" &&
|
|
linguisticAnalysis.partOfSpeech[lineNumber]
|
|
) {
|
|
highlightInfo = (
|
|
<div className="text-xs text-muted-foreground mt-1 grid grid-cols-4 gap-1">
|
|
{linguisticAnalysis.partOfSpeech[
|
|
lineNumber
|
|
].map((pos, j) => (
|
|
<Badge
|
|
key={j}
|
|
variant="outline"
|
|
className="justify-self-start"
|
|
style={{ borderColor: pos.color }}
|
|
>
|
|
{pos.term}: {pos.tag}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Sentiment highlighting
|
|
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>
|
|
);
|
|
}
|
|
|
|
// Meter highlighting
|
|
if (
|
|
highlightMode === "meter" &&
|
|
linguisticAnalysis.meter[lineNumber]
|
|
) {
|
|
const meter =
|
|
linguisticAnalysis.meter[lineNumber];
|
|
|
|
highlightInfo = (
|
|
<div className="text-xs mt-1 font-mono">
|
|
{meter.join(" ")}
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={i}
|
|
className="mb-4 p-2 rounded hover:bg-muted/30 transition-colors"
|
|
>
|
|
<div className="flex">
|
|
<div className="w-12 text-right pr-4 text-muted-foreground select-none">
|
|
{lineNumber}
|
|
</div>
|
|
<div className="flex-1">
|
|
<div>{lineDisplay}</div>
|
|
{highlightInfo}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Compare Tab */}
|
|
{activeTab === "compare" && (
|
|
<div>
|
|
<div className="mb-6">
|
|
<h2 className="text-2xl font-serif font-medium text-primary mb-4">
|
|
Compare Translations
|
|
</h2>
|
|
|
|
{/* Language filter */}
|
|
<div className="mb-6">
|
|
<h3 className="text-sm font-medium mb-2">
|
|
Filter by Language
|
|
</h3>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
variant={
|
|
selectedLanguageFilter === null
|
|
? "default"
|
|
: "outline"
|
|
}
|
|
size="sm"
|
|
onClick={() => setSelectedLanguageFilter(null)}
|
|
>
|
|
All Languages
|
|
</Button>
|
|
|
|
{languageOptions.map((language) => (
|
|
<Button
|
|
key={language}
|
|
variant={
|
|
selectedLanguageFilter === language
|
|
? "default"
|
|
: "outline"
|
|
}
|
|
size="sm"
|
|
onClick={() => setSelectedLanguageFilter(language)}
|
|
>
|
|
{language}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Translations Grid */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg: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("read");
|
|
}}
|
|
>
|
|
Read Original
|
|
</Button>
|
|
</CardFooter>
|
|
</Card>
|
|
|
|
{/* Translation Cards */}
|
|
{translations
|
|
?.filter(
|
|
(t) =>
|
|
selectedLanguageFilter === null ||
|
|
t.language === selectedLanguageFilter,
|
|
)
|
|
.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(Number(translation.id));
|
|
setActiveTab("read");
|
|
}}
|
|
>
|
|
Read Translation
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="w-full"
|
|
onClick={() => {
|
|
if (currentView !== "parallel")
|
|
setCurrentView("parallel");
|
|
setSelectedTranslationId(undefined);
|
|
setSecondaryTranslationId(Number(translation.id));
|
|
setActiveTab("read");
|
|
}}
|
|
>
|
|
Compare with Original
|
|
</Button>
|
|
</CardFooter>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Zen mode exit button - only visible in zen mode */}
|
|
{zenMode && (
|
|
<div className="fixed bottom-6 right-6 bg-background/80 backdrop-blur-sm rounded-full shadow-lg opacity-30 hover:opacity-100 transition-opacity">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setZenMode(false)}
|
|
className="h-12 w-12 rounded-full"
|
|
>
|
|
<X className="h-6 w-6" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
</div>
|
|
</PageLayout>
|
|
);
|
|
}
|