tercul-frontend/client/src/components/reading/AnnotationSystem.tsx
Damir Mukimov ea2ef8fa6d
Fix/typescript testing issues (#12)
* feat: Add missing shared schema types and fix TypeScript imports

- Add AuthorWithStats and AnnotationWithUser schemas with relations
- Add corresponding TypeScript type exports
- Update tsconfig.json with @shared/* path mapping
- Fix @shared/schema import issues across components
- Resolve major TypeScript compilation errors

Next: Fix remaining type mismatches and component prop issues

* fix: resolve major TypeScript errors and type mismatches

- Add AuthorWithStats and AnnotationWithUser schemas with proper relations
- Fix AnnotationSystem component type issues (string IDs, nested user objects)
- Update component props to match schema expectations
- Fix function parameter types for annotation operations
- Resolve null/undefined type assignments
- Add missing required properties (type, isOfficial) to annotations

Remaining issues: Test ES module configuration and some component prop type mismatches

* fix: resolve remaining TypeScript errors and improve type safety

- Fix tag-manager component to work with string IDs from schema
- Update author-stats component to use schema-based AuthorWithStats type
- Add missing utility functions (formatNumber, formatRating) to author utils
- Fix WorkCard test to use correct schema types with string IDs
- Resolve type mismatches in component props and form handling
- Update interface definitions to match schema requirements

Linting:  90%+ resolved, remaining minor issues
Testing: ⚠️ ES module configuration needs refinement

* fix: complete TypeScript fixes and testing refinements

- Fix remaining AnnotationSystem component type issues
- Update FilterSidebar to use string tag IDs
- Resolve all major TypeScript compilation errors
- Testing infrastructure fully functional with Jest + ES modules
- Linting errors reduced to minor unused variable warnings

All critical type safety and testing issues resolved!

* Fix annotation types and author utils

* Fix TypeScript and testing infrastructure issues

- Fix AnnotationSystem component types (string IDs, user objects, liked/likes properties)
- Add formatNumber and formatRating utilities for author components
- Update FilterSidebar to use correct tag ID types (string vs number)
- Fix EnhancedReadingView translation and work ID type mismatches
- Resolve Playwright dependency issues in testing setup
- Update Jest configuration for ES module compatibility
- Fix import paths and type conflicts across components

All unit tests now pass and major TypeScript compilation errors resolved.

* Fix Vite build configuration for CI

- Set root to 'client' directory to find index.html
- Configure path aliases (@/* and @shared/*) for proper module resolution
- Set build output directory to '../dist' to place files in frontend root

Resolves CI build failure: 'Could not resolve entry module index.html'

* Fix Docker build for Yarn v4

- Replace deprecated 'yarn install --immutable --production' with 'yarn workspaces focus --production'
- This resolves the YN0050 error in CI Docker builds

Yarn v4 deprecated the --production flag on install command.
2025-11-30 15:39:18 +01:00

433 lines
12 KiB
TypeScript

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import {
Edit,
MessageCircle,
MessageSquare,
ThumbsUp,
Trash,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { AnnotationWithUser } from "../../../../shared/schema";
interface AnnotationSystemProps {
workId: string;
selectedLineNumber: number | null;
onClose: () => void;
translationId?: string;
}
export function AnnotationSystem({
workId,
selectedLineNumber,
onClose,
translationId,
}: AnnotationSystemProps) {
const { toast } = useToast();
const [annotations, setAnnotations] = useState<AnnotationWithUser[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [newAnnotation, setNewAnnotation] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [editingAnnotationId, setEditingAnnotationId] = useState<string | null>(
null,
);
const [editText, setEditText] = useState("");
const annotationRef = useRef<HTMLDivElement>(null);
// Mock user data - in a real app this would come from auth
const currentUser = {
id: "1",
name: "Anonymous",
avatar: null,
};
// Fetch annotations for the selected line
useEffect(() => {
if (!selectedLineNumber) return;
setIsLoading(true);
// Simulate API call to get annotations for the selected line
setTimeout(() => {
// These would be fetched from the API in a real app
const mockAnnotations: AnnotationWithUser[] = [
{
id: "1" as const,
workId: workId,
translationId: translationId,
lineNumber: selectedLineNumber,
userId: "2" as const,
user: {
name: "Literary Scholar",
avatar: undefined,
},
likes: 5,
liked: false,
content:
"This line demonstrates the poet's use of alliteration, creating a rhythmic pattern that emphasizes the emotional tone.",
type: "analysis" as const,
isOfficial: false,
createdAt: new Date(Date.now() - 1000000).toISOString(),
},
{
id: "2" as const,
workId: workId,
translationId: translationId,
lineNumber: selectedLineNumber,
userId: "3" as const,
user: {
name: "Translator",
avatar: undefined,
},
likes: 3,
liked: false,
content:
"The original meaning in Russian contains a wordplay that is difficult to capture in English. A more literal translation might read as...",
type: "translation" as const,
isOfficial: false,
createdAt: new Date(Date.now() - 5000000).toISOString(),
},
];
setAnnotations(mockAnnotations);
setIsLoading(false);
}, 600);
}, [workId, selectedLineNumber, translationId]);
// Submit new annotation
const handleSubmitAnnotation = async () => {
if (!newAnnotation.trim() || !selectedLineNumber) return;
setIsSubmitting(true);
try {
// In a real app, this would be an API call
// Mock API response
const newAnnotationObj: AnnotationWithUser = {
id: Date.now().toString(),
workId,
translationId,
lineNumber: selectedLineNumber,
userId: currentUser.id.toString(),
user: {
name: currentUser.name,
avatar: currentUser.avatar || undefined,
},
content: newAnnotation,
type: "comment",
isOfficial: false,
createdAt: new Date().toISOString(),
likes: 0,
liked: false,
};
// Optimistically update UI
setAnnotations((prev) => [newAnnotationObj, ...prev]);
setNewAnnotation("");
toast({
description: "Annotation added successfully",
});
// In a real app, this would invalidate the query cache
// queryClient.invalidateQueries({ queryKey: [`/api/works/${workId}/annotations/${selectedLineNumber}`] });
} catch (_error) {
toast({
title: "Error",
description: "Failed to add annotation",
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
// Like an annotation
const handleLikeAnnotation = async (annotationId: string) => {
try {
// Optimistically update UI
setAnnotations((prev) =>
prev.map((anno) =>
anno.id === annotationId
? {
...anno,
liked: !anno.liked,
likes: anno.liked ? (anno.likes || 0) - 1 : (anno.likes || 0) + 1,
}
: anno,
),
);
// In a real app, this would be an API call
// await apiRequest('POST', `/api/annotations/${annotationId}/like`, { userId: currentUser.id });
} catch (_error) {
// Revert optimistic update if there's an error
setAnnotations((prev) => [...prev]);
toast({
title: "Error",
description: "Failed to update like",
variant: "destructive",
});
}
};
// Delete annotation
const handleDeleteAnnotation = async (annotationId: string) => {
try {
// Optimistically update UI
const filteredAnnotations = annotations.filter(
(anno) => anno.id !== annotationId,
);
setAnnotations(filteredAnnotations);
// In a real app, this would be an API call
// await apiRequest('DELETE', `/api/annotations/${annotationId}`);
toast({
description: "Annotation deleted",
});
} catch (_error) {
// Revert optimistic update if there's an error
toast({
title: "Error",
description: "Failed to delete annotation",
variant: "destructive",
});
}
};
// Start editing an annotation
const handleStartEdit = (annotation: AnnotationWithUser) => {
setEditingAnnotationId(annotation.id);
setEditText(annotation.content);
};
// Save edited annotation
const handleSaveEdit = async (annotationId: string) => {
if (!editText.trim()) return;
try {
// Optimistically update UI
setAnnotations((prev) =>
prev.map((anno) =>
anno.id === annotationId ? { ...anno, content: editText } : anno,
),
);
// Reset edit state
setEditingAnnotationId(null);
setEditText("");
// In a real app, this would be an API call
// await apiRequest('PATCH', `/api/annotations/${annotationId}`, { content: editText });
toast({
description: "Annotation updated",
});
} catch (_error) {
toast({
title: "Error",
description: "Failed to update annotation",
variant: "destructive",
});
}
};
// Cancel editing
const handleCancelEdit = () => {
setEditingAnnotationId(null);
setEditText("");
};
// If no line is selected, don't render anything
if (!selectedLineNumber) return null;
return (
<div
ref={annotationRef}
className="annotation-panel bg-cream dark:bg-dark-surface border-l border-sage/20 dark:border-sage/10 w-80 lg:w-96 fixed top-0 right-0 bottom-0 z-50 overflow-y-auto transition-transform shadow-xl"
>
<div className="sticky top-0 z-10 bg-cream dark:bg-dark-surface border-b border-sage/20 dark:border-sage/10 px-4 py-3 flex justify-between items-center">
<div className="flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-russet" />
<h3 className="font-medium text-navy dark:text-cream">
Line {selectedLineNumber} Annotations
</h3>
</div>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-5 w-5" />
<span className="sr-only">Close</span>
</Button>
</div>
<div className="p-4">
{/* New annotation form */}
<div className="mb-6">
<Textarea
placeholder="Add your annotation to this line..."
className="min-h-24 resize-y"
value={newAnnotation}
onChange={(e) => setNewAnnotation(e.target.value)}
/>
<div className="flex justify-end mt-2">
<Button
onClick={handleSubmitAnnotation}
disabled={isSubmitting || !newAnnotation.trim()}
className="bg-russet hover:bg-russet/90 text-white"
>
{isSubmitting ? "Submitting..." : "Add Annotation"}
</Button>
</div>
</div>
{/* Annotations list */}
<div className="space-y-4">
{isLoading ? (
<div className="text-center py-8">
<div className="loader"></div>
<p className="text-navy/70 dark:text-cream/70 mt-2">
Loading annotations...
</p>
</div>
) : annotations.length === 0 ? (
<div className="text-center py-8">
<MessageCircle className="h-12 w-12 mx-auto text-navy/30 dark:text-cream/30" />
<p className="text-navy/70 dark:text-cream/70 mt-2">
No annotations yet
</p>
<p className="text-sm text-navy/50 dark:text-cream/50">
Be the first to annotate this line
</p>
</div>
) : (
annotations.map((annotation) => (
<Card
key={annotation.id}
className="border-sage/20 dark:border-sage/10"
>
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarImage
src={annotation.user.avatar || ""}
alt={annotation.user.name}
/>
<AvatarFallback className="text-xs bg-navy/10 dark:bg-navy/20 text-navy dark:text-cream">
{annotation.user.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<CardTitle className="text-sm font-medium text-navy dark:text-cream">
{annotation.user.name}
</CardTitle>
<p className="text-xs text-navy/60 dark:text-cream/60">
{new Date(annotation.createdAt).toLocaleDateString()}
</p>
</div>
</div>
{/* Edit/Delete buttons for user's own annotations */}
{annotation.userId === currentUser.id.toString() && (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleStartEdit(annotation)}
>
<Edit className="h-4 w-4" />
<span className="sr-only">Edit</span>
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
onClick={() => handleDeleteAnnotation(annotation.id)}
>
<Trash className="h-4 w-4" />
<span className="sr-only">Delete</span>
</Button>
</div>
)}
</div>
</CardHeader>
<CardContent className="pt-2">
{editingAnnotationId === annotation.id ? (
<div>
<Textarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
className="min-h-24 resize-y mb-2"
/>
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={handleCancelEdit}
>
Cancel
</Button>
<Button
size="sm"
onClick={() => handleSaveEdit(annotation.id)}
disabled={!editText.trim()}
>
Save
</Button>
</div>
</div>
) : (
<p className="text-sm text-navy/90 dark:text-cream/90 whitespace-pre-wrap">
{annotation.content}
</p>
)}
</CardContent>
<CardFooter className="pt-2 flex justify-between items-center">
<Button
variant="ghost"
size="sm"
className={`flex items-center gap-1 text-xs ${
annotation.liked
? "text-russet"
: "text-navy/70 dark:text-cream/70"
}`}
onClick={() => handleLikeAnnotation(annotation.id)}
>
<ThumbsUp
className={`h-4 w-4 ${annotation.liked ? "fill-russet" : ""}`}
/>
<span>{annotation.likes}</span>
</Button>
<Button
variant="ghost"
size="sm"
className="text-xs text-navy/70 dark:text-cream/70"
>
<MessageCircle className="h-4 w-4 mr-1" />
<span>Reply</span>
</Button>
</CardFooter>
</Card>
))
)}
</div>
</div>
</div>
);
}