mirror of
https://github.com/SamyRai/tercul-frontend.git
synced 2025-12-27 03:41:34 +00:00
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:
parent
7acaa3c393
commit
a67e7fc530
@ -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} />
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -343,6 +336,20 @@ export default function NewWorkReading() {
|
||||
};
|
||||
};
|
||||
|
||||
// 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 = () => {
|
||||
setIsBookmarked(!isBookmarked);
|
||||
|
||||
366
client/src/pages/works/SimpleWorkReading.tsx
Normal file
366
client/src/pages/works/SimpleWorkReading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user