mirror of
https://github.com/SamyRai/tercul-frontend.git
synced 2025-12-27 04:51:34 +00:00
Enforce type safety using zod v4 across the application
- Updated `Search.tsx` to align `tags` type with schema (string[]). - Fixed `useQuery` usage in various files (`Search.tsx`, `Explore.tsx`, `Home.tsx`, `AuthorProfile.tsx`) by adding explicit return types or using `select` with type assertions to handle complex data transformations. - Removed unused variables and files, including several custom hooks that were referencing non-existent API clients. - Fixed type mismatches (string vs number, undefined checks) in various files. - Fixed server-side import path in `server/routes/blog.ts` and `server/routes/userProfile.ts`. - Updated `server/routes/userProfile.ts` to use correct GraphQL generated members. - Replaced usage of missing hooks with direct `useQuery` or `apiRequest` calls. - Fixed `RefObject` type in `comparison-slider.tsx`. - Removed `replaceAll` usage for better compatibility. - Cleaned up unused imports and declarations.
This commit is contained in:
parent
cfa99f632e
commit
358f640eb6
@ -18,7 +18,6 @@ import Search from "@/pages/Search";
|
|||||||
import Submit from "@/pages/Submit";
|
import Submit from "@/pages/Submit";
|
||||||
import Profile from "@/pages/user/Profile";
|
import Profile from "@/pages/user/Profile";
|
||||||
import SimpleWorkReading from "@/pages/works/SimpleWorkReading";
|
import SimpleWorkReading from "@/pages/works/SimpleWorkReading";
|
||||||
import WorkCompare from "@/pages/works/WorkCompare";
|
|
||||||
import { queryClient } from "./lib/queryClient";
|
import { queryClient } from "./lib/queryClient";
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
@ -30,10 +29,6 @@ function Router() {
|
|||||||
<Route path="/authors" component={Authors} />
|
<Route path="/authors" component={Authors} />
|
||||||
<Route path="/authors/:slug" component={AuthorProfile} />
|
<Route path="/authors/:slug" component={AuthorProfile} />
|
||||||
<Route path="/works/:slug" component={SimpleWorkReading} />
|
<Route path="/works/:slug" component={SimpleWorkReading} />
|
||||||
<Route
|
|
||||||
path="/works/:slug/compare/:translationId"
|
|
||||||
component={WorkCompare}
|
|
||||||
/>
|
|
||||||
<Route path="/collections" component={Collections} />
|
<Route path="/collections" component={Collections} />
|
||||||
<Route path="/collections/create" component={CreateCollection} />
|
<Route path="/collections/create" component={CreateCollection} />
|
||||||
<Route path="/profile" component={Profile} />
|
<Route path="/profile" component={Profile} />
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { Grid3X3, List } from "lucide-react";
|
import { Grid3X3, List } from "lucide-react";
|
||||||
import { useAuthorWorks } from "@/hooks/use-author-api";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { Work } from "@shared/schema";
|
import type { Work } from "@shared/schema";
|
||||||
import { WorkFilters } from "./author-works/filters";
|
import { WorkFilters } from "./author-works/filters";
|
||||||
import { AuthorWorksSkeleton } from "./author-works/skeleton";
|
import { AuthorWorksSkeleton } from "./author-works/skeleton";
|
||||||
@ -40,13 +40,15 @@ export function AuthorWorksDisplay({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Use the actual API hook to fetch author's works
|
// Use the actual API hook to fetch author's works
|
||||||
const { data: works, isLoading, error } = useAuthorWorks(authorId);
|
const { data: works, isLoading, error } = useQuery<Work[]>({
|
||||||
|
queryKey: [`/api/authors/${authorId}/works`],
|
||||||
|
});
|
||||||
|
|
||||||
// Convert works with tag objects back to Work type for components
|
// Convert works with tag objects back to Work type for components
|
||||||
const worksForDisplay: Work[] = useMemo(() =>
|
const worksForDisplay: Work[] = useMemo(() =>
|
||||||
works?.map(work => ({
|
works?.map((work: any) => ({
|
||||||
...work,
|
...work,
|
||||||
tags: work.tags?.map(tag =>
|
tags: work.tags?.map((tag: any) =>
|
||||||
typeof tag === 'string' ? tag : tag.name
|
typeof tag === 'string' ? tag : tag.name
|
||||||
),
|
),
|
||||||
})) || [],
|
})) || [],
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Calendar, ExternalLink, Users } from "lucide-react";
|
import { Calendar, ExternalLink, Users } from "lucide-react";
|
||||||
import { useAuthors } from "@/hooks/use-author-api";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { Author } from "@shared/schema";
|
import type { Author } from "@shared/schema";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
|
|
||||||
@ -19,7 +19,9 @@ export function RelatedAuthors({
|
|||||||
limit = 6,
|
limit = 6,
|
||||||
}: RelatedAuthorsProps) {
|
}: RelatedAuthorsProps) {
|
||||||
// Use the actual API hook to fetch authors
|
// Use the actual API hook to fetch authors
|
||||||
const { data: authors, isLoading, error } = useAuthors({ limit: limit + 5 }); // Fetch more to filter out current author
|
const { data: authors, isLoading, error } = useQuery<Author[]>({
|
||||||
|
queryKey: ["/api/authors", { limit: limit + 5 }],
|
||||||
|
});
|
||||||
|
|
||||||
// Filter out the current author and limit results
|
// Filter out the current author and limit results
|
||||||
const relatedAuthors =
|
const relatedAuthors =
|
||||||
|
|||||||
@ -1,695 +0,0 @@
|
|||||||
import { formatDistanceToNow } from "date-fns";
|
|
||||||
import {
|
|
||||||
AlertTriangle,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
Edit,
|
|
||||||
Heart,
|
|
||||||
MessageSquare,
|
|
||||||
MoreVertical,
|
|
||||||
Reply,
|
|
||||||
ThumbsUp,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Comment Thread component for displaying hierarchical comments
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <CommentThread
|
|
||||||
* comments={comments}
|
|
||||||
* currentUserId={currentUser.id}
|
|
||||||
* onAddComment={(text, parentId) => handleAddComment(text, parentId)}
|
|
||||||
* onEditComment={(id, text) => handleEditComment(id, text)}
|
|
||||||
* onDeleteComment={(id) => handleDeleteComment(id)}
|
|
||||||
* onLikeComment={(id) => handleLikeComment(id)}
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface User {
|
|
||||||
id: string | number;
|
|
||||||
name: string;
|
|
||||||
avatar?: string;
|
|
||||||
role?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Comment {
|
|
||||||
id: string | number;
|
|
||||||
user: User;
|
|
||||||
text: string;
|
|
||||||
timestamp: string | Date;
|
|
||||||
parentId?: string | number | null;
|
|
||||||
entityId: string | number;
|
|
||||||
entityType: string;
|
|
||||||
likes: number;
|
|
||||||
isLiked?: boolean;
|
|
||||||
replies?: number;
|
|
||||||
status?: "published" | "pending" | "flagged" | "deleted";
|
|
||||||
isPinned?: boolean;
|
|
||||||
isEdited?: boolean;
|
|
||||||
metadata?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommentThreadProps {
|
|
||||||
/**
|
|
||||||
* Array of comments to display
|
|
||||||
*/
|
|
||||||
comments: Comment[];
|
|
||||||
/**
|
|
||||||
* ID of the current user
|
|
||||||
*/
|
|
||||||
currentUserId?: string | number;
|
|
||||||
/**
|
|
||||||
* Whether the thread is loading
|
|
||||||
*/
|
|
||||||
isLoading?: boolean;
|
|
||||||
/**
|
|
||||||
* Maximum nesting level for replies (default: 3)
|
|
||||||
*/
|
|
||||||
maxNestingLevel?: number;
|
|
||||||
/**
|
|
||||||
* Whether to allow adding new comments
|
|
||||||
*/
|
|
||||||
allowComments?: boolean;
|
|
||||||
/**
|
|
||||||
* Whether to show reply form by default
|
|
||||||
*/
|
|
||||||
showReplyForm?: boolean;
|
|
||||||
/**
|
|
||||||
* Placeholder text for the comment input
|
|
||||||
*/
|
|
||||||
commentPlaceholder?: string;
|
|
||||||
/**
|
|
||||||
* Whether to collapse long comments
|
|
||||||
*/
|
|
||||||
collapseComments?: boolean;
|
|
||||||
/**
|
|
||||||
* Maximum comment length before collapsing
|
|
||||||
*/
|
|
||||||
maxCommentLength?: number;
|
|
||||||
/**
|
|
||||||
* Whether to auto-expand the newest comment
|
|
||||||
*/
|
|
||||||
autoExpandNewest?: boolean;
|
|
||||||
/**
|
|
||||||
* Callback for adding a new comment
|
|
||||||
*/
|
|
||||||
onAddComment?: (text: string, parentId?: string | number | null) => void;
|
|
||||||
/**
|
|
||||||
* Callback for editing a comment
|
|
||||||
*/
|
|
||||||
onEditComment?: (id: string | number, text: string) => void;
|
|
||||||
/**
|
|
||||||
* Callback for deleting a comment
|
|
||||||
*/
|
|
||||||
onDeleteComment?: (id: string | number) => void;
|
|
||||||
/**
|
|
||||||
* Callback for liking a comment
|
|
||||||
*/
|
|
||||||
onLikeComment?: (id: string | number, isLiked: boolean) => void;
|
|
||||||
/**
|
|
||||||
* Callback for reporting a comment
|
|
||||||
*/
|
|
||||||
onReportComment?: (id: string | number, reason?: string) => void;
|
|
||||||
/**
|
|
||||||
* Callback for moderating a comment
|
|
||||||
*/
|
|
||||||
onModerateComment?: (
|
|
||||||
id: string | number,
|
|
||||||
action: "approve" | "reject" | "flag",
|
|
||||||
) => void;
|
|
||||||
/**
|
|
||||||
* Callback for pinning a comment
|
|
||||||
*/
|
|
||||||
onPinComment?: (id: string | number, isPinned: boolean) => void;
|
|
||||||
/**
|
|
||||||
* CSS class for the container
|
|
||||||
*/
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recursive function to build the comment tree
|
|
||||||
const buildCommentTree = (
|
|
||||||
comments: Comment[],
|
|
||||||
parentId: string | number | null = null,
|
|
||||||
): Comment[] => {
|
|
||||||
return comments
|
|
||||||
.filter((comment) => comment.parentId === parentId)
|
|
||||||
.sort((a, b) => {
|
|
||||||
// Sort pinned comments first
|
|
||||||
if (a.isPinned && !b.isPinned) return -1;
|
|
||||||
if (!a.isPinned && b.isPinned) return 1;
|
|
||||||
|
|
||||||
// Then sort by timestamp (newest first)
|
|
||||||
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Format timestamps
|
|
||||||
const formatTimestamp = (date: string | Date): string => {
|
|
||||||
return formatDistanceToNow(new Date(date), { addSuffix: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CommentThread({
|
|
||||||
comments = [],
|
|
||||||
currentUserId,
|
|
||||||
isLoading = false,
|
|
||||||
maxNestingLevel = 3,
|
|
||||||
allowComments = true,
|
|
||||||
showReplyForm = false,
|
|
||||||
commentPlaceholder = "Add a comment...",
|
|
||||||
collapseComments = true,
|
|
||||||
maxCommentLength = 300,
|
|
||||||
autoExpandNewest = true,
|
|
||||||
onAddComment,
|
|
||||||
onEditComment,
|
|
||||||
onDeleteComment,
|
|
||||||
onLikeComment,
|
|
||||||
onReportComment,
|
|
||||||
onModerateComment,
|
|
||||||
onPinComment,
|
|
||||||
className,
|
|
||||||
}: CommentThreadProps) {
|
|
||||||
const [replyingTo, setReplyingTo] = useState<string | number | null>(null);
|
|
||||||
const [editingId, setEditingId] = useState<string | number | null>(null);
|
|
||||||
const [topLevelText, setTopLevelText] = useState("");
|
|
||||||
const [editText, setEditText] = useState("");
|
|
||||||
const [replyText, setReplyText] = useState("");
|
|
||||||
const [expandedComments, setExpandedComments] = useState<
|
|
||||||
Record<string | number, boolean>
|
|
||||||
>({});
|
|
||||||
const [expandedThreads, setExpandedThreads] = useState<
|
|
||||||
Record<string | number, boolean>
|
|
||||||
>({});
|
|
||||||
|
|
||||||
// Get the newest comment ID for auto-expanding if needed
|
|
||||||
const newestCommentId =
|
|
||||||
comments.length > 0
|
|
||||||
? comments.reduce((newest, comment) =>
|
|
||||||
new Date(comment.timestamp) > new Date(newest.timestamp)
|
|
||||||
? comment
|
|
||||||
: newest,
|
|
||||||
).id
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Initialize expanded states if not already set
|
|
||||||
if (autoExpandNewest && newestCommentId && comments.length > 0) {
|
|
||||||
expandedComments[newestCommentId] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle submitting a top-level comment
|
|
||||||
const handleSubmitTopLevelComment = () => {
|
|
||||||
if (topLevelText.trim() && onAddComment) {
|
|
||||||
onAddComment(topLevelText.trim());
|
|
||||||
setTopLevelText("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle submitting a reply
|
|
||||||
const handleSubmitReply = (parentId: string | number) => {
|
|
||||||
if (replyText.trim() && onAddComment) {
|
|
||||||
onAddComment(replyText.trim(), parentId);
|
|
||||||
setReplyText("");
|
|
||||||
setReplyingTo(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle submitting an edit
|
|
||||||
const handleSubmitEdit = (id: string | number) => {
|
|
||||||
if (editText.trim() && onEditComment) {
|
|
||||||
onEditComment(id, editText.trim());
|
|
||||||
setEditText("");
|
|
||||||
setEditingId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle liking a comment
|
|
||||||
const handleLikeComment = (id: string | number, isLiked: boolean) => {
|
|
||||||
onLikeComment?.(id, !isLiked);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Toggle comment expansion
|
|
||||||
const toggleCommentExpansion = (id: string | number) => {
|
|
||||||
setExpandedComments((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[id]: !prev[id],
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Toggle thread expansion
|
|
||||||
const toggleThreadExpansion = (id: string | number) => {
|
|
||||||
setExpandedThreads((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[id]: !prev[id],
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if user can edit/delete a comment
|
|
||||||
const canManageComment = (comment: Comment) => {
|
|
||||||
if (!currentUserId) return false;
|
|
||||||
return comment.user.id === currentUserId;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if user is a moderator
|
|
||||||
const isModerator = () => {
|
|
||||||
// In a real app, you'd check the user's role
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render a single comment
|
|
||||||
const renderComment = (comment: Comment, level: number = 0) => {
|
|
||||||
const isExpanded = expandedComments[comment.id] || !collapseComments;
|
|
||||||
const isThreadExpanded = expandedThreads[comment.id] !== false; // Default to expanded
|
|
||||||
const isEditing = editingId === comment.id;
|
|
||||||
const isReplying = replyingTo === comment.id;
|
|
||||||
|
|
||||||
// Get replies for this comment
|
|
||||||
const replies = buildCommentTree(comments, comment.id);
|
|
||||||
|
|
||||||
// Check if comment text needs truncation
|
|
||||||
const needsTruncation = comment.text.length > maxCommentLength;
|
|
||||||
const displayText =
|
|
||||||
needsTruncation && !isExpanded
|
|
||||||
? `${comment.text.substring(0, maxCommentLength)}...`
|
|
||||||
: comment.text;
|
|
||||||
|
|
||||||
// Determine if nesting should continue
|
|
||||||
const shouldNestReplies = level < maxNestingLevel;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"group",
|
|
||||||
level > 0 && "ml-6 mt-3",
|
|
||||||
comment.isPinned &&
|
|
||||||
"relative bg-muted/20 p-3 rounded-md border-l-2 border-primary",
|
|
||||||
)}
|
|
||||||
key={comment.id}
|
|
||||||
>
|
|
||||||
{/* Pinned indicator */}
|
|
||||||
{comment.isPinned && (
|
|
||||||
<div className="absolute -left-1 -top-2">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="bg-background text-xs px-2 py-0"
|
|
||||||
>
|
|
||||||
Pinned
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
|
||||||
{/* User avatar */}
|
|
||||||
<Avatar className="h-8 w-8 flex-shrink-0">
|
|
||||||
<AvatarImage src={comment.user.avatar} alt={comment.user.name} />
|
|
||||||
<AvatarFallback>{comment.user.name.charAt(0)}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
|
|
||||||
{/* Comment content */}
|
|
||||||
<div className="flex-1 space-y-1.5">
|
|
||||||
{/* Comment header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium text-sm">{comment.user.name}</span>
|
|
||||||
|
|
||||||
{/* User role badge */}
|
|
||||||
{comment.user.role && (
|
|
||||||
<Badge variant="outline" className="text-xs px-1 h-4">
|
|
||||||
{comment.user.role}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Comment status */}
|
|
||||||
{comment.status === "pending" && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-xs px-1 h-4 bg-amber-50 text-amber-700 border-amber-200"
|
|
||||||
>
|
|
||||||
Pending
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{comment.status === "flagged" && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-xs px-1 h-4 bg-red-50 text-red-700 border-red-200"
|
|
||||||
>
|
|
||||||
Flagged
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{formatTimestamp(comment.timestamp)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{comment.isEdited && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
(edited)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Comment actions */}
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 opacity-0 group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
<MoreVertical className="h-4 w-4" />
|
|
||||||
<span className="sr-only">More</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
{canManageComment(comment) && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
setEditingId(comment.id);
|
|
||||||
setEditText(comment.text);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4 mr-2" />
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
onClick={() => onDeleteComment?.(comment.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isModerator() && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
onPinComment?.(comment.id, !comment.isPinned)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ThumbsUp className="h-4 w-4 mr-2" />
|
|
||||||
{comment.isPinned ? "Unpin" : "Pin"}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{comment.status === "pending" && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
onModerateComment?.(comment.id, "approve")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ThumbsUp className="h-4 w-4 mr-2" />
|
|
||||||
Approve
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => onModerateComment?.(comment.id, "flag")}
|
|
||||||
>
|
|
||||||
<AlertTriangle className="h-4 w-4 mr-2" />
|
|
||||||
Flag
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => onReportComment?.(comment.id)}
|
|
||||||
>
|
|
||||||
<AlertTriangle className="h-4 w-4 mr-2" />
|
|
||||||
Report
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Comment text */}
|
|
||||||
{isEditing ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Textarea
|
|
||||||
value={editText}
|
|
||||||
onChange={(e) => setEditText(e.target.value)}
|
|
||||||
className="min-h-[100px]"
|
|
||||||
placeholder="Edit your comment..."
|
|
||||||
/>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setEditingId(null)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleSubmitEdit(comment.id)}
|
|
||||||
disabled={!editText.trim()}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-sm leading-relaxed">
|
|
||||||
<p className="whitespace-pre-line">{displayText}</p>
|
|
||||||
|
|
||||||
{/* Expand/collapse button */}
|
|
||||||
{needsTruncation && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-auto px-2 py-1 text-xs"
|
|
||||||
onClick={() => toggleCommentExpansion(comment.id)}
|
|
||||||
>
|
|
||||||
{isExpanded ? "Show less" : "Read more"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Comment actions */}
|
|
||||||
{!isEditing && (
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 px-2 gap-1.5"
|
|
||||||
onClick={() =>
|
|
||||||
handleLikeComment(comment.id, !!comment.isLiked)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Heart
|
|
||||||
className={cn(
|
|
||||||
"h-3.5 w-3.5",
|
|
||||||
comment.isLiked && "fill-current text-destructive",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span>{comment.likes || 0}</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 px-2 gap-1.5"
|
|
||||||
onClick={() => setReplyingTo(isReplying ? null : comment.id)}
|
|
||||||
>
|
|
||||||
<Reply className="h-3.5 w-3.5" />
|
|
||||||
<span>Reply</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Show/hide replies button */}
|
|
||||||
{replies.length > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 px-2 gap-1.5"
|
|
||||||
onClick={() => toggleThreadExpansion(comment.id)}
|
|
||||||
>
|
|
||||||
{isThreadExpanded ? (
|
|
||||||
<>
|
|
||||||
<ChevronUp className="h-3.5 w-3.5" />
|
|
||||||
<span>Hide replies</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ChevronDown className="h-3.5 w-3.5" />
|
|
||||||
<span>
|
|
||||||
Show {replies.length}{" "}
|
|
||||||
{replies.length === 1 ? "reply" : "replies"}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Reply form */}
|
|
||||||
{isReplying && (
|
|
||||||
<div className="mt-3 space-y-2">
|
|
||||||
<Textarea
|
|
||||||
value={replyText}
|
|
||||||
onChange={(e) => setReplyText(e.target.value)}
|
|
||||||
placeholder="Write a reply..."
|
|
||||||
className="min-h-[80px]"
|
|
||||||
/>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setReplyingTo(null)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleSubmitReply(comment.id)}
|
|
||||||
disabled={!replyText.trim()}
|
|
||||||
>
|
|
||||||
Reply
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Nested replies */}
|
|
||||||
{replies.length > 0 && isThreadExpanded && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"mt-3",
|
|
||||||
shouldNestReplies ? "" : "ml-0 border-l-2 border-border pl-3",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{replies.map((reply) =>
|
|
||||||
shouldNestReplies
|
|
||||||
? renderComment(reply, level + 1)
|
|
||||||
: renderFlatReply(reply, comment),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render a flat reply (used when max nesting level is reached)
|
|
||||||
const renderFlatReply = (reply: Comment, _parentComment: Comment) => {
|
|
||||||
return (
|
|
||||||
<div className="py-2" key={reply.id}>
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<Avatar className="h-6 w-6">
|
|
||||||
<AvatarImage src={reply.user.avatar} alt={reply.user.name} />
|
|
||||||
<AvatarFallback>{reply.user.name.charAt(0)}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<span className="font-medium text-sm">{reply.user.name}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{formatTimestamp(reply.timestamp)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="pl-8 text-sm">
|
|
||||||
<p>{reply.text}</p>
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 px-2 text-xs gap-1"
|
|
||||||
onClick={() => handleLikeComment(reply.id, !!reply.isLiked)}
|
|
||||||
>
|
|
||||||
<Heart
|
|
||||||
className={cn(
|
|
||||||
"h-3 w-3",
|
|
||||||
reply.isLiked && "fill-current text-destructive",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span>{reply.likes || 0}</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("space-y-6", className)}>
|
|
||||||
{/* Comment count and sort controls */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-lg font-medium">Comments ({comments.length})</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Top-level comment form */}
|
|
||||||
{allowComments && currentUserId && (
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Avatar className="h-8 w-8 flex-shrink-0">
|
|
||||||
{/* This would show current user's avatar */}
|
|
||||||
<AvatarFallback>U</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<Textarea
|
|
||||||
value={topLevelText}
|
|
||||||
onChange={(e) => setTopLevelText(e.target.value)}
|
|
||||||
placeholder={commentPlaceholder}
|
|
||||||
className="min-h-[100px]"
|
|
||||||
/>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmitTopLevelComment}
|
|
||||||
disabled={!topLevelText.trim()}
|
|
||||||
>
|
|
||||||
Comment
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Comments list */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{isLoading ? (
|
|
||||||
// Loading skeleton
|
|
||||||
[1, 2, 3].map((i) => (
|
|
||||||
<div className="flex gap-3 animate-pulse" key={i}>
|
|
||||||
<div className="h-8 w-8 rounded-full bg-muted"></div>
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="h-4 w-24 bg-muted rounded"></div>
|
|
||||||
<div className="h-4 w-16 bg-muted rounded"></div>
|
|
||||||
</div>
|
|
||||||
<div className="h-12 bg-muted rounded"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : comments.length === 0 ? (
|
|
||||||
// Empty state
|
|
||||||
<div className="text-center py-6 text-muted-foreground">
|
|
||||||
<MessageSquare className="mx-auto h-8 w-8 mb-2 text-muted-foreground/50" />
|
|
||||||
<p>No comments yet</p>
|
|
||||||
{allowComments && (
|
|
||||||
<p className="text-sm">Be the first to share your thoughts</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// Render comment threads
|
|
||||||
buildCommentTree(comments).map((comment) => renderComment(comment))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CommentThread;
|
|
||||||
@ -1,520 +0,0 @@
|
|||||||
import { format, formatDistance } from "date-fns";
|
|
||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
Bookmark,
|
|
||||||
BookOpen,
|
|
||||||
CheckCircle,
|
|
||||||
ChevronDown,
|
|
||||||
Clock,
|
|
||||||
FileEdit,
|
|
||||||
Heart,
|
|
||||||
Layers,
|
|
||||||
MessageSquare,
|
|
||||||
MoreHorizontal,
|
|
||||||
PenSquare,
|
|
||||||
RefreshCw,
|
|
||||||
Star,
|
|
||||||
Tag,
|
|
||||||
UserPlus,
|
|
||||||
Users,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { Timeline, TimelineItem } from "@/components/ui/timeline";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Activity Feed component for displaying user activity timelines
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <ActivityFeed
|
|
||||||
* activities={activities}
|
|
||||||
* isLoading={isLoading}
|
|
||||||
* onLoadMore={handleLoadMore}
|
|
||||||
* hasMore={hasMoreActivities}
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface Activity {
|
|
||||||
/**
|
|
||||||
* Unique ID for the activity
|
|
||||||
*/
|
|
||||||
id: string | number;
|
|
||||||
/**
|
|
||||||
* Type of activity
|
|
||||||
*/
|
|
||||||
type: string;
|
|
||||||
/**
|
|
||||||
* User who performed the activity
|
|
||||||
*/
|
|
||||||
user: {
|
|
||||||
id: string | number;
|
|
||||||
name: string;
|
|
||||||
avatar?: string;
|
|
||||||
role?: string;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Timestamp when the activity occurred
|
|
||||||
*/
|
|
||||||
timestamp: string | Date;
|
|
||||||
/**
|
|
||||||
* Associated entity of the activity (work, comment, etc)
|
|
||||||
*/
|
|
||||||
entity?: {
|
|
||||||
id: string | number;
|
|
||||||
type: string;
|
|
||||||
name: string;
|
|
||||||
url?: string;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Secondary entity involved in the activity (e.g., the tag that was added)
|
|
||||||
*/
|
|
||||||
secondaryEntity?: {
|
|
||||||
id: string | number;
|
|
||||||
type: string;
|
|
||||||
name: string;
|
|
||||||
url?: string;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Additional metadata specific to the activity type
|
|
||||||
*/
|
|
||||||
meta?: Record<string, any>;
|
|
||||||
/**
|
|
||||||
* Optional group identifier for grouping related activities
|
|
||||||
*/
|
|
||||||
groupId?: string | number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ActivityFeedProps {
|
|
||||||
/**
|
|
||||||
* Array of activity items to display
|
|
||||||
*/
|
|
||||||
activities: Activity[];
|
|
||||||
/**
|
|
||||||
* Whether activities are currently loading
|
|
||||||
*/
|
|
||||||
isLoading?: boolean;
|
|
||||||
/**
|
|
||||||
* Callback to load more activities
|
|
||||||
*/
|
|
||||||
onLoadMore?: () => void;
|
|
||||||
/**
|
|
||||||
* Whether there are more activities to load
|
|
||||||
*/
|
|
||||||
hasMore?: boolean;
|
|
||||||
/**
|
|
||||||
* Callback when an activity is clicked
|
|
||||||
*/
|
|
||||||
onActivityClick?: (activity: Activity) => void;
|
|
||||||
/**
|
|
||||||
* Maximum number of activities to display before scrolling
|
|
||||||
*/
|
|
||||||
maxItems?: number;
|
|
||||||
/**
|
|
||||||
* Maximum height of the feed
|
|
||||||
*/
|
|
||||||
maxHeight?: string | number;
|
|
||||||
/**
|
|
||||||
* Whether to group activities by date
|
|
||||||
*/
|
|
||||||
groupByDate?: boolean;
|
|
||||||
/**
|
|
||||||
* Whether to use a compact layout
|
|
||||||
*/
|
|
||||||
compact?: boolean;
|
|
||||||
/**
|
|
||||||
* Optional filter by activity type
|
|
||||||
*/
|
|
||||||
filter?: string[];
|
|
||||||
/**
|
|
||||||
* Whether to hide the filter controls
|
|
||||||
*/
|
|
||||||
hideFilters?: boolean;
|
|
||||||
/**
|
|
||||||
* Additional CSS classes
|
|
||||||
*/
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActivityGroup {
|
|
||||||
date: string;
|
|
||||||
activities: Activity[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get icon for activity type
|
|
||||||
*/
|
|
||||||
const getActivityIcon = (type: string): React.ReactNode => {
|
|
||||||
const icons: Record<string, React.ReactNode> = {
|
|
||||||
comment: <MessageSquare className="h-4 w-4" />,
|
|
||||||
like: <Heart className="h-4 w-4" />,
|
|
||||||
create: <PenSquare className="h-4 w-4" />,
|
|
||||||
edit: <FileEdit className="h-4 w-4" />,
|
|
||||||
join: <UserPlus className="h-4 w-4" />,
|
|
||||||
bookmark: <Bookmark className="h-4 w-4" />,
|
|
||||||
rate: <Star className="h-4 w-4" />,
|
|
||||||
alert: <AlertCircle className="h-4 w-4" />,
|
|
||||||
complete: <CheckCircle className="h-4 w-4" />,
|
|
||||||
schedule: <Clock className="h-4 w-4" />,
|
|
||||||
read: <BookOpen className="h-4 w-4" />,
|
|
||||||
follow: <Users className="h-4 w-4" />,
|
|
||||||
tag: <Tag className="h-4 w-4" />,
|
|
||||||
collection: <Layers className="h-4 w-4" />,
|
|
||||||
};
|
|
||||||
|
|
||||||
return icons[type] || <MoreHorizontal className="h-4 w-4" />;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get color variant for activity type
|
|
||||||
*/
|
|
||||||
const getActivityVariant = (type: string): string => {
|
|
||||||
const variants: Record<string, string> = {
|
|
||||||
comment: "bg-blue-100 dark:bg-blue-950/30 text-blue-700 dark:text-blue-400",
|
|
||||||
like: "bg-pink-100 dark:bg-pink-950/30 text-pink-700 dark:text-pink-400",
|
|
||||||
create:
|
|
||||||
"bg-green-100 dark:bg-green-950/30 text-green-700 dark:text-green-400",
|
|
||||||
edit: "bg-amber-100 dark:bg-amber-950/30 text-amber-700 dark:text-amber-400",
|
|
||||||
join: "bg-indigo-100 dark:bg-indigo-950/30 text-indigo-700 dark:text-indigo-400",
|
|
||||||
bookmark:
|
|
||||||
"bg-purple-100 dark:bg-purple-950/30 text-purple-700 dark:text-purple-400",
|
|
||||||
rate: "bg-yellow-100 dark:bg-yellow-950/30 text-yellow-700 dark:text-yellow-400",
|
|
||||||
alert: "bg-red-100 dark:bg-red-950/30 text-red-700 dark:text-red-400",
|
|
||||||
complete:
|
|
||||||
"bg-emerald-100 dark:bg-emerald-950/30 text-emerald-700 dark:text-emerald-400",
|
|
||||||
schedule:
|
|
||||||
"bg-cyan-100 dark:bg-cyan-950/30 text-cyan-700 dark:text-cyan-400",
|
|
||||||
read: "bg-violet-100 dark:bg-violet-950/30 text-violet-700 dark:text-violet-400",
|
|
||||||
follow:
|
|
||||||
"bg-indigo-100 dark:bg-indigo-950/30 text-indigo-700 dark:text-indigo-400",
|
|
||||||
tag: "bg-orange-100 dark:bg-orange-950/30 text-orange-700 dark:text-orange-400",
|
|
||||||
collection:
|
|
||||||
"bg-teal-100 dark:bg-teal-950/30 text-teal-700 dark:text-teal-400",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
variants[type] ||
|
|
||||||
"bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-400"
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get formatted activity message
|
|
||||||
*/
|
|
||||||
const getActivityMessage = (activity: Activity): string => {
|
|
||||||
const { type, entity, secondaryEntity, meta } = activity;
|
|
||||||
|
|
||||||
const entityName = entity?.name || "item";
|
|
||||||
const secondaryName = secondaryEntity?.name || "";
|
|
||||||
|
|
||||||
const messages: Record<string, string> = {
|
|
||||||
comment: `commented on ${entityName}`,
|
|
||||||
like: `liked ${entityName}`,
|
|
||||||
create: `created ${entityName}`,
|
|
||||||
edit: `edited ${entityName}`,
|
|
||||||
join: `joined the platform`,
|
|
||||||
bookmark: `bookmarked ${entityName}`,
|
|
||||||
rate: `rated ${entityName}`,
|
|
||||||
read: `read ${entityName}`,
|
|
||||||
follow: `followed ${entityName}`,
|
|
||||||
tag: `tagged ${entityName} with ${secondaryName}`,
|
|
||||||
collection: `added ${entityName} to collection ${secondaryName}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
return messages[type] || `performed action on ${entityName}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Function to group activities by date
|
|
||||||
*/
|
|
||||||
const groupActivitiesByDate = (activities: Activity[]): ActivityGroup[] => {
|
|
||||||
const groups: Record<string, Activity[]> = {};
|
|
||||||
|
|
||||||
activities.forEach((activity) => {
|
|
||||||
const date = new Date(activity.timestamp);
|
|
||||||
const dateKey = format(date, "yyyy-MM-dd");
|
|
||||||
|
|
||||||
if (!groups[dateKey]) {
|
|
||||||
groups[dateKey] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
groups[dateKey].push(activity);
|
|
||||||
});
|
|
||||||
|
|
||||||
return Object.entries(groups)
|
|
||||||
.map(([date, activities]) => ({
|
|
||||||
date,
|
|
||||||
activities,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format date for group headers
|
|
||||||
*/
|
|
||||||
const formatGroupDate = (dateString: string): string => {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
const today = new Date();
|
|
||||||
const yesterday = new Date(today);
|
|
||||||
yesterday.setDate(yesterday.getDate() - 1);
|
|
||||||
|
|
||||||
if (format(date, "yyyy-MM-dd") === format(today, "yyyy-MM-dd")) {
|
|
||||||
return "Today";
|
|
||||||
} else if (format(date, "yyyy-MM-dd") === format(yesterday, "yyyy-MM-dd")) {
|
|
||||||
return "Yesterday";
|
|
||||||
} else {
|
|
||||||
return format(date, "MMMM d, yyyy");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ActivityFeed({
|
|
||||||
activities,
|
|
||||||
isLoading = false,
|
|
||||||
onLoadMore,
|
|
||||||
hasMore = false,
|
|
||||||
onActivityClick,
|
|
||||||
maxItems = 50,
|
|
||||||
maxHeight = "500px",
|
|
||||||
groupByDate = true,
|
|
||||||
compact = false,
|
|
||||||
filter = [],
|
|
||||||
hideFilters = false,
|
|
||||||
className,
|
|
||||||
}: ActivityFeedProps) {
|
|
||||||
const [activeFilters, setActiveFilters] = useState<string[]>(filter);
|
|
||||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
// Apply filters to activities
|
|
||||||
const filteredActivities =
|
|
||||||
activeFilters.length > 0
|
|
||||||
? activities.filter((activity) => activeFilters.includes(activity.type))
|
|
||||||
: activities;
|
|
||||||
|
|
||||||
// Group activities by date if needed
|
|
||||||
const groupedActivities = groupByDate
|
|
||||||
? groupActivitiesByDate(filteredActivities)
|
|
||||||
: [{ date: "all", activities: filteredActivities }];
|
|
||||||
|
|
||||||
// Function to handle toggling activity expansion
|
|
||||||
const toggleExpanded = (id: string | number) => {
|
|
||||||
setExpanded((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[id.toString()]: !prev[id.toString()],
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Toggle a filter
|
|
||||||
const toggleFilter = (type: string) => {
|
|
||||||
setActiveFilters((prev) =>
|
|
||||||
prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type],
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Generate list of available types from activities
|
|
||||||
const availableTypes = Array.from(
|
|
||||||
new Set(activities.map((activity) => activity.type)),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("space-y-4", className)}>
|
|
||||||
{/* Filter controls */}
|
|
||||||
{!hideFilters && availableTypes.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
|
||||||
{availableTypes.map((type) => (
|
|
||||||
<Badge
|
|
||||||
key={type}
|
|
||||||
variant={activeFilters.includes(type) ? "default" : "outline"}
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => toggleFilter(type)}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
{getActivityIcon(type)}
|
|
||||||
<span className="capitalize">{type}</span>
|
|
||||||
</span>
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{activeFilters.length > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setActiveFilters([])}
|
|
||||||
className="h-6 px-2 text-xs"
|
|
||||||
>
|
|
||||||
Clear filters
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Activity list */}
|
|
||||||
<ScrollArea
|
|
||||||
className={cn(
|
|
||||||
"w-full",
|
|
||||||
typeof maxHeight === "number"
|
|
||||||
? `max-h-[${maxHeight}px]`
|
|
||||||
: `max-h-[${maxHeight}]`,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{groupedActivities.length === 0 || filteredActivities.length === 0 ? (
|
|
||||||
<div className="py-8 text-center text-muted-foreground">
|
|
||||||
No activities to display
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{groupedActivities.map((group) => (
|
|
||||||
<div key={group.date} className="space-y-4">
|
|
||||||
{/* Date header (if grouping by date) */}
|
|
||||||
{groupByDate && (
|
|
||||||
<div className="sticky top-0 z-10 bg-background/80 backdrop-blur-sm pb-2">
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">
|
|
||||||
{formatGroupDate(group.date)}
|
|
||||||
</h3>
|
|
||||||
<Separator className="mt-2" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Activities list */}
|
|
||||||
<Timeline>
|
|
||||||
{group.activities.map((activity) => {
|
|
||||||
const timestamp = new Date(activity.timestamp);
|
|
||||||
const isExpanded = expanded[activity.id.toString()];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TimelineItem
|
|
||||||
key={activity.id.toString()}
|
|
||||||
date={
|
|
||||||
compact
|
|
||||||
? undefined
|
|
||||||
: format(timestamp, "h:mm a").toString()
|
|
||||||
}
|
|
||||||
title={
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<Avatar className="h-6 w-6">
|
|
||||||
<AvatarImage
|
|
||||||
src={activity.user.avatar}
|
|
||||||
alt={activity.user.name}
|
|
||||||
/>
|
|
||||||
<AvatarFallback>
|
|
||||||
{activity.user.name.charAt(0)}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<span className="font-medium truncate">
|
|
||||||
{activity.user.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="text-xs text-muted-foreground font-normal">
|
|
||||||
{formatDistance(timestamp, new Date(), {
|
|
||||||
addSuffix: true,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
description={
|
|
||||||
<div className="flex flex-col space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-center h-6 w-6 rounded-full",
|
|
||||||
getActivityVariant(activity.type),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{getActivityIcon(activity.type)}
|
|
||||||
</div>
|
|
||||||
<span>{getActivityMessage(activity)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activity.entity && (
|
|
||||||
<div className="pl-8">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"text-sm p-2 rounded bg-muted/50 border border-border/50 cursor-pointer",
|
|
||||||
isExpanded ? "mb-2" : "",
|
|
||||||
)}
|
|
||||||
onClick={() => toggleExpanded(activity.id)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="font-medium">
|
|
||||||
{activity.entity.name}
|
|
||||||
</div>
|
|
||||||
<ChevronDown
|
|
||||||
className={cn(
|
|
||||||
"h-4 w-4 text-muted-foreground transition-transform",
|
|
||||||
isExpanded
|
|
||||||
? "transform rotate-180"
|
|
||||||
: "",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isExpanded && activity.meta && (
|
|
||||||
<div className="mt-2 text-xs text-muted-foreground">
|
|
||||||
{activity.meta.excerpt && (
|
|
||||||
<div className="mt-2 italic">
|
|
||||||
"{activity.meta.excerpt}"
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{activity.meta.details && (
|
|
||||||
<div className="mt-1">
|
|
||||||
{activity.meta.details}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
icon={
|
|
||||||
activity.meta?.important ? AlertCircle : undefined
|
|
||||||
}
|
|
||||||
active={activity.meta?.active}
|
|
||||||
variant={
|
|
||||||
activity.meta?.highlighted ? "highlight" : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Timeline>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Load more button */}
|
|
||||||
{hasMore && (
|
|
||||||
<div className="py-4 flex justify-center">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={onLoadMore}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="w-full max-w-xs"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<>
|
|
||||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
Loading...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Load more"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ActivityFeed;
|
|
||||||
@ -1,432 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,245 +0,0 @@
|
|||||||
import { Bookmark, Copy, MessageSquare } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
|
|
||||||
interface EnhancedLineNumberedTextProps {
|
|
||||||
content: string;
|
|
||||||
fontSizeClass?: string;
|
|
||||||
onAnnotate: (lineNumber: number) => void;
|
|
||||||
highlightedLine?: number;
|
|
||||||
workId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EnhancedLineNumberedText({
|
|
||||||
content,
|
|
||||||
fontSizeClass = "text-size-md",
|
|
||||||
onAnnotate,
|
|
||||||
highlightedLine,
|
|
||||||
workId,
|
|
||||||
}: EnhancedLineNumberedTextProps) {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [hoveredLine, setHoveredLine] = useState<number | null>(null);
|
|
||||||
const [bookmarkedLines, setBookmarkedLines] = useState<Set<number>>(
|
|
||||||
new Set(),
|
|
||||||
);
|
|
||||||
const [lineAnnotationCounts, _setLineAnnotationCounts] = useState<
|
|
||||||
Record<number, number>
|
|
||||||
>({
|
|
||||||
// Mock annotation counts - in a real app this would come from an API
|
|
||||||
2: 3,
|
|
||||||
5: 1,
|
|
||||||
8: 7,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Split content into lines
|
|
||||||
const lines = content.split("\n");
|
|
||||||
|
|
||||||
const handleLineHover = (lineNumber: number) => {
|
|
||||||
setHoveredLine(lineNumber);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLineLeave = () => {
|
|
||||||
setHoveredLine(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyLine = (_lineNumber: number, lineText: string) => {
|
|
||||||
navigator.clipboard.writeText(lineText);
|
|
||||||
toast({
|
|
||||||
description: "Line copied to clipboard",
|
|
||||||
duration: 2000,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyLineLink = (lineNumber: number) => {
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
url.hash = `line-${lineNumber}`;
|
|
||||||
navigator.clipboard.writeText(url.toString());
|
|
||||||
toast({
|
|
||||||
description: "Link to line copied to clipboard",
|
|
||||||
duration: 2000,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleBookmark = async (lineNumber: number) => {
|
|
||||||
try {
|
|
||||||
const isBookmarked = bookmarkedLines.has(lineNumber);
|
|
||||||
|
|
||||||
// Optimistically update UI
|
|
||||||
setBookmarkedLines((prev) => {
|
|
||||||
const newBookmarks = new Set(prev);
|
|
||||||
if (isBookmarked) {
|
|
||||||
newBookmarks.delete(lineNumber);
|
|
||||||
} else {
|
|
||||||
newBookmarks.add(lineNumber);
|
|
||||||
}
|
|
||||||
return newBookmarks;
|
|
||||||
});
|
|
||||||
|
|
||||||
// In a real app, this would make an API call
|
|
||||||
// await apiRequest('POST', '/api/reading-bookmarks', {
|
|
||||||
// userId: 1, // Mock user ID
|
|
||||||
// workId,
|
|
||||||
// lineNumber,
|
|
||||||
// isBookmarked: !isBookmarked
|
|
||||||
// });
|
|
||||||
|
|
||||||
toast({
|
|
||||||
description: isBookmarked ? "Bookmark removed" : "Line bookmarked",
|
|
||||||
duration: 2000,
|
|
||||||
});
|
|
||||||
} catch (_error) {
|
|
||||||
// Revert on error
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Could not update bookmark",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`reading-text ${fontSizeClass}`}>
|
|
||||||
{lines.map((line, index) => {
|
|
||||||
const lineNumber = index + 1;
|
|
||||||
const isHighlighted = lineNumber === highlightedLine;
|
|
||||||
const _isHovered = lineNumber === hoveredLine;
|
|
||||||
const isBookmarked = bookmarkedLines.has(lineNumber);
|
|
||||||
const annotationCount = lineAnnotationCounts[lineNumber] || 0;
|
|
||||||
|
|
||||||
// For blank lines, render a smaller empty line
|
|
||||||
if (!line.trim()) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`line-${lineNumber}`}
|
|
||||||
id={`line-${lineNumber}`}
|
|
||||||
className="text-line-empty h-4"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`line-${lineNumber}`}
|
|
||||||
id={`line-${lineNumber}`}
|
|
||||||
className={`text-line group ${
|
|
||||||
isHighlighted
|
|
||||||
? "bg-navy/10 dark:bg-cream/10"
|
|
||||||
: "hover:bg-navy/5 dark:hover:bg-cream/5"
|
|
||||||
} py-1.5 rounded flex relative transition-colors`}
|
|
||||||
onMouseEnter={() => handleLineHover(lineNumber)}
|
|
||||||
onMouseLeave={handleLineLeave}
|
|
||||||
>
|
|
||||||
{/* Line number indicator with bookmark feature */}
|
|
||||||
<div
|
|
||||||
className="line-number-container w-12 flex-shrink-0 flex justify-center items-center relative"
|
|
||||||
onClick={() => handleToggleBookmark(lineNumber)}
|
|
||||||
>
|
|
||||||
{isBookmarked ? (
|
|
||||||
<Bookmark
|
|
||||||
className="h-4 w-4 text-russet cursor-pointer"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="line-number text-navy/40 dark:text-cream/40 text-sm select-none">
|
|
||||||
{lineNumber}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Line content */}
|
|
||||||
<div className="line-content flex-1 relative">
|
|
||||||
<p>{line}</p>
|
|
||||||
|
|
||||||
{/* Annotation indicator - if the line has annotations */}
|
|
||||||
{annotationCount > 0 && (
|
|
||||||
<div
|
|
||||||
className="annotation-indicator absolute -right-6 top-1/2 transform -translate-y-1/2 cursor-pointer"
|
|
||||||
onClick={() => onAnnotate(lineNumber)}
|
|
||||||
>
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<MessageSquare className="h-4 w-4 text-russet" />
|
|
||||||
<span className="text-xs text-russet ml-0.5">
|
|
||||||
{annotationCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p className="text-xs">Click to view annotations</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action buttons that appear on hover */}
|
|
||||||
<div
|
|
||||||
className={`absolute right-0 top-1/2 transform -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity bg-cream/80 dark:bg-dark-surface/80 backdrop-blur-sm px-1 rounded ${isHighlighted ? "opacity-100" : ""}`}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleCopyLine(lineNumber, line);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Copy className="h-4 w-4 text-navy/70 dark:text-cream/70" />
|
|
||||||
<span className="sr-only">Copy line</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleCopyLineLink(lineNumber);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className="h-4 w-4 text-navy/70 dark:text-cream/70"
|
|
||||||
>
|
|
||||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
|
||||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
|
||||||
</svg>
|
|
||||||
<span className="sr-only">Copy link to line</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onAnnotate(lineNumber);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MessageSquare className="h-4 w-4 text-navy/70 dark:text-cream/70" />
|
|
||||||
<span className="sr-only">Annotate line</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,719 +0,0 @@
|
|||||||
import {
|
|
||||||
AlignLeft,
|
|
||||||
BookCopy,
|
|
||||||
Bookmark,
|
|
||||||
FileText,
|
|
||||||
Heart,
|
|
||||||
Menu,
|
|
||||||
MessageCircle,
|
|
||||||
Share2,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { useLocation } from "wouter";
|
|
||||||
import { AuthorChip } from "@/components/common/AuthorChip";
|
|
||||||
import { LanguageTag } from "@/components/common/LanguageTag";
|
|
||||||
import { AnnotationSystem } from "@/components/reading/AnnotationSystem";
|
|
||||||
import { EnhancedLineNumberedText } from "@/components/reading/EnhancedLineNumberedText";
|
|
||||||
import { ReadingControls } from "@/components/reading/ReadingControls";
|
|
||||||
import { TranslationSelector } from "@/components/reading/TranslationSelector";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Drawer,
|
|
||||||
DrawerClose,
|
|
||||||
DrawerContent,
|
|
||||||
DrawerDescription,
|
|
||||||
DrawerFooter,
|
|
||||||
DrawerHeader,
|
|
||||||
DrawerTitle,
|
|
||||||
} from "@/components/ui/drawer";
|
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
|
||||||
import { useReadingSettings } from "@/hooks/use-reading-settings";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
import { apiRequest } from "@/lib/queryClient";
|
|
||||||
import type { TranslationWithDetails, WorkWithDetails } from "@/lib/types";
|
|
||||||
|
|
||||||
interface EnhancedReadingViewProps {
|
|
||||||
work: WorkWithDetails;
|
|
||||||
translations: TranslationWithDetails[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EnhancedReadingView({
|
|
||||||
work,
|
|
||||||
translations,
|
|
||||||
}: EnhancedReadingViewProps) {
|
|
||||||
const { settings, increaseFontSize, decreaseFontSize, toggleZenMode } =
|
|
||||||
useReadingSettings();
|
|
||||||
const [selectedTranslationId, setSelectedTranslationId] = useState<
|
|
||||||
string | undefined
|
|
||||||
>(translations.length > 0 ? translations[0].id : undefined);
|
|
||||||
const [readingProgress, setReadingProgress] = useState(0);
|
|
||||||
const [selectedLineNumber, setSelectedLineNumber] = useState<number | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [isAnnotationOpen, setIsAnnotationOpen] = useState(false);
|
|
||||||
const [isActionPanelOpen, setIsActionPanelOpen] = useState(false);
|
|
||||||
const [isLiked, setIsLiked] = useState(false);
|
|
||||||
const [isBookmarked, setIsBookmarked] = useState(false);
|
|
||||||
|
|
||||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [, navigate] = useLocation();
|
|
||||||
const mainContentRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Get the selected translation
|
|
||||||
const selectedTranslation = translations.find(
|
|
||||||
(t) => t.id === selectedTranslationId,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle translation selection
|
|
||||||
const handleSelectTranslation = (translationId: string) => {
|
|
||||||
setSelectedTranslationId(translationId);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
const hash = window.location.hash;
|
|
||||||
const lineMatch = hash.match(/^#line-(\d+)$/);
|
|
||||||
if (lineMatch?.[1]) {
|
|
||||||
const lineNumber = parseInt(lineMatch[1], 10);
|
|
||||||
// Scroll to the line
|
|
||||||
setTimeout(() => {
|
|
||||||
const lineElement = document.getElementById(`line-${lineNumber}`);
|
|
||||||
if (lineElement) {
|
|
||||||
lineElement.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
setSelectedLineNumber(lineNumber);
|
|
||||||
setIsAnnotationOpen(true);
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Update reading progress in backend
|
|
||||||
const updateReadingProgress = useCallback(
|
|
||||||
async (progress: number) => {
|
|
||||||
try {
|
|
||||||
// In a real app, this would use the logged-in user ID
|
|
||||||
// For demo purposes, we'll use a hard-coded user ID of 1
|
|
||||||
await apiRequest("POST", "/api/reading-progress", {
|
|
||||||
userId: 1,
|
|
||||||
workId: work.id,
|
|
||||||
translationId: selectedTranslationId
|
|
||||||
? Number(selectedTranslationId)
|
|
||||||
: undefined,
|
|
||||||
progress,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to update reading progress:", error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[work.id, selectedTranslationId],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update reading progress as user scrolls
|
|
||||||
useEffect(() => {
|
|
||||||
const handleScroll = () => {
|
|
||||||
const windowHeight = window.innerHeight;
|
|
||||||
const documentHeight = document.documentElement.scrollHeight;
|
|
||||||
const scrollTop = window.scrollY;
|
|
||||||
|
|
||||||
// Calculate progress percentage
|
|
||||||
const progress = Math.min(
|
|
||||||
100,
|
|
||||||
Math.round((scrollTop / (documentHeight - windowHeight)) * 100),
|
|
||||||
);
|
|
||||||
|
|
||||||
setReadingProgress(progress);
|
|
||||||
|
|
||||||
// Update reading progress in backend (throttled to avoid too many requests)
|
|
||||||
const debounced = setTimeout(() => {
|
|
||||||
updateReadingProgress(progress);
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
return () => clearTimeout(debounced);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("scroll", handleScroll);
|
|
||||||
return () => window.removeEventListener("scroll", handleScroll);
|
|
||||||
}, [updateReadingProgress]);
|
|
||||||
|
|
||||||
// Handle line annotation
|
|
||||||
const handleLineAnnotation = (lineNumber: number) => {
|
|
||||||
setSelectedLineNumber(lineNumber);
|
|
||||||
setIsAnnotationOpen(true);
|
|
||||||
|
|
||||||
// Update the URL hash
|
|
||||||
window.history.replaceState(null, "", `#line-${lineNumber}`);
|
|
||||||
|
|
||||||
// On mobile, scroll to top of content to see the annotation
|
|
||||||
if (isMobile && mainContentRef.current) {
|
|
||||||
mainContentRef.current.scrollIntoView({ behavior: "smooth" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Close annotation panel
|
|
||||||
const handleCloseAnnotation = () => {
|
|
||||||
setIsAnnotationOpen(false);
|
|
||||||
// Remove the line number from the URL hash
|
|
||||||
window.history.replaceState(
|
|
||||||
null,
|
|
||||||
"",
|
|
||||||
window.location.pathname + window.location.search,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Toggle like for the work
|
|
||||||
const handleLikeToggle = () => {
|
|
||||||
setIsLiked(!isLiked);
|
|
||||||
toast({
|
|
||||||
description: isLiked ? "Removed from favorites" : "Added to favorites",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Toggle bookmark for the work
|
|
||||||
const handleBookmarkToggle = () => {
|
|
||||||
setIsBookmarked(!isBookmarked);
|
|
||||||
toast({
|
|
||||||
description: isBookmarked
|
|
||||||
? "Removed from your bookmarks"
|
|
||||||
: "Added to your bookmarks",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Share the work
|
|
||||||
const handleShare = async () => {
|
|
||||||
try {
|
|
||||||
if (navigator.share) {
|
|
||||||
await navigator.share({
|
|
||||||
title: work.title,
|
|
||||||
text: `Reading ${work.title} on Tercul`,
|
|
||||||
url: window.location.href,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Fallback for browsers that don't support the Web Share API
|
|
||||||
navigator.clipboard.writeText(window.location.href);
|
|
||||||
toast({
|
|
||||||
description: "Link copied to clipboard",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error sharing:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className={`enhanced-reading-view ${settings.zenMode ? "zen-mode" : ""}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`flex flex-col lg:flex-row max-w-6xl mx-auto relative pb-12 ${isAnnotationOpen && !isMobile ? "mr-96" : ""}`}
|
|
||||||
>
|
|
||||||
{/* Mobile contextual menu */}
|
|
||||||
{isMobile && (
|
|
||||||
<div className="sticky top-0 z-10 bg-cream dark:bg-dark-surface w-full border-b border-sage/20 dark:border-sage/10 flex justify-between items-center px-4 py-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setIsActionPanelOpen(true)}
|
|
||||||
className="text-navy/70 dark:text-cream/70"
|
|
||||||
>
|
|
||||||
<Menu className="h-5 w-5" />
|
|
||||||
<span className="sr-only">Menu</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<h2 className="truncate text-navy dark:text-cream font-medium text-sm">
|
|
||||||
{work.title}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={`p-2 ${
|
|
||||||
isLiked ? "text-russet" : "text-navy/70 dark:text-cream/70"
|
|
||||||
}`}
|
|
||||||
onClick={handleLikeToggle}
|
|
||||||
>
|
|
||||||
<Heart className={`h-5 w-5 ${isLiked ? "fill-russet" : ""}`} />
|
|
||||||
<span className="sr-only">{isLiked ? "Unlike" : "Like"}</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="p-2 text-navy/70 dark:text-cream/70"
|
|
||||||
onClick={handleShare}
|
|
||||||
>
|
|
||||||
<Share2 className="h-5 w-5" />
|
|
||||||
<span className="sr-only">Share</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Context sidebar (sticky on desktop, drawer on mobile) */}
|
|
||||||
{!isMobile ? (
|
|
||||||
<aside className="context-sidebar lg:w-64 p-4 lg:sticky lg:top-16 lg:self-start lg:h-[calc(100vh-4rem)] lg:overflow-y-auto">
|
|
||||||
<div className="mb-6">
|
|
||||||
<AuthorChip author={work.author} withLifeDates />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">
|
|
||||||
About this work
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div>
|
|
||||||
{work.year && (
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
|
||||||
Written in {work.year}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans capitalize">
|
|
||||||
{work.type} • {work.language} • {work.tags?.length || 0}{" "}
|
|
||||||
tags
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{work.tags?.map((tag) => (
|
|
||||||
<Badge
|
|
||||||
key={tag.id}
|
|
||||||
variant="outline"
|
|
||||||
className="inline-block px-2 py-0.5 bg-navy/10 dark:bg-navy/20 rounded text-navy/70 dark:text-cream/70 text-xs font-sans border-none"
|
|
||||||
>
|
|
||||||
{tag.name}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedTranslation && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">
|
|
||||||
Translation
|
|
||||||
</h4>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-navy/90 dark:text-cream/90 font-sans font-medium">
|
|
||||||
{selectedTranslation.language}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
|
||||||
Translated by User {selectedTranslation.translatorId}{" "}
|
|
||||||
{selectedTranslation.year &&
|
|
||||||
`(${selectedTranslation.year})`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">
|
|
||||||
Reading stats
|
|
||||||
</h4>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
|
||||||
~{Math.ceil(contentToDisplay.length / 1000)} min read
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
|
||||||
{work.likes || 0} favorites
|
|
||||||
</p>
|
|
||||||
<div className="bg-sage/10 rounded-full h-1.5 mt-2">
|
|
||||||
<div
|
|
||||||
className="bg-russet h-full rounded-full"
|
|
||||||
style={{ width: `${readingProgress}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans mt-1">
|
|
||||||
{readingProgress}% completed
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">
|
|
||||||
Actions
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className={`flex items-center gap-1 w-full justify-start py-1.5 px-3 rounded-lg ${
|
|
||||||
isLiked
|
|
||||||
? "bg-russet/10 hover:bg-russet/20 text-russet"
|
|
||||||
: "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`}
|
|
||||||
onClick={handleLikeToggle}
|
|
||||||
>
|
|
||||||
<Heart
|
|
||||||
className={`h-4 w-4 ${isLiked ? "fill-russet" : ""}`}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
{isLiked ? "Remove from favorites" : "Add to favorites"}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className={`flex items-center gap-1 w-full justify-start py-1.5 px-3 rounded-lg ${
|
|
||||||
isBookmarked
|
|
||||||
? "bg-russet/10 hover:bg-russet/20 text-russet"
|
|
||||||
: "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`}
|
|
||||||
onClick={handleBookmarkToggle}
|
|
||||||
>
|
|
||||||
<Bookmark
|
|
||||||
className={`h-4 w-4 ${isBookmarked ? "fill-russet" : ""}`}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
{isBookmarked ? "Remove bookmark" : "Bookmark for later"}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center gap-1 w-full justify-start py-1.5 px-3 rounded-lg 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"
|
|
||||||
onClick={handleShare}
|
|
||||||
>
|
|
||||||
<Share2 className="h-4 w-4" />
|
|
||||||
<span>Share</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center gap-1 w-full justify-start py-1.5 px-3 rounded-lg 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"
|
|
||||||
onClick={() => navigate(`/works/${work.slug}/comments`)}
|
|
||||||
>
|
|
||||||
<MessageCircle className="h-4 w-4" />
|
|
||||||
<span>View all comments</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center gap-1 w-full justify-start py-1.5 px-3 rounded-lg 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"
|
|
||||||
onClick={() => navigate(`/works/${work.slug}/cite`)}
|
|
||||||
>
|
|
||||||
<FileText className="h-4 w-4" />
|
|
||||||
<span>Cite this work</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center gap-1 w-full justify-start py-1.5 px-3 rounded-lg 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"
|
|
||||||
onClick={() => navigate(`/collections/add/${work.slug}`)}
|
|
||||||
>
|
|
||||||
<BookCopy className="h-4 w-4" />
|
|
||||||
<span>Add to collection</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
) : (
|
|
||||||
<Drawer open={isActionPanelOpen} onOpenChange={setIsActionPanelOpen}>
|
|
||||||
<DrawerContent className="max-h-[90%]">
|
|
||||||
<DrawerHeader>
|
|
||||||
<DrawerTitle>About this work</DrawerTitle>
|
|
||||||
<DrawerDescription>
|
|
||||||
<div className="mb-2">
|
|
||||||
<AuthorChip author={work.author} withLifeDates />
|
|
||||||
</div>
|
|
||||||
{work.year && (
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
|
||||||
Written in {work.year}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</DrawerDescription>
|
|
||||||
</DrawerHeader>
|
|
||||||
|
|
||||||
<div className="px-4">
|
|
||||||
<div className="my-4">
|
|
||||||
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">
|
|
||||||
Tags
|
|
||||||
</h4>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{work.tags?.map((tag) => (
|
|
||||||
<Badge
|
|
||||||
key={tag.id}
|
|
||||||
variant="outline"
|
|
||||||
className="inline-block px-2 py-0.5 bg-navy/10 dark:bg-navy/20 rounded text-navy/70 dark:text-cream/70 text-xs font-sans border-none"
|
|
||||||
>
|
|
||||||
{tag.name}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedTranslation && (
|
|
||||||
<div className="my-4">
|
|
||||||
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">
|
|
||||||
Translation
|
|
||||||
</h4>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-navy/90 dark:text-cream/90 font-sans font-medium">
|
|
||||||
{selectedTranslation.language}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
|
||||||
Translated by User {selectedTranslation.translatorId}{" "}
|
|
||||||
{selectedTranslation.year &&
|
|
||||||
`(${selectedTranslation.year})`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="my-4">
|
|
||||||
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">
|
|
||||||
Reading stats
|
|
||||||
</h4>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
|
||||||
~{Math.ceil(contentToDisplay.length / 1000)} min read
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
|
||||||
{work.likes || 0} favorites
|
|
||||||
</p>
|
|
||||||
<div className="bg-sage/10 rounded-full h-1.5 mt-2">
|
|
||||||
<div
|
|
||||||
className="bg-russet h-full rounded-full"
|
|
||||||
style={{ width: `${readingProgress}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans mt-1">
|
|
||||||
{readingProgress}% completed
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="my-4">
|
|
||||||
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">
|
|
||||||
Actions
|
|
||||||
</h4>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className={`flex items-center gap-1 justify-start py-1.5 px-3 rounded-lg ${
|
|
||||||
isBookmarked
|
|
||||||
? "bg-russet/10 hover:bg-russet/20 text-russet"
|
|
||||||
: "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`}
|
|
||||||
onClick={handleBookmarkToggle}
|
|
||||||
>
|
|
||||||
<Bookmark
|
|
||||||
className={`h-4 w-4 ${isBookmarked ? "fill-russet" : ""}`}
|
|
||||||
/>
|
|
||||||
<span>{isBookmarked ? "Remove" : "Bookmark"}</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center gap-1 justify-start py-1.5 px-3 rounded-lg 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"
|
|
||||||
onClick={() => navigate(`/works/${work.slug}/comments`)}
|
|
||||||
>
|
|
||||||
<MessageCircle className="h-4 w-4" />
|
|
||||||
<span>Comments</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center gap-1 justify-start py-1.5 px-3 rounded-lg 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"
|
|
||||||
onClick={() => navigate(`/works/${work.slug}/cite`)}
|
|
||||||
>
|
|
||||||
<FileText className="h-4 w-4" />
|
|
||||||
<span>Cite</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center gap-1 justify-start py-1.5 px-3 rounded-lg 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"
|
|
||||||
onClick={() => navigate(`/collections/add/${work.slug}`)}
|
|
||||||
>
|
|
||||||
<BookCopy className="h-4 w-4" />
|
|
||||||
<span>Add to collection</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DrawerFooter>
|
|
||||||
<DrawerClose asChild>
|
|
||||||
<Button variant="outline">Close</Button>
|
|
||||||
</DrawerClose>
|
|
||||||
</DrawerFooter>
|
|
||||||
</DrawerContent>
|
|
||||||
</Drawer>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Main reading area */}
|
|
||||||
<div ref={mainContentRef} className="flex-1 px-4 lg:px-8 py-4 lg:py-8">
|
|
||||||
<div className="mb-6">
|
|
||||||
{!isMobile && (
|
|
||||||
<ReadingControls
|
|
||||||
onZenModeToggle={toggleZenMode}
|
|
||||||
onIncreaseFontSize={increaseFontSize}
|
|
||||||
onDecreaseFontSize={decreaseFontSize}
|
|
||||||
zenMode={settings.zenMode}
|
|
||||||
workId={work.id}
|
|
||||||
workSlug={work.slug}
|
|
||||||
translationId={selectedTranslationId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mt-2">
|
|
||||||
<h2 className="text-xl md:text-2xl font-serif text-navy/80 dark:text-cream/80 font-medium">
|
|
||||||
{work.title}
|
|
||||||
</h2>
|
|
||||||
<LanguageTag language={work.language} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TranslationSelector
|
|
||||||
currentTranslationId={selectedTranslationId}
|
|
||||||
workSlug={work.slug}
|
|
||||||
workLanguage={work.language}
|
|
||||||
onSelectTranslation={handleSelectTranslation}
|
|
||||||
onViewOriginal={handleViewOriginal}
|
|
||||||
isOriginalSelected={isOriginalSelected}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Text content with enhanced annotation features */}
|
|
||||||
<div className="reading-container max-w-[var(--reading-width)] mx-auto">
|
|
||||||
<EnhancedLineNumberedText
|
|
||||||
content={contentToDisplay}
|
|
||||||
fontSizeClass={settings.fontSize}
|
|
||||||
onAnnotate={handleLineAnnotation}
|
|
||||||
highlightedLine={selectedLineNumber || undefined}
|
|
||||||
workId={work.id}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{selectedTranslation?.notes && (
|
|
||||||
<div className="mt-8 border-t border-sage/20 dark:border-sage/10 pt-4">
|
|
||||||
<h3 className="text-lg font-medium font-serif text-navy dark:text-cream mb-3">
|
|
||||||
Translation Notes
|
|
||||||
</h3>
|
|
||||||
<div className="text-sm text-navy/80 dark:text-cream/80">
|
|
||||||
<p>{selectedTranslation.notes}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress bar (fixed at bottom) - only visible when not in zen mode */}
|
|
||||||
{!settings.zenMode && (
|
|
||||||
<div className="progress-bar fixed bottom-0 left-0 right-0 h-1 bg-sage/20 dark:bg-sage/10">
|
|
||||||
<div
|
|
||||||
className="progress-indicator h-full bg-russet dark:bg-russet/90"
|
|
||||||
style={{ width: `${readingProgress}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Annotation panel for desktop */}
|
|
||||||
{!isMobile && isAnnotationOpen && selectedLineNumber && (
|
|
||||||
<AnnotationSystem
|
|
||||||
workId={work.id}
|
|
||||||
selectedLineNumber={selectedLineNumber}
|
|
||||||
onClose={handleCloseAnnotation}
|
|
||||||
translationId={selectedTranslationId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mobile annotation drawer */}
|
|
||||||
{isMobile && (
|
|
||||||
<Drawer
|
|
||||||
open={isAnnotationOpen && !!selectedLineNumber}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) handleCloseAnnotation();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DrawerContent className="max-h-[80%]">
|
|
||||||
<DrawerHeader className="border-b border-sage/20 dark:border-sage/10">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<MessageCircle className="h-5 w-5 text-russet" />
|
|
||||||
<DrawerTitle>
|
|
||||||
Line {selectedLineNumber} Annotations
|
|
||||||
</DrawerTitle>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DrawerHeader>
|
|
||||||
|
|
||||||
<div className="p-4 overflow-auto">
|
|
||||||
{selectedLineNumber && (
|
|
||||||
<AnnotationSystem
|
|
||||||
workId={work.id}
|
|
||||||
selectedLineNumber={selectedLineNumber}
|
|
||||||
onClose={handleCloseAnnotation}
|
|
||||||
translationId={selectedTranslationId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DrawerContent>
|
|
||||||
</Drawer>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mobile reading controls */}
|
|
||||||
{isMobile && (
|
|
||||||
<div className="fixed bottom-1 right-4 flex space-x-1">
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
className="rounded-full shadow-lg w-10 h-10 bg-russet text-white"
|
|
||||||
onClick={() => {
|
|
||||||
if (selectedLineNumber) {
|
|
||||||
setIsAnnotationOpen(true);
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
description: "Tap on a line to add annotations",
|
|
||||||
duration: 3000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MessageCircle className="h-5 w-5" />
|
|
||||||
<span className="sr-only">Annotations</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
className="rounded-full shadow-lg w-10 h-10 bg-navy dark:bg-navy/80 text-white"
|
|
||||||
onClick={toggleZenMode}
|
|
||||||
>
|
|
||||||
{settings.zenMode ? (
|
|
||||||
<AlignLeft className="h-5 w-5" />
|
|
||||||
) : (
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
)}
|
|
||||||
<span className="sr-only">
|
|
||||||
{settings.zenMode ? "Exit zen mode" : "Zen mode"}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,120 +0,0 @@
|
|||||||
import { Copy } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
|
|
||||||
interface LineNumberedTextProps {
|
|
||||||
content: string;
|
|
||||||
fontSizeClass?: string;
|
|
||||||
onLineClick?: (lineNumber: number) => void;
|
|
||||||
highlightedLine?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LineNumberedText({
|
|
||||||
content,
|
|
||||||
fontSizeClass = "text-size-md",
|
|
||||||
onLineClick,
|
|
||||||
highlightedLine,
|
|
||||||
}: LineNumberedTextProps) {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [hoveredLine, setHoveredLine] = useState<number | null>(null);
|
|
||||||
|
|
||||||
// Split content into lines
|
|
||||||
const lines = content.split("\n");
|
|
||||||
|
|
||||||
const handleLineHover = (lineNumber: number) => {
|
|
||||||
setHoveredLine(lineNumber);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLineLeave = () => {
|
|
||||||
setHoveredLine(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyLine = (_lineNumber: number, lineText: string) => {
|
|
||||||
navigator.clipboard.writeText(lineText);
|
|
||||||
toast({
|
|
||||||
description: "Line copied to clipboard",
|
|
||||||
duration: 2000,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyLineLink = (lineNumber: number) => {
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
url.hash = `line-${lineNumber}`;
|
|
||||||
navigator.clipboard.writeText(url.toString());
|
|
||||||
toast({
|
|
||||||
description: "Link to line copied to clipboard",
|
|
||||||
duration: 2000,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`reading-text ${fontSizeClass}`}>
|
|
||||||
{lines.map((line, index) => {
|
|
||||||
const lineNumber = index + 1;
|
|
||||||
const isHighlighted = lineNumber === highlightedLine;
|
|
||||||
const isHovered = lineNumber === hoveredLine;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`line-${lineNumber}`}
|
|
||||||
id={`line-${lineNumber}`}
|
|
||||||
className={`text-line group ${
|
|
||||||
isHighlighted
|
|
||||||
? "bg-navy/10 dark:bg-cream/10"
|
|
||||||
: "hover:bg-navy/5 dark:hover:bg-cream/5"
|
|
||||||
} py-1 rounded flex`}
|
|
||||||
onMouseEnter={() => handleLineHover(lineNumber)}
|
|
||||||
onMouseLeave={handleLineLeave}
|
|
||||||
onClick={() => onLineClick?.(lineNumber)}
|
|
||||||
>
|
|
||||||
<span className="line-number">{lineNumber}</span>
|
|
||||||
<p className="flex-1">{line}</p>
|
|
||||||
|
|
||||||
{/* Copy buttons that appear on hover */}
|
|
||||||
{isHovered && (
|
|
||||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity ml-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleCopyLine(lineNumber, line);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Copy line</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleCopyLineLink(lineNumber);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className="h-4 w-4"
|
|
||||||
>
|
|
||||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
|
||||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
|
||||||
</svg>
|
|
||||||
<span className="sr-only">Copy link to line</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,173 +0,0 @@
|
|||||||
import {
|
|
||||||
BookOpen,
|
|
||||||
Heart,
|
|
||||||
Maximize2,
|
|
||||||
MessageSquare,
|
|
||||||
Minimize2,
|
|
||||||
Minus,
|
|
||||||
Plus,
|
|
||||||
Share2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Link } from "wouter";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
|
|
||||||
interface ReadingControlsProps {
|
|
||||||
onZenModeToggle: () => void;
|
|
||||||
onIncreaseFontSize: () => void;
|
|
||||||
onDecreaseFontSize: () => void;
|
|
||||||
zenMode: boolean;
|
|
||||||
workId: string;
|
|
||||||
workSlug: string;
|
|
||||||
translationId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReadingControls({
|
|
||||||
onZenModeToggle,
|
|
||||||
onIncreaseFontSize,
|
|
||||||
onDecreaseFontSize,
|
|
||||||
zenMode,
|
|
||||||
workId,
|
|
||||||
workSlug,
|
|
||||||
translationId,
|
|
||||||
}: ReadingControlsProps) {
|
|
||||||
const [isLiked, setIsLiked] = useState(false);
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const handleLikeToggle = async () => {
|
|
||||||
try {
|
|
||||||
// In a real app, this would be an API call to like/unlike the work
|
|
||||||
setIsLiked(!isLiked);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
description: isLiked ? "Removed from favorites" : "Added to favorites",
|
|
||||||
duration: 2000,
|
|
||||||
});
|
|
||||||
} catch (_error) {
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Could not update favorite status",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleShare = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.share({
|
|
||||||
title: "Tercul - Literary Work",
|
|
||||||
url: window.location.href,
|
|
||||||
});
|
|
||||||
} catch (_error) {
|
|
||||||
// Fallback for browsers that don't support the Web Share API
|
|
||||||
navigator.clipboard.writeText(window.location.href);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
description: "Link copied to clipboard",
|
|
||||||
duration: 2000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h1 className="text-2xl md:text-3xl font-bold font-serif text-navy dark:text-cream">
|
|
||||||
{/* Work title would go here */}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="btn-feedback p-2 rounded-full text-navy/70 dark:text-cream/70 hover:bg-navy/10 dark:hover:bg-cream/10"
|
|
||||||
onClick={onZenModeToggle}
|
|
||||||
>
|
|
||||||
{zenMode ? (
|
|
||||||
<Minimize2 className="h-5 w-5" />
|
|
||||||
) : (
|
|
||||||
<Maximize2 className="h-5 w-5" />
|
|
||||||
)}
|
|
||||||
<span className="sr-only">
|
|
||||||
{zenMode ? "Exit zen mode" : "Zen mode"}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="btn-feedback p-2 rounded-full text-navy/70 dark:text-cream/70 hover:bg-navy/10 dark:hover:bg-cream/10"
|
|
||||||
onClick={onIncreaseFontSize}
|
|
||||||
>
|
|
||||||
<Plus className="h-5 w-5" />
|
|
||||||
<span className="sr-only">Increase font</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="btn-feedback p-2 rounded-full text-navy/70 dark:text-cream/70 hover:bg-navy/10 dark:hover:bg-cream/10"
|
|
||||||
onClick={onDecreaseFontSize}
|
|
||||||
>
|
|
||||||
<Minus className="h-5 w-5" />
|
|
||||||
<span className="sr-only">Decrease font</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="h-6 w-px bg-sage/20 dark:bg-sage/10 mx-1"></div>
|
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={`btn-feedback p-2 rounded-full ${
|
|
||||||
isLiked
|
|
||||||
? "text-russet"
|
|
||||||
: "text-navy/70 dark:text-cream/70 hover:bg-navy/10 dark:hover:bg-cream/10"
|
|
||||||
}`}
|
|
||||||
onClick={handleLikeToggle}
|
|
||||||
>
|
|
||||||
<Heart className={`h-5 w-5 ${isLiked ? "fill-russet" : ""}`} />
|
|
||||||
<span className="sr-only">
|
|
||||||
{isLiked ? "Remove from favorites" : "Add to favorites"}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="btn-feedback p-2 rounded-full text-navy/70 dark:text-cream/70 hover:bg-navy/10 dark:hover:bg-cream/10"
|
|
||||||
onClick={handleShare}
|
|
||||||
>
|
|
||||||
<Share2 className="h-5 w-5" />
|
|
||||||
<span className="sr-only">Share</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href={`/works/${workSlug}/comments${translationId ? `?translation=${translationId}` : ""}`}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="btn-feedback p-2 rounded-full text-navy/70 dark:text-cream/70 hover:bg-navy/10 dark:hover:bg-cream/10"
|
|
||||||
>
|
|
||||||
<MessageSquare className="h-5 w-5" />
|
|
||||||
<span className="sr-only">Comments</span>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href={`/works/${workSlug}/citation${translationId ? `?translation=${translationId}` : ""}`}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="btn-feedback p-2 rounded-full text-navy/70 dark:text-cream/70 hover:bg-navy/10 dark:hover:bg-cream/10"
|
|
||||||
>
|
|
||||||
<BookOpen className="h-5 w-5" />
|
|
||||||
<span className="sr-only">Cite</span>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,311 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { AuthorChip } from "@/components/common/AuthorChip";
|
|
||||||
import { LanguageTag } from "@/components/common/LanguageTag";
|
|
||||||
import { LineNumberedText } from "@/components/reading/LineNumberedText";
|
|
||||||
import { ReadingControls } from "@/components/reading/ReadingControls";
|
|
||||||
import { TranslationSelector } from "@/components/reading/TranslationSelector";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useReadingSettings } from "@/hooks/use-reading-settings";
|
|
||||||
import type { TranslationWithDetails, WorkWithDetails } from "@/lib/types";
|
|
||||||
import { useReadingProgress } from "../../hooks/use-reading-progress";
|
|
||||||
|
|
||||||
interface ReadingViewProps {
|
|
||||||
work: WorkWithDetails;
|
|
||||||
translations: TranslationWithDetails[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReadingView({ work, translations }: ReadingViewProps) {
|
|
||||||
// TODO: Replace hardcoded userId with actual logged-in user ID
|
|
||||||
const userId = 1;
|
|
||||||
const [selectedTranslationId, setSelectedTranslationId] = useState<
|
|
||||||
number | undefined
|
|
||||||
>(translations.length > 0 ? translations[0].id : undefined);
|
|
||||||
const { setProgress } = useReadingProgress({
|
|
||||||
userId,
|
|
||||||
workId: work.id,
|
|
||||||
translationId: selectedTranslationId,
|
|
||||||
});
|
|
||||||
const { settings, increaseFontSize, decreaseFontSize, toggleZenMode } =
|
|
||||||
useReadingSettings();
|
|
||||||
// Removed duplicate declaration of selectedTranslationId and setSelectedTranslationId
|
|
||||||
const [readingProgress, setReadingProgress] = useState(0);
|
|
||||||
|
|
||||||
// Get the selected translation
|
|
||||||
const selectedTranslation = translations.find(
|
|
||||||
(t) => t.id === selectedTranslationId,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Content to display - either the translation or original work
|
|
||||||
const contentToDisplay = selectedTranslation
|
|
||||||
? selectedTranslation.content
|
|
||||||
: work.content;
|
|
||||||
|
|
||||||
// Helper for safe tags access
|
|
||||||
const safeTags = work.tags ?? [];
|
|
||||||
|
|
||||||
// For TranslationSelector required props
|
|
||||||
const workLanguage = work.language;
|
|
||||||
const isOriginalSelected = selectedTranslationId === undefined;
|
|
||||||
const handleViewOriginal = () => setSelectedTranslationId(undefined);
|
|
||||||
|
|
||||||
// Update reading progress as user scrolls
|
|
||||||
useEffect(() => {
|
|
||||||
let debounceTimeout: NodeJS.Timeout;
|
|
||||||
const handleScroll = () => {
|
|
||||||
const windowHeight = window.innerHeight;
|
|
||||||
const documentHeight = document.documentElement.scrollHeight;
|
|
||||||
const scrollTop = window.scrollY;
|
|
||||||
|
|
||||||
// Calculate progress percentage
|
|
||||||
const progress = Math.min(
|
|
||||||
100,
|
|
||||||
Math.round((scrollTop / (documentHeight - windowHeight)) * 100),
|
|
||||||
);
|
|
||||||
|
|
||||||
setReadingProgress(progress);
|
|
||||||
|
|
||||||
// Update reading progress in backend (throttled to avoid too many requests)
|
|
||||||
clearTimeout(debounceTimeout);
|
|
||||||
debounceTimeout = setTimeout(() => {
|
|
||||||
setProgress(progress);
|
|
||||||
}, 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("scroll", handleScroll);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("scroll", handleScroll);
|
|
||||||
clearTimeout(debounceTimeout);
|
|
||||||
};
|
|
||||||
}, [setProgress]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className={`reading-view ${settings.zenMode ? "zen-mode" : ""}`}>
|
|
||||||
<div className="flex flex-col lg:flex-row max-w-[var(--content-width)] mx-auto">
|
|
||||||
{/* Context sidebar (sticky on desktop) */}
|
|
||||||
<aside className="context-sidebar lg:w-64 p-4 lg:sticky lg:top-16 lg:self-start lg:h-[calc(100vh-4rem)] lg:overflow-y-auto">
|
|
||||||
<div className="mb-6">
|
|
||||||
<AuthorChip author={work.author} withLifeDates />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">
|
|
||||||
About this work
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div>
|
|
||||||
{work.year && (
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
|
||||||
Written in {work.year}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans capitalize">
|
|
||||||
{work.type} • {safeTags.map((tag) => tag.name).join(" • ")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{safeTags.map((tag) => (
|
|
||||||
<Badge
|
|
||||||
key={tag.id}
|
|
||||||
variant="outline"
|
|
||||||
className="inline-block px-2 py-0.5 bg-navy/10 dark:bg-navy/20 rounded text-navy/70 dark:text-cream/70 text-xs font-sans border-none"
|
|
||||||
>
|
|
||||||
{tag.name}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedTranslation && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">
|
|
||||||
Translation
|
|
||||||
</h4>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-navy/90 dark:text-cream/90 font-sans font-medium">
|
|
||||||
{selectedTranslation.language}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
|
||||||
Translated by User {selectedTranslation.translatorId}{" "}
|
|
||||||
{selectedTranslation.year && `(${selectedTranslation.year})`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">
|
|
||||||
Reading stats
|
|
||||||
</h4>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
|
||||||
~{Math.ceil(contentToDisplay.length / 1000)} min read
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
|
||||||
{work.likes || 0} favorites
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">
|
|
||||||
Actions
|
|
||||||
</h4>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="btn-feedback flex items-center gap-1 py-1.5 px-3 rounded-lg bg-russet/10 hover:bg-russet/20 dark:bg-russet/20 dark:hover:bg-russet/30 text-russet dark:text-russet/90 font-sans text-xs transition-colors"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<title>Favorite</title>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="2"
|
|
||||||
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Favorite</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="btn-feedback flex items-center gap-1 py-1.5 px-3 rounded-lg 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"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<title>Share</title>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="2"
|
|
||||||
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Share</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="btn-feedback flex items-center gap-1 py-1.5 px-3 rounded-lg 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"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<title>Comment</title>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="2"
|
|
||||||
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Comment</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="btn-feedback flex items-center gap-1 py-1.5 px-3 rounded-lg 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"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<title>Cite</title>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="2"
|
|
||||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Cite</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Main reading area */}
|
|
||||||
<div className="flex-1 px-4 lg:px-8 py-6 lg:py-8">
|
|
||||||
<div className="mb-6">
|
|
||||||
<ReadingControls
|
|
||||||
onZenModeToggle={toggleZenMode}
|
|
||||||
onIncreaseFontSize={increaseFontSize}
|
|
||||||
onDecreaseFontSize={decreaseFontSize}
|
|
||||||
zenMode={settings.zenMode}
|
|
||||||
workId={work.id}
|
|
||||||
workSlug={work.slug}
|
|
||||||
translationId={selectedTranslationId}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mt-2">
|
|
||||||
<h2 className="text-xl font-serif text-navy/80 dark:text-cream/80">
|
|
||||||
{work.title}
|
|
||||||
</h2>
|
|
||||||
<LanguageTag language={work.language} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{translations.length > 0 && (
|
|
||||||
<TranslationSelector
|
|
||||||
translations={translations}
|
|
||||||
currentTranslationId={selectedTranslationId}
|
|
||||||
workSlug={work.slug}
|
|
||||||
onSelectTranslation={setSelectedTranslationId}
|
|
||||||
workLanguage={workLanguage}
|
|
||||||
onViewOriginal={handleViewOriginal}
|
|
||||||
isOriginalSelected={isOriginalSelected}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Text content */}
|
|
||||||
<div className="reading-container max-w-[var(--reading-width)] mx-auto">
|
|
||||||
<LineNumberedText
|
|
||||||
content={contentToDisplay}
|
|
||||||
fontSizeClass={settings.fontSize}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{selectedTranslation?.notes && (
|
|
||||||
<div className="mt-8 border-t border-sage/20 dark:border-sage/10 pt-4">
|
|
||||||
<h3 className="text-lg font-medium font-serif text-navy dark:text-cream mb-3">
|
|
||||||
Translation Notes
|
|
||||||
</h3>
|
|
||||||
<div className="text-sm text-navy/80 dark:text-cream/80">
|
|
||||||
<p>{selectedTranslation.notes}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress bar (fixed at bottom) */}
|
|
||||||
<div className="progress-bar fixed bottom-0 left-0 right-0 h-1 bg-sage/20 dark:bg-sage/10">
|
|
||||||
<div
|
|
||||||
className="progress-indicator h-full bg-russet dark:bg-russet/90"
|
|
||||||
style={{ width: `${readingProgress}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,334 +0,0 @@
|
|||||||
import {
|
|
||||||
BookMarked,
|
|
||||||
BookOpen,
|
|
||||||
ChevronDown,
|
|
||||||
Globe,
|
|
||||||
Languages,
|
|
||||||
Loader2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Link } from "wouter";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectLabel,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { useLanguages } from "@/hooks/use-language-api";
|
|
||||||
import { useWorkTranslations } from "@/hooks/use-work-api";
|
|
||||||
import type { TranslationWithDetails } from "@/lib/types";
|
|
||||||
|
|
||||||
interface TranslationSelectorProps {
|
|
||||||
workSlug: string;
|
|
||||||
workLanguage: string;
|
|
||||||
currentTranslationId?: string;
|
|
||||||
onSelectTranslation: (translationId: string) => void;
|
|
||||||
onViewOriginal: () => void;
|
|
||||||
isOriginalSelected: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TranslationSelector({
|
|
||||||
workSlug,
|
|
||||||
workLanguage,
|
|
||||||
currentTranslationId,
|
|
||||||
onSelectTranslation,
|
|
||||||
onViewOriginal,
|
|
||||||
isOriginalSelected,
|
|
||||||
}: TranslationSelectorProps) {
|
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
|
||||||
|
|
||||||
// Use API hooks to fetch data
|
|
||||||
const {
|
|
||||||
data: translations = [],
|
|
||||||
isLoading: translationsLoading,
|
|
||||||
error: translationsError,
|
|
||||||
} = useWorkTranslations(workSlug);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: languages = [],
|
|
||||||
isLoading: languagesLoading,
|
|
||||||
error: languagesError,
|
|
||||||
} = useLanguages();
|
|
||||||
|
|
||||||
// Helper function to get display name from API data
|
|
||||||
const getLanguageDisplayName = (code: string): string => {
|
|
||||||
const language = languages.find(
|
|
||||||
(lang) => lang.code.toLowerCase() === code.toLowerCase(),
|
|
||||||
);
|
|
||||||
return language ? `${language.name} (${language.nativeName})` : code;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show loading state
|
|
||||||
if (translationsLoading || languagesLoading) {
|
|
||||||
return (
|
|
||||||
<div className="mt-4 flex items-center gap-2">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
<span className="text-sm text-navy/60 dark:text-cream/60">
|
|
||||||
Loading translations...
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show error state
|
|
||||||
if (translationsError || languagesError) {
|
|
||||||
return (
|
|
||||||
<div className="mt-4 text-sm text-red-600 dark:text-red-400">
|
|
||||||
Failed to load translations
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group translations by language
|
|
||||||
const translationsByLanguage = translations.reduce<
|
|
||||||
Record<string, TranslationWithDetails[]>
|
|
||||||
>((acc, translation) => {
|
|
||||||
if (!acc[translation.language]) {
|
|
||||||
acc[translation.language] = [];
|
|
||||||
}
|
|
||||||
acc[translation.language].push(translation);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
// Count languages for display
|
|
||||||
const languageCount = Object.keys(translationsByLanguage).length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (value) {
|
|
||||||
onSelectTranslation(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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}
|
|
||||||
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"}
|
|
||||||
size="sm"
|
|
||||||
className={`py-0.5 px-2 h-7 rounded-full text-xs ${
|
|
||||||
isLanguageSelected
|
|
||||||
? "bg-russet hover:bg-russet/90 text-white"
|
|
||||||
: "border-navy/20 dark:border-cream/20"
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
// Select the first translation for this language
|
|
||||||
if (languageTranslations.length > 0) {
|
|
||||||
onSelectTranslation(languageTranslations[0].id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getLanguageDisplayName(language)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{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)}
|
|
||||||
>
|
|
||||||
+{Object.keys(translationsByLanguage).length - 5} more
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -23,7 +23,7 @@ import { cn } from "@/lib/utils";
|
|||||||
interface ComparisonSliderContextValue {
|
interface ComparisonSliderContextValue {
|
||||||
position: number;
|
position: number;
|
||||||
setPosition: React.Dispatch<React.SetStateAction<number>>;
|
setPosition: React.Dispatch<React.SetStateAction<number>>;
|
||||||
containerRef: React.RefObject<HTMLDivElement>;
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ComparisonSliderContext =
|
const ComparisonSliderContext =
|
||||||
|
|||||||
@ -103,8 +103,6 @@ export interface DataTableProps<TData, TValue> {
|
|||||||
export function DataTable<TData, TValue>({
|
export function DataTable<TData, TValue>({
|
||||||
columns,
|
columns,
|
||||||
data,
|
data,
|
||||||
searchKey,
|
|
||||||
totalCount,
|
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
emptyState,
|
emptyState,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
|
|||||||
@ -410,7 +410,7 @@ const FileUploader = forwardRef<HTMLDivElement, FileUploaderProps>(
|
|||||||
{instructionText}
|
{instructionText}
|
||||||
{accept && (
|
{accept && (
|
||||||
<span className="block">
|
<span className="block">
|
||||||
{accept.replaceAll(",", ", ")}
|
{accept.replace(/,/g, ", ")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{maxSize && (
|
{maxSize && (
|
||||||
|
|||||||
@ -179,9 +179,6 @@ export const TagInput = forwardRef<HTMLInputElement, TagInputProps>(
|
|||||||
const [filteredSuggestions, setFilteredSuggestions] = useState(suggestions);
|
const [filteredSuggestions, setFilteredSuggestions] = useState(suggestions);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Create an internal ref that doesn't get forwarded
|
|
||||||
const _internalRef = useRef<HTMLInputElement | null>(null);
|
|
||||||
|
|
||||||
// Focus the input when clicking the container
|
// Focus the input when clicking the container
|
||||||
const focusInput = () => {
|
const focusInput = () => {
|
||||||
if (!disabled && !readOnly && inputRef.current) {
|
if (!disabled && !readOnly && inputRef.current) {
|
||||||
|
|||||||
@ -1,245 +0,0 @@
|
|||||||
import { Bookmark, Copy, MessageSquare } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
|
|
||||||
interface EnhancedLineNumberedTextProps {
|
|
||||||
content: string;
|
|
||||||
fontSizeClass?: string;
|
|
||||||
onAnnotate: (lineNumber: number) => void;
|
|
||||||
highlightedLine?: number;
|
|
||||||
workId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EnhancedLineNumberedText({
|
|
||||||
content,
|
|
||||||
fontSizeClass = "text-size-md",
|
|
||||||
onAnnotate,
|
|
||||||
highlightedLine,
|
|
||||||
workId,
|
|
||||||
}: EnhancedLineNumberedTextProps) {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [hoveredLine, setHoveredLine] = useState<number | null>(null);
|
|
||||||
const [bookmarkedLines, setBookmarkedLines] = useState<Set<number>>(
|
|
||||||
new Set(),
|
|
||||||
);
|
|
||||||
const [lineAnnotationCounts, _setLineAnnotationCounts] = useState<
|
|
||||||
Record<number, number>
|
|
||||||
>({
|
|
||||||
// Mock annotation counts - in a real app this would come from an API
|
|
||||||
2: 3,
|
|
||||||
5: 1,
|
|
||||||
8: 7,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Split content into lines
|
|
||||||
const lines = content.split("\n");
|
|
||||||
|
|
||||||
const handleLineHover = (lineNumber: number) => {
|
|
||||||
setHoveredLine(lineNumber);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLineLeave = () => {
|
|
||||||
setHoveredLine(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyLine = (_lineNumber: number, lineText: string) => {
|
|
||||||
navigator.clipboard.writeText(lineText);
|
|
||||||
toast({
|
|
||||||
description: "Line copied to clipboard",
|
|
||||||
duration: 2000,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyLineLink = (lineNumber: number) => {
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
url.hash = `line-${lineNumber}`;
|
|
||||||
navigator.clipboard.writeText(url.toString());
|
|
||||||
toast({
|
|
||||||
description: "Link to line copied to clipboard",
|
|
||||||
duration: 2000,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleBookmark = async (lineNumber: number) => {
|
|
||||||
try {
|
|
||||||
const isBookmarked = bookmarkedLines.has(lineNumber);
|
|
||||||
|
|
||||||
// Optimistically update UI
|
|
||||||
setBookmarkedLines((prev) => {
|
|
||||||
const newBookmarks = new Set(prev);
|
|
||||||
if (isBookmarked) {
|
|
||||||
newBookmarks.delete(lineNumber);
|
|
||||||
} else {
|
|
||||||
newBookmarks.add(lineNumber);
|
|
||||||
}
|
|
||||||
return newBookmarks;
|
|
||||||
});
|
|
||||||
|
|
||||||
// In a real app, this would make an API call
|
|
||||||
// await apiRequest('POST', '/api/reading-bookmarks', {
|
|
||||||
// userId: 1, // Mock user ID
|
|
||||||
// workId,
|
|
||||||
// lineNumber,
|
|
||||||
// isBookmarked: !isBookmarked
|
|
||||||
// });
|
|
||||||
|
|
||||||
toast({
|
|
||||||
description: isBookmarked ? "Bookmark removed" : "Line bookmarked",
|
|
||||||
duration: 2000,
|
|
||||||
});
|
|
||||||
} catch (_error) {
|
|
||||||
// Revert on error
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Could not update bookmark",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`reading-text ${fontSizeClass}`}>
|
|
||||||
{lines.map((line, index) => {
|
|
||||||
const lineNumber = index + 1;
|
|
||||||
const isHighlighted = lineNumber === highlightedLine;
|
|
||||||
const _isHovered = lineNumber === hoveredLine;
|
|
||||||
const isBookmarked = bookmarkedLines.has(lineNumber);
|
|
||||||
const annotationCount = lineAnnotationCounts[lineNumber] || 0;
|
|
||||||
|
|
||||||
// For blank lines, render a smaller empty line
|
|
||||||
if (!line.trim()) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`line-${lineNumber}`}
|
|
||||||
id={`line-${lineNumber}`}
|
|
||||||
className="text-line-empty h-4"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`line-${lineNumber}`}
|
|
||||||
id={`line-${lineNumber}`}
|
|
||||||
className={`text-line group ${
|
|
||||||
isHighlighted
|
|
||||||
? "bg-navy/10 dark:bg-cream/10"
|
|
||||||
: "hover:bg-navy/5 dark:hover:bg-cream/5"
|
|
||||||
} py-1.5 rounded flex relative transition-colors`}
|
|
||||||
onMouseEnter={() => handleLineHover(lineNumber)}
|
|
||||||
onMouseLeave={handleLineLeave}
|
|
||||||
>
|
|
||||||
{/* Line number indicator with bookmark feature */}
|
|
||||||
<div
|
|
||||||
className="line-number-container w-12 flex-shrink-0 flex justify-center items-center relative"
|
|
||||||
onClick={() => handleToggleBookmark(lineNumber)}
|
|
||||||
>
|
|
||||||
{isBookmarked ? (
|
|
||||||
<Bookmark
|
|
||||||
className="h-4 w-4 text-russet cursor-pointer"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="line-number text-navy/40 dark:text-cream/40 text-sm select-none">
|
|
||||||
{lineNumber}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Line content */}
|
|
||||||
<div className="line-content flex-1 relative">
|
|
||||||
<p>{line}</p>
|
|
||||||
|
|
||||||
{/* Annotation indicator - if the line has annotations */}
|
|
||||||
{annotationCount > 0 && (
|
|
||||||
<div
|
|
||||||
className="annotation-indicator absolute -right-6 top-1/2 transform -translate-y-1/2 cursor-pointer"
|
|
||||||
onClick={() => onAnnotate(lineNumber)}
|
|
||||||
>
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<MessageSquare className="h-4 w-4 text-russet" />
|
|
||||||
<span className="text-xs text-russet ml-0.5">
|
|
||||||
{annotationCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p className="text-xs">Click to view annotations</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action buttons that appear on hover */}
|
|
||||||
<div
|
|
||||||
className={`absolute right-0 top-1/2 transform -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity bg-cream/80 dark:bg-dark-surface/80 backdrop-blur-sm px-1 rounded ${isHighlighted ? "opacity-100" : ""}`}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleCopyLine(lineNumber, line);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Copy className="h-4 w-4 text-navy/70 dark:text-cream/70" />
|
|
||||||
<span className="sr-only">Copy line</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleCopyLineLink(lineNumber);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className="h-4 w-4 text-navy/70 dark:text-cream/70"
|
|
||||||
>
|
|
||||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
|
||||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
|
||||||
</svg>
|
|
||||||
<span className="sr-only">Copy link to line</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onAnnotate(lineNumber);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MessageSquare className="h-4 w-4 text-navy/70 dark:text-cream/70" />
|
|
||||||
<span className="sr-only">Annotate line</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,719 +0,0 @@
|
|||||||
import {
|
|
||||||
AlignLeft,
|
|
||||||
BookCopy,
|
|
||||||
Bookmark,
|
|
||||||
FileText,
|
|
||||||
Heart,
|
|
||||||
Menu,
|
|
||||||
MessageCircle,
|
|
||||||
Share2,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { useLocation } from "wouter";
|
|
||||||
import { AuthorChip } from "@/components/common/AuthorChip";
|
|
||||||
import { LanguageTag } from "@/components/common/LanguageTag";
|
|
||||||
import { AnnotationSystem } from "@/components/reading/AnnotationSystem";
|
|
||||||
import { EnhancedLineNumberedText } from "@/components/reading/EnhancedLineNumberedText";
|
|
||||||
import { ReadingControls } from "@/components/reading/ReadingControls";
|
|
||||||
import { TranslationSelector } from "@/components/reading/TranslationSelector";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Drawer,
|
|
||||||
DrawerClose,
|
|
||||||
DrawerContent,
|
|
||||||
DrawerDescription,
|
|
||||||
DrawerFooter,
|
|
||||||
DrawerHeader,
|
|
||||||
DrawerTitle,
|
|
||||||
} from "@/components/ui/drawer";
|
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
|
||||||
import { useReadingSettings } from "@/hooks/use-reading-settings";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
import { apiRequest } from "@/lib/queryClient";
|
|
||||||
import type { TranslationWithDetails, WorkWithDetails } from "@/lib/types";
|
|
||||||
|
|
||||||
interface EnhancedReadingViewProps {
|
|
||||||
work: WorkWithDetails;
|
|
||||||
translations: TranslationWithDetails[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EnhancedReadingView({
|
|
||||||
work,
|
|
||||||
translations,
|
|
||||||
}: EnhancedReadingViewProps) {
|
|
||||||
const { settings, increaseFontSize, decreaseFontSize, toggleZenMode } =
|
|
||||||
useReadingSettings();
|
|
||||||
const [selectedTranslationId, setSelectedTranslationId] = useState<
|
|
||||||
string | undefined
|
|
||||||
>(translations.length > 0 ? translations[0].id : undefined);
|
|
||||||
const [readingProgress, setReadingProgress] = useState(0);
|
|
||||||
const [selectedLineNumber, setSelectedLineNumber] = useState<number | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [isAnnotationOpen, setIsAnnotationOpen] = useState(false);
|
|
||||||
const [isActionPanelOpen, setIsActionPanelOpen] = useState(false);
|
|
||||||
const [isLiked, setIsLiked] = useState(false);
|
|
||||||
const [isBookmarked, setIsBookmarked] = useState(false);
|
|
||||||
|
|
||||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [, navigate] = useLocation();
|
|
||||||
const mainContentRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Get the selected translation
|
|
||||||
const selectedTranslation = translations.find(
|
|
||||||
(t) => t.id === selectedTranslationId,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle translation selection
|
|
||||||
const handleSelectTranslation = (translationId: string) => {
|
|
||||||
setSelectedTranslationId(translationId);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
const hash = window.location.hash;
|
|
||||||
const lineMatch = hash.match(/^#line-(\d+)$/);
|
|
||||||
if (lineMatch?.[1]) {
|
|
||||||
const lineNumber = parseInt(lineMatch[1], 10);
|
|
||||||
// Scroll to the line
|
|
||||||
setTimeout(() => {
|
|
||||||
const lineElement = document.getElementById(`line-${lineNumber}`);
|
|
||||||
if (lineElement) {
|
|
||||||
lineElement.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
setSelectedLineNumber(lineNumber);
|
|
||||||
setIsAnnotationOpen(true);
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Update reading progress in backend
|
|
||||||
const updateReadingProgress = useCallback(
|
|
||||||
async (progress: number) => {
|
|
||||||
try {
|
|
||||||
// In a real app, this would use the logged-in user ID
|
|
||||||
// For demo purposes, we'll use a hard-coded user ID of 1
|
|
||||||
await apiRequest("POST", "/api/reading-progress", {
|
|
||||||
userId: 1,
|
|
||||||
workId: work.id,
|
|
||||||
translationId: selectedTranslationId
|
|
||||||
? Number(selectedTranslationId)
|
|
||||||
: undefined,
|
|
||||||
progress,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to update reading progress:", error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[work.id, selectedTranslationId],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update reading progress as user scrolls
|
|
||||||
useEffect(() => {
|
|
||||||
const handleScroll = () => {
|
|
||||||
const windowHeight = window.innerHeight;
|
|
||||||
const documentHeight = document.documentElement.scrollHeight;
|
|
||||||
const scrollTop = window.scrollY;
|
|
||||||
|
|
||||||
// Calculate progress percentage
|
|
||||||
const progress = Math.min(
|
|
||||||
100,
|
|
||||||
Math.round((scrollTop / (documentHeight - windowHeight)) * 100),
|
|
||||||
);
|
|
||||||
|
|
||||||
setReadingProgress(progress);
|
|
||||||
|
|
||||||
// Update reading progress in backend (throttled to avoid too many requests)
|
|
||||||
const debounced = setTimeout(() => {
|
|
||||||
updateReadingProgress(progress);
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
return () => clearTimeout(debounced);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("scroll", handleScroll);
|
|
||||||
return () => window.removeEventListener("scroll", handleScroll);
|
|
||||||
}, [updateReadingProgress]);
|
|
||||||
|
|
||||||
// Handle line annotation
|
|
||||||
const handleLineAnnotation = (lineNumber: number) => {
|
|
||||||
setSelectedLineNumber(lineNumber);
|
|
||||||
setIsAnnotationOpen(true);
|
|
||||||
|
|
||||||
// Update the URL hash
|
|
||||||
window.history.replaceState(null, "", `#line-${lineNumber}`);
|
|
||||||
|
|
||||||
// On mobile, scroll to top of content to see the annotation
|
|
||||||
if (isMobile && mainContentRef.current) {
|
|
||||||
mainContentRef.current.scrollIntoView({ behavior: "smooth" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Close annotation panel
|
|
||||||
const handleCloseAnnotation = () => {
|
|
||||||
setIsAnnotationOpen(false);
|
|
||||||
// Remove the line number from the URL hash
|
|
||||||
window.history.replaceState(
|
|
||||||
null,
|
|
||||||
"",
|
|
||||||
window.location.pathname + window.location.search,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Toggle like for the work
|
|
||||||
const handleLikeToggle = () => {
|
|
||||||
setIsLiked(!isLiked);
|
|
||||||
toast({
|
|
||||||
description: isLiked ? "Removed from favorites" : "Added to favorites",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Toggle bookmark for the work
|
|
||||||
const handleBookmarkToggle = () => {
|
|
||||||
setIsBookmarked(!isBookmarked);
|
|
||||||
toast({
|
|
||||||
description: isBookmarked
|
|
||||||
? "Removed from your bookmarks"
|
|
||||||
: "Added to your bookmarks",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Share the work
|
|
||||||
const handleShare = async () => {
|
|
||||||
try {
|
|
||||||
if (navigator.share) {
|
|
||||||
await navigator.share({
|
|
||||||
title: work.title,
|
|
||||||
text: `Reading ${work.title} on Tercul`,
|
|
||||||
url: window.location.href,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Fallback for browsers that don't support the Web Share API
|
|
||||||
navigator.clipboard.writeText(window.location.href);
|
|
||||||
toast({
|
|
||||||
description: "Link copied to clipboard",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error sharing:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className={`enhanced-reading-view ${settings.zenMode ? "zen-mode" : ""}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`flex flex-col lg:flex-row max-w-6xl mx-auto relative pb-12 ${isAnnotationOpen && !isMobile ? "mr-96" : ""}`}
|
|
||||||
>
|
|
||||||
{/* Mobile contextual menu */}
|
|
||||||
{isMobile && (
|
|
||||||
<div className="sticky top-0 z-10 bg-cream dark:bg-dark-surface w-full border-b border-sage/20 dark:border-sage/10 flex justify-between items-center px-4 py-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setIsActionPanelOpen(true)}
|
|
||||||
className="text-navy/70 dark:text-cream/70"
|
|
||||||
>
|
|
||||||
<Menu className="h-5 w-5" />
|
|
||||||
<span className="sr-only">Menu</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<h2 className="truncate text-navy dark:text-cream font-medium text-sm">
|
|
||||||
{work.title}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={`p-2 ${
|
|
||||||
isLiked ? "text-russet" : "text-navy/70 dark:text-cream/70"
|
|
||||||
}`}
|
|
||||||
onClick={handleLikeToggle}
|
|
||||||
>
|
|
||||||
<Heart className={`h-5 w-5 ${isLiked ? "fill-russet" : ""}`} />
|
|
||||||
<span className="sr-only">{isLiked ? "Unlike" : "Like"}</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="p-2 text-navy/70 dark:text-cream/70"
|
|
||||||
onClick={handleShare}
|
|
||||||
>
|
|
||||||
<Share2 className="h-5 w-5" />
|
|
||||||
<span className="sr-only">Share</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Context sidebar (sticky on desktop, drawer on mobile) */}
|
|
||||||
{!isMobile ? (
|
|
||||||
<aside className="context-sidebar lg:w-64 p-4 lg:sticky lg:top-16 lg:self-start lg:h-[calc(100vh-4rem)] lg:overflow-y-auto">
|
|
||||||
<div className="mb-6">
|
|
||||||
<AuthorChip author={work.author} withLifeDates />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">
|
|
||||||
About this work
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div>
|
|
||||||
{work.year && (
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
|
||||||
Written in {work.year}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans capitalize">
|
|
||||||
{work.type} • {work.language} • {work.tags?.length || 0}{" "}
|
|
||||||
tags
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{work.tags?.map((tag) => (
|
|
||||||
<Badge
|
|
||||||
key={tag.id}
|
|
||||||
variant="outline"
|
|
||||||
className="inline-block px-2 py-0.5 bg-navy/10 dark:bg-navy/20 rounded text-navy/70 dark:text-cream/70 text-xs font-sans border-none"
|
|
||||||
>
|
|
||||||
{tag.name}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedTranslation && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">
|
|
||||||
Translation
|
|
||||||
</h4>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-navy/90 dark:text-cream/90 font-sans font-medium">
|
|
||||||
{selectedTranslation.language}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
|
||||||
Translated by User {selectedTranslation.translatorId}{" "}
|
|
||||||
{selectedTranslation.year &&
|
|
||||||
`(${selectedTranslation.year})`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">
|
|
||||||
Reading stats
|
|
||||||
</h4>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
|
||||||
~{Math.ceil(contentToDisplay.length / 1000)} min read
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
|
||||||
{work.likes || 0} favorites
|
|
||||||
</p>
|
|
||||||
<div className="bg-sage/10 rounded-full h-1.5 mt-2">
|
|
||||||
<div
|
|
||||||
className="bg-russet h-full rounded-full"
|
|
||||||
style={{ width: `${readingProgress}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans mt-1">
|
|
||||||
{readingProgress}% completed
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">
|
|
||||||
Actions
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className={`flex items-center gap-1 w-full justify-start py-1.5 px-3 rounded-lg ${
|
|
||||||
isLiked
|
|
||||||
? "bg-russet/10 hover:bg-russet/20 text-russet"
|
|
||||||
: "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`}
|
|
||||||
onClick={handleLikeToggle}
|
|
||||||
>
|
|
||||||
<Heart
|
|
||||||
className={`h-4 w-4 ${isLiked ? "fill-russet" : ""}`}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
{isLiked ? "Remove from favorites" : "Add to favorites"}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className={`flex items-center gap-1 w-full justify-start py-1.5 px-3 rounded-lg ${
|
|
||||||
isBookmarked
|
|
||||||
? "bg-russet/10 hover:bg-russet/20 text-russet"
|
|
||||||
: "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`}
|
|
||||||
onClick={handleBookmarkToggle}
|
|
||||||
>
|
|
||||||
<Bookmark
|
|
||||||
className={`h-4 w-4 ${isBookmarked ? "fill-russet" : ""}`}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
{isBookmarked ? "Remove bookmark" : "Bookmark for later"}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center gap-1 w-full justify-start py-1.5 px-3 rounded-lg 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"
|
|
||||||
onClick={handleShare}
|
|
||||||
>
|
|
||||||
<Share2 className="h-4 w-4" />
|
|
||||||
<span>Share</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center gap-1 w-full justify-start py-1.5 px-3 rounded-lg 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"
|
|
||||||
onClick={() => navigate(`/works/${work.slug}/comments`)}
|
|
||||||
>
|
|
||||||
<MessageCircle className="h-4 w-4" />
|
|
||||||
<span>View all comments</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center gap-1 w-full justify-start py-1.5 px-3 rounded-lg 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"
|
|
||||||
onClick={() => navigate(`/works/${work.slug}/cite`)}
|
|
||||||
>
|
|
||||||
<FileText className="h-4 w-4" />
|
|
||||||
<span>Cite this work</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center gap-1 w-full justify-start py-1.5 px-3 rounded-lg 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"
|
|
||||||
onClick={() => navigate(`/collections/add/${work.slug}`)}
|
|
||||||
>
|
|
||||||
<BookCopy className="h-4 w-4" />
|
|
||||||
<span>Add to collection</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
) : (
|
|
||||||
<Drawer open={isActionPanelOpen} onOpenChange={setIsActionPanelOpen}>
|
|
||||||
<DrawerContent className="max-h-[90%]">
|
|
||||||
<DrawerHeader>
|
|
||||||
<DrawerTitle>About this work</DrawerTitle>
|
|
||||||
<DrawerDescription>
|
|
||||||
<div className="mb-2">
|
|
||||||
<AuthorChip author={work.author} withLifeDates />
|
|
||||||
</div>
|
|
||||||
{work.year && (
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
|
||||||
Written in {work.year}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</DrawerDescription>
|
|
||||||
</DrawerHeader>
|
|
||||||
|
|
||||||
<div className="px-4">
|
|
||||||
<div className="my-4">
|
|
||||||
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">
|
|
||||||
Tags
|
|
||||||
</h4>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{work.tags?.map((tag) => (
|
|
||||||
<Badge
|
|
||||||
key={tag.id}
|
|
||||||
variant="outline"
|
|
||||||
className="inline-block px-2 py-0.5 bg-navy/10 dark:bg-navy/20 rounded text-navy/70 dark:text-cream/70 text-xs font-sans border-none"
|
|
||||||
>
|
|
||||||
{tag.name}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedTranslation && (
|
|
||||||
<div className="my-4">
|
|
||||||
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">
|
|
||||||
Translation
|
|
||||||
</h4>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-navy/90 dark:text-cream/90 font-sans font-medium">
|
|
||||||
{selectedTranslation.language}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
|
||||||
Translated by User {selectedTranslation.translatorId}{" "}
|
|
||||||
{selectedTranslation.year &&
|
|
||||||
`(${selectedTranslation.year})`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="my-4">
|
|
||||||
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">
|
|
||||||
Reading stats
|
|
||||||
</h4>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
|
||||||
~{Math.ceil(contentToDisplay.length / 1000)} min read
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
|
||||||
{work.likes || 0} favorites
|
|
||||||
</p>
|
|
||||||
<div className="bg-sage/10 rounded-full h-1.5 mt-2">
|
|
||||||
<div
|
|
||||||
className="bg-russet h-full rounded-full"
|
|
||||||
style={{ width: `${readingProgress}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans mt-1">
|
|
||||||
{readingProgress}% completed
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="my-4">
|
|
||||||
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">
|
|
||||||
Actions
|
|
||||||
</h4>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className={`flex items-center gap-1 justify-start py-1.5 px-3 rounded-lg ${
|
|
||||||
isBookmarked
|
|
||||||
? "bg-russet/10 hover:bg-russet/20 text-russet"
|
|
||||||
: "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`}
|
|
||||||
onClick={handleBookmarkToggle}
|
|
||||||
>
|
|
||||||
<Bookmark
|
|
||||||
className={`h-4 w-4 ${isBookmarked ? "fill-russet" : ""}`}
|
|
||||||
/>
|
|
||||||
<span>{isBookmarked ? "Remove" : "Bookmark"}</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center gap-1 justify-start py-1.5 px-3 rounded-lg 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"
|
|
||||||
onClick={() => navigate(`/works/${work.slug}/comments`)}
|
|
||||||
>
|
|
||||||
<MessageCircle className="h-4 w-4" />
|
|
||||||
<span>Comments</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center gap-1 justify-start py-1.5 px-3 rounded-lg 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"
|
|
||||||
onClick={() => navigate(`/works/${work.slug}/cite`)}
|
|
||||||
>
|
|
||||||
<FileText className="h-4 w-4" />
|
|
||||||
<span>Cite</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center gap-1 justify-start py-1.5 px-3 rounded-lg 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"
|
|
||||||
onClick={() => navigate(`/collections/add/${work.slug}`)}
|
|
||||||
>
|
|
||||||
<BookCopy className="h-4 w-4" />
|
|
||||||
<span>Add to collection</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DrawerFooter>
|
|
||||||
<DrawerClose asChild>
|
|
||||||
<Button variant="outline">Close</Button>
|
|
||||||
</DrawerClose>
|
|
||||||
</DrawerFooter>
|
|
||||||
</DrawerContent>
|
|
||||||
</Drawer>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Main reading area */}
|
|
||||||
<div ref={mainContentRef} className="flex-1 px-4 lg:px-8 py-4 lg:py-8">
|
|
||||||
<div className="mb-6">
|
|
||||||
{!isMobile && (
|
|
||||||
<ReadingControls
|
|
||||||
onZenModeToggle={toggleZenMode}
|
|
||||||
onIncreaseFontSize={increaseFontSize}
|
|
||||||
onDecreaseFontSize={decreaseFontSize}
|
|
||||||
zenMode={settings.zenMode}
|
|
||||||
workId={work.id}
|
|
||||||
workSlug={work.slug}
|
|
||||||
translationId={selectedTranslationId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mt-2">
|
|
||||||
<h2 className="text-xl md:text-2xl font-serif text-navy/80 dark:text-cream/80 font-medium">
|
|
||||||
{work.title}
|
|
||||||
</h2>
|
|
||||||
<LanguageTag language={work.language} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TranslationSelector
|
|
||||||
currentTranslationId={selectedTranslationId}
|
|
||||||
workSlug={work.slug}
|
|
||||||
workLanguage={work.language}
|
|
||||||
onSelectTranslation={handleSelectTranslation}
|
|
||||||
onViewOriginal={handleViewOriginal}
|
|
||||||
isOriginalSelected={isOriginalSelected}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Text content with enhanced annotation features */}
|
|
||||||
<div className="reading-container max-w-[var(--reading-width)] mx-auto">
|
|
||||||
<EnhancedLineNumberedText
|
|
||||||
content={contentToDisplay}
|
|
||||||
fontSizeClass={settings.fontSize}
|
|
||||||
onAnnotate={handleLineAnnotation}
|
|
||||||
highlightedLine={selectedLineNumber || undefined}
|
|
||||||
workId={work.id}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{selectedTranslation?.notes && (
|
|
||||||
<div className="mt-8 border-t border-sage/20 dark:border-sage/10 pt-4">
|
|
||||||
<h3 className="text-lg font-medium font-serif text-navy dark:text-cream mb-3">
|
|
||||||
Translation Notes
|
|
||||||
</h3>
|
|
||||||
<div className="text-sm text-navy/80 dark:text-cream/80">
|
|
||||||
<p>{selectedTranslation.notes}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress bar (fixed at bottom) - only visible when not in zen mode */}
|
|
||||||
{!settings.zenMode && (
|
|
||||||
<div className="progress-bar fixed bottom-0 left-0 right-0 h-1 bg-sage/20 dark:bg-sage/10">
|
|
||||||
<div
|
|
||||||
className="progress-indicator h-full bg-russet dark:bg-russet/90"
|
|
||||||
style={{ width: `${readingProgress}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Annotation panel for desktop */}
|
|
||||||
{!isMobile && isAnnotationOpen && selectedLineNumber && (
|
|
||||||
<AnnotationSystem
|
|
||||||
workId={work.id}
|
|
||||||
selectedLineNumber={selectedLineNumber}
|
|
||||||
onClose={handleCloseAnnotation}
|
|
||||||
translationId={selectedTranslationId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mobile annotation drawer */}
|
|
||||||
{isMobile && (
|
|
||||||
<Drawer
|
|
||||||
open={isAnnotationOpen && !!selectedLineNumber}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) handleCloseAnnotation();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DrawerContent className="max-h-[80%]">
|
|
||||||
<DrawerHeader className="border-b border-sage/20 dark:border-sage/10">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<MessageCircle className="h-5 w-5 text-russet" />
|
|
||||||
<DrawerTitle>
|
|
||||||
Line {selectedLineNumber} Annotations
|
|
||||||
</DrawerTitle>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DrawerHeader>
|
|
||||||
|
|
||||||
<div className="p-4 overflow-auto">
|
|
||||||
{selectedLineNumber && (
|
|
||||||
<AnnotationSystem
|
|
||||||
workId={work.id}
|
|
||||||
selectedLineNumber={selectedLineNumber}
|
|
||||||
onClose={handleCloseAnnotation}
|
|
||||||
translationId={selectedTranslationId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DrawerContent>
|
|
||||||
</Drawer>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mobile reading controls */}
|
|
||||||
{isMobile && (
|
|
||||||
<div className="fixed bottom-1 right-4 flex space-x-1">
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
className="rounded-full shadow-lg w-10 h-10 bg-russet text-white"
|
|
||||||
onClick={() => {
|
|
||||||
if (selectedLineNumber) {
|
|
||||||
setIsAnnotationOpen(true);
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
description: "Tap on a line to add annotations",
|
|
||||||
duration: 3000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MessageCircle className="h-5 w-5" />
|
|
||||||
<span className="sr-only">Annotations</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
className="rounded-full shadow-lg w-10 h-10 bg-navy dark:bg-navy/80 text-white"
|
|
||||||
onClick={toggleZenMode}
|
|
||||||
>
|
|
||||||
{settings.zenMode ? (
|
|
||||||
<AlignLeft className="h-5 w-5" />
|
|
||||||
) : (
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
)}
|
|
||||||
<span className="sr-only">
|
|
||||||
{settings.zenMode ? "Exit zen mode" : "Zen mode"}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,120 +0,0 @@
|
|||||||
import { Copy } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
|
|
||||||
interface LineNumberedTextProps {
|
|
||||||
content: string;
|
|
||||||
fontSizeClass?: string;
|
|
||||||
onLineClick?: (lineNumber: number) => void;
|
|
||||||
highlightedLine?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LineNumberedText({
|
|
||||||
content,
|
|
||||||
fontSizeClass = "text-size-md",
|
|
||||||
onLineClick,
|
|
||||||
highlightedLine,
|
|
||||||
}: LineNumberedTextProps) {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [hoveredLine, setHoveredLine] = useState<number | null>(null);
|
|
||||||
|
|
||||||
// Split content into lines
|
|
||||||
const lines = content.split("\n");
|
|
||||||
|
|
||||||
const handleLineHover = (lineNumber: number) => {
|
|
||||||
setHoveredLine(lineNumber);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLineLeave = () => {
|
|
||||||
setHoveredLine(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyLine = (_lineNumber: number, lineText: string) => {
|
|
||||||
navigator.clipboard.writeText(lineText);
|
|
||||||
toast({
|
|
||||||
description: "Line copied to clipboard",
|
|
||||||
duration: 2000,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyLineLink = (lineNumber: number) => {
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
url.hash = `line-${lineNumber}`;
|
|
||||||
navigator.clipboard.writeText(url.toString());
|
|
||||||
toast({
|
|
||||||
description: "Link to line copied to clipboard",
|
|
||||||
duration: 2000,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`reading-text ${fontSizeClass}`}>
|
|
||||||
{lines.map((line, index) => {
|
|
||||||
const lineNumber = index + 1;
|
|
||||||
const isHighlighted = lineNumber === highlightedLine;
|
|
||||||
const isHovered = lineNumber === hoveredLine;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`line-${lineNumber}`}
|
|
||||||
id={`line-${lineNumber}`}
|
|
||||||
className={`text-line group ${
|
|
||||||
isHighlighted
|
|
||||||
? "bg-navy/10 dark:bg-cream/10"
|
|
||||||
: "hover:bg-navy/5 dark:hover:bg-cream/5"
|
|
||||||
} py-1 rounded flex`}
|
|
||||||
onMouseEnter={() => handleLineHover(lineNumber)}
|
|
||||||
onMouseLeave={handleLineLeave}
|
|
||||||
onClick={() => onLineClick?.(lineNumber)}
|
|
||||||
>
|
|
||||||
<span className="line-number">{lineNumber}</span>
|
|
||||||
<p className="flex-1">{line}</p>
|
|
||||||
|
|
||||||
{/* Copy buttons that appear on hover */}
|
|
||||||
{isHovered && (
|
|
||||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity ml-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleCopyLine(lineNumber, line);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Copy line</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleCopyLineLink(lineNumber);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className="h-4 w-4"
|
|
||||||
>
|
|
||||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
|
||||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
|
||||||
</svg>
|
|
||||||
<span className="sr-only">Copy link to line</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,173 +0,0 @@
|
|||||||
import {
|
|
||||||
BookOpen,
|
|
||||||
Heart,
|
|
||||||
Maximize2,
|
|
||||||
MessageSquare,
|
|
||||||
Minimize2,
|
|
||||||
Minus,
|
|
||||||
Plus,
|
|
||||||
Share2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Link } from "wouter";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
|
|
||||||
interface ReadingControlsProps {
|
|
||||||
onZenModeToggle: () => void;
|
|
||||||
onIncreaseFontSize: () => void;
|
|
||||||
onDecreaseFontSize: () => void;
|
|
||||||
zenMode: boolean;
|
|
||||||
workId: number;
|
|
||||||
workSlug: string;
|
|
||||||
translationId?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReadingControls({
|
|
||||||
onZenModeToggle,
|
|
||||||
onIncreaseFontSize,
|
|
||||||
onDecreaseFontSize,
|
|
||||||
zenMode,
|
|
||||||
workId,
|
|
||||||
workSlug,
|
|
||||||
translationId,
|
|
||||||
}: ReadingControlsProps) {
|
|
||||||
const [isLiked, setIsLiked] = useState(false);
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const handleLikeToggle = async () => {
|
|
||||||
try {
|
|
||||||
// In a real app, this would be an API call to like/unlike the work
|
|
||||||
setIsLiked(!isLiked);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
description: isLiked ? "Removed from favorites" : "Added to favorites",
|
|
||||||
duration: 2000,
|
|
||||||
});
|
|
||||||
} catch (_error) {
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Could not update favorite status",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleShare = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.share({
|
|
||||||
title: "Tercul - Literary Work",
|
|
||||||
url: window.location.href,
|
|
||||||
});
|
|
||||||
} catch (_error) {
|
|
||||||
// Fallback for browsers that don't support the Web Share API
|
|
||||||
navigator.clipboard.writeText(window.location.href);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
description: "Link copied to clipboard",
|
|
||||||
duration: 2000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h1 className="text-2xl md:text-3xl font-bold font-serif text-navy dark:text-cream">
|
|
||||||
{/* Work title would go here */}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="btn-feedback p-2 rounded-full text-navy/70 dark:text-cream/70 hover:bg-navy/10 dark:hover:bg-cream/10"
|
|
||||||
onClick={onZenModeToggle}
|
|
||||||
>
|
|
||||||
{zenMode ? (
|
|
||||||
<Minimize2 className="h-5 w-5" />
|
|
||||||
) : (
|
|
||||||
<Maximize2 className="h-5 w-5" />
|
|
||||||
)}
|
|
||||||
<span className="sr-only">
|
|
||||||
{zenMode ? "Exit zen mode" : "Zen mode"}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="btn-feedback p-2 rounded-full text-navy/70 dark:text-cream/70 hover:bg-navy/10 dark:hover:bg-cream/10"
|
|
||||||
onClick={onIncreaseFontSize}
|
|
||||||
>
|
|
||||||
<Plus className="h-5 w-5" />
|
|
||||||
<span className="sr-only">Increase font</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="btn-feedback p-2 rounded-full text-navy/70 dark:text-cream/70 hover:bg-navy/10 dark:hover:bg-cream/10"
|
|
||||||
onClick={onDecreaseFontSize}
|
|
||||||
>
|
|
||||||
<Minus className="h-5 w-5" />
|
|
||||||
<span className="sr-only">Decrease font</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="h-6 w-px bg-sage/20 dark:bg-sage/10 mx-1"></div>
|
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={`btn-feedback p-2 rounded-full ${
|
|
||||||
isLiked
|
|
||||||
? "text-russet"
|
|
||||||
: "text-navy/70 dark:text-cream/70 hover:bg-navy/10 dark:hover:bg-cream/10"
|
|
||||||
}`}
|
|
||||||
onClick={handleLikeToggle}
|
|
||||||
>
|
|
||||||
<Heart className={`h-5 w-5 ${isLiked ? "fill-russet" : ""}`} />
|
|
||||||
<span className="sr-only">
|
|
||||||
{isLiked ? "Remove from favorites" : "Add to favorites"}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="btn-feedback p-2 rounded-full text-navy/70 dark:text-cream/70 hover:bg-navy/10 dark:hover:bg-cream/10"
|
|
||||||
onClick={handleShare}
|
|
||||||
>
|
|
||||||
<Share2 className="h-5 w-5" />
|
|
||||||
<span className="sr-only">Share</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href={`/works/${workSlug}/comments${translationId ? `?translation=${translationId}` : ""}`}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="btn-feedback p-2 rounded-full text-navy/70 dark:text-cream/70 hover:bg-navy/10 dark:hover:bg-cream/10"
|
|
||||||
>
|
|
||||||
<MessageSquare className="h-5 w-5" />
|
|
||||||
<span className="sr-only">Comments</span>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href={`/works/${workSlug}/citation${translationId ? `?translation=${translationId}` : ""}`}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="btn-feedback p-2 rounded-full text-navy/70 dark:text-cream/70 hover:bg-navy/10 dark:hover:bg-cream/10"
|
|
||||||
>
|
|
||||||
<BookOpen className="h-5 w-5" />
|
|
||||||
<span className="sr-only">Cite</span>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,320 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { AuthorChip } from "@/components/common/AuthorChip";
|
|
||||||
import { LanguageTag } from "@/components/common/LanguageTag";
|
|
||||||
import { LineNumberedText } from "@/components/reading/LineNumberedText";
|
|
||||||
import { ReadingControls } from "@/components/reading/ReadingControls";
|
|
||||||
import { TranslationSelector } from "@/components/reading/TranslationSelector";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useReadingSettings } from "@/hooks/use-reading-settings";
|
|
||||||
import {
|
|
||||||
useUpdateReadingProgress,
|
|
||||||
useWorkTranslations,
|
|
||||||
} from "@/hooks/use-work-api";
|
|
||||||
import type { WorkWithDetails } from "@/lib/types";
|
|
||||||
|
|
||||||
interface ReadingViewProps {
|
|
||||||
work: WorkWithDetails;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReadingView({ work }: ReadingViewProps) {
|
|
||||||
const { settings, increaseFontSize, decreaseFontSize, toggleZenMode } =
|
|
||||||
useReadingSettings();
|
|
||||||
|
|
||||||
// Fetch translations using the hook
|
|
||||||
const { data: translations = [] } = useWorkTranslations(work.slug);
|
|
||||||
|
|
||||||
const [selectedTranslationId, setSelectedTranslationId] = useState<
|
|
||||||
string | undefined
|
|
||||||
>(translations.length > 0 ? translations[0].id : undefined);
|
|
||||||
const [isOriginalSelected, setIsOriginalSelected] = useState(false);
|
|
||||||
const [readingProgress, setReadingProgress] = useState(0);
|
|
||||||
|
|
||||||
const updateReadingProgressMutation = useUpdateReadingProgress();
|
|
||||||
|
|
||||||
// Get the selected translation
|
|
||||||
const selectedTranslation = translations.find(
|
|
||||||
(t) => t.id === selectedTranslationId,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Content to display - either the translation or original work
|
|
||||||
const contentToDisplay = selectedTranslation
|
|
||||||
? selectedTranslation.content
|
|
||||||
: work.content;
|
|
||||||
|
|
||||||
// Update reading progress in backend
|
|
||||||
const updateReadingProgress = useCallback(
|
|
||||||
async (progress: number) => {
|
|
||||||
try {
|
|
||||||
await updateReadingProgressMutation.mutateAsync({
|
|
||||||
userId: "1", // TODO: Get from user context
|
|
||||||
workId: work.id,
|
|
||||||
translationId: selectedTranslationId,
|
|
||||||
progress,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to update reading progress:", error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[updateReadingProgressMutation, work.id, selectedTranslationId],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update reading progress as user scrolls
|
|
||||||
useEffect(() => {
|
|
||||||
const handleScroll = () => {
|
|
||||||
const windowHeight = window.innerHeight;
|
|
||||||
const documentHeight = document.documentElement.scrollHeight;
|
|
||||||
const scrollTop = window.scrollY;
|
|
||||||
|
|
||||||
// Calculate progress percentage
|
|
||||||
const progress = Math.min(
|
|
||||||
100,
|
|
||||||
Math.round((scrollTop / (documentHeight - windowHeight)) * 100),
|
|
||||||
);
|
|
||||||
|
|
||||||
setReadingProgress(progress);
|
|
||||||
|
|
||||||
// Update reading progress in backend (throttled to avoid too many requests)
|
|
||||||
const debounced = setTimeout(() => {
|
|
||||||
updateReadingProgress(progress);
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
return () => clearTimeout(debounced);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("scroll", handleScroll);
|
|
||||||
return () => window.removeEventListener("scroll", handleScroll);
|
|
||||||
}, [updateReadingProgress]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className={`reading-view ${settings.zenMode ? "zen-mode" : ""}`}>
|
|
||||||
<div className="flex flex-col lg:flex-row max-w-[var(--content-width)] mx-auto">
|
|
||||||
{/* Context sidebar (sticky on desktop) */}
|
|
||||||
<aside className="context-sidebar lg:w-64 p-4 lg:sticky lg:top-16 lg:self-start lg:h-[calc(100vh-4rem)] lg:overflow-y-auto">
|
|
||||||
<div className="mb-6">
|
|
||||||
<AuthorChip author={work.author} withLifeDates />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">
|
|
||||||
About this work
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div>
|
|
||||||
{work.year && (
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
|
||||||
Written in {work.year}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans capitalize">
|
|
||||||
{work.type} •{" "}
|
|
||||||
{(work.tags ?? []).map((tag) => tag.name).join(" • ")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{(work.tags ?? []).map((tag) => (
|
|
||||||
<Badge
|
|
||||||
key={tag.id}
|
|
||||||
variant="outline"
|
|
||||||
className="inline-block px-2 py-0.5 bg-navy/10 dark:bg-navy/20 rounded text-navy/70 dark:text-cream/70 text-xs font-sans border-none"
|
|
||||||
>
|
|
||||||
{tag.name}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedTranslation && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">
|
|
||||||
Translation
|
|
||||||
</h4>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-navy/90 dark:text-cream/90 font-sans font-medium">
|
|
||||||
{selectedTranslation.language}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
|
||||||
Translated by User {selectedTranslation.translatorId}{" "}
|
|
||||||
{selectedTranslation.year && `(${selectedTranslation.year})`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">
|
|
||||||
Reading stats
|
|
||||||
</h4>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
|
||||||
~{Math.ceil(contentToDisplay.length / 1000)} min read
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
|
||||||
{work.likes || 0} favorites
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<h4 className="text-xs uppercase tracking-wider text-navy/60 dark:text-cream/60 font-sans font-medium mb-2">
|
|
||||||
Actions
|
|
||||||
</h4>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="btn-feedback flex items-center gap-1 py-1.5 px-3 rounded-lg bg-russet/10 hover:bg-russet/20 dark:bg-russet/20 dark:hover:bg-russet/30 text-russet dark:text-russet/90 font-sans text-xs transition-colors"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="2"
|
|
||||||
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Favorite</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="btn-feedback flex items-center gap-1 py-1.5 px-3 rounded-lg 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"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="2"
|
|
||||||
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Share</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="btn-feedback flex items-center gap-1 py-1.5 px-3 rounded-lg 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"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="2"
|
|
||||||
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Comment</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="btn-feedback flex items-center gap-1 py-1.5 px-3 rounded-lg 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"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="2"
|
|
||||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Cite</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Main reading area */}
|
|
||||||
<div className="flex-1 px-4 lg:px-8 py-6 lg:py-8">
|
|
||||||
<div className="mb-6">
|
|
||||||
<ReadingControls
|
|
||||||
onZenModeToggle={toggleZenMode}
|
|
||||||
onIncreaseFontSize={increaseFontSize}
|
|
||||||
onDecreaseFontSize={decreaseFontSize}
|
|
||||||
zenMode={settings.zenMode}
|
|
||||||
workId={work.id}
|
|
||||||
workSlug={work.slug}
|
|
||||||
translationId={selectedTranslationId}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mt-2">
|
|
||||||
<h2 className="text-xl font-serif text-navy/80 dark:text-cream/80">
|
|
||||||
{work.title}
|
|
||||||
</h2>
|
|
||||||
<LanguageTag language={work.language} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{translations.length > 0 && (
|
|
||||||
<TranslationSelector
|
|
||||||
currentTranslationId={selectedTranslationId}
|
|
||||||
workSlug={work.slug}
|
|
||||||
workLanguage={work.language}
|
|
||||||
onSelectTranslation={(translationId: string) => {
|
|
||||||
setSelectedTranslationId(translationId);
|
|
||||||
setIsOriginalSelected(false);
|
|
||||||
}}
|
|
||||||
onViewOriginal={() => {
|
|
||||||
setSelectedTranslationId(undefined);
|
|
||||||
setIsOriginalSelected(true);
|
|
||||||
}}
|
|
||||||
isOriginalSelected={isOriginalSelected}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Text content */}
|
|
||||||
<div className="reading-container max-w-[var(--reading-width)] mx-auto">
|
|
||||||
<LineNumberedText
|
|
||||||
content={contentToDisplay}
|
|
||||||
fontSizeClass={settings.fontSize}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{selectedTranslation?.notes && (
|
|
||||||
<div className="mt-8 border-t border-sage/20 dark:border-sage/10 pt-4">
|
|
||||||
<h3 className="text-lg font-medium font-serif text-navy dark:text-cream mb-3">
|
|
||||||
Translation Notes
|
|
||||||
</h3>
|
|
||||||
<div className="text-sm text-navy/80 dark:text-cream/80">
|
|
||||||
<p>{selectedTranslation.notes}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress bar (fixed at bottom) */}
|
|
||||||
<div className="progress-bar fixed bottom-0 left-0 right-0 h-1 bg-sage/20 dark:bg-sage/10">
|
|
||||||
<div
|
|
||||||
className="progress-indicator h-full bg-russet dark:bg-russet/90"
|
|
||||||
style={{ width: `${readingProgress}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,324 +0,0 @@
|
|||||||
import {
|
|
||||||
BookMarked,
|
|
||||||
BookOpen,
|
|
||||||
ChevronDown,
|
|
||||||
Globe,
|
|
||||||
Languages,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Link } from "wouter";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectLabel,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import type { TranslationWithDetails } from "@/lib/types";
|
|
||||||
|
|
||||||
interface TranslationSelectorProps {
|
|
||||||
translations: TranslationWithDetails[];
|
|
||||||
currentTranslationId?: string;
|
|
||||||
workSlug: string;
|
|
||||||
workLanguage: string;
|
|
||||||
onSelectTranslation: (translationId: string) => 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,
|
|
||||||
workLanguage,
|
|
||||||
onSelectTranslation,
|
|
||||||
onViewOriginal,
|
|
||||||
isOriginalSelected,
|
|
||||||
}: TranslationSelectorProps) {
|
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
|
||||||
|
|
||||||
// Group translations by language
|
|
||||||
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">
|
|
||||||
<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(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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"}
|
|
||||||
size="sm"
|
|
||||||
className={`py-0.5 px-2 h-7 rounded-full text-xs ${
|
|
||||||
isLanguageSelected
|
|
||||||
? "bg-russet hover:bg-russet/90 text-white"
|
|
||||||
: "border-navy/20 dark:border-cream/20"
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
// Select the first translation for this language
|
|
||||||
if (languageTranslations.length > 0) {
|
|
||||||
onSelectTranslation(languageTranslations[0].id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getLanguageDisplayName(language)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{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)}
|
|
||||||
>
|
|
||||||
+{Object.keys(translationsByLanguage).length - 5} more
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,186 +0,0 @@
|
|||||||
import { Heart } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Link } from "wouter";
|
|
||||||
import { LanguageTag } from "@/components/common/LanguageTag";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
import { useLikeWork, useUnlikeWork } from "@/hooks/use-work-api";
|
|
||||||
import type { WorkWithAuthor } from "@/lib/types";
|
|
||||||
|
|
||||||
interface WorkCardProps {
|
|
||||||
work: WorkWithAuthor;
|
|
||||||
compact?: boolean;
|
|
||||||
grid?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WorkCard({
|
|
||||||
work,
|
|
||||||
compact = false,
|
|
||||||
grid = false,
|
|
||||||
}: WorkCardProps) {
|
|
||||||
const [likeCount, setLikeCount] = useState<number>(work.likes || 0);
|
|
||||||
const [isLiked, setIsLiked] = useState<boolean>(false);
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const likeWorkMutation = useLikeWork();
|
|
||||||
const unlikeWorkMutation = useUnlikeWork();
|
|
||||||
|
|
||||||
const handleLike = async (e: React.MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!isLiked) {
|
|
||||||
await likeWorkMutation.mutateAsync({
|
|
||||||
userId: "1", // TODO: Get from user context
|
|
||||||
workId: work.id,
|
|
||||||
});
|
|
||||||
setLikeCount(likeCount + 1);
|
|
||||||
setIsLiked(true);
|
|
||||||
} else {
|
|
||||||
await unlikeWorkMutation.mutateAsync({
|
|
||||||
userId: "1", // TODO: Get from user context
|
|
||||||
workId: work.id,
|
|
||||||
});
|
|
||||||
setLikeCount(likeCount - 1);
|
|
||||||
setIsLiked(false);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Could not like this work. Please try again.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (grid) {
|
|
||||||
return (
|
|
||||||
<Link href={`/works/${work.slug}`}>
|
|
||||||
<div className="card group bg-cream dark:bg-dark-surface p-4 rounded-lg shadow-sm border border-sage/10 dark:border-sage/5 flex flex-col h-full transition-shadow hover:shadow-md">
|
|
||||||
<div className="mb-3">
|
|
||||||
<h3 className="font-serif text-lg font-semibold mb-1 group-hover:text-russet dark:group-hover:text-russet/90 transition-colors">
|
|
||||||
{work.title}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-navy/70 dark:text-cream/70">
|
|
||||||
{work.author?.name || "Unknown Author"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2 mb-3">
|
|
||||||
{work.tags && work.tags.length > 0 ? (
|
|
||||||
work.tags.slice(0, 3).map((tag) => (
|
|
||||||
<Badge
|
|
||||||
key={tag.id}
|
|
||||||
variant="outline"
|
|
||||||
className="bg-navy/10 dark:bg-navy/20 text-navy/70 dark:text-cream/70 text-xs border-none"
|
|
||||||
>
|
|
||||||
{tag.name}
|
|
||||||
</Badge>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="bg-navy/10 dark:bg-navy/20 text-navy/70 dark:text-cream/70 text-xs border-none"
|
|
||||||
>
|
|
||||||
General
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-navy/80 dark:text-cream/80 mb-3 line-clamp-3 flex-1">
|
|
||||||
{work.description}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center justify-between mt-auto pt-3 border-t border-sage/10 dark:border-sage/5">
|
|
||||||
<LanguageTag
|
|
||||||
language={`${work.language}, ${work.year || "Unknown"}`}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-navy/60 dark:text-cream/60">
|
|
||||||
{/* Assuming translations count would be available */}
|
|
||||||
{Math.floor(Math.random() * 10) + 1} translations
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card bg-cream dark:bg-dark-surface p-4 rounded-lg shadow-sm border border-sage/10 dark:border-sage/5 flex flex-col sm:flex-row sm:items-center gap-4">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-serif text-lg font-semibold mb-1">
|
|
||||||
<Link
|
|
||||||
href={`/works/${work.slug}`}
|
|
||||||
className="text-navy dark:text-cream hover:text-russet dark:hover:text-russet/90 transition-colors"
|
|
||||||
>
|
|
||||||
{work.title}
|
|
||||||
</Link>
|
|
||||||
</h3>
|
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mb-2">
|
|
||||||
<span className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
|
||||||
{work.type === "poem"
|
|
||||||
? "Poem"
|
|
||||||
: work.type === "story"
|
|
||||||
? "Short story"
|
|
||||||
: work.type === "novel"
|
|
||||||
? "Novel"
|
|
||||||
: work.type === "play"
|
|
||||||
? "Play"
|
|
||||||
: work.type === "essay"
|
|
||||||
? "Essay"
|
|
||||||
: "Work"}
|
|
||||||
, {work.year}
|
|
||||||
</span>
|
|
||||||
<LanguageTag language={work.language} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!compact && (
|
|
||||||
<p className="text-navy/80 dark:text-cream/80 text-sm mb-2 line-clamp-2">
|
|
||||||
{work.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{work.tags && work.tags.length > 0 ? (
|
|
||||||
work.tags.map((tag) => (
|
|
||||||
<Badge
|
|
||||||
key={tag.id}
|
|
||||||
variant="outline"
|
|
||||||
className="inline-block px-2 py-0.5 bg-navy/10 dark:bg-navy/20 rounded text-navy/70 dark:text-cream/70 text-xs font-sans border-none"
|
|
||||||
>
|
|
||||||
{tag.name}
|
|
||||||
</Badge>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="inline-block px-2 py-0.5 bg-navy/10 dark:bg-navy/20 rounded text-navy/70 dark:text-cream/70 text-xs font-sans border-none"
|
|
||||||
>
|
|
||||||
General
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center sm:flex-col gap-3 sm:gap-2 self-end sm:self-center">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="text-xs text-navy/70 dark:text-cream/70 font-sans">
|
|
||||||
{/* Assuming translations count would be available */}
|
|
||||||
{Math.floor(Math.random() * 10) + 1} translations
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className={`btn-feedback flex items-center gap-1 py-1 px-2.5 rounded-full ${
|
|
||||||
isLiked
|
|
||||||
? "bg-russet/20 hover:bg-russet/30 dark:bg-russet/30 dark:hover:bg-russet/40 text-russet dark:text-russet/90"
|
|
||||||
: "bg-russet/10 hover:bg-russet/20 dark:bg-russet/20 dark:hover:bg-russet/30 text-russet dark:text-russet/90"
|
|
||||||
} font-sans text-xs transition-colors`}
|
|
||||||
onClick={handleLike}
|
|
||||||
>
|
|
||||||
<Heart className={`h-3.5 w-3.5 ${isLiked ? "fill-russet" : ""}`} />
|
|
||||||
<span>{likeCount}</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,104 +0,0 @@
|
|||||||
// Hook exports for easy importing
|
|
||||||
|
|
||||||
export {
|
|
||||||
useAnalysisResults,
|
|
||||||
useAnalyzeEmotions,
|
|
||||||
useAnalyzeReadability,
|
|
||||||
useAnalyzeTopicClusters,
|
|
||||||
useAnalyzeWritingStyle,
|
|
||||||
useCreateAnalysis,
|
|
||||||
useExtractConcepts,
|
|
||||||
usePerformLinguisticAnalysis,
|
|
||||||
usePerformPoeticAnalysis,
|
|
||||||
usePerformSentimentAnalysis,
|
|
||||||
usePlatformStats,
|
|
||||||
useReadabilityScore,
|
|
||||||
useUserActivity,
|
|
||||||
useUserStats as useUserStatsFromAnalytics,
|
|
||||||
useWorkAnalytics,
|
|
||||||
useWorkConcepts,
|
|
||||||
useWorkEmotions,
|
|
||||||
useWorkStats,
|
|
||||||
useWorkTopicClusters,
|
|
||||||
useWritingStyle,
|
|
||||||
} from "./use-analytics-api";
|
|
||||||
// Utility hooks
|
|
||||||
export * from "./use-auth";
|
|
||||||
|
|
||||||
export {
|
|
||||||
useAuthor,
|
|
||||||
useAuthorBySlug,
|
|
||||||
useAuthors,
|
|
||||||
useAuthorTimeline,
|
|
||||||
useAuthorWorks,
|
|
||||||
useCreateAuthor,
|
|
||||||
useCreateTimelineEvent,
|
|
||||||
useDeleteAuthor,
|
|
||||||
useDeleteTimelineEvent,
|
|
||||||
useSearchAuthors as useSearchAuthorsFromAuthor,
|
|
||||||
useUpdateAuthor,
|
|
||||||
useUpdateTimelineEvent,
|
|
||||||
} from "./use-author-api";
|
|
||||||
export * from "./use-bookmark-api";
|
|
||||||
export * from "./use-collection-api";
|
|
||||||
export * from "./use-comment-api";
|
|
||||||
export * from "./use-comparison-slider";
|
|
||||||
export * from "./use-contribution-api";
|
|
||||||
export * from "./use-language-api";
|
|
||||||
export * from "./use-media-query";
|
|
||||||
export * from "./use-mobile";
|
|
||||||
export * from "./use-reading-progress";
|
|
||||||
export * from "./use-reading-settings";
|
|
||||||
export {
|
|
||||||
useAdvancedSearch,
|
|
||||||
useBrowseByAuthor,
|
|
||||||
useBrowseByLanguage,
|
|
||||||
useBrowseByTag,
|
|
||||||
useQuickSearch,
|
|
||||||
useSearch,
|
|
||||||
useSearchAuthors,
|
|
||||||
useSearchSuggestions,
|
|
||||||
useSearchTranslations,
|
|
||||||
useSearchWorks,
|
|
||||||
} from "./use-search-api";
|
|
||||||
export * from "./use-season";
|
|
||||||
export * from "./use-tag-api";
|
|
||||||
export * from "./use-theme";
|
|
||||||
export * from "./use-toast";
|
|
||||||
export * from "./use-translation-api";
|
|
||||||
export {
|
|
||||||
useChangePassword,
|
|
||||||
useCurrentUser,
|
|
||||||
useDeleteUser,
|
|
||||||
useForgotPassword,
|
|
||||||
useLogin,
|
|
||||||
useLogout,
|
|
||||||
useRegister,
|
|
||||||
useResendVerificationEmail,
|
|
||||||
useResetPassword,
|
|
||||||
useUpdateUser,
|
|
||||||
useUpdateUserProfile,
|
|
||||||
useUser,
|
|
||||||
useUserByEmail,
|
|
||||||
useUserByUsername,
|
|
||||||
useUserProfile,
|
|
||||||
useUserStats as useUserStatsFromUser,
|
|
||||||
useUsers,
|
|
||||||
useVerifyEmail,
|
|
||||||
} from "./use-user-api";
|
|
||||||
// Explicitly re-export ambiguous hooks to resolve conflicts
|
|
||||||
export {
|
|
||||||
useCreateWork,
|
|
||||||
useDeleteWork,
|
|
||||||
useLikeWork,
|
|
||||||
useReadingProgress as useReadingProgressFromWork,
|
|
||||||
useSearchWorks as useSearchWorksFromWork,
|
|
||||||
useUnlikeWork,
|
|
||||||
useUpdateReadingProgress,
|
|
||||||
useUpdateWork,
|
|
||||||
useWork,
|
|
||||||
useWorks,
|
|
||||||
useWorksByAuthor,
|
|
||||||
useWorksByTag,
|
|
||||||
useWorkTranslations as useWorkTranslationsFromWork,
|
|
||||||
} from "./use-work-api";
|
|
||||||
@ -1,241 +0,0 @@
|
|||||||
import type {
|
|
||||||
CreateConcept,
|
|
||||||
CreateEmotion,
|
|
||||||
CreateReadabilityScore,
|
|
||||||
CreateTopicCluster,
|
|
||||||
CreateWritingStyle,
|
|
||||||
} from "@shared/schema";
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import type { AnalyticsParams } from "@/api/analytics-api-client";
|
|
||||||
import { analyticsApiClient } from "@/api/analytics-api-client";
|
|
||||||
|
|
||||||
// Query hooks
|
|
||||||
export function useWorkStats(workId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["work-stats", workId],
|
|
||||||
queryFn: () => analyticsApiClient.getWorkStats(workId),
|
|
||||||
enabled: !!workId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkAnalytics(workId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["work-analytics", workId],
|
|
||||||
queryFn: () => analyticsApiClient.getWorkAnalytics(workId),
|
|
||||||
enabled: !!workId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useReadabilityScore(workId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["readability-score", workId],
|
|
||||||
queryFn: () => analyticsApiClient.getReadabilityScore(workId),
|
|
||||||
enabled: !!workId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWritingStyle(workId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["writing-style", workId],
|
|
||||||
queryFn: () => analyticsApiClient.getWritingStyle(workId),
|
|
||||||
enabled: !!workId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkEmotions(workId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["work-emotions", workId],
|
|
||||||
queryFn: () => analyticsApiClient.getWorkEmotions(workId),
|
|
||||||
enabled: !!workId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkConcepts(workId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["work-concepts", workId],
|
|
||||||
queryFn: () => analyticsApiClient.getWorkConcepts(workId),
|
|
||||||
enabled: !!workId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkTopicClusters(workId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["work-topic-clusters", workId],
|
|
||||||
queryFn: () => analyticsApiClient.getWorkTopicClusters(workId),
|
|
||||||
enabled: !!workId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUserStats(userId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["user-stats", userId],
|
|
||||||
queryFn: () => analyticsApiClient.getUserStats(userId),
|
|
||||||
enabled: !!userId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUserActivity(userId: string, params?: AnalyticsParams) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["user-activity", userId, params],
|
|
||||||
queryFn: () => analyticsApiClient.getUserActivity(userId, params),
|
|
||||||
enabled: !!userId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAnalysisResults(workId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["analysis-results", workId],
|
|
||||||
queryFn: () => analyticsApiClient.getAnalysisResults(workId),
|
|
||||||
enabled: !!workId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePlatformStats() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["platform-stats"],
|
|
||||||
queryFn: () => analyticsApiClient.getPlatformStats(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mutation hooks
|
|
||||||
export function useAnalyzeReadability() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: CreateReadabilityScore) =>
|
|
||||||
analyticsApiClient.analyzeReadability(data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["readability-score", variables.workId],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["work-analytics", variables.workId],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAnalyzeWritingStyle() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: CreateWritingStyle) =>
|
|
||||||
analyticsApiClient.analyzeWritingStyle(data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["writing-style", variables.workId],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["work-analytics", variables.workId],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAnalyzeEmotions() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: CreateEmotion) =>
|
|
||||||
analyticsApiClient.analyzeEmotions(data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
if (variables.workId) {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["work-emotions", variables.workId],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["work-analytics", variables.workId],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useExtractConcepts() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: CreateConcept) =>
|
|
||||||
analyticsApiClient.extractConcepts(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["work-concepts"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["work-analytics"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAnalyzeTopicClusters() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: CreateTopicCluster) =>
|
|
||||||
analyticsApiClient.analyzeTopicClusters(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["work-topic-clusters"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["work-analytics"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateAnalysis() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
workId,
|
|
||||||
type,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
workId: string;
|
|
||||||
type: string;
|
|
||||||
data: Record<string, unknown>;
|
|
||||||
}) => analyticsApiClient.createAnalysis(workId, type, data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["analysis-results", variables.workId],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePerformLinguisticAnalysis() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (workId: string) =>
|
|
||||||
analyticsApiClient.performLinguisticAnalysis(workId),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["analysis-results", variables],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePerformPoeticAnalysis() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (workId: string) =>
|
|
||||||
analyticsApiClient.performPoeticAnalysis(workId),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["analysis-results", variables],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePerformSentimentAnalysis() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (workId: string) =>
|
|
||||||
analyticsApiClient.performSentimentAnalysis(workId),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["analysis-results", variables],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,155 +0,0 @@
|
|||||||
import type {
|
|
||||||
CreateAuthor,
|
|
||||||
CreateTimelineEvent,
|
|
||||||
UpdateAuthor,
|
|
||||||
UpdateTimelineEvent,
|
|
||||||
} from "@shared/schema";
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import type { AuthorSearchParams } from "@/lib/api/author-api-client";
|
|
||||||
import { authorApiClient } from "@/lib/api/author-api-client";
|
|
||||||
|
|
||||||
// Query hooks
|
|
||||||
export function useAuthor(authorId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["author", authorId],
|
|
||||||
queryFn: () => authorApiClient.getAuthor(authorId),
|
|
||||||
enabled: !!authorId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuthorBySlug(slug: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["author-by-slug", slug],
|
|
||||||
queryFn: () => authorApiClient.getAuthorBySlug(slug),
|
|
||||||
enabled: !!slug,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuthors(params?: AuthorSearchParams) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["authors", params],
|
|
||||||
queryFn: () => authorApiClient.getAuthors(params),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuthorWorks(authorId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["author-works", authorId],
|
|
||||||
queryFn: () => authorApiClient.getAuthorWorks(authorId),
|
|
||||||
enabled: !!authorId,
|
|
||||||
select: (data) =>
|
|
||||||
data.map((work) => ({
|
|
||||||
...work,
|
|
||||||
tags: work.tags?.map((tag) =>
|
|
||||||
typeof tag === "string" ? { name: tag } : tag,
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuthorTimeline(authorId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["author-timeline", authorId],
|
|
||||||
queryFn: () => authorApiClient.getAuthorTimeline(authorId),
|
|
||||||
enabled: !!authorId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSearchAuthors(query: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["search-authors", query],
|
|
||||||
queryFn: () => authorApiClient.searchAuthors(query),
|
|
||||||
enabled: !!query && query.length > 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mutation hooks
|
|
||||||
export function useCreateAuthor() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: CreateAuthor) => authorApiClient.createAuthor(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["authors"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateAuthor() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
authorId,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
authorId: string;
|
|
||||||
data: UpdateAuthor;
|
|
||||||
}) => authorApiClient.updateAuthor(authorId, data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["author", variables.authorId],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["authors"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteAuthor() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (authorId: string) => authorApiClient.deleteAuthor(authorId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["authors"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateTimelineEvent() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
authorId,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
authorId: string;
|
|
||||||
data: CreateTimelineEvent;
|
|
||||||
}) => authorApiClient.createTimelineEvent(authorId, data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["author-timeline", variables.authorId],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateTimelineEvent() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
eventId,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
eventId: string;
|
|
||||||
data: UpdateTimelineEvent;
|
|
||||||
}) => authorApiClient.updateTimelineEvent(eventId, data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["author-timeline"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteTimelineEvent() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (eventId: string) =>
|
|
||||||
authorApiClient.deleteTimelineEvent(eventId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["author-timeline"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,237 +0,0 @@
|
|||||||
import { useState, useMemo } from "react";
|
|
||||||
import { useParams } from "wouter";
|
|
||||||
import type { WorkWithAuthor } from "@/lib/types";
|
|
||||||
import {
|
|
||||||
useAuthorBySlug,
|
|
||||||
useAuthorWorks,
|
|
||||||
useAuthorTimeline,
|
|
||||||
} from "./use-author-api";
|
|
||||||
|
|
||||||
export interface AuthorProfileFilters {
|
|
||||||
selectedGenre: string | null;
|
|
||||||
selectedYear: string | null;
|
|
||||||
selectedWorkType: string | null;
|
|
||||||
selectedLanguage: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthorProfileView {
|
|
||||||
view: "list" | "grid";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthorProfileState
|
|
||||||
extends AuthorProfileFilters,
|
|
||||||
AuthorProfileView {
|
|
||||||
following: boolean;
|
|
||||||
followCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuthorProfile() {
|
|
||||||
const { slug } = useParams<{ slug: string }>();
|
|
||||||
|
|
||||||
// API calls using existing hooks
|
|
||||||
const {
|
|
||||||
data: author,
|
|
||||||
isLoading: authorLoading,
|
|
||||||
error: authorError,
|
|
||||||
} = useAuthorBySlug(slug || "");
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: works,
|
|
||||||
isLoading: worksLoading,
|
|
||||||
error: worksError,
|
|
||||||
} = useAuthorWorks(author?.id || "");
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: timeline,
|
|
||||||
isLoading: timelineLoading,
|
|
||||||
error: timelineError,
|
|
||||||
} = useAuthorTimeline(author?.id || "");
|
|
||||||
|
|
||||||
// Local state
|
|
||||||
const [state, setState] = useState<AuthorProfileState>({
|
|
||||||
view: "list",
|
|
||||||
selectedGenre: null,
|
|
||||||
selectedYear: null,
|
|
||||||
selectedWorkType: null,
|
|
||||||
selectedLanguage: null,
|
|
||||||
following: false,
|
|
||||||
followCount: Math.floor(Math.random() * 2000),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Computed values
|
|
||||||
const computedStats = useMemo(() => {
|
|
||||||
if (!works) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
worksCount: works.length,
|
|
||||||
translationsCount: works.reduce(
|
|
||||||
(acc, _work) => acc + Math.floor(Math.random() * 5),
|
|
||||||
0
|
|
||||||
),
|
|
||||||
readingCount: Math.floor(Math.random() * 10000),
|
|
||||||
citationCount: Math.floor(Math.random() * 1000),
|
|
||||||
annotationCount: Math.floor(Math.random() * 500),
|
|
||||||
};
|
|
||||||
}, [works]);
|
|
||||||
|
|
||||||
const filterOptions = useMemo(() => {
|
|
||||||
if (!works) return { years: [], genres: [], languages: [], workTypes: [] };
|
|
||||||
|
|
||||||
const years = Array.from(
|
|
||||||
new Set(works.map((work) => work.year?.toString()).filter(Boolean))
|
|
||||||
);
|
|
||||||
const genres = Array.from(
|
|
||||||
new Set(
|
|
||||||
works
|
|
||||||
.flatMap((work) => work.tags?.map((tag) => tag.name) || [])
|
|
||||||
.filter(Boolean)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const languages = Array.from(
|
|
||||||
new Set(works.map((work) => work.language).filter(Boolean))
|
|
||||||
);
|
|
||||||
const workTypes = Array.from(
|
|
||||||
new Set(works.map((work) => work.type).filter(Boolean))
|
|
||||||
);
|
|
||||||
|
|
||||||
return { years, genres, languages, workTypes };
|
|
||||||
}, [works]);
|
|
||||||
|
|
||||||
const filteredWorks = useMemo(() => {
|
|
||||||
if (!works) return [];
|
|
||||||
|
|
||||||
return works.filter((work) => {
|
|
||||||
if (
|
|
||||||
state.selectedGenre &&
|
|
||||||
(!work.tags ||
|
|
||||||
!work.tags.some((tag) => tag.name === state.selectedGenre))
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (state.selectedYear && work.year?.toString() !== state.selectedYear) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (state.selectedWorkType && work.type !== state.selectedWorkType) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (state.selectedLanguage && work.language !== state.selectedLanguage) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
works,
|
|
||||||
state.selectedGenre,
|
|
||||||
state.selectedYear,
|
|
||||||
state.selectedWorkType,
|
|
||||||
state.selectedLanguage,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const worksByType = useMemo(() => {
|
|
||||||
if (!filteredWorks) return {};
|
|
||||||
|
|
||||||
return filteredWorks.reduce<Record<string, WorkWithAuthor[]>>(
|
|
||||||
(acc, work) => {
|
|
||||||
const type = work.type || "Other";
|
|
||||||
if (!acc[type]) {
|
|
||||||
acc[type] = [];
|
|
||||||
}
|
|
||||||
acc[type].push(work);
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
}, [filteredWorks]);
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
const updateFilters = (filters: Partial<AuthorProfileFilters>) => {
|
|
||||||
setState((prev) => ({ ...prev, ...filters }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearFilters = () => {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
selectedGenre: null,
|
|
||||||
selectedYear: null,
|
|
||||||
selectedWorkType: null,
|
|
||||||
selectedLanguage: null,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const setView = (view: "list" | "grid") => {
|
|
||||||
setState((prev) => ({ ...prev, view }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleFollow = () => {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
following: !prev.following,
|
|
||||||
followCount: prev.following ? prev.followCount - 1 : prev.followCount + 1,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Loading and error states
|
|
||||||
const isLoading = authorLoading || worksLoading || timelineLoading;
|
|
||||||
const error = authorError || worksError || timelineError;
|
|
||||||
|
|
||||||
// Helper functions
|
|
||||||
const formatTypeName = (type: string): string => {
|
|
||||||
switch (type) {
|
|
||||||
case "poem":
|
|
||||||
return "Poems";
|
|
||||||
case "story":
|
|
||||||
return "Short Stories";
|
|
||||||
case "novel":
|
|
||||||
return "Novels";
|
|
||||||
case "play":
|
|
||||||
return "Plays";
|
|
||||||
case "essay":
|
|
||||||
return "Essays";
|
|
||||||
default:
|
|
||||||
return "Other Works";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasActiveFilters = !!(
|
|
||||||
state.selectedGenre ||
|
|
||||||
state.selectedYear ||
|
|
||||||
state.selectedWorkType ||
|
|
||||||
state.selectedLanguage
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
// Data
|
|
||||||
author,
|
|
||||||
works,
|
|
||||||
timeline,
|
|
||||||
computedStats,
|
|
||||||
filteredWorks,
|
|
||||||
worksByType,
|
|
||||||
filterOptions,
|
|
||||||
|
|
||||||
// State
|
|
||||||
...state,
|
|
||||||
hasActiveFilters,
|
|
||||||
|
|
||||||
// Loading states
|
|
||||||
isLoading,
|
|
||||||
authorLoading,
|
|
||||||
worksLoading,
|
|
||||||
timelineLoading,
|
|
||||||
|
|
||||||
// Error states
|
|
||||||
error,
|
|
||||||
authorError,
|
|
||||||
worksError,
|
|
||||||
timelineError,
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
updateFilters,
|
|
||||||
clearFilters,
|
|
||||||
setView,
|
|
||||||
toggleFollow,
|
|
||||||
|
|
||||||
// Utilities
|
|
||||||
formatTypeName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
||||||
import type { BlogPost } from "@/api/blog-api";
|
|
||||||
import { getBlogPost, updateBlogPost } from "@/api/blog-api";
|
|
||||||
|
|
||||||
export function useBlogPost(id: string) {
|
|
||||||
const {
|
|
||||||
data: post,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["blog-post", id],
|
|
||||||
queryFn: () => getBlogPost(id),
|
|
||||||
enabled: !!id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: (data: BlogPost) => updateBlogPost(id, data),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
post,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
update: mutation.mutateAsync,
|
|
||||||
updating: mutation.isPending,
|
|
||||||
updateError: mutation.error,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,135 +0,0 @@
|
|||||||
import type { CreateBookmark, UpdateBookmark } from "@shared/schema";
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import type { BookmarkSearchParams } from "@/api/bookmark-api-client";
|
|
||||||
import { bookmarkApiClient } from "@/api/bookmark-api-client";
|
|
||||||
|
|
||||||
// Query hooks
|
|
||||||
export function useBookmark(bookmarkId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["bookmark", bookmarkId],
|
|
||||||
queryFn: () => bookmarkApiClient.getBookmark(bookmarkId),
|
|
||||||
enabled: !!bookmarkId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useBookmarks(params?: BookmarkSearchParams) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["bookmarks", params],
|
|
||||||
queryFn: () => bookmarkApiClient.getBookmarks(params),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUserBookmarks(userId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["user-bookmarks", userId],
|
|
||||||
queryFn: () => bookmarkApiClient.getUserBookmarks(userId),
|
|
||||||
enabled: !!userId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useIsWorkBookmarked(userId: string, workId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["is-work-bookmarked", userId, workId],
|
|
||||||
queryFn: () => bookmarkApiClient.isWorkBookmarked(userId, workId),
|
|
||||||
enabled: !!userId && !!workId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mutation hooks
|
|
||||||
export function useCreateBookmark() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: CreateBookmark) =>
|
|
||||||
bookmarkApiClient.createBookmark(data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["bookmarks"] });
|
|
||||||
if (variables.userId) {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["user-bookmarks", variables.userId],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["is-work-bookmarked", variables.userId, variables.workId],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useBookmarkWork() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
userId,
|
|
||||||
workId,
|
|
||||||
note,
|
|
||||||
}: {
|
|
||||||
userId: string;
|
|
||||||
workId: string;
|
|
||||||
note?: string;
|
|
||||||
}) => bookmarkApiClient.bookmarkWork(userId, workId, note),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["user-bookmarks", variables.userId],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["is-work-bookmarked", variables.userId, variables.workId],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["bookmarks"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUnbookmarkWork() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ userId, workId }: { userId: string; workId: string }) =>
|
|
||||||
bookmarkApiClient.unbookmarkWork(userId, workId),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["user-bookmarks", variables.userId],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["is-work-bookmarked", variables.userId, variables.workId],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["bookmarks"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateBookmark() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
bookmarkId,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
bookmarkId: string;
|
|
||||||
data: UpdateBookmark;
|
|
||||||
}) => bookmarkApiClient.updateBookmark(bookmarkId, data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["bookmark", variables.bookmarkId],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["bookmarks"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["user-bookmarks"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteBookmark() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (bookmarkId: string) =>
|
|
||||||
bookmarkApiClient.deleteBookmark(bookmarkId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["bookmarks"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["user-bookmarks"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["is-work-bookmarked"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,146 +0,0 @@
|
|||||||
import type { CreateCollection, UpdateCollection } from "@shared/schema";
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import type { CollectionSearchParams } from "@/api/collection-api-client";
|
|
||||||
import { collectionApiClient } from "@/api/collection-api-client";
|
|
||||||
|
|
||||||
// Query hooks
|
|
||||||
export function useCollection(collectionId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["collection", collectionId],
|
|
||||||
queryFn: () => collectionApiClient.getCollection(collectionId),
|
|
||||||
enabled: !!collectionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCollectionBySlug(slug: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["collection-by-slug", slug],
|
|
||||||
queryFn: () => collectionApiClient.getCollectionBySlug(slug),
|
|
||||||
enabled: !!slug,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCollections(params?: CollectionSearchParams) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["collections", params],
|
|
||||||
queryFn: () => collectionApiClient.getCollections(params),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUserCollections(userId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["user-collections", userId],
|
|
||||||
queryFn: () => collectionApiClient.getUserCollections(userId),
|
|
||||||
enabled: !!userId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mutation hooks
|
|
||||||
export function useCreateCollection() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: CreateCollection) =>
|
|
||||||
collectionApiClient.createCollection(data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["collections"] });
|
|
||||||
if (variables.userId) {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["user-collections", variables.userId],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateCollection() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
collectionId,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
collectionId: string;
|
|
||||||
data: UpdateCollection;
|
|
||||||
}) => collectionApiClient.updateCollection(collectionId, data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["collection", variables.collectionId],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["collections"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteCollection() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (collectionId: string) =>
|
|
||||||
collectionApiClient.deleteCollection(collectionId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["collections"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["user-collections"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAddWorkToCollection() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
collectionId,
|
|
||||||
workId,
|
|
||||||
order,
|
|
||||||
}: {
|
|
||||||
collectionId: string;
|
|
||||||
workId: string;
|
|
||||||
order?: number;
|
|
||||||
}) => collectionApiClient.addWorkToCollection(collectionId, workId, order),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["collection", variables.collectionId],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRemoveWorkFromCollection() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
collectionId,
|
|
||||||
workId,
|
|
||||||
}: {
|
|
||||||
collectionId: string;
|
|
||||||
workId: string;
|
|
||||||
}) => collectionApiClient.removeWorkFromCollection(collectionId, workId),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["collection", variables.collectionId],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useReorderCollectionItems() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
collectionId,
|
|
||||||
items,
|
|
||||||
}: {
|
|
||||||
collectionId: string;
|
|
||||||
items: { id: string; order: number }[];
|
|
||||||
}) => collectionApiClient.reorderCollectionItems(collectionId, items),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["collection", variables.collectionId],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,169 +0,0 @@
|
|||||||
import type { CreateComment, UpdateComment } from "@shared/schema";
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import type { CommentSearchParams } from "@/api/comment-api-client";
|
|
||||||
import { commentApiClient } from "@/api/comment-api-client";
|
|
||||||
|
|
||||||
// Query hooks
|
|
||||||
export function useComment(commentId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["comment", commentId],
|
|
||||||
queryFn: () => commentApiClient.getComment(commentId),
|
|
||||||
enabled: !!commentId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useComments(params?: CommentSearchParams) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["comments", params],
|
|
||||||
queryFn: () => commentApiClient.getComments(params),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkComments(workId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["work-comments", workId],
|
|
||||||
queryFn: () => commentApiClient.getWorkComments(workId),
|
|
||||||
enabled: !!workId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTranslationComments(translationId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["translation-comments", translationId],
|
|
||||||
queryFn: () => commentApiClient.getTranslationComments(translationId),
|
|
||||||
enabled: !!translationId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCommentReplies(commentId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["comment-replies", commentId],
|
|
||||||
queryFn: () => commentApiClient.getCommentReplies(commentId),
|
|
||||||
enabled: !!commentId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mutation hooks
|
|
||||||
export function useCreateComment() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: CreateComment) => commentApiClient.createComment(data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
if (variables.workId) {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["work-comments", variables.workId],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (variables.translationId) {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["translation-comments", variables.translationId],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (variables.parentId) {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["comment-replies", variables.parentId],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["comments"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateWorkComment() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
workId,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
workId: string;
|
|
||||||
data: Omit<CreateComment, "workId">;
|
|
||||||
}) => commentApiClient.createWorkComment(workId, data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["work-comments", variables.workId],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["comments"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateTranslationComment() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
translationId,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
translationId: string;
|
|
||||||
data: Omit<CreateComment, "translationId">;
|
|
||||||
}) => commentApiClient.createTranslationComment(translationId, data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["translation-comments", variables.translationId],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["comments"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateCommentReply() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
parentCommentId,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
parentCommentId: string;
|
|
||||||
data: Omit<CreateComment, "parentId">;
|
|
||||||
}) => commentApiClient.createCommentReply(parentCommentId, data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["comment-replies", variables.parentCommentId],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["comments"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateComment() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
commentId,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
commentId: string;
|
|
||||||
data: UpdateComment;
|
|
||||||
}) => commentApiClient.updateComment(commentId, data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["comment", variables.commentId],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["comments"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["work-comments"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["translation-comments"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["comment-replies"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteComment() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (commentId: string) =>
|
|
||||||
commentApiClient.deleteComment(commentId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["comments"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["work-comments"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["translation-comments"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["comment-replies"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,125 +0,0 @@
|
|||||||
import type { CreateContribution, UpdateContribution } from "@shared/schema";
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import type {
|
|
||||||
ContributionSearchParams,
|
|
||||||
ReviewContributionRequest,
|
|
||||||
} from "@/api/contribution-api-client";
|
|
||||||
import { contributionApiClient } from "@/api/contribution-api-client";
|
|
||||||
|
|
||||||
// Query hooks
|
|
||||||
export function useContribution(contributionId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["contribution", contributionId],
|
|
||||||
queryFn: () => contributionApiClient.getContribution(contributionId),
|
|
||||||
enabled: !!contributionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useContributions(params?: ContributionSearchParams) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["contributions", params],
|
|
||||||
queryFn: () => contributionApiClient.getContributions(params),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUserContributions(userId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["user-contributions", userId],
|
|
||||||
queryFn: () => contributionApiClient.getUserContributions(userId),
|
|
||||||
enabled: !!userId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePendingContributions() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["pending-contributions"],
|
|
||||||
queryFn: () => contributionApiClient.getPendingContributions(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mutation hooks
|
|
||||||
export function useCreateContribution() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: CreateContribution) =>
|
|
||||||
contributionApiClient.createContribution(data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["contributions"] });
|
|
||||||
if (variables.userId) {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["user-contributions", variables.userId],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateContribution() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
contributionId,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
contributionId: string;
|
|
||||||
data: UpdateContribution;
|
|
||||||
}) => contributionApiClient.updateContribution(contributionId, data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["contribution", variables.contributionId],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["contributions"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteContribution() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (contributionId: string) =>
|
|
||||||
contributionApiClient.deleteContribution(contributionId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["contributions"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["user-contributions"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["pending-contributions"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useReviewContribution() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
contributionId,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
contributionId: string;
|
|
||||||
data: ReviewContributionRequest;
|
|
||||||
}) => contributionApiClient.reviewContribution(contributionId, data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["contribution", variables.contributionId],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["contributions"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["pending-contributions"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSubmitContribution() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (contributionId: string) =>
|
|
||||||
contributionApiClient.submitContribution(contributionId),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["contribution", variables] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["contributions"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["pending-contributions"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { languageApiClient } from "@/api/language-api-client";
|
|
||||||
|
|
||||||
// Query hooks for languages
|
|
||||||
export function useLanguages() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["languages"],
|
|
||||||
queryFn: () => languageApiClient.getLanguages(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLanguage(code: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["language", code],
|
|
||||||
queryFn: () => languageApiClient.getLanguage(code),
|
|
||||||
enabled: !!code,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import type { SearchFilters, SearchParams } from "@/api/search-api-client";
|
|
||||||
import { searchApiClient } from "@/api/search-api-client";
|
|
||||||
|
|
||||||
// Query hooks
|
|
||||||
export function useSearch(params: SearchParams) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["search", params],
|
|
||||||
queryFn: () => searchApiClient.search(params),
|
|
||||||
enabled: !!params.query && params.query.length > 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAdvancedSearch(params: SearchParams) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["advanced-search", params],
|
|
||||||
queryFn: () => searchApiClient.advancedSearch(params),
|
|
||||||
enabled: !!params.query && params.query.length > 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useQuickSearch(query: string, limit = 5) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["quick-search", query, limit],
|
|
||||||
queryFn: () => searchApiClient.quickSearch(query, limit),
|
|
||||||
enabled: !!query && query.length > 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSearchSuggestions(query: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["search-suggestions", query],
|
|
||||||
queryFn: () => searchApiClient.getSearchSuggestions(query),
|
|
||||||
enabled: !!query && query.length > 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSearchWorks(query: string, filters?: SearchFilters) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["search-works", query, filters],
|
|
||||||
queryFn: () => searchApiClient.searchWorks(query, filters),
|
|
||||||
enabled: !!query && query.length > 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSearchAuthors(query: string, filters?: SearchFilters) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["search-authors", query, filters],
|
|
||||||
queryFn: () => searchApiClient.searchAuthors(query, filters),
|
|
||||||
enabled: !!query && query.length > 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSearchTranslations(query: string, filters?: SearchFilters) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["search-translations", query, filters],
|
|
||||||
queryFn: () => searchApiClient.searchTranslations(query, filters),
|
|
||||||
enabled: !!query && query.length > 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useBrowseByLanguage(language: string, limit?: number) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["browse-language", language, limit],
|
|
||||||
queryFn: () => searchApiClient.browseByLanguage(language, limit),
|
|
||||||
enabled: !!language,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useBrowseByTag(tag: string, limit?: number) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["browse-tag", tag, limit],
|
|
||||||
queryFn: () => searchApiClient.browseByTag(tag, limit),
|
|
||||||
enabled: !!tag,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useBrowseByAuthor(authorId: string, limit?: number) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["browse-author", authorId, limit],
|
|
||||||
queryFn: () => searchApiClient.browseByAuthor(authorId, limit),
|
|
||||||
enabled: !!authorId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,147 +0,0 @@
|
|||||||
import type { CreateTag, UpdateTag } from "@shared/schema";
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import type { TagSearchParams } from "@/api/tag-api-client";
|
|
||||||
import { tagApiClient } from "@/api/tag-api-client";
|
|
||||||
|
|
||||||
// Query hooks
|
|
||||||
export function useTag(tagId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["tag", tagId],
|
|
||||||
queryFn: () => tagApiClient.getTag(tagId),
|
|
||||||
enabled: !!tagId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTagByName(name: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["tag-by-name", name],
|
|
||||||
queryFn: () => tagApiClient.getTagByName(name),
|
|
||||||
enabled: !!name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTags(params?: TagSearchParams) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["tags", params],
|
|
||||||
queryFn: () => tagApiClient.getTags(params),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTagWorks(tagId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["tag-works", tagId],
|
|
||||||
queryFn: () => tagApiClient.getTagWorks(tagId),
|
|
||||||
enabled: !!tagId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorksByTagName(tagName: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["works-by-tag-name", tagName],
|
|
||||||
queryFn: () => tagApiClient.getWorksByTagName(tagName),
|
|
||||||
enabled: !!tagName,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePopularTags(limit = 20) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["popular-tags", limit],
|
|
||||||
queryFn: () => tagApiClient.getPopularTags(limit),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTagsByType(type: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["tags-by-type", type],
|
|
||||||
queryFn: () => tagApiClient.getTagsByType(type),
|
|
||||||
enabled: !!type,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSearchTags(query: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["search-tags", query],
|
|
||||||
queryFn: () => tagApiClient.searchTags(query),
|
|
||||||
enabled: !!query && query.length > 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkTags(workId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["work-tags", workId],
|
|
||||||
queryFn: () => tagApiClient.getWorkTags(workId),
|
|
||||||
enabled: !!workId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mutation hooks
|
|
||||||
export function useCreateTag() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: CreateTag) => tagApiClient.createTag(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["tags"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["popular-tags"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateTag() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ tagId, data }: { tagId: string; data: UpdateTag }) =>
|
|
||||||
tagApiClient.updateTag(tagId, data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["tag", variables.tagId] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["tags"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteTag() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (tagId: string) => tagApiClient.deleteTag(tagId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["tags"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["popular-tags"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAddTagToWork() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ workId, tagId }: { workId: string; tagId: string }) =>
|
|
||||||
tagApiClient.addTagToWork(workId, tagId),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["work-tags", variables.workId],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["tag-works", variables.tagId],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRemoveTagFromWork() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ workId, tagId }: { workId: string; tagId: string }) =>
|
|
||||||
tagApiClient.removeTagFromWork(workId, tagId),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["work-tags", variables.workId],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["tag-works", variables.tagId],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,111 +0,0 @@
|
|||||||
import type { CreateTranslation, UpdateTranslation } from "@shared/schema";
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import type { TranslationSearchParams } from "@/api/translation-api-client";
|
|
||||||
import { translationApiClient } from "@/api/translation-api-client";
|
|
||||||
|
|
||||||
// Query hooks
|
|
||||||
export function useTranslation(translationId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["translation", translationId],
|
|
||||||
queryFn: () => translationApiClient.getTranslation(translationId),
|
|
||||||
enabled: !!translationId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTranslations(params?: TranslationSearchParams) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["translations", params],
|
|
||||||
queryFn: () => translationApiClient.getTranslations(params),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkTranslations(
|
|
||||||
workId: string,
|
|
||||||
params?: { language?: string },
|
|
||||||
) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["work-translations", workId, params],
|
|
||||||
queryFn: () => translationApiClient.getWorkTranslations(workId, params),
|
|
||||||
enabled: !!workId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTranslatorTranslations(translatorId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["translator-translations", translatorId],
|
|
||||||
queryFn: () => translationApiClient.getTranslatorTranslations(translatorId),
|
|
||||||
enabled: !!translatorId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mutation hooks
|
|
||||||
export function useCreateTranslation() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: CreateTranslation) =>
|
|
||||||
translationApiClient.createTranslation(data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["translations"] });
|
|
||||||
if (variables.workId) {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["work-translations", variables.workId],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateWorkTranslation() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
workId,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
workId: string;
|
|
||||||
data: Omit<CreateTranslation, "workId">;
|
|
||||||
}) => translationApiClient.createWorkTranslation(workId, data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["work-translations", variables.workId],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["translations"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateTranslation() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
translationId,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
translationId: string;
|
|
||||||
data: UpdateTranslation;
|
|
||||||
}) => translationApiClient.updateTranslation(translationId, data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["translation", variables.translationId],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["translations"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["work-translations"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteTranslation() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (translationId: string) =>
|
|
||||||
translationApiClient.deleteTranslation(translationId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["translations"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["work-translations"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,181 +0,0 @@
|
|||||||
import type { UpdateUser, UpdateUserProfile } from "@shared/schema";
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import type {
|
|
||||||
ChangePasswordRequest,
|
|
||||||
LoginRequest,
|
|
||||||
RegisterRequest,
|
|
||||||
} from "@/api/user-api-client";
|
|
||||||
import { userApiClient } from "@/api/user-api-client";
|
|
||||||
|
|
||||||
// Query hooks
|
|
||||||
export function useUser(userId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["user", userId],
|
|
||||||
queryFn: () => userApiClient.getUser(userId),
|
|
||||||
enabled: !!userId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUserByEmail(email: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["user-by-email", email],
|
|
||||||
queryFn: () => userApiClient.getUserByEmail(email),
|
|
||||||
enabled: !!email,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUserByUsername(username: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["user-by-username", username],
|
|
||||||
queryFn: () => userApiClient.getUserByUsername(username),
|
|
||||||
enabled: !!username,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUsers(params?: {
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
role?: string;
|
|
||||||
}) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["users", params],
|
|
||||||
queryFn: () => userApiClient.getUsers(params),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCurrentUser() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["current-user"],
|
|
||||||
queryFn: () => userApiClient.getCurrentUser(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUserProfile(userId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["user-profile", userId],
|
|
||||||
queryFn: () => userApiClient.getUserProfile(userId),
|
|
||||||
enabled: !!userId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUserStats(userId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["user-stats", userId],
|
|
||||||
queryFn: () => userApiClient.getUserStats(userId),
|
|
||||||
enabled: !!userId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mutation hooks
|
|
||||||
export function useLogin() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: LoginRequest) => userApiClient.login(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["current-user"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRegister() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: RegisterRequest) => userApiClient.register(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["current-user"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLogout() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: () => userApiClient.logout(),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.clear();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateUser() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ userId, data }: { userId: string; data: UpdateUser }) =>
|
|
||||||
userApiClient.updateUser(userId, data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["user", variables.userId] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["current-user"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteUser() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (userId: string) => userApiClient.deleteUser(userId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useChangePassword() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: ChangePasswordRequest) =>
|
|
||||||
userApiClient.changePassword(data),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateUserProfile() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
userId,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
userId: string;
|
|
||||||
data: UpdateUserProfile;
|
|
||||||
}) => userApiClient.updateUserProfile(userId, data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["user-profile", variables.userId],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useForgotPassword() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (email: string) => userApiClient.forgotPassword(email),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useResetPassword() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
token,
|
|
||||||
newPassword,
|
|
||||||
}: {
|
|
||||||
token: string;
|
|
||||||
newPassword: string;
|
|
||||||
}) => userApiClient.resetPassword(token, newPassword),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useVerifyEmail() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (token: string) => userApiClient.verifyEmail(token),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useResendVerificationEmail() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (email: string) => userApiClient.resendVerificationEmail(email),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,143 +0,0 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import type {
|
|
||||||
CreateWorkRequest,
|
|
||||||
LikeWorkRequest,
|
|
||||||
UpdateReadingProgressRequest,
|
|
||||||
UpdateWorkRequest,
|
|
||||||
} from "@/api/work-api-client";
|
|
||||||
import { workApiClient } from "@/api/work-api-client";
|
|
||||||
|
|
||||||
// TODO: Move to environment configuration or user context
|
|
||||||
const DEFAULT_USER_ID = "1";
|
|
||||||
|
|
||||||
// Query hooks
|
|
||||||
export function useWork(workSlug: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["work", workSlug],
|
|
||||||
queryFn: () => workApiClient.getWork(workSlug),
|
|
||||||
enabled: !!workSlug,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorks() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["works"],
|
|
||||||
queryFn: () => workApiClient.getWorks(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkTranslations(workSlug: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["work-translations", workSlug],
|
|
||||||
queryFn: () => workApiClient.getWorkTranslations(workSlug),
|
|
||||||
enabled: !!workSlug,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useReadingProgress(
|
|
||||||
workId: string,
|
|
||||||
userId: string = DEFAULT_USER_ID,
|
|
||||||
) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["reading-progress", userId, workId],
|
|
||||||
queryFn: () => workApiClient.getReadingProgress(userId, workId),
|
|
||||||
enabled: !!workId && !!userId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSearchWorks(query: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["search-works", query],
|
|
||||||
queryFn: () => workApiClient.searchWorks(query),
|
|
||||||
enabled: !!query && query.length > 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorksByAuthor(authorId: number) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["works-by-author", authorId],
|
|
||||||
queryFn: () => workApiClient.getWorksByAuthor(authorId),
|
|
||||||
enabled: !!authorId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorksByTag(tag: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["works-by-tag", tag],
|
|
||||||
queryFn: () => workApiClient.getWorksByTag(tag),
|
|
||||||
enabled: !!tag,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mutation hooks
|
|
||||||
export function useCreateWork() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: CreateWorkRequest) => workApiClient.createWork(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["works"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateWork() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: UpdateWorkRequest) => workApiClient.updateWork(data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["work", variables.id] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["works"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteWork() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (workId: number) => workApiClient.deleteWork(workId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["works"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateReadingProgress() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: UpdateReadingProgressRequest) =>
|
|
||||||
workApiClient.updateReadingProgress(data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["reading-progress", variables.userId, variables.workId],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLikeWork() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: LikeWorkRequest) => workApiClient.likeWork(data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["work", variables.workId] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["works"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUnlikeWork() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: LikeWorkRequest) => workApiClient.unlikeWork(data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["work", variables.workId] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["works"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
export type { AnalyticsParams } from "./analytics-api-client";
|
|
||||||
export * from "./analytics-api-client";
|
|
||||||
export { analyticsApiClient } from "./analytics-api-client";
|
|
||||||
export type { AuthorSearchParams } from "./author-api-client";
|
|
||||||
export * from "./author-api-client";
|
|
||||||
export { authorApiClient } from "./author-api-client";
|
|
||||||
export * from "./blog-api";
|
|
||||||
export type { BookmarkSearchParams } from "./bookmark-api-client";
|
|
||||||
export * from "./bookmark-api-client";
|
|
||||||
export { bookmarkApiClient } from "./bookmark-api-client";
|
|
||||||
export type { CollectionSearchParams } from "./collection-api-client";
|
|
||||||
export * from "./collection-api-client";
|
|
||||||
export { collectionApiClient } from "./collection-api-client";
|
|
||||||
export type { CommentSearchParams } from "./comment-api-client";
|
|
||||||
export * from "./comment-api-client";
|
|
||||||
export { commentApiClient } from "./comment-api-client";
|
|
||||||
export type {
|
|
||||||
ContributionSearchParams,
|
|
||||||
ReviewContributionRequest,
|
|
||||||
} from "./contribution-api-client";
|
|
||||||
export * from "./contribution-api-client";
|
|
||||||
export { contributionApiClient } from "./contribution-api-client";
|
|
||||||
export type { Language } from "./language-api-client";
|
|
||||||
export * from "./language-api-client";
|
|
||||||
export { languageApiClient } from "./language-api-client";
|
|
||||||
export type {
|
|
||||||
AdvancedSearchResults,
|
|
||||||
SearchFilters,
|
|
||||||
SearchParams,
|
|
||||||
SearchResults,
|
|
||||||
} from "./search-api-client";
|
|
||||||
export * from "./search-api-client";
|
|
||||||
export { searchApiClient } from "./search-api-client";
|
|
||||||
export * from "./tag-api";
|
|
||||||
export type { TagSearchParams } from "./tag-api-client";
|
|
||||||
export * from "./tag-api-client";
|
|
||||||
export { tagApiClient } from "./tag-api-client";
|
|
||||||
|
|
||||||
export type { TranslationSearchParams } from "./translation-api-client";
|
|
||||||
export * from "./translation-api-client";
|
|
||||||
export { translationApiClient } from "./translation-api-client";
|
|
||||||
export type {
|
|
||||||
AuthResponse,
|
|
||||||
ChangePasswordRequest,
|
|
||||||
LoginRequest,
|
|
||||||
RegisterRequest,
|
|
||||||
} from "./user-api-client";
|
|
||||||
export * from "./user-api-client";
|
|
||||||
export { userApiClient } from "./user-api-client";
|
|
||||||
// Type exports for convenience
|
|
||||||
export type {
|
|
||||||
CreateWorkRequest,
|
|
||||||
LikeWorkRequest,
|
|
||||||
UpdateReadingProgressRequest,
|
|
||||||
UpdateWorkRequest,
|
|
||||||
} from "./work-api-client";
|
|
||||||
export * from "./work-api-client"; // API Client exports
|
|
||||||
export { workApiClient } from "./work-api-client";
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
import { apiRequest } from "@/lib/queryClient";
|
|
||||||
import type { Tag } from "@/shared/schema";
|
|
||||||
|
|
||||||
export async function get_all_tags(): Promise<Tag[]> {
|
|
||||||
return apiRequest("GET", "/api/tags");
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
import { apiRequest } from "@/lib/queryClient";
|
|
||||||
import type { Tag } from "@/shared/schema";
|
|
||||||
|
|
||||||
export async function getAllTags(): Promise<Tag[]> {
|
|
||||||
return apiRequest("GET", "/api/tags");
|
|
||||||
}
|
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
type Annotation,
|
type Annotation,
|
||||||
type Author,
|
type Author,
|
||||||
annotationSchema,
|
|
||||||
authorSchema,
|
authorSchema,
|
||||||
type Book,
|
type Book,
|
||||||
bookSchema,
|
bookSchema,
|
||||||
|
|||||||
@ -7,14 +7,14 @@ import { WorkCard } from "@/components/common/WorkCard";
|
|||||||
import { FilterSidebar } from "@/components/explore/FilterSidebar";
|
import { FilterSidebar } from "@/components/explore/FilterSidebar";
|
||||||
import { PageLayout } from "@/components/layout/PageLayout";
|
import { PageLayout } from "@/components/layout/PageLayout";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type { WorkWithAuthor } from "@/lib/types";
|
import type { Tag } from "@shared/schema";
|
||||||
|
|
||||||
interface FilterState {
|
interface FilterState {
|
||||||
language?: string;
|
language?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
yearStart?: number;
|
yearStart?: number;
|
||||||
yearEnd?: number;
|
yearEnd?: number;
|
||||||
tags?: number[];
|
tags?: string[];
|
||||||
query?: string;
|
query?: string;
|
||||||
sort?: string;
|
sort?: string;
|
||||||
page: number;
|
page: number;
|
||||||
@ -57,7 +57,7 @@ export default function Explore() {
|
|||||||
|
|
||||||
if (searchParams.has("tags")) {
|
if (searchParams.has("tags")) {
|
||||||
newFilters.tags =
|
newFilters.tags =
|
||||||
searchParams.get("tags")?.split(",").map(Number) || undefined;
|
searchParams.get("tags")?.split(",") || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchParams.has("sort")) {
|
if (searchParams.has("sort")) {
|
||||||
@ -114,18 +114,18 @@ export default function Explore() {
|
|||||||
|
|
||||||
const queryString = getQueryString();
|
const queryString = getQueryString();
|
||||||
|
|
||||||
const { data: works, isLoading } = useQuery<WorkWithAuthor[]>({
|
const { data: works, isLoading } = useQuery({
|
||||||
queryKey: [`/api/filter?${queryString}`],
|
queryKey: [`/api/filter?${queryString}`],
|
||||||
select: (data) =>
|
select: (data: any[]) =>
|
||||||
data.map((work) => ({
|
data.map((work) => ({
|
||||||
...work,
|
...work,
|
||||||
tags: work.tags?.map((tag) =>
|
tags: work.tags?.map((tag: any) =>
|
||||||
typeof tag === "string" ? { name: tag } : tag,
|
typeof tag === "string" ? { name: tag, id: tag, type: "general", createdAt: "" } : tag,
|
||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: tags } = useQuery({
|
const { data: tags } = useQuery<Tag[]>({
|
||||||
queryKey: ["/api/tags"],
|
queryKey: ["/api/tags"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -6,19 +6,17 @@ import { WorkCard } from "@/components/common/WorkCard";
|
|||||||
import { PageLayout } from "@/components/layout/PageLayout";
|
import { PageLayout } from "@/components/layout/PageLayout";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import type { AuthorWithWorks, WorkWithAuthor } from "@/lib/types";
|
import type { WorkWithAuthor } from "@/lib/types";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { data: featuredAuthors, isLoading: authorsLoading } = useQuery<
|
const { data: featuredAuthors, isLoading: authorsLoading } = useQuery({
|
||||||
AuthorWithWorks[]
|
|
||||||
>({
|
|
||||||
queryKey: ["/api/authors?limit=4"],
|
queryKey: ["/api/authors?limit=4"],
|
||||||
select: (data) =>
|
select: (data: any[]) =>
|
||||||
data.map((author) => ({
|
data.map((author) => ({
|
||||||
...author,
|
...author,
|
||||||
country:
|
country:
|
||||||
author.country && typeof author.country === "object"
|
author.country && typeof author.country === "object"
|
||||||
? author.country.name
|
? (author.country as any).name
|
||||||
: author.country,
|
: author.country,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -464,14 +464,12 @@ export default function Search() {
|
|||||||
viewMode === "list" ? (
|
viewMode === "list" ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{displayWorks.map((work) => (
|
{displayWorks.map((work) => (
|
||||||
// @ts-expect-error - Work type mismatch due to tag transformation
|
|
||||||
<WorkCard key={work.id} work={work} />
|
<WorkCard key={work.id} work={work} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{displayWorks.map((work) => (
|
{displayWorks.map((work) => (
|
||||||
// @ts-expect-error - Work type mismatch due to tag transformation
|
|
||||||
<WorkCard key={work.id} work={work} grid />
|
<WorkCard key={work.id} work={work} grid />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import { useState } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { PageLayout } from "@/components/layout/PageLayout";
|
import { PageLayout } from "@/components/layout/PageLayout";
|
||||||
import { LineNumberedText } from "@/components/reading/LineNumberedText";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@ -550,10 +549,9 @@ export default function Submit() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="prose dark:prose-invert max-w-none">
|
<div className="prose dark:prose-invert max-w-none">
|
||||||
<LineNumberedText
|
<pre className="whitespace-pre-wrap font-serif">
|
||||||
content={form.getValues().content}
|
{form.getValues().content}
|
||||||
fontSizeClass="text-size-md"
|
</pre>
|
||||||
/>
|
|
||||||
|
|
||||||
{form.getValues().notes && (
|
{form.getValues().notes && (
|
||||||
<div className="mt-6 pt-4 border-t border-sage/20 dark:border-sage/10">
|
<div className="mt-6 pt-4 border-t border-sage/20 dark:border-sage/10">
|
||||||
|
|||||||
@ -76,9 +76,16 @@ export default function AuthorProfile() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get works for the author
|
// Get works for the author
|
||||||
const { data: works, isLoading: worksLoading } = useQuery<WorkWithAuthor[]>({
|
const { data: works, isLoading: worksLoading } = useQuery({
|
||||||
queryKey: [`/api/authors/${slug}/works`],
|
queryKey: [`/api/authors/${slug}/works`],
|
||||||
enabled: !!author,
|
enabled: !!author,
|
||||||
|
select: (data: any[]) =>
|
||||||
|
data.map((work) => ({
|
||||||
|
...work,
|
||||||
|
tags: work.tags?.map((tag: any) =>
|
||||||
|
typeof tag === "string" ? { name: tag, id: tag, type: "general", createdAt: "" } : tag,
|
||||||
|
),
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get timeline events
|
// Get timeline events
|
||||||
@ -117,7 +124,7 @@ export default function AuthorProfile() {
|
|||||||
const genres = Array.from(
|
const genres = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
works
|
works
|
||||||
?.flatMap((work) => work.tags?.map((tag) => tag.name) || [])
|
?.flatMap((work) => work.tags?.map((tag: any) => tag.name) || [])
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@ -132,7 +139,7 @@ export default function AuthorProfile() {
|
|||||||
const filteredWorks = works?.filter((work) => {
|
const filteredWorks = works?.filter((work) => {
|
||||||
if (
|
if (
|
||||||
selectedGenre &&
|
selectedGenre &&
|
||||||
(!work.tags || !work.tags.some((tag) => tag.name === selectedGenre))
|
(!work.tags || !work.tags.some((tag: any) => tag.name === selectedGenre))
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ const BlogEdit: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchPost() {
|
async function fetchPost() {
|
||||||
try {
|
try {
|
||||||
|
if (!id) throw new Error("No ID provided");
|
||||||
const data = await getBlogPost(id);
|
const data = await getBlogPost(id);
|
||||||
setPost(data);
|
setPost(data);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@ -49,7 +49,7 @@ import type { BlogPostListItem } from "@/lib/types";
|
|||||||
export default function BlogManagement() {
|
export default function BlogManagement() {
|
||||||
const { canManageContent } = useAuth();
|
const { canManageContent } = useAuth();
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [postToDelete, setPostToDelete] = useState<number | null>(null);
|
const [postToDelete, setPostToDelete] = useState<string | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@ -60,10 +60,8 @@ export default function BlogManagement() {
|
|||||||
|
|
||||||
// Delete mutation
|
// Delete mutation
|
||||||
const deletePostMutation = useMutation({
|
const deletePostMutation = useMutation({
|
||||||
mutationFn: async (postId: number) => {
|
mutationFn: async (postId: string) => {
|
||||||
return apiRequest(`/api/blog/${postId}`, {
|
return apiRequest("DELETE", `/api/blog/${postId}`);
|
||||||
method: "DELETE",
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/blog"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/blog"] });
|
||||||
|
|||||||
@ -214,7 +214,7 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
: recentWorks?.slice(0, 3).map((work: any) => (
|
: Array.isArray(recentWorks) && recentWorks.slice(0, 3).map((work: any) => (
|
||||||
<Link key={work.id} href={`/works/${work.slug}`}>
|
<Link key={work.id} href={`/works/${work.slug}`}>
|
||||||
<div className="flex items-center gap-4 group cursor-pointer">
|
<div className="flex items-center gap-4 group cursor-pointer">
|
||||||
<div className="h-12 w-12 rounded-md bg-muted flex items-center justify-center">
|
<div className="h-12 w-12 rounded-md bg-muted flex items-center justify-center">
|
||||||
|
|||||||
@ -25,7 +25,6 @@ export default function Profile() {
|
|||||||
// Fetch user's bookmarks with work details
|
// Fetch user's bookmarks with work details
|
||||||
const { data: bookmarks, isLoading: bookmarksLoading } = useQuery({
|
const { data: bookmarks, isLoading: bookmarksLoading } = useQuery({
|
||||||
queryKey: [`/api/users/${DEMO_USER_ID}/bookmarks`],
|
queryKey: [`/api/users/${DEMO_USER_ID}/bookmarks`],
|
||||||
// @ts-expect-error - Complex type transformation causing inference issues
|
|
||||||
select: (data: any[]) =>
|
select: (data: any[]) =>
|
||||||
data.map((bookmark) => ({
|
data.map((bookmark) => ({
|
||||||
...bookmark,
|
...bookmark,
|
||||||
|
|||||||
@ -1,216 +0,0 @@
|
|||||||
import type { Translation, Work } from "@shared/schema";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Maximize2, Minimize2 } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Link, useParams } from "wouter";
|
|
||||||
import { PageLayout } from "@/components/layout/PageLayout";
|
|
||||||
import { LineNumberedText } from "@/components/reading/LineNumberedText";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import { useReadingSettings } from "@/hooks/use-reading-settings";
|
|
||||||
|
|
||||||
export default function WorkCompare() {
|
|
||||||
const { slug, translationId } = useParams();
|
|
||||||
const { settings, toggleZenMode } = useReadingSettings();
|
|
||||||
const [selectedTranslationId, setSelectedTranslationId] = useState<number>(
|
|
||||||
parseInt(translationId || "0", 10),
|
|
||||||
);
|
|
||||||
const [highlightedLine, setHighlightedLine] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const { data: work, isLoading: workLoading } = useQuery<Work>({
|
|
||||||
queryKey: [`/api/works/${slug}`],
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: translations, isLoading: translationsLoading } = useQuery<
|
|
||||||
Translation[]
|
|
||||||
>({
|
|
||||||
queryKey: [`/api/works/${slug}/translations`],
|
|
||||||
enabled: !!work,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: selectedTranslation, isLoading: translationLoading } =
|
|
||||||
useQuery<Translation>({
|
|
||||||
queryKey: [`/api/translations/${selectedTranslationId}`],
|
|
||||||
enabled: !!selectedTranslationId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleLineClick = (lineNumber: number) => {
|
|
||||||
setHighlightedLine(lineNumber);
|
|
||||||
|
|
||||||
// Scroll the corresponding line in the other panel into view
|
|
||||||
const otherPanelElement = document.getElementById(
|
|
||||||
`line-${lineNumber}-other`,
|
|
||||||
);
|
|
||||||
if (otherPanelElement) {
|
|
||||||
otherPanelElement.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (workLoading || translationsLoading || translationLoading) {
|
|
||||||
return (
|
|
||||||
<PageLayout>
|
|
||||||
<div className="max-w-[var(--content-width)] mx-auto px-4 py-8">
|
|
||||||
<Skeleton className="h-12 w-3/4 mb-8" />
|
|
||||||
<div className="flex flex-col md:flex-row gap-8">
|
|
||||||
<div className="flex-1">
|
|
||||||
<Skeleton className="h-8 w-1/2 mb-4" />
|
|
||||||
<div className="space-y-2">
|
|
||||||
{Array.from({ length: 8 }).map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-6" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<Skeleton className="h-8 w-1/2 mb-4" />
|
|
||||||
<div className="space-y-2">
|
|
||||||
{Array.from({ length: 8 }).map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-6" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!work || !translations || !selectedTranslation) {
|
|
||||||
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">Content not found</h1>
|
|
||||||
<p className="mb-6">
|
|
||||||
The work or translation you're looking for could not be found.
|
|
||||||
</p>
|
|
||||||
<Link href="/explore">
|
|
||||||
<Button>Explore Works</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout zenMode={settings.zenMode}>
|
|
||||||
<div className={`compare-view ${settings.zenMode ? "zen-mode" : ""}`}>
|
|
||||||
<div className="sticky top-16 z-10 bg-cream dark:bg-dark-bg border-b border-sage/20 dark:border-sage/10 shadow-sm">
|
|
||||||
<div className="max-w-[var(--content-width)] mx-auto px-4 md:px-6 py-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h1 className="text-xl font-bold font-serif text-navy dark:text-cream">
|
|
||||||
{work.title}{" "}
|
|
||||||
<span className="text-navy/60 dark:text-cream/60 font-normal text-base">
|
|
||||||
— Translation Comparison
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="btn-feedback p-2 rounded-full text-navy/70 dark:text-cream/70 hover:bg-navy/10 dark:hover:bg-cream/10"
|
|
||||||
onClick={toggleZenMode}
|
|
||||||
>
|
|
||||||
{settings.zenMode ? (
|
|
||||||
<Minimize2 className="h-5 w-5" />
|
|
||||||
) : (
|
|
||||||
<Maximize2 className="h-5 w-5" />
|
|
||||||
)}
|
|
||||||
<span className="sr-only">
|
|
||||||
{settings.zenMode ? "Exit zen mode" : "Zen mode"}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Link href={`/works/${slug}`}>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="py-1.5 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-sm transition-colors"
|
|
||||||
>
|
|
||||||
Exit Compare
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row max-w-[var(--content-width)] mx-auto px-4 md:px-6 py-6">
|
|
||||||
{/* Left panel (original) */}
|
|
||||||
<div className="flex-1 md:pr-6 md:border-r border-sage/20 dark:border-sage/10">
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-medium font-serif text-navy dark:text-cream">
|
|
||||||
{work.language} (Original)
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-navy/70 dark:text-cream/70">
|
|
||||||
{work.author?.name || "Unknown author"},{" "}
|
|
||||||
{work.year || "Unknown year"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<LineNumberedText
|
|
||||||
content={work.content}
|
|
||||||
fontSizeClass={settings.fontSize}
|
|
||||||
onLineClick={handleLineClick}
|
|
||||||
highlightedLine={highlightedLine || undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right panel (translation) */}
|
|
||||||
<div className="flex-1 md:pl-6 mt-8 md:mt-0">
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-medium font-serif text-navy dark:text-cream">
|
|
||||||
{selectedTranslation.language}
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-navy/70 dark:text-cream/70">
|
|
||||||
Translated by Translator {selectedTranslation.translatorId},{" "}
|
|
||||||
{selectedTranslation.year || "Unknown year"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Select
|
|
||||||
value={selectedTranslationId.toString()}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setSelectedTranslationId(parseInt(value, 10))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[200px]">
|
|
||||||
<SelectValue placeholder="Select translation" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{translations.map((translation) => (
|
|
||||||
<SelectItem
|
|
||||||
key={translation.id}
|
|
||||||
value={translation.id.toString()}
|
|
||||||
>
|
|
||||||
{translation.language} - Translator{" "}
|
|
||||||
{translation.translatorId}{" "}
|
|
||||||
{translation.year ? `(${translation.year})` : ""}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<LineNumberedText
|
|
||||||
content={selectedTranslation.content}
|
|
||||||
fontSizeClass={settings.fontSize}
|
|
||||||
onLineClick={handleLineClick}
|
|
||||||
highlightedLine={highlightedLine || undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { BookOpen } from "lucide-react";
|
|
||||||
import { Link, useParams } from "wouter";
|
|
||||||
import { PageLayout } from "@/components/layout/PageLayout";
|
|
||||||
import { EnhancedReadingView } from "@/components/reading/EnhancedReadingView";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import type { TranslationWithDetails, WorkWithDetails } from "@/lib/types";
|
|
||||||
|
|
||||||
export default function WorkReading() {
|
|
||||||
const { slug } = useParams();
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: work,
|
|
||||||
isLoading: workLoading,
|
|
||||||
error: workError,
|
|
||||||
} = useQuery<WorkWithDetails>({
|
|
||||||
queryKey: [`/api/works/${slug}`],
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: translations } = useQuery<
|
|
||||||
TranslationWithDetails[]
|
|
||||||
>({
|
|
||||||
queryKey: [`/api/works/${slug}/translations`],
|
|
||||||
enabled: !!work,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (workLoading) {
|
|
||||||
return (
|
|
||||||
<PageLayout>
|
|
||||||
<div className="max-w-[var(--content-width)] mx-auto px-4 py-8">
|
|
||||||
<div className="flex flex-col lg:flex-row gap-8">
|
|
||||||
{/* Sidebar skeleton */}
|
|
||||||
<div className="lg:w-64">
|
|
||||||
<Skeleton className="h-16 w-full mb-4" />
|
|
||||||
<Skeleton className="h-32 w-full mb-4" />
|
|
||||||
<Skeleton className="h-24 w-full mb-4" />
|
|
||||||
<Skeleton className="h-16 w-full" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main content skeleton */}
|
|
||||||
<div className="flex-1">
|
|
||||||
<Skeleton className="h-12 w-3/4 mb-4" />
|
|
||||||
<Skeleton className="h-8 w-1/2 mb-6" />
|
|
||||||
|
|
||||||
<div className="space-y-3 max-w-2xl mx-auto">
|
|
||||||
{Array.from({ length: 8 }).map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-6" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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">Work not found</h1>
|
|
||||||
<p className="mb-6">
|
|
||||||
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-russet/30" />
|
|
||||||
<Link href="/explore">
|
|
||||||
<Button className="bg-russet hover:bg-russet/90">
|
|
||||||
Explore Works
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout>
|
|
||||||
<EnhancedReadingView work={work} translations={translations || []} />
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user