sp/src/components/TopicEdit.jsx

656 lines
24 KiB
JavaScript

import React, { useState, useEffect, useRef } from "react";
import MDEditor, { commands } from '@uiw/react-md-editor';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from './ui/select';
import { Button } from './ui/button';
import { Separator } from './ui/separator';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
import { CustomTabs } from './ui/tabs';
import Loader from "./ui/loader";
import Cookies from 'js-cookie';
const PUBLIC_TOPIC_API_URL = import.meta.env.PUBLIC_TOPIC_API_URL;
const PUBLIC_MINIO_UPLOAD_URL = 'https://file-vault-prod-pdgctwgsnkiy.siliconpin.com/upload';
// const PUBLIC_MINIO_UPLOAD_URL = import.meta.env.PUBLIC_MINIO_UPLOAD_URL;
const urlParams = new URLSearchParams(window.location.search);
const slug = urlParams.get('slug');
// console.log('find slug from url', slug);
export default function EditTopic (){
// const { slug } = useParams();
// const navigate = useNavigate();
// Form state
const [formData, setFormData] = useState({ status: 'draft', category: '', slug: '', title: '', content: '', imageUrl: '' });
// UI state
const [isSubmitting, setIsSubmitting] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(false);
const [imageFile, setImageFile] = useState(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [imageDialogOpen, setImageDialogOpen] = useState(false);
const [imageUrlInput, setImageUrlInput] = useState('');
const [imageUploadFile, setImageUploadFile] = useState(null);
const [imageUploadPreview, setImageUploadPreview] = useState('');
const [editorMode, setEditorMode] = useState('edit');
const [galleryImgDesc, setGalleryImgDesc] = useState('');
const [galleryImg, setGalleryImg] = useState('');
const [statusMessage, setStatusMessage] = useState({success : "", message: ""});
const [allGalleryImages, setAllGalleryImages] = useState([]);
const fileInputRef = useRef(null);
const fetchGalleryImage = async () => {
try{
const response = await fetch(`https://gallery-image-pocndhvgmcnbacgb.siliconpin.com/gallery-image-delevery?slug=${slug}`)
const data = await response.json();
setAllGalleryImages(data.images);
// console.log('data.images', data.images)
}catch(error){
console.error(error)
}
}
// Fetch topic data on component mount
useEffect(() => {
const fetchTopic = async () => {
try {
const response = await fetch(`${PUBLIC_TOPIC_API_URL}?query=get-single-topic&slug=${slug}`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error('Failed to fetch topic');
}
const data = await response.json();
const topic = data.data[0]
// console.log('Single topic data', data)
setFormData({
status: topic.status || 'draft',
category: topic.category || '',
title: topic.title || '',
slug: topic.slug || '',
content: topic.content || '',
imageUrl: topic.img || ''
});
} catch (err) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
fetchTopic(); fetchGalleryImage();
}, [slug]);
// Upload Gallery Images
const uploadGalleryImages = async (file, slug) => {
setIsSubmitting(true);
const formData = new FormData();
formData.append('file', file);
formData.append('description', galleryImgDesc);
formData.append('slug', slug);
try {
const response = await fetch(`https://gallery-image-pocndhvgmcnbacgb.siliconpin.com/upload`, {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success === true) {
setGalleryImgDesc('');
setStatusMessage({ success: true, message: "Image Uploaded! you can upload more images" });
setGalleryImg(null);
fetchGalleryImage();
if (fileInputRef.current) fileInputRef.current.value = null;
} else {
setStatusMessage({ success: false, message: "Failed to upload image" });
}
} catch (error) {
console.error(error);
setStatusMessage({ success: false, message: "Failed to upload image" });
}
setIsSubmitting(false);
};
// Upload file to MinIO (same as NewTopic)
const uploadToMinIO = async (file, onProgress) => {
const siliconId = Cookies.get('siliconId'); // Get from cookie or anywhere
const token = Cookies.get('token'); // Get from cookie or storage
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch(PUBLIC_MINIO_UPLOAD_URL, {
method: 'POST',
headers: {
'x-user-data': siliconId,
'Authorization': token,
},
body: formData
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.statusText}`);
}
const data = await response.json();
// console.log('Upload successful:', data);
return data;
} catch (error) {
console.error('Upload error:', error);
throw error;
}
};
// Generate slug from title (same as NewTopic)
// useEffect(() => {
// if (formData.title) {
// const newSlug = formData.title
// .toLowerCase()
// .replace(/[^\w\s]/g, '')
// .replace(/\s+/g, '-');
// setFormData(prev => ({ ...prev, slug: newSlug }));
// }
// }, [formData.title]);
// Custom image command for MDEditor (same as NewTopic)
const customImageCommand = {
name: 'image',
keyCommand: 'image',
buttonProps: { 'aria-label': 'Insert image' },
icon: (
<svg width="12" height="12" viewBox="0 0 20 20">
<path fill="currentColor" d="M15 9c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm4-7H1c-.55 0-1 .45-1 1v14c0 .55.45 1 1 1h18c.55 0 1-.45 1-1V3c0-.55-.45-1-1-1zm-1 13l-6-5-2 2-4-5-4 8V4h16v11z"/>
</svg>
),
execute: () => {
setImageDialogOpen(true);
},
};
// Custom <br> insert command
const lineBreakCommand = {
name: 'linebreak',
keyCommand: 'linebreak',
buttonProps: { 'aria-label': 'Insert line break' },
icon: (
<svg width="16px" height="16px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M20 7V8.2C20 9.88016 20 10.7202 19.673 11.362C19.3854 11.9265 18.9265 12.3854 18.362 12.673C17.7202 13 16.8802 13 15.2 13H4M4 13L8 9M4 13L8 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path> </g></svg>
),
execute: () => {
const textarea = document.querySelector('.w-md-editor-text-input');
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const value = formData.content || "";
const breakTag = '<br>';
const newValue = value.slice(0, start) + breakTag + value.slice(end);
setFormData(prev => ({
...prev,
content: newValue
}));
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd = start + breakTag.length;
textarea.focus();
}, 0);
}
};
// Get all commands (same as NewTopic)
const allCommands = [
...commands.getCommands().map(cmd => {
if (cmd.name === 'image') {
return customImageCommand;
}
return cmd;
}),
lineBreakCommand,
];
// Handle image URL insertion (same as NewTopic)
const handleInsertImageUrl = () => {
if (imageUrlInput) {
const imgMarkdown = `![Image](${imageUrlInput})`;
const textarea = document.querySelector('.w-md-editor-text-input');
if (textarea) {
const startPos = textarea.selectionStart;
const endPos = textarea.selectionEnd;
const currentValue = formData.content;
const newValue =
currentValue.substring(0, startPos) +
imgMarkdown +
currentValue.substring(endPos);
setFormData(prev => ({ ...prev, content: newValue }));
}
setImageDialogOpen(false);
setImageUrlInput('');
}
};
// Handle image file selection (same as NewTopic)
const handleImageFileSelect = (e) => {
const file = e.target.files[0];
if (file) {
setImageUploadFile(file);
const reader = new FileReader();
reader.onload = () => {
setImageUploadPreview(reader.result);
};
reader.readAsDataURL(file);
}
};
// Upload image file (same as NewTopic)
const handleImageUpload = async () => {
if (!imageUploadFile) return;
try {
setIsSubmitting(true);
setUploadProgress(0);
const uploadedUrl = await uploadToMinIO(imageUploadFile, (progress) => {
setUploadProgress(progress);
});
const imgMarkdown = `![Image](${uploadedUrl.url})`;
const textarea = document.querySelector('.w-md-editor-text-input');
if (textarea) {
const startPos = textarea.selectionStart;
const endPos = textarea.selectionEnd;
const currentValue = formData.content;
const newValue =
currentValue.substring(0, startPos) +
imgMarkdown +
currentValue.substring(endPos);
setFormData(prev => ({ ...prev, content: newValue }));
}
setImageDialogOpen(false);
setImageUploadFile(null);
setImageUploadPreview('');
} catch (error) {
setError('Failed to upload image: ' + error.message);
} finally {
setIsSubmitting(false);
}
};
// Form submission for EDIT
const handleSubmit = async (e) => {
e.preventDefault();
setIsSubmitting(true);
setError(null);
setUploadProgress(0);
try {
// Upload new featured image if selected
let imageUrl = formData.imageUrl;
if (imageFile) {
imageUrl = await uploadToMinIO(imageFile, (progress) => {
setUploadProgress(progress);
});
}
imageUrl = imageUrl.url;
// Prepare payload
const payload = {
...formData,
imageUrl
};
// Submit to API (PUT request for update)
const response = await fetch(`${PUBLIC_TOPIC_API_URL}?query=update-topic&slug=${formData.slug}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to update topic');
}
setSuccess(true);
setTimeout(() => {
navigate(`/topic/${formData.slug}`);
}, 1500);
} catch (err) {
setError(err.message);
} finally {
setIsSubmitting(false);
setUploadProgress(0);
}
};
// Handle form field changes (same as NewTopic)
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
// Handle featured image file selection (same as NewTopic)
const handleFileChange = (e) => {
const file = e.target.files[0];
if (file) {
setImageFile(file);
const reader = new FileReader();
reader.onload = () => {
setFormData(prev => ({ ...prev, imageUrl: reader.result }));
};
reader.readAsDataURL(file);
}
};
// Loading state
if (isLoading) {
return (
<Loader />
);
}
// Success state
if (success) {
return (
<div className="container mx-auto px-4 text-center py-8">
<h3 className="text-xl font-semibold mb-4">Topic has been successfully updated!</h3>
<p className="mb-4">You are being automatically redirected to the topic page...</p>
<div className="flex justify-center gap-4">
<Button onClick={() => window.location.href = `/topic/${formData.slug}`}>Preview this Topic</Button>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-3xl mx-auto bg-neutral-800 rounded-lg shadow-md p-6">
<h2 className="text-2xl font-bold text-[#6d9e37] mb-2">Edit Topic</h2>
<p className="text-gray-600 mb-6">{formData.title}</p>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select
name="status"
required
value={formData.status}
onValueChange={(value) => setFormData(prev => ({ ...prev, status: value }))}
>
<SelectTrigger id="status" className="text-sm sm:text-base w-full">
<SelectValue placeholder="Select Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="published">Publish</SelectItem>
<SelectItem value="archived">Archive</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="category">Category *</Label>
<Select
name="category"
required
value={formData.category}
onValueChange={(value) => setFormData(prev => ({ ...prev, category: value }))}
>
<SelectTrigger id="category" className="text-sm sm:text-base w-full">
<SelectValue placeholder="Select Category" />
</SelectTrigger>
<SelectContent position="popper" className="z-50">
<SelectItem value="php">PHP Hosting</SelectItem>
<SelectItem value="nodejs">Node.js Hosting</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Title and Slug */}
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
type="text"
name="title"
value={formData.title}
onChange={handleChange}
placeholder="Wriet Topic Title"
required
/>
</div>
<div className="space-y-2">
{/* <Label htmlFor="slug">URL Slug</Label> */}
<input
type="hidden"
name="slug"
value={formData.slug}
onChange={handleChange}
className="bg-gray-50"
/>
{/* <p className="text-xs text-gray-500">This will be used in the topic URL</p> */}
</div>
{/* Featured Image Upload */}
<div className="space-y-2">
<Label >Featured Image</Label>
<div className="flex flex-wrap space-x-4">
<Label htmlFor="image" className="cursor-pointer">
<svg viewBox="0 0 512 512" width="100px" height="100px" fill="#000000">
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
<g id="SVGRepo_tracerCarrier" strokeLinecap="round" strokeLinejoin="round"></g>
<g id="SVGRepo_iconCarrier">
<path fill="#6d9e37" d="M512,0h-40v16h24v32h16V0z M432,0h-40v16h40V0z M352,0h-40v16h40V0z M272,0h-40v16h40V0z M192,0h-40 v16h40V0z M112,0H72v16h40V0z M32,0H0v16h32V0z M16,48H0v40h16V48z M16,128H0v40h16V128z M16,208H0v40h16V208z M16,288H0v40h16V288z M16,368H0v40h16V368z M16,448H0v40h16V448z M56,496H16v16h40V496z M136,496H96v16h40V496z M216,496h-40v16h40V496z M296,496h-40v16 h40V496z M376,496h-40v16h40V496z M456,496h-40v16h40V496z M512,488h-16v8l0,0v16h16V488z M512,408h-16v40h16V408z M512,328h-16v40 h16V328z M512,248h-16v40h16V248z M512,168h-16v40h16V168z M512,88h-16v40h16V88z"></path>
<g>
<rect x="244" y="175.976" fill="#6d9e37" width="24" height="160.08"></rect>
<rect x="175.976" y="244" fill="#6d9e37" width="160.08" height="24"></rect>
</g>
</g>
</svg>
</Label>
<Input
type="file"
id="image"
onChange={handleFileChange}
accept="image/*"
className="cursor-pointer hidden"
/>
{formData.imageUrl && (
<div className="relative group">
<img
src={formData.imageUrl}
alt="Preview"
className="w-[100px] h-[100px] rounded-md border"
/>
<button onClick={(e) => {e.preventDefault(); setFormData({...formData, imageUrl : null})}} className="absolute inset-0 opacity-0 group-hover:opacity-100 bg-white/20 transition-all duration-500 text-2xl font-bold text-red-500">X</button>
</div>
)}
</div>
</div>
{/* Content Editor */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<Label htmlFor="content">Content *</Label>
<button
type="button"
onClick={() => setEditorMode(editorMode === 'edit' ? 'preview' : 'edit')}
className={`ml-2 ${editorMode !== 'edit' ? 'bg-[#6d9e37]' : ''} text-white border border-[#6d9e37] text-[#6d9e37] px-2 py-1 rounded-md`}
>
{editorMode === 'edit' ? 'Preview' : 'Edit'}
</button>
</div>
<div data-color-mode="light">
<MDEditor
value={formData.content}
onChange={(value) => setFormData(prev => ({ ...prev, content: value || '' }))}
height={400}
preview={editorMode}
commands={allCommands}
/>
</div>
</div>
{/* Image Upload Dialog (same as NewTopic) */}
<Dialog open={imageDialogOpen} onOpenChange={setImageDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add Image</DialogTitle>
</DialogHeader>
<CustomTabs
tabs={[
{
label: "URL",
value: "url",
content: (
<div className="space-y-4">
<Input
type="text"
placeholder="Image URL"
value={imageUrlInput}
onChange={(e) => setImageUrlInput(e.target.value)}
/>
<div className="flex justify-end">
<Button
type="button"
onClick={handleInsertImageUrl}
disabled={!imageUrlInput}
>
Upload Image
</Button>
</div>
</div>
)
},
{
label: "Upload",
value: "upload",
content: (
<div className="space-y-4">
<Input
type="file"
accept="image/*"
onChange={handleImageFileSelect}
/>
{imageUploadPreview && (
<div className="mt-2">
<img
src={imageUploadPreview}
alt="Preview"
className="max-h-40 rounded-md border"
/>
</div>
)}
{uploadProgress > 0 && uploadProgress < 100 && (
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className="bg-blue-600 h-2.5 rounded-full"
style={{ width: `${uploadProgress}%` }}
></div>
</div>
)}
<div className="flex justify-end">
<Button
type="button"
onClick={handleImageUpload}
disabled={!imageUploadFile || isSubmitting}
>
{isSubmitting ? 'Uploading...' : 'Upload'}
</Button>
</div>
</div>
)
}
]}
/>
</DialogContent>
</Dialog>
{
allGalleryImages && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 gap-4 mt-4">
{allGalleryImages.map((img, index) => (
<img key={index} src={`https://gallery-image-pocndhvgmcnbacgb.siliconpin.com${img}`} alt={`gallery-${index}`} className="w-full h-auto rounded shadow" />
))}
</div>
)
}
<div>
<Label htmlFor="galleryImage">
Gallery Image: <span className="text-xs text-gray-500">(First write description (Min 10 Char) to proceed image upload!)</span>
</Label>
<div className="flex gap-x-4 mt-1">
<Input
onChange={(e) => setGalleryImgDesc(e.target.value)}
value={galleryImgDesc}
type="text"
placeholder="Write description to proceed image upload!"
/>
<Input
ref={fileInputRef}
onChange={(e) => setGalleryImg(e.target.files[0])}
type="file"
className={`${galleryImgDesc.trim().length > 9 ? 'border-[#6d9e37] file:bg-[#6d9e37] file:text-white file:border-0 file:rounded-md' : ''}`}
disabled={galleryImgDesc.trim().length < 10}
/>
<Button onClick={() => uploadGalleryImages(galleryImg, slug)} disabled={isSubmitting}>
{isSubmitting ? 'Uploading...' : 'Upload'}
</Button>
</div>
<p className={`mt-2 text-center ${statusMessage.success === true ? 'text-[#6d9e37]' : statusMessage.success === false ? 'text-red-500' : ''}`}>
{statusMessage.success === true && <span className="mr-1">&#10003;</span>}
{statusMessage.success === false && <span className="mr-1">&#10060;</span>}
{statusMessage.message}
</p>
</div>
<Separator />
{/* Form Actions */}
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() => navigate(-1)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting}
className="min-w-32 bg-[#6d9e37] hover:bg-[#5a8a2a]"
>
{isSubmitting ? (
<span className="flex items-center gap-2">
<span className="animate-spin"></span>
Uploading...
</span>
) : 'Update'}
</Button>
</div>
{error && (
<div className="p-4 bg-red-50 text-red-600 rounded-md">
Error: {error}
</div>
)}
</form>
</div>
</div>
);
};