457 lines
15 KiB
TypeScript
457 lines
15 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import { useNavigate, Link } from "react-router-dom";
|
|
import { useAuth } from "../contexts/AuthContext";
|
|
import { forumAPI, addressAPI } from "../services/api";
|
|
import { uploadFiles } from "../services/uploadService";
|
|
import TagInput from "../components/TagInput";
|
|
import ForumImageUpload from "../components/ForumImageUpload";
|
|
import { Address } from "../types";
|
|
|
|
const CreateForumPost: React.FC = () => {
|
|
const { user } = useAuth();
|
|
const navigate = useNavigate();
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [userAddresses, setUserAddresses] = useState<Address[]>([]);
|
|
|
|
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();
|
|
}, []);
|
|
|
|
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 remainingSlots = 5 - imageFiles.length;
|
|
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) => {
|
|
setImageFiles((prev) => prev.filter((_, i) => i !== index));
|
|
setImagePreviews((prev) => prev.filter((_, i) => i !== index));
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
|
|
// Validation
|
|
if (!formData.title.trim()) {
|
|
setError("Title is required");
|
|
return;
|
|
}
|
|
|
|
if (formData.title.length < 10) {
|
|
setError("Title must be at least 10 characters long");
|
|
return;
|
|
}
|
|
|
|
if (!formData.content.trim()) {
|
|
setError("Content is required");
|
|
return;
|
|
}
|
|
|
|
if (formData.content.length < 20) {
|
|
setError("Content must be at least 20 characters long");
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Add S3 image keys
|
|
if (imageFilenames.length > 0) {
|
|
postData.imageFilenames = imageFilenames;
|
|
}
|
|
|
|
const response = await forumAPI.createPost(postData);
|
|
navigate(`/forum/${response.data.id}`);
|
|
} catch (err: any) {
|
|
setError(err.response?.data?.error || err.message || "Failed to create post");
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
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 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>
|
|
);
|
|
}
|
|
|
|
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>
|
|
<li className="breadcrumb-item active" aria-current="page">
|
|
Create Post
|
|
</li>
|
|
</ol>
|
|
</nav>
|
|
|
|
<div className="row">
|
|
<div className="col-lg-8 mx-auto">
|
|
{/* Guidelines Card */}
|
|
<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">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"
|
|
id="title"
|
|
name="title"
|
|
value={formData.title}
|
|
onChange={handleInputChange}
|
|
placeholder="Enter a descriptive title..."
|
|
maxLength={200}
|
|
disabled={isSubmitting}
|
|
required
|
|
/>
|
|
<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"
|
|
id="content"
|
|
name="content"
|
|
rows={10}
|
|
value={formData.content}
|
|
onChange={handleInputChange}
|
|
placeholder="Write your post content here..."
|
|
disabled={isSubmitting}
|
|
required
|
|
/>
|
|
<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>
|
|
Creating...
|
|
</>
|
|
) : (
|
|
<>
|
|
<i className="bi bi-send me-2"></i>
|
|
Create Post
|
|
</>
|
|
)}
|
|
</button>
|
|
<Link
|
|
to="/forum"
|
|
className={`btn btn-secondary ${
|
|
isSubmitting ? "disabled" : ""
|
|
}`}
|
|
>
|
|
Cancel
|
|
</Link>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default CreateForumPost;
|