Files
rentall-app/frontend/src/pages/CreateForumPost.tsx
2025-12-13 20:32:25 -05:00

550 lines
19 KiB
TypeScript

import React, { useState, useEffect } from "react";
import { useNavigate, Link, useParams } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import { forumAPI, addressAPI } from "../services/api";
import { uploadFiles, getPublicImageUrl } from "../services/uploadService";
import TagInput from "../components/TagInput";
import ForumImageUpload from "../components/ForumImageUpload";
import { Address, ForumPost } from "../types";
const CreateForumPost: React.FC = () => {
const { id } = useParams<{ id: string }>();
const isEditMode = !!id;
const { user } = useAuth();
const navigate = useNavigate();
const [loading, setLoading] = useState(isEditMode);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [userAddresses, setUserAddresses] = useState<Address[]>([]);
const [existingImageKeys, setExistingImageKeys] = useState<string[]>([]);
const [formData, setFormData] = useState({
title: "",
content: "",
category: "general_discussion" as
| "item_request"
| "technical_support"
| "community_resources"
| "general_discussion",
tags: [] as string[],
zipCode: user?.zipCode || "",
latitude: undefined as number | undefined,
longitude: undefined as number | undefined,
});
const [imageFiles, setImageFiles] = useState<File[]>([]);
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
useEffect(() => {
fetchUserAddresses();
if (isEditMode && id) {
fetchPost();
}
}, [id, isEditMode]);
const fetchPost = async () => {
try {
setLoading(true);
const response = await forumAPI.getPost(id!);
const post: ForumPost = response.data;
// Verify user is the author
if (post.authorId !== user?.id) {
setError("You are not authorized to edit this post");
setLoading(false);
return;
}
// Populate form with existing data
setFormData({
title: post.title,
content: post.content,
category: post.category as any,
tags: (post.tags || []).map((t) => t.tagName),
zipCode: post.zipCode || user?.zipCode || "",
latitude: post.latitude,
longitude: post.longitude,
});
// Set existing images
if (post.imageFilenames && post.imageFilenames.length > 0) {
setExistingImageKeys(post.imageFilenames);
setImagePreviews(
post.imageFilenames.map((key: string) => getPublicImageUrl(key))
);
}
} catch (err: any) {
setError(err.response?.data?.error || "Failed to fetch post");
} finally {
setLoading(false);
}
};
const fetchUserAddresses = async () => {
try {
const response = await addressAPI.getAddresses();
setUserAddresses(response.data);
} catch (error) {
console.error("Error fetching addresses:", error);
}
};
const categories = [
{
value: "item_request",
label: "Item Request",
description: "Looking to rent a specific item",
},
{
value: "technical_support",
label: "Technical Support",
description: "Get help with using the platform",
},
{
value: "community_resources",
label: "Community Resources",
description: "Share tips, guides, and resources",
},
{
value: "general_discussion",
label: "General Discussion",
description: "Open-ended conversations",
},
];
const handleInputChange = (
e: React.ChangeEvent<
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
>
) => {
const { name, value } = e.target;
// If category is being changed to item_request and user has addresses, autopopulate location data
if (name === "category" && value === "item_request" && userAddresses.length > 0) {
// Try to find primary address first, otherwise use first address
const primaryAddress = userAddresses.find(addr => addr.isPrimary) || userAddresses[0];
setFormData((prev) => ({
...prev,
[name]: value,
zipCode: primaryAddress.zipCode,
latitude: primaryAddress.latitude,
longitude: primaryAddress.longitude
}));
} else {
setFormData((prev) => ({ ...prev, [name]: value }));
}
};
const handleTagsChange = (tags: string[]) => {
setFormData((prev) => ({ ...prev, tags }));
};
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
const totalImages = existingImageKeys.length + imageFiles.length;
const remainingSlots = 5 - totalImages;
const filesToAdd = files.slice(0, remainingSlots);
setImageFiles((prev) => [...prev, ...filesToAdd]);
// Create preview URLs
filesToAdd.forEach((file) => {
const reader = new FileReader();
reader.onloadend = () => {
setImagePreviews((prev) => [...prev, reader.result as string]);
};
reader.readAsDataURL(file);
});
// Reset input
e.target.value = "";
};
const handleRemoveImage = (index: number) => {
if (index < existingImageKeys.length) {
// Removing an existing S3 image
setExistingImageKeys((prev) => prev.filter((_, i) => i !== index));
} else {
// Removing a new upload
const newFileIndex = index - existingImageKeys.length;
setImageFiles((prev) => prev.filter((_, i) => i !== newFileIndex));
}
setImagePreviews((prev) => prev.filter((_, i) => i !== index));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
// Validation - character minimums shown inline, just prevent submission
if (!formData.title.trim() || formData.title.length < 10) {
return;
}
if (!formData.content.trim() || formData.content.length < 20) {
return;
}
if (formData.category === "item_request" && !formData.zipCode.trim()) {
setError("Zip code is required for item requests");
return;
}
try {
setIsSubmitting(true);
// Upload images to S3 first (if any)
let imageFilenames: string[] = [];
if (imageFiles.length > 0) {
const uploadResults = await uploadFiles("forum", imageFiles);
imageFilenames = uploadResults.map((result) => result.key);
}
// Build the post data
const postData: {
title: string;
content: string;
category: string;
tags?: string[];
zipCode?: string;
latitude?: number;
longitude?: number;
imageFilenames?: string[];
} = {
title: formData.title,
content: formData.content,
category: formData.category,
};
// Add tags if present
if (formData.tags.length > 0) {
postData.tags = formData.tags;
}
// Add location data for item requests
if (formData.category === 'item_request' && formData.zipCode) {
postData.zipCode = formData.zipCode;
// If we have coordinates from a saved address, send them to avoid re-geocoding
if (formData.latitude !== undefined && formData.longitude !== undefined) {
postData.latitude = formData.latitude;
postData.longitude = formData.longitude;
}
}
// Combine existing and new S3 image keys
const allImageKeys = [...existingImageKeys, ...imageFilenames];
if (allImageKeys.length > 0) {
postData.imageFilenames = allImageKeys;
}
if (isEditMode) {
await forumAPI.updatePost(id!, postData);
navigate(`/forum/${id}`);
} else {
const response = await forumAPI.createPost(postData);
navigate(`/forum/${response.data.id}`);
}
} catch (err: any) {
setError(err.response?.data?.error || err.message || `Failed to ${isEditMode ? 'update' : 'create'} post`);
setIsSubmitting(false);
}
};
if (loading) {
return (
<div className="container mt-4">
<div className="text-center py-5">
<div className="spinner-border" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
</div>
);
}
if (!user) {
return (
<div className="container mt-4">
<div className="alert alert-warning" role="alert">
<i className="bi bi-exclamation-triangle me-2"></i>
You must be logged in to {isEditMode ? 'edit' : 'create'} a post.
</div>
<Link to="/forum" className="btn btn-secondary">
<i className="bi bi-arrow-left me-2"></i>
Back to Forum
</Link>
</div>
);
}
if (error && error.includes("authorized")) {
return (
<div className="container mt-4">
<div className="alert alert-danger" role="alert">
<i className="bi bi-exclamation-triangle me-2"></i>
{error}
</div>
<Link to="/forum" className="btn btn-secondary">
<i className="bi bi-arrow-left me-2"></i>
Back to Forum
</Link>
</div>
);
}
return (
<div className="container mt-4">
<nav aria-label="breadcrumb" className="mb-3">
<ol className="breadcrumb">
<li className="breadcrumb-item">
<Link to="/forum">Forum</Link>
</li>
{isEditMode && (
<li className="breadcrumb-item">
<Link to={`/forum/${id}`}>Post</Link>
</li>
)}
<li className="breadcrumb-item active" aria-current="page">
{isEditMode ? 'Edit' : 'Create Post'}
</li>
</ol>
</nav>
<div className="row">
<div className="col-lg-8 mx-auto">
{/* Guidelines Card - only show for new posts */}
{!isEditMode && (
<div className="card mb-3">
<div className="card-header">
<h6 className="mb-0">
<i className="bi bi-info-circle me-2"></i>
Community Guidelines
</h6>
</div>
<div className="card-body">
<ul className="mb-0">
<li>Be respectful and courteous to others</li>
<li>Stay on topic and relevant to the category</li>
<li>No spam, advertising, or self-promotion</li>
<li>Search before posting to avoid duplicates</li>
<li>Use clear, descriptive titles</li>
<li>Provide helpful and constructive feedback</li>
</ul>
</div>
</div>
)}
<div className="card">
<div className="card-header">
<h3 className="mb-0">{isEditMode ? 'Edit Post' : 'Create New Post'}</h3>
</div>
<div className="card-body">
{error && (
<div className="alert alert-danger" role="alert">
{error}
</div>
)}
<form onSubmit={handleSubmit}>
{/* Title */}
<div className="mb-3">
<label htmlFor="title" className="form-label">
Title <span className="text-danger">*</span>
</label>
<input
type="text"
className={`form-control ${formData.title.length > 0 && formData.title.length < 10 ? 'is-invalid' : ''}`}
id="title"
name="title"
value={formData.title}
onChange={handleInputChange}
placeholder="Enter a descriptive title..."
maxLength={200}
disabled={isSubmitting}
required
/>
{formData.title.length > 0 && formData.title.length < 10 ? (
<div className="invalid-feedback d-block">
{10 - formData.title.length} more characters needed (minimum 10)
</div>
) : (
<div className="form-text">
{formData.title.length}/200 characters (minimum 10)
</div>
)}
</div>
{/* Category */}
<div className="mb-3">
<label htmlFor="category" className="form-label">
Category <span className="text-danger">*</span>
</label>
<select
className="form-select"
id="category"
name="category"
value={formData.category}
onChange={handleInputChange}
disabled={isSubmitting}
required
>
{categories.map((cat) => (
<option key={cat.value} value={cat.value}>
{cat.label}
</option>
))}
</select>
<div className="form-text">
Choose the category that best fits your post
</div>
</div>
{/* Location fields for item requests */}
{formData.category === "item_request" && (
<div className="mb-3">
<label htmlFor="zipCode" className="form-label">
Zip Code <span className="text-danger">*</span>
</label>
<input
type="text"
className="form-control"
id="zipCode"
name="zipCode"
value={formData.zipCode}
onChange={handleInputChange}
placeholder="Enter your zip code..."
maxLength={10}
disabled={isSubmitting}
required
/>
<div className="form-text">
Your zip code helps notify nearby users who might have
the item you're looking for
</div>
</div>
)}
{/* Content */}
<div className="mb-3">
<label htmlFor="content" className="form-label">
Content <span className="text-danger">*</span>
</label>
<textarea
className={`form-control ${formData.content.length > 0 && formData.content.length < 20 ? 'is-invalid' : ''}`}
id="content"
name="content"
rows={10}
value={formData.content}
onChange={handleInputChange}
placeholder="Write your post content here..."
disabled={isSubmitting}
required
/>
{formData.content.length > 0 && formData.content.length < 20 ? (
<div className="invalid-feedback d-block">
{20 - formData.content.length} more characters needed (minimum 20)
</div>
) : (
<div className="form-text">
{formData.content.length} characters (minimum 20)
</div>
)}
</div>
{/* Tags */}
<div className="mb-3">
<label className="form-label">
Tags <span className="text-muted">(optional)</span>
</label>
<TagInput
selectedTags={formData.tags}
onChange={handleTagsChange}
placeholder="Add tags to help others find your post..."
/>
<div className="form-text">
Add up to 5 relevant tags. Press Enter after each tag.
</div>
</div>
{/* Images */}
<div className="mb-4">
<ForumImageUpload
imageFiles={imageFiles}
imagePreviews={imagePreviews}
onImageChange={handleImageChange}
onRemoveImage={handleRemoveImage}
/>
</div>
{/* Category-specific guidelines */}
{formData.category === "item_request" && (
<div className="alert alert-info mb-3">
<strong>Item Request Tips:</strong>
<ul className="mb-0 mt-2">
<li>Be specific about what you're looking for</li>
<li>Include your general location</li>
<li>Specify when you need the item</li>
<li>Mention your budget range if applicable</li>
</ul>
</div>
)}
{formData.category === "technical_support" && (
<div className="alert alert-info mb-3">
<strong>Technical Support Tips:</strong>
<ul className="mb-0 mt-2">
<li>Describe the issue you're experiencing</li>
<li>Include steps to reproduce the problem</li>
<li>Mention your device/browser if relevant</li>
<li>Include any error messages you see</li>
</ul>
</div>
)}
{/* Submit buttons */}
<div className="d-flex gap-2">
<button
type="submit"
className="btn btn-primary"
disabled={
isSubmitting ||
!formData.title.trim() ||
!formData.content.trim()
}
>
{isSubmitting ? (
<>
<span
className="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
{isEditMode ? 'Saving...' : 'Creating...'}
</>
) : (
<>
<i className={`bi ${isEditMode ? 'bi-check-lg' : 'bi-send'} me-2`}></i>
{isEditMode ? 'Save Changes' : 'Create Post'}
</>
)}
</button>
<Link
to={isEditMode ? `/forum/${id}` : '/forum'}
className={`btn btn-secondary ${
isSubmitting ? "disabled" : ""
}`}
>
Cancel
</Link>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
);
};
export default CreateForumPost;