Enable users to create and modify literary works with metadata and content

Implements the WorkEditor component with form using React Hook Form and Zod.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: bddfbb2b-6d6b-457b-b18c-05792cdaa035
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/39b5c689-6e8a-4d5a-9792-69cc81a56534/e62d5cbe-5096-47f5-a9d2-160416058752.jpg
This commit is contained in:
mukimovd 2025-05-10 21:53:28 +00:00
parent 5fc570e3f2
commit 47743e62cc
2 changed files with 723 additions and 1 deletions

View File

@ -27,7 +27,7 @@ This document tracks the implementation status of all components for the Tercul
| Component | Status | File Path | Notes |
|-----------|--------|-----------|-------|
| Work Preview | ✅ Implemented | `client/src/components/work/work-preview.tsx` | Complete with multiple display variants |
| Work Editor | ⬜️ Planned | `client/src/components/work/work-editor.tsx` | |
| Work Editor | ✅ Implemented | `client/src/components/work/work-editor.tsx` | Complete form with metadata, content editor, and preview |
| Work Header | ✅ Implemented | `client/src/components/work/work-header.tsx` | Complete with metadata, actions, and stats display |
| Comparison View | ⬜️ Planned | `client/src/components/work/comparison-view.tsx` | |

View File

@ -0,0 +1,722 @@
import { cn } from "@/lib/utils";
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import TagInput, { TagItem } from "@/components/ui/tag-input";
import { FileUploader } from "@/components/ui/file-uploader";
import { RichTextEditor } from "@/components/ui/rich-text-editor";
import { Separator } from "@/components/ui/separator";
import { AlertCircle, BookOpen, BookText, Trash2 } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Author } from "./work-header";
/**
* Work Editor component for creating and editing literary works
*
* @example
* ```tsx
* <WorkEditor
* work={existingWork}
* authors={authors}
* languages={languages}
* genres={genres}
* onSave={handleSave}
* onCancel={handleCancel}
* />
* ```
*/
// Define the schema for the work form
const workFormSchema = z.object({
title: z.string().min(2, "Title must be at least 2 characters").max(200, "Title must be less than 200 characters"),
subtitle: z.string().optional(),
authorId: z.string().min(1, "Author is required"),
description: z.string().optional(),
publicationYear: z.string()
.refine(val => !val || /^\d{4}$/.test(val), {
message: "Year must be a 4-digit number",
})
.transform(val => val ? parseInt(val) : undefined)
.optional(),
language: z.string().min(1, "Language is required"),
content: z.string().min(10, "Content is required").optional(),
status: z.enum(["draft", "review", "published", "archived"]).default("draft"),
});
// Infer the form values type from the schema
type WorkFormValues = z.infer<typeof workFormSchema>;
export interface Work {
id?: number | string;
title: string;
subtitle?: string;
authorId: number | string;
description?: string;
publicationYear?: number;
language: string;
content?: string;
status?: 'draft' | 'review' | 'published' | 'archived';
genres?: string[];
coverImage?: string;
slug?: string;
}
export interface WorkEditorProps {
/**
* Work data for editing (optional, if creating new)
*/
work?: Partial<Work>;
/**
* Available authors
*/
authors: Author[];
/**
* Available languages
*/
languages: { code: string; name: string }[];
/**
* Available genres for selection
*/
genres?: { id: string | number; name: string }[];
/**
* Whether the form is in loading state
*/
isLoading?: boolean;
/**
* Whether the form is in read-only mode
*/
readOnly?: boolean;
/**
* Whether to show translation fields
*/
showTranslationFields?: boolean;
/**
* Whether to show a preview tab
*/
showPreview?: boolean;
/**
* Callback when saving the work
*/
onSave?: (data: Work) => void;
/**
* Callback when publication status changes
*/
onStatusChange?: (status: string) => void;
/**
* Callback when canceling
*/
onCancel?: () => void;
/**
* Callback for deleting a work
*/
onDelete?: (id: number | string) => void;
/**
* CSS class for the container
*/
className?: string;
}
export function WorkEditor({
work,
authors = [],
languages = [],
genres = [],
isLoading = false,
readOnly = false,
showTranslationFields = false,
showPreview = true,
onSave,
onStatusChange,
onCancel,
onDelete,
className,
}: WorkEditorProps) {
const [activeTab, setActiveTab] = useState("basic");
const [coverImage, setCoverImage] = useState<File | null>(null);
const [coverPreview, setCoverPreview] = useState<string | undefined>(work?.coverImage);
const [selectedGenres, setSelectedGenres] = useState<TagItem[]>(
work?.genres?.map(genre => ({ value: genre, label: genre })) || []
);
const [previewMode, setPreviewMode] = useState(false);
const [workContent, setWorkContent] = useState(work?.content || "");
// Initialize form with default values
const form = useForm({
resolver: zodResolver(workFormSchema),
defaultValues: {
title: work?.title || "",
subtitle: work?.subtitle || "",
authorId: work?.authorId?.toString() || "",
description: work?.description || "",
publicationYear: work?.publicationYear?.toString() || "",
language: work?.language || "",
content: work?.content || "",
status: work?.status || "draft",
},
mode: "onChange",
}) as any;
// Watch values for preview
const watchedValues = form.watch();
// Handle form submission
const onSubmit = (formData: any) => {
if (onSave) {
// Convert to our strongly typed form values
const data = formData as WorkFormValues;
// Prepare the work data
const workData: Work = {
title: data.title,
language: data.language,
authorId: data.authorId,
subtitle: data.subtitle,
description: data.description,
publicationYear: data.publicationYear,
content: data.content,
status: data.status,
genres: selectedGenres.map(tag => tag.value),
};
// If there's a new cover image, we'd typically upload it here
// and add the URL to workData
if (coverImage) {
// In a real app, you'd upload the file and get a URL
// workData.coverImage = uploadedUrl;
} else if (coverPreview) {
workData.coverImage = coverPreview;
}
// Add content if we have it
if (workContent) {
workData.content = workContent;
}
// If editing, include the ID
if (work?.id) {
workData.id = work.id;
}
onSave(workData);
}
};
// Handle status change
const handleStatusChange = (status: string) => {
form.setValue("status", status as any);
onStatusChange?.(status);
};
// Handle file selection for cover image
const handleCoverImageSelect = (files: File[]) => {
if (files.length > 0) {
setCoverImage(files[0]);
setCoverPreview(URL.createObjectURL(files[0]));
}
};
return (
<div className={cn("space-y-6", className)}>
{/* Form title */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{work?.id ? "Edit Work" : "Create New Work"}
</h1>
<p className="text-muted-foreground">
{work?.id
? "Update the details of your literary work"
: "Enter the details of your new literary work"
}
</p>
</div>
{/* Status selection for existing works */}
{work?.id && (
<Select
value={form.getValues("status")}
onValueChange={handleStatusChange}
disabled={isLoading || readOnly}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="review">Under Review</SelectItem>
<SelectItem value="published">Published</SelectItem>
<SelectItem value="archived">Archived</SelectItem>
</SelectContent>
</Select>
)}
</div>
{/* Main form */}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<Tabs
defaultValue="basic"
value={activeTab}
onValueChange={setActiveTab}
className="w-full"
>
<TabsList className="grid grid-cols-3">
<TabsTrigger value="basic">Basic Information</TabsTrigger>
<TabsTrigger value="content">
Content
</TabsTrigger>
{showPreview && (
<TabsTrigger value="preview">
Preview
</TabsTrigger>
)}
</TabsList>
{/* Basic Information Tab */}
<TabsContent value="basic" className="space-y-4 py-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Left column - Metadata */}
<div className="md:col-span-2 space-y-4">
<Card>
<CardHeader>
<CardTitle>Work Details</CardTitle>
<CardDescription>Basic information about this literary work</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Title */}
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input
placeholder="Enter the title of your work"
{...field}
disabled={isLoading || readOnly}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Subtitle */}
<FormField
control={form.control}
name="subtitle"
render={({ field }) => (
<FormItem>
<FormLabel>Subtitle (optional)</FormLabel>
<FormControl>
<Input
placeholder="Enter a subtitle if applicable"
{...field}
disabled={isLoading || readOnly}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Author */}
<FormField
control={form.control}
name="authorId"
render={({ field }) => (
<FormItem>
<FormLabel>Author</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
disabled={isLoading || readOnly}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an author" />
</SelectTrigger>
</FormControl>
<SelectContent>
{authors.map((author) => (
<SelectItem
key={author.id.toString()}
value={author.id.toString()}
>
{author.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
{/* Language */}
<FormField
control={form.control}
name="language"
render={({ field }) => (
<FormItem>
<FormLabel>Language</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
disabled={isLoading || readOnly}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select language" />
</SelectTrigger>
</FormControl>
<SelectContent>
{languages.map((language) => (
<SelectItem
key={language.code}
value={language.code}
>
{language.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Publication Year */}
<FormField
control={form.control}
name="publicationYear"
render={({ field }) => (
<FormItem>
<FormLabel>Publication Year</FormLabel>
<FormControl>
<Input
placeholder="e.g. 1866"
{...field}
disabled={isLoading || readOnly}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Description */}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Enter a brief description or synopsis"
className="min-h-[120px]"
{...field}
disabled={isLoading || readOnly}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Genres */}
<FormItem>
<FormLabel>Genres</FormLabel>
<TagInput
placeholder="Add genres"
tags={selectedGenres}
onTagsChange={setSelectedGenres}
suggestions={genres.map(genre => ({
value: genre.name,
label: genre.name
}))}
allowNew={true}
readOnly={readOnly}
disabled={isLoading}
/>
<FormDescription>
Select from existing genres or create new ones
</FormDescription>
</FormItem>
</CardContent>
</Card>
{/* Translation Fields (if applicable) */}
{showTranslationFields && (
<Card>
<CardHeader>
<CardTitle>Translation Information</CardTitle>
<CardDescription>Details about this translation</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="translator">Translator</Label>
<Input
id="translator"
placeholder="Translator's name"
disabled={isLoading || readOnly}
/>
</div>
<div>
<Label htmlFor="translation-year">Translation Year</Label>
<Input
id="translation-year"
placeholder="e.g. 2020"
disabled={isLoading || readOnly}
/>
</div>
</div>
<div>
<Label htmlFor="translation-notes">Translation Notes</Label>
<Textarea
id="translation-notes"
placeholder="Enter any notes about this translation"
className="min-h-[100px]"
disabled={isLoading || readOnly}
/>
</div>
</CardContent>
</Card>
)}
</div>
{/* Right column - Cover Image */}
<div>
<Card>
<CardHeader>
<CardTitle>Cover Image</CardTitle>
<CardDescription>Upload a cover image for your work</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Image preview */}
{coverPreview ? (
<div className="relative aspect-[2/3] rounded-md overflow-hidden border">
<img
src={coverPreview}
alt="Cover preview"
className="object-cover w-full h-full"
/>
{!readOnly && (
<Button
variant="destructive"
size="icon"
className="absolute top-2 right-2 h-8 w-8 rounded-full"
onClick={() => {
setCoverImage(null);
setCoverPreview(undefined);
}}
type="button"
disabled={isLoading}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Remove cover image</span>
</Button>
)}
</div>
) : (
<div className="aspect-[2/3] rounded-md border border-dashed flex items-center justify-center bg-muted/50">
<div className="text-center p-4">
<BookText className="h-8 w-8 mx-auto text-muted-foreground" />
<p className="text-sm text-muted-foreground mt-2">
No cover image
</p>
</div>
</div>
)}
{/* File uploader */}
{!readOnly && (
<FileUploader
onFilesSelected={handleCoverImageSelect}
accept=".jpg, .jpeg, .png"
maxFiles={1}
maxSize={5 * 1024 * 1024} // 5MB
buttonText="Select cover image"
showSelectedFiles={false}
disabled={isLoading}
/>
)}
</CardContent>
</Card>
</div>
</div>
</TabsContent>
{/* Content Tab */}
<TabsContent value="content" className="py-4">
<Card>
<CardHeader>
<CardTitle>Work Content</CardTitle>
<CardDescription>
Enter the content of your literary work
</CardDescription>
{!form.getValues("content") && (
<Alert className="bg-amber-50 text-amber-800 border-amber-200 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-700/30">
<AlertCircle className="h-4 w-4" />
<AlertTitle>No content yet</AlertTitle>
<AlertDescription>
Enter the complete text of your work in this section.
</AlertDescription>
</Alert>
)}
</CardHeader>
<CardContent>
<div className="min-h-[500px]">
<RichTextEditor
value={workContent}
onChange={setWorkContent}
placeholder="Enter your work content here..."
readOnly={readOnly || isLoading}
/>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Preview Tab */}
{showPreview && (
<TabsContent value="preview" className="py-4">
<Card>
<CardHeader>
<CardTitle>Preview</CardTitle>
<CardDescription>
Preview how your work will appear to readers
</CardDescription>
<div className="flex items-center gap-2">
<Button
type="button"
variant={previewMode ? "default" : "outline"}
size="sm"
onClick={() => setPreviewMode(false)}
>
<BookOpen className="h-4 w-4 mr-2" />
Work View
</Button>
{/* Additional preview modes could be added here */}
</div>
</CardHeader>
<CardContent>
<div className="border rounded-lg p-6 min-h-[500px]">
{/* Preview title */}
<h1 className="text-3xl font-bold font-serif mb-2">
{watchedValues.title || "Untitled Work"}
</h1>
{/* Preview subtitle */}
{watchedValues.subtitle && (
<h2 className="text-xl text-muted-foreground font-serif mb-4">
{watchedValues.subtitle}
</h2>
)}
{/* Author and metadata */}
<div className="flex items-center gap-2 mb-6">
<span className="text-sm text-muted-foreground">
by {authors.find(a => a.id.toString() === watchedValues.authorId)?.name || "Unknown Author"}
</span>
{watchedValues.publicationYear && (
<span className="text-sm text-muted-foreground">
{watchedValues.publicationYear}
</span>
)}
</div>
<Separator className="my-4" />
{/* Content preview */}
<div className="prose dark:prose-invert max-w-none">
{workContent ? (
<div dangerouslySetInnerHTML={{ __html: workContent }} />
) : (
<p className="text-muted-foreground italic">
No content to preview. Add content in the "Content" tab.
</p>
)}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
)}
</Tabs>
{/* Form actions */}
<div className="flex justify-between mt-6">
<div>
{work?.id && onDelete && !readOnly && (
<Button
type="button"
variant="destructive"
onClick={() => onDelete(work.id!)}
disabled={isLoading}
>
Delete Work
</Button>
)}
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isLoading}
>
Cancel
</Button>
{!readOnly && (
<Button
type="submit"
disabled={isLoading || !form.formState.isValid}
>
{isLoading ? "Saving..." : (work?.id ? "Update Work" : "Create Work")}
</Button>
)}
</div>
</div>
</form>
</Form>
</div>
);
}
export default WorkEditor;