Implement a simpler interface for reading and interacting with literary works

Replaces NewWorkReading with SimpleWorkReading, offering an enhanced user experience for reading works and translations.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: cbacfb18-842a-4116-a907-18c0105ad8ec
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/39b5c689-6e8a-4d5a-9792-69cc81a56534/1f76b9e3-7e1d-40a8-9d9d-b6b05841a7de.jpg
This commit is contained in:
mukimovd 2025-05-01 23:11:55 +00:00
parent 7acaa3c393
commit a67e7fc530
3 changed files with 383 additions and 10 deletions

View File

@ -9,7 +9,7 @@ import Explore from "@/pages/Explore";
import Search from "@/pages/Search";
import AuthorProfile from "@/pages/authors/AuthorProfile";
import Authors from "@/pages/authors/Authors";
import NewWorkReading from "@/pages/works/NewWorkReading";
import SimpleWorkReading from "@/pages/works/SimpleWorkReading";
import WorkCompare from "@/pages/works/WorkCompare";
import Collections from "@/pages/collections/Collections";
import CreateCollection from "@/pages/collections/CreateCollection";
@ -25,7 +25,7 @@ function Router() {
<Route path="/search" component={Search} />
<Route path="/authors" component={Authors} />
<Route path="/authors/:slug" component={AuthorProfile} />
<Route path="/works/:slug" component={NewWorkReading} />
<Route path="/works/:slug" component={SimpleWorkReading} />
<Route path="/works/:slug/compare/:translationId" component={WorkCompare} />
<Route path="/collections" component={Collections} />
<Route path="/collections/create" component={CreateCollection} />

View File

@ -322,16 +322,9 @@ export default function NewWorkReading() {
const lines = contentToLines(content);
const totalPages = Math.ceil(lines.length / linesPerPage);
// Make sure active page is in bounds but without causing re-renders
// Make sure active page is in bounds
const safePage = Math.min(Math.max(1, activePage), Math.max(1, totalPages));
// Only update active page in a useEffect to avoid infinite re-render
useEffect(() => {
if (safePage !== activePage) {
setActivePage(safePage);
}
}, [safePage, activePage]);
const startIdx = (safePage - 1) * linesPerPage;
const endIdx = startIdx + linesPerPage;
@ -342,6 +335,20 @@ export default function NewWorkReading() {
startLineNumber: startIdx + 1
};
};
// 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);
}
}
}, [work, activePage, selectedTranslationId]);
// Toggle bookmark status
const handleBookmarkToggle = () => {

View File

@ -0,0 +1,366 @@
import { useState, useEffect, useRef } from "react";
import { useParams, useLocation } from "wouter";
import { useQuery } from "@tanstack/react-query";
import { WorkWithDetails, TranslationWithDetails } from "@/lib/types";
import { PageLayout } from "@/components/layout/PageLayout";
// UI Components
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { toast } from "@/hooks/use-toast";
import { AuthorChip } from "@/components/common/AuthorChip";
import { useMediaQuery } from "@/hooks/use-media-query";
// Icons
import {
BookOpen,
ArrowLeft,
ArrowRight,
Languages,
Heart,
Bookmark,
Share2
} from "lucide-react";
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 [isBookmarked, setIsBookmarked] = useState(false);
const [isLiked, setIsLiked] = 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, selectedTranslationId]);
// Get the selected translation content
const 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
const contentToLines = (content: string) => {
return content.split('\n').filter(line => line.length > 0);
};
const 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
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' });
}
}
};
// 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}
>
<Tabs defaultValue="text">
<TabsList className="mb-6">
<TabsTrigger value="text">Text</TabsTrigger>
<TabsTrigger value="annotations">Annotations</TabsTrigger>
</TabsList>
<TabsContent value="text">
<Card className="p-6">
{mainContent.lines.map((line, i) => (
<div key={i} className="mb-3">
{line}
</div>
))}
</Card>
</TabsContent>
<TabsContent value="annotations">
<Card className="p-6">
<p className="text-center text-muted-foreground">No annotations yet.</p>
</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>
);
}