can add images to forum posts and comments
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import ForumImageUpload from './ForumImageUpload';
|
||||
|
||||
interface CommentFormProps {
|
||||
onSubmit: (content: string) => Promise<void>;
|
||||
onSubmit: (content: string, images: File[]) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
placeholder?: string;
|
||||
buttonText?: string;
|
||||
@@ -16,9 +17,34 @@ const CommentForm: React.FC<CommentFormProps> = ({
|
||||
isReply = false,
|
||||
}) => {
|
||||
const [content, setContent] = useState('');
|
||||
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
||||
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
const remainingSlots = 3 - imageFiles.length;
|
||||
const filesToAdd = files.slice(0, remainingSlots);
|
||||
|
||||
setImageFiles((prev) => [...prev, ...filesToAdd]);
|
||||
|
||||
filesToAdd.forEach((file) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setImagePreviews((prev) => [...prev, reader.result as string]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleRemoveImage = (index: number) => {
|
||||
setImageFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
setImagePreviews((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -31,8 +57,10 @@ const CommentForm: React.FC<CommentFormProps> = ({
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await onSubmit(content);
|
||||
await onSubmit(content, imageFiles);
|
||||
setContent('');
|
||||
setImageFiles([]);
|
||||
setImagePreviews([]);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to post comment');
|
||||
} finally {
|
||||
@@ -53,6 +81,16 @@ const CommentForm: React.FC<CommentFormProps> = ({
|
||||
/>
|
||||
{error && <div className="invalid-feedback">{error}</div>}
|
||||
</div>
|
||||
{!isReply && (
|
||||
<ForumImageUpload
|
||||
imageFiles={imageFiles}
|
||||
imagePreviews={imagePreviews}
|
||||
onImageChange={handleImageChange}
|
||||
onRemoveImage={handleRemoveImage}
|
||||
maxImages={3}
|
||||
compact={true}
|
||||
/>
|
||||
)}
|
||||
<div className="d-flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from "react";
|
||||
import { ForumComment } from "../types";
|
||||
import CommentForm from "./CommentForm";
|
||||
import { getForumImageUrl } from "../services/api";
|
||||
|
||||
interface CommentThreadProps {
|
||||
comment: ForumComment;
|
||||
@@ -51,7 +52,8 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleReply = async (content: string) => {
|
||||
const handleReply = async (content: string, images: File[]) => {
|
||||
// Replies don't support images, so we ignore the images parameter
|
||||
await onReply(comment.id, content);
|
||||
setShowReplyForm(false);
|
||||
};
|
||||
@@ -188,9 +190,33 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="card-text mb-2" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{comment.content}
|
||||
</p>
|
||||
<>
|
||||
<p className="card-text mb-2" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{comment.content}
|
||||
</p>
|
||||
{comment.images && comment.images.length > 0 && (
|
||||
<div className="row g-2 mb-2">
|
||||
{comment.images.map((image, index) => (
|
||||
<div key={index} className="col-4 col-md-3">
|
||||
<img
|
||||
src={getForumImageUrl(image)}
|
||||
alt={`Comment image`}
|
||||
className="img-fluid rounded"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100px",
|
||||
objectFit: "cover",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() =>
|
||||
window.open(getForumImageUrl(image), "_blank")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="d-flex gap-2">
|
||||
|
||||
72
frontend/src/components/ForumImageUpload.tsx
Normal file
72
frontend/src/components/ForumImageUpload.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ForumImageUploadProps {
|
||||
imageFiles: File[];
|
||||
imagePreviews: string[];
|
||||
onImageChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onRemoveImage: (index: number) => void;
|
||||
maxImages?: number;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const ForumImageUpload: React.FC<ForumImageUploadProps> = ({
|
||||
imageFiles,
|
||||
imagePreviews,
|
||||
onImageChange,
|
||||
onRemoveImage,
|
||||
maxImages = 5,
|
||||
compact = false
|
||||
}) => {
|
||||
return (
|
||||
<div className={compact ? 'mb-2' : 'mb-3'}>
|
||||
<label className="form-label mb-1">
|
||||
<i className="bi bi-image me-1"></i>
|
||||
Add Images (Max {maxImages})
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
className="form-control form-control-sm"
|
||||
onChange={onImageChange}
|
||||
accept="image/*"
|
||||
multiple
|
||||
disabled={imageFiles.length >= maxImages}
|
||||
/>
|
||||
{imageFiles.length > 0 && (
|
||||
<small className="text-muted d-block mt-1">
|
||||
{imageFiles.length} / {maxImages} images selected
|
||||
</small>
|
||||
)}
|
||||
|
||||
{imagePreviews.length > 0 && (
|
||||
<div className="row mt-2 g-2">
|
||||
{imagePreviews.map((preview, index) => (
|
||||
<div key={`${imageFiles[index]?.name}-${index}`} className={compact ? 'col-4' : 'col-6 col-md-4 col-lg-3'}>
|
||||
<div className="position-relative border rounded p-1" style={{ backgroundColor: '#f8f9fa' }}>
|
||||
<img
|
||||
src={preview}
|
||||
alt={`Preview ${index + 1}`}
|
||||
className="img-fluid rounded"
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: compact ? '150px' : '200px',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger position-absolute top-0 end-0 m-1"
|
||||
onClick={() => onRemoveImage(index)}
|
||||
style={{ padding: '2px 6px', fontSize: '12px' }}
|
||||
>
|
||||
<i className="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForumImageUpload;
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ForumPost } from '../types';
|
||||
import CategoryBadge from './CategoryBadge';
|
||||
import PostStatusBadge from './PostStatusBadge';
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ForumPost } from "../types";
|
||||
import CategoryBadge from "./CategoryBadge";
|
||||
import PostStatusBadge from "./PostStatusBadge";
|
||||
|
||||
interface ForumPostListItemProps {
|
||||
post: ForumPost;
|
||||
@@ -18,7 +18,7 @@ const ForumPostListItem: React.FC<ForumPostListItemProps> = ({ post }) => {
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMinutes < 1) {
|
||||
return 'Just now';
|
||||
return "Just now";
|
||||
} else if (diffMinutes < 60) {
|
||||
return `${diffMinutes}m ago`;
|
||||
} else if (diffHours < 24) {
|
||||
@@ -32,8 +32,10 @@ const ForumPostListItem: React.FC<ForumPostListItemProps> = ({ post }) => {
|
||||
|
||||
// Strip HTML tags for preview
|
||||
const getTextPreview = (html: string, maxLength: number = 100) => {
|
||||
const text = html.replace(/<[^>]*>/g, '');
|
||||
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
|
||||
const text = html.replace(/<[^>]*>/g, "");
|
||||
return text.length > maxLength
|
||||
? `${text.substring(0, maxLength)}...`
|
||||
: text;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -43,32 +45,30 @@ const ForumPostListItem: React.FC<ForumPostListItemProps> = ({ post }) => {
|
||||
{/* Main content - 60% */}
|
||||
<div className="col-md-7">
|
||||
<div className="d-flex align-items-center gap-2 mb-1">
|
||||
{post.isPinned && (
|
||||
<span className="badge bg-danger badge-sm">
|
||||
<i className="bi bi-pin-angle-fill"></i>
|
||||
</span>
|
||||
)}
|
||||
<CategoryBadge category={post.category} />
|
||||
<PostStatusBadge status={post.status} />
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<>
|
||||
{post.tags.slice(0, 2).map((tag) => (
|
||||
<div className="d-flex gap-2">
|
||||
{post.isPinned && (
|
||||
<span className="badge bg-danger badge-sm">
|
||||
<i className="bi bi-pin-angle-fill"></i>
|
||||
</span>
|
||||
)}
|
||||
<CategoryBadge category={post.category} />
|
||||
<PostStatusBadge status={post.status} />
|
||||
{post.tags &&
|
||||
post.tags.length > 0 &&
|
||||
post.tags.slice(0, 2).map((tag) => (
|
||||
<span key={tag.id} className="badge bg-light text-dark">
|
||||
#{tag.tagName}
|
||||
</span>
|
||||
))}
|
||||
{post.tags.length > 2 && (
|
||||
<span className="badge bg-light text-dark">
|
||||
+{post.tags.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{post.tags && post.tags.length > 2 && (
|
||||
<span className="badge bg-light text-dark">
|
||||
+{post.tags.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 className="mb-1 text-dark">
|
||||
{post.title}
|
||||
</h6>
|
||||
<h6 className="mb-1 text-dark">{post.title}</h6>
|
||||
|
||||
<p className="text-muted small mb-0">
|
||||
{getTextPreview(post.content)}
|
||||
@@ -78,13 +78,21 @@ const ForumPostListItem: React.FC<ForumPostListItemProps> = ({ post }) => {
|
||||
{/* Author - 20% */}
|
||||
<div className="col-md-3">
|
||||
<div className="d-flex align-items-center">
|
||||
<div className="avatar bg-primary text-white rounded-circle d-flex align-items-center justify-content-center me-2"
|
||||
style={{ width: '32px', height: '32px', fontSize: '14px', flexShrink: 0 }}>
|
||||
{post.author?.firstName?.charAt(0) || '?'}
|
||||
<div
|
||||
className="avatar bg-primary text-white rounded-circle d-flex align-items-center justify-content-center me-2"
|
||||
style={{
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
fontSize: "14px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{post.author?.firstName?.charAt(0) || "?"}
|
||||
</div>
|
||||
<div className="text-truncate">
|
||||
<small className="text-dark d-block text-truncate">
|
||||
{post.author?.firstName || 'Unknown'} {post.author?.lastName || ''}
|
||||
{post.author?.firstName || "Unknown"}{" "}
|
||||
{post.author?.lastName || ""}
|
||||
</small>
|
||||
<small className="text-muted">
|
||||
{formatDate(post.updatedAt)}
|
||||
@@ -98,7 +106,8 @@ const ForumPostListItem: React.FC<ForumPostListItemProps> = ({ post }) => {
|
||||
<div className="d-flex flex-column align-items-end gap-1">
|
||||
<small className="text-muted">
|
||||
<i className="bi bi-chat me-1"></i>
|
||||
{post.commentCount || 0} {post.commentCount === 1 ? 'reply' : 'replies'}
|
||||
{post.commentCount || 0}{" "}
|
||||
{post.commentCount === 1 ? "reply" : "replies"}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user