Improve language selection and reading experience for translated works

Enhance the language selection UI and reading view to support multiple translations using i18n features.

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/32087fef-c332-4acc-a7da-d0e55b5b0716.jpg
This commit is contained in:
mukimovd 2025-05-01 03:26:26 +00:00
parent fc244419be
commit 73c88cc6aa
3 changed files with 287 additions and 91 deletions

View File

@ -58,10 +58,18 @@ export function EnhancedReadingView({ work, translations }: EnhancedReadingViewP
// Get the selected translation
const selectedTranslation = translations.find(t => t.id === selectedTranslationId);
// Determine if original text is selected
const isOriginalSelected = !selectedTranslationId;
// Content to display - either the translation or original work
const contentToDisplay = selectedTranslation ? selectedTranslation.content : work.content;
// Handler for viewing original text
const handleViewOriginal = () => {
setSelectedTranslationId(undefined);
};
// Check if there's a line number in the URL hash
useEffect(() => {
if (window.location.hash) {
@ -504,14 +512,15 @@ export function EnhancedReadingView({ work, translations }: EnhancedReadingViewP
<LanguageTag language={work.language} />
</div>
{translations.length > 0 && (
<TranslationSelector
translations={translations}
currentTranslationId={selectedTranslationId}
workSlug={work.slug}
onSelectTranslation={setSelectedTranslationId}
/>
)}
<TranslationSelector
translations={translations}
currentTranslationId={selectedTranslationId}
workSlug={work.slug}
workLanguage={work.language}
onSelectTranslation={setSelectedTranslationId}
onViewOriginal={handleViewOriginal}
isOriginalSelected={isOriginalSelected}
/>
</div>
{/* Text content with enhanced annotation features */}

View File

@ -1,118 +1,299 @@
import { TranslationWithDetails } from "@/lib/types";
import { Translation } from "@shared/schema";
import { Button } from "@/components/ui/button";
import { Link } from "wouter";
import { Languages } from "lucide-react";
import { Languages, ChevronDown, Globe, BookOpen, BookMarked } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
SelectGroup,
SelectLabel
} from "@/components/ui/select";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
interface TranslationSelectorProps {
translations: Translation[];
translations: TranslationWithDetails[];
currentTranslationId?: number;
workSlug: string;
workLanguage: string;
onSelectTranslation: (translationId: number) => void;
onViewOriginal: () => void;
isOriginalSelected: boolean;
}
// Language display names for common languages
const languageNames: Record<string, string> = {
'en': 'English',
'fr': 'French (Français)',
'es': 'Spanish (Español)',
'de': 'German (Deutsch)',
'it': 'Italian (Italiano)',
'pt': 'Portuguese (Português)',
'ru': 'Russian (Русский)',
'zh': 'Chinese (中文)',
'ja': 'Japanese (日本語)',
'ko': 'Korean (한국어)',
'ar': 'Arabic (العربية)',
'hi': 'Hindi (हिन्दी)',
'bn': 'Bengali (বাংলা)',
};
// Helper function to get display name
const getLanguageDisplayName = (code: string): string => {
return languageNames[code.toLowerCase()] || code;
};
export function TranslationSelector({
translations,
currentTranslationId,
workSlug,
onSelectTranslation
workLanguage,
onSelectTranslation,
onViewOriginal,
isOriginalSelected
}: TranslationSelectorProps) {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
// Group translations by language
const translationsByLanguage = translations.reduce<Record<string, Translation[]>>((acc, translation) => {
const translationsByLanguage = translations.reduce<Record<string, TranslationWithDetails[]>>((acc, translation) => {
if (!acc[translation.language]) {
acc[translation.language] = [];
}
acc[translation.language].push(translation);
return acc;
}, {});
// Get current translation
const currentTranslation = translations.find(t => t.id === currentTranslationId);
// Count languages for display
const languageCount = Object.keys(translationsByLanguage).length;
// Format date/year for display
const formatYear = (year?: number) => {
if (!year) return '';
return year.toString();
};
return (
<div className="mt-4 overflow-x-auto">
<div className="flex space-x-2 min-w-max">
{Object.entries(translationsByLanguage).map(([language, languageTranslations]) => {
// Find if any translation in this language is currently selected
const isLanguageSelected = languageTranslations.some(t => t.id === currentTranslationId);
return (
<div key={language} className="relative group">
<div className="mt-4">
<h3 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2 flex items-center gap-1">
<Languages className="h-3.5 w-3.5" />
<span>Language Options</span>
<Badge variant="outline" className="ml-1 text-xs py-0 px-1.5 h-4 bg-navy/5 dark:bg-navy/20 border-none">
{languageCount + 1} languages
</Badge>
</h3>
<div className="flex flex-wrap gap-2">
{/* Original language button */}
<Button
variant={isOriginalSelected ? "default" : "outline"}
size="sm"
className={`py-1 px-3 rounded-lg flex items-center gap-1 ${
isOriginalSelected
? "bg-russet hover:bg-russet/90 text-white"
: "border-navy/20 dark:border-cream/20 hover:bg-navy/5 dark:hover:bg-cream/5"
}`}
onClick={onViewOriginal}
>
<BookOpen className="h-3.5 w-3.5" />
<span>Original ({getLanguageDisplayName(workLanguage)})</span>
</Button>
{/* Simple mobile-friendly dropdown for all translations */}
<Select
value={currentTranslationId?.toString()}
onValueChange={(value) => {
if (value) {
onSelectTranslation(parseInt(value, 10));
}
}}
>
<SelectTrigger
className="w-auto border-navy/20 dark:border-cream/20 rounded-lg h-8 px-3"
>
<SelectValue placeholder="Select translation" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Translations</SelectLabel>
{translations.map(translation => (
<SelectItem
key={translation.id}
value={translation.id.toString()}
className="cursor-pointer"
>
{getLanguageDisplayName(translation.language)} {translation.year ? `(${translation.year})` : ''}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{/* Advanced translation selector with Popover */}
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="py-1 px-3 rounded-lg border-navy/20 dark:border-cream/20 flex items-center gap-1"
>
<Globe className="h-3.5 w-3.5" />
<span>All Translations</span>
<ChevronDown className="h-3.5 w-3.5 ml-1 opacity-70" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="start">
<Tabs defaultValue="languages" className="w-full">
<TabsList className="grid grid-cols-2 w-full">
<TabsTrigger value="languages">By Language</TabsTrigger>
<TabsTrigger value="translators">By Translator</TabsTrigger>
</TabsList>
<TabsContent value="languages" className="p-0">
<ScrollArea className="h-60">
<div className="p-4 space-y-4">
{Object.entries(translationsByLanguage).map(([language, languageTranslations]) => (
<div key={language} className="space-y-2">
<h4 className="text-sm font-medium">{getLanguageDisplayName(language)}</h4>
<div className="ml-4 space-y-1.5">
{languageTranslations.map(translation => (
<Button
key={translation.id}
variant="ghost"
size="sm"
className={`w-full justify-start text-left py-1 h-auto ${
translation.id === currentTranslationId
? "bg-russet/10 text-russet font-medium"
: ""
}`}
onClick={() => {
onSelectTranslation(translation.id);
setIsPopoverOpen(false);
}}
>
<div className="flex flex-col items-start">
<span>
{translation.translator?.displayName || `Translator ${translation.translatorId}`}
{translation.year ? ` (${translation.year})` : ''}
</span>
</div>
</Button>
))}
</div>
</div>
))}
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="translators" className="p-0">
<ScrollArea className="h-60">
<div className="p-4 space-y-2">
{translations.map(translation => (
<Button
key={translation.id}
variant="ghost"
size="sm"
className={`w-full justify-start text-left h-auto ${
translation.id === currentTranslationId
? "bg-russet/10 text-russet font-medium"
: ""
}`}
onClick={() => {
onSelectTranslation(translation.id);
setIsPopoverOpen(false);
}}
>
<div className="flex flex-col items-start">
<div className="flex items-center gap-1">
<span className="font-medium">
{translation.translator?.displayName || `Translator ${translation.translatorId}`}
</span>
</div>
<div className="text-xs flex gap-2 mt-0.5">
<span>{getLanguageDisplayName(translation.language)}</span>
{translation.year && <span> {translation.year}</span>}
</div>
</div>
</Button>
))}
</div>
</ScrollArea>
</TabsContent>
</Tabs>
<div className="p-2 border-t border-sage/20 dark:border-sage/10">
<Link href={`/works/${workSlug}/compare${currentTranslationId ? `/${currentTranslationId}` : ''}`}>
<Button
variant="outline"
size="sm"
className="w-full flex items-center gap-1 border-navy/20 dark:border-cream/20"
>
<BookMarked className="h-4 w-4" />
<span>Compare Translations</span>
</Button>
</Link>
</div>
</PopoverContent>
</Popover>
{/* Quick language pills for most common languages */}
<div className="flex flex-wrap gap-1.5 mt-2">
{Object.keys(translationsByLanguage).slice(0, 5).map(language => {
const languageTranslations = translationsByLanguage[language];
const isLanguageSelected = languageTranslations.some(t => t.id === currentTranslationId);
return (
<Button
key={language}
variant={isLanguageSelected ? "default" : "outline"}
className={`py-1 px-3 rounded-full ${
size="sm"
className={`py-0.5 px-2 h-7 rounded-full text-xs ${
isLanguageSelected
? "bg-russet dark:bg-russet/90 text-white"
: "bg-navy/10 hover:bg-navy/20 dark:bg-navy/20 dark:hover:bg-navy/30 text-navy dark:text-cream/90"
} font-sans text-xs transition-colors`}
? "bg-russet hover:bg-russet/90 text-white"
: "border-navy/20 dark:border-cream/20"
}`}
onClick={() => {
// If there's only one translation for this language, select it directly
if (languageTranslations.length === 1) {
// Select the first translation for this language
if (languageTranslations.length > 0) {
onSelectTranslation(languageTranslations[0].id);
}
// Otherwise, the dropdown will handle it
}}
>
{language}
{languageTranslations.length > 1 && (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-3 w-3 ml-1 inline-block"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
)}
{getLanguageDisplayName(language)}
</Button>
{/* Dropdown for multiple translations of the same language */}
{languageTranslations.length > 1 && (
<div className="absolute left-0 top-full mt-1 bg-cream dark:bg-dark-surface border border-sage/20 dark:border-sage/10 rounded-md shadow-lg z-10 hidden group-hover:block min-w-max">
{languageTranslations.map(translation => (
<button
key={translation.id}
className={`block w-full text-left px-4 py-2 text-sm ${
translation.id === currentTranslationId
? "bg-russet/10 text-russet"
: "text-navy/80 dark:text-cream/80 hover:bg-navy/5 dark:hover:bg-cream/5"
}`}
onClick={() => onSelectTranslation(translation.id)}
>
{translation.year ? `${translation.year} · ` : ""}
Translator {translation.translatorId}
</button>
))}
</div>
)}
</div>
);
})}
<Link href={`/works/${workSlug}/compare/${currentTranslationId}`}>
<Button
variant="outline"
className="py-1 px-3 rounded-full bg-navy/10 hover:bg-navy/20 dark:bg-navy/20 dark:hover:bg-navy/30 text-navy dark:text-cream/90 font-sans text-xs transition-colors flex items-center gap-1"
>
<span>Compare</span>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
);
})}
{Object.keys(translationsByLanguage).length > 5 && (
<Button
variant="outline"
size="sm"
className="py-0.5 px-2 h-7 rounded-full text-xs border-navy/20 dark:border-cream/20"
onClick={() => setIsPopoverOpen(true)}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</Button>
</Link>
+{Object.keys(translationsByLanguage).length - 5} more
</Button>
)}
</div>
</div>
</div>
);

View File

@ -1,15 +1,21 @@
import { useState, useEffect } from 'react';
/**
* Custom hook that returns whether a media query matches
* @param query The media query to check
* @returns True if the media query matches, false otherwise
*/
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(false);
useEffect(() => {
// Create the media query list
const mediaQuery = window.matchMedia(query);
// Set initial value
setMatches(mediaQuery.matches);
// Define callback function to handle changes
// Define a callback function to handle changes
const handleChange = (event: MediaQueryListEvent) => {
setMatches(event.matches);
};
@ -21,7 +27,7 @@ export function useMediaQuery(query: string): boolean {
return () => {
mediaQuery.removeEventListener('change', handleChange);
};
}, [query]);
}, [query]); // Only re-run if the query changes
return matches;
}