mirror of
https://github.com/SamyRai/tercul-frontend.git
synced 2025-12-27 04:51:34 +00:00
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:
parent
fc244419be
commit
73c88cc6aa
@ -59,9 +59,17 @@ 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}
|
||||
workLanguage={work.language}
|
||||
onSelectTranslation={setSelectedTranslationId}
|
||||
onViewOriginal={handleViewOriginal}
|
||||
isOriginalSelected={isOriginalSelected}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Text content with enhanced annotation features */}
|
||||
|
||||
@ -1,23 +1,77 @@
|
||||
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] = [];
|
||||
}
|
||||
@ -25,94 +79,221 @@ export function TranslationSelector({
|
||||
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
|
||||
<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 (
|
||||
<div key={language} className="relative group">
|
||||
<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}`}>
|
||||
{Object.keys(translationsByLanguage).length > 5 && (
|
||||
<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"
|
||||
size="sm"
|
||||
className="py-0.5 px-2 h-7 rounded-full text-xs border-navy/20 dark:border-cream/20"
|
||||
onClick={() => setIsPopoverOpen(true)}
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
+{Object.keys(translationsByLanguage).length - 5} more
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user