mirror of
https://github.com/SamyRai/tercul-frontend.git
synced 2025-12-27 02:31:34 +00:00
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:
parent
5fc570e3f2
commit
47743e62cc
@ -27,7 +27,7 @@ This document tracks the implementation status of all components for the Tercul
|
|||||||
| Component | Status | File Path | Notes |
|
| Component | Status | File Path | Notes |
|
||||||
|-----------|--------|-----------|-------|
|
|-----------|--------|-----------|-------|
|
||||||
| Work Preview | ✅ Implemented | `client/src/components/work/work-preview.tsx` | Complete with multiple display variants |
|
| 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 |
|
| 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` | |
|
| Comparison View | ⬜️ Planned | `client/src/components/work/comparison-view.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
|
||||||
|
* <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;
|
||||||
Loading…
Reference in New Issue
Block a user