From 47743e62ccbd6568537fc3874bb230c8a7715da4 Mon Sep 17 00:00:00 2001 From: mukimovd <41473651-mukimovd@users.noreply.replit.com> Date: Sat, 10 May 2025 21:53:28 +0000 Subject: [PATCH] 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 --- COMPONENT-IMPLEMENTATION-TRACKER.md | 2 +- client/src/components/work/work-editor.tsx | 722 +++++++++++++++++++++ 2 files changed, 723 insertions(+), 1 deletion(-) diff --git a/COMPONENT-IMPLEMENTATION-TRACKER.md b/COMPONENT-IMPLEMENTATION-TRACKER.md index 1756bb8..ba05efd 100644 --- a/COMPONENT-IMPLEMENTATION-TRACKER.md +++ b/COMPONENT-IMPLEMENTATION-TRACKER.md @@ -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` | | diff --git a/client/src/components/work/work-editor.tsx b/client/src/components/work/work-editor.tsx index e69de29..0dbdd8a 100644 --- a/client/src/components/work/work-editor.tsx +++ b/client/src/components/work/work-editor.tsx @@ -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 + * + * ``` + */ + +// 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; + +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; + /** + * 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(null); + const [coverPreview, setCoverPreview] = useState(work?.coverImage); + const [selectedGenres, setSelectedGenres] = useState( + 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 ( +
+ {/* Form title */} +
+
+

+ {work?.id ? "Edit Work" : "Create New Work"} +

+

+ {work?.id + ? "Update the details of your literary work" + : "Enter the details of your new literary work" + } +

+
+ + {/* Status selection for existing works */} + {work?.id && ( + + )} +
+ + {/* Main form */} +
+ + + + Basic Information + + Content + + {showPreview && ( + + Preview + + )} + + + {/* Basic Information Tab */} + +
+ {/* Left column - Metadata */} +
+ + + Work Details + Basic information about this literary work + + + {/* Title */} + ( + + Title + + + + + + )} + /> + + {/* Subtitle */} + ( + + Subtitle (optional) + + + + + + )} + /> + + {/* Author */} + ( + + Author + + + + )} + /> + +
+ {/* Language */} + ( + + Language + + + + )} + /> + + {/* Publication Year */} + ( + + Publication Year + + + + + + )} + /> +
+ + {/* Description */} + ( + + Description + +