essential forum code
This commit is contained in:
33
frontend/src/components/CategoryBadge.tsx
Normal file
33
frontend/src/components/CategoryBadge.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CategoryBadgeProps {
|
||||
category: "item_request" | "technical_support" | "community_resources" | "general_discussion";
|
||||
}
|
||||
|
||||
const CategoryBadge: React.FC<CategoryBadgeProps> = ({ category }) => {
|
||||
const getCategoryConfig = (cat: string) => {
|
||||
switch (cat) {
|
||||
case 'item_request':
|
||||
return { label: 'Item Request', color: 'primary', icon: 'bi-box-seam' };
|
||||
case 'technical_support':
|
||||
return { label: 'Technical Support', color: 'warning', icon: 'bi-tools' };
|
||||
case 'community_resources':
|
||||
return { label: 'Community Resources', color: 'info', icon: 'bi-people' };
|
||||
case 'general_discussion':
|
||||
return { label: 'General Discussion', color: 'secondary', icon: 'bi-chat-dots' };
|
||||
default:
|
||||
return { label: 'General', color: 'secondary', icon: 'bi-chat' };
|
||||
}
|
||||
};
|
||||
|
||||
const config = getCategoryConfig(category);
|
||||
|
||||
return (
|
||||
<span className={`badge bg-${config.color}`}>
|
||||
<i className={`bi ${config.icon} me-1`}></i>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryBadge;
|
||||
86
frontend/src/components/CommentForm.tsx
Normal file
86
frontend/src/components/CommentForm.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface CommentFormProps {
|
||||
onSubmit: (content: string) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
placeholder?: string;
|
||||
buttonText?: string;
|
||||
isReply?: boolean;
|
||||
}
|
||||
|
||||
const CommentForm: React.FC<CommentFormProps> = ({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
placeholder = 'Write your comment...',
|
||||
buttonText = 'Post Comment',
|
||||
isReply = false,
|
||||
}) => {
|
||||
const [content, setContent] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!content.trim()) {
|
||||
setError('Comment cannot be empty');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await onSubmit(content);
|
||||
setContent('');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to post comment');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={isReply ? 'ms-4' : ''}>
|
||||
<div className="mb-2">
|
||||
<textarea
|
||||
className={`form-control ${error ? 'is-invalid' : ''}`}
|
||||
rows={isReply ? 2 : 3}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{error && <div className="invalid-feedback">{error}</div>}
|
||||
</div>
|
||||
<div className="d-flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className={`btn btn-${isReply ? 'sm btn-outline-' : ''}primary`}
|
||||
disabled={isSubmitting || !content.trim()}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||
Posting...
|
||||
</>
|
||||
) : (
|
||||
buttonText
|
||||
)}
|
||||
</button>
|
||||
{onCancel && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-${isReply ? 'sm btn-outline-' : ''}secondary`}
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentForm;
|
||||
227
frontend/src/components/CommentThread.tsx
Normal file
227
frontend/src/components/CommentThread.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ForumComment } from '../types';
|
||||
import CommentForm from './CommentForm';
|
||||
|
||||
interface CommentThreadProps {
|
||||
comment: ForumComment;
|
||||
onReply: (commentId: string, content: string) => Promise<void>;
|
||||
onEdit?: (commentId: string, content: string) => Promise<void>;
|
||||
onDelete?: (commentId: string) => Promise<void>;
|
||||
currentUserId?: string;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
const CommentThread: React.FC<CommentThreadProps> = ({
|
||||
comment,
|
||||
onReply,
|
||||
onEdit,
|
||||
onDelete,
|
||||
currentUserId,
|
||||
depth = 0,
|
||||
}) => {
|
||||
const [showReplyForm, setShowReplyForm] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editContent, setEditContent] = useState(comment.content);
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMinutes < 1) {
|
||||
return 'Just now';
|
||||
} else if (diffMinutes < 60) {
|
||||
return `${diffMinutes}m ago`;
|
||||
} else if (diffHours < 24) {
|
||||
return `${diffHours}h ago`;
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays}d ago`;
|
||||
} else {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
};
|
||||
|
||||
const handleReply = async (content: string) => {
|
||||
await onReply(comment.id, content);
|
||||
setShowReplyForm(false);
|
||||
};
|
||||
|
||||
const handleEdit = async () => {
|
||||
if (onEdit && editContent.trim() !== comment.content) {
|
||||
await onEdit(comment.id, editContent);
|
||||
setIsEditing(false);
|
||||
} else {
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (onDelete && window.confirm('Are you sure you want to delete this comment?')) {
|
||||
await onDelete(comment.id);
|
||||
}
|
||||
};
|
||||
|
||||
const isAuthor = currentUserId === comment.authorId;
|
||||
const maxDepth = 5;
|
||||
const canNest = depth < maxDepth;
|
||||
|
||||
if (comment.isDeleted) {
|
||||
return (
|
||||
<div className={`comment-deleted ${depth > 0 ? 'ms-4' : ''} mb-3`}>
|
||||
<div className="card bg-light">
|
||||
<div className="card-body py-2">
|
||||
<small className="text-muted fst-italic">
|
||||
[Comment deleted]
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
{comment.replies && comment.replies.length > 0 && (
|
||||
<div className="replies mt-2">
|
||||
{comment.replies.map((reply) => (
|
||||
<CommentThread
|
||||
key={reply.id}
|
||||
comment={reply}
|
||||
onReply={onReply}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
currentUserId={currentUserId}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`comment ${depth > 0 ? 'ms-4' : ''} mb-3`}>
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<div className="d-flex justify-content-between align-items-start mb-2">
|
||||
<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' }}>
|
||||
{comment.author?.firstName?.charAt(0) || '?'}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="d-block">
|
||||
{comment.author?.firstName || 'Unknown'} {comment.author?.lastName || ''}
|
||||
</strong>
|
||||
<small className="text-muted">
|
||||
{formatDate(comment.createdAt)}
|
||||
{comment.updatedAt !== comment.createdAt && ' (edited)'}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
{comment.replies && comment.replies.length > 0 && (
|
||||
<button
|
||||
className="btn btn-sm btn-link text-decoration-none"
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
<i className={`bi bi-chevron-${isCollapsed ? 'down' : 'up'}`}></i>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="mb-2">
|
||||
<textarea
|
||||
className="form-control mb-2"
|
||||
rows={3}
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
/>
|
||||
<div className="d-flex gap-2">
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={handleEdit}
|
||||
disabled={!editContent.trim()}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
setEditContent(comment.content);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="card-text mb-2" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{comment.content}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="d-flex gap-2">
|
||||
{!isEditing && canNest && (
|
||||
<button
|
||||
className="btn btn-sm btn-link text-decoration-none p-0"
|
||||
onClick={() => setShowReplyForm(!showReplyForm)}
|
||||
>
|
||||
<i className="bi bi-reply me-1"></i>
|
||||
Reply
|
||||
</button>
|
||||
)}
|
||||
{isAuthor && onEdit && !isEditing && (
|
||||
<button
|
||||
className="btn btn-sm btn-link text-decoration-none p-0"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<i className="bi bi-pencil me-1"></i>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{isAuthor && onDelete && !isEditing && (
|
||||
<button
|
||||
className="btn btn-sm btn-link text-danger text-decoration-none p-0"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<i className="bi bi-trash me-1"></i>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showReplyForm && (
|
||||
<div className="mt-2">
|
||||
<CommentForm
|
||||
onSubmit={handleReply}
|
||||
onCancel={() => setShowReplyForm(false)}
|
||||
placeholder="Write your reply..."
|
||||
buttonText="Post Reply"
|
||||
isReply={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCollapsed && comment.replies && comment.replies.length > 0 && (
|
||||
<div className="replies mt-2">
|
||||
{comment.replies.map((reply) => (
|
||||
<CommentThread
|
||||
key={reply.id}
|
||||
comment={reply}
|
||||
onReply={onReply}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
currentUserId={currentUserId}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentThread;
|
||||
115
frontend/src/components/ForumPostCard.tsx
Normal file
115
frontend/src/components/ForumPostCard.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ForumPost } from '../types';
|
||||
import CategoryBadge from './CategoryBadge';
|
||||
import PostStatusBadge from './PostStatusBadge';
|
||||
|
||||
interface ForumPostCardProps {
|
||||
post: ForumPost;
|
||||
showActions?: boolean;
|
||||
}
|
||||
|
||||
const ForumPostCard: React.FC<ForumPostCardProps> = ({ post, showActions = true }) => {
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffHours < 1) {
|
||||
return 'Just now';
|
||||
} else if (diffHours < 24) {
|
||||
return `${diffHours}h ago`;
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays}d ago`;
|
||||
} else {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
};
|
||||
|
||||
// Strip HTML tags for preview
|
||||
const getTextPreview = (html: string, maxLength: number = 150) => {
|
||||
const text = html.replace(/<[^>]*>/g, '');
|
||||
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card h-100 shadow-sm hover-shadow">
|
||||
<div className="card-body">
|
||||
{post.isPinned && (
|
||||
<div className="mb-2">
|
||||
<span className="badge bg-danger">
|
||||
<i className="bi bi-pin-angle-fill me-1"></i>
|
||||
Pinned
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="d-flex justify-content-between align-items-start mb-2">
|
||||
<h5 className="card-title text-truncate flex-grow-1 me-2">{post.title}</h5>
|
||||
<PostStatusBadge status={post.status} />
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
<CategoryBadge category={post.category} />
|
||||
</div>
|
||||
|
||||
<p className="card-text text-muted small mb-2">
|
||||
{getTextPreview(post.content)}
|
||||
</p>
|
||||
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div className="mb-2">
|
||||
{post.tags.slice(0, 3).map((tag) => (
|
||||
<span key={tag.id} className="badge bg-light text-dark me-1 mb-1">
|
||||
#{tag.tagName}
|
||||
</span>
|
||||
))}
|
||||
{post.tags.length > 3 && (
|
||||
<span className="badge bg-light text-dark">
|
||||
+{post.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-2">
|
||||
<small className="text-muted">
|
||||
<i className="bi bi-person me-1"></i>
|
||||
{post.author?.firstName || 'Unknown'} {post.author?.lastName || ''}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
<div className="d-flex gap-3">
|
||||
<small className="text-muted">
|
||||
<i className="bi bi-chat me-1"></i>
|
||||
{post.commentCount || 0}
|
||||
</small>
|
||||
<small className="text-muted">
|
||||
<i className="bi bi-eye me-1"></i>
|
||||
{post.viewCount || 0}
|
||||
</small>
|
||||
</div>
|
||||
<small className="text-muted">
|
||||
{formatDate(post.updatedAt)}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{showActions && (
|
||||
<div className="d-grid gap-2 mt-3">
|
||||
<Link
|
||||
to={`/forum/${post.id}`}
|
||||
className="btn btn-outline-primary btn-sm"
|
||||
>
|
||||
View Post
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForumPostCard;
|
||||
@@ -1,111 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ItemRequest } from '../types';
|
||||
|
||||
interface ItemRequestCardProps {
|
||||
request: ItemRequest;
|
||||
showActions?: boolean;
|
||||
}
|
||||
|
||||
const ItemRequestCard: React.FC<ItemRequestCardProps> = ({ request, showActions = true }) => {
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return 'Flexible';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
const getLocationString = () => {
|
||||
const parts = [];
|
||||
if (request.city) parts.push(request.city);
|
||||
if (request.state) parts.push(request.state);
|
||||
return parts.join(', ') || 'Location not specified';
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'open':
|
||||
return 'success';
|
||||
case 'fulfilled':
|
||||
return 'primary';
|
||||
case 'closed':
|
||||
return 'secondary';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card h-100 shadow-sm hover-shadow">
|
||||
<div className="card-body">
|
||||
<div className="d-flex justify-content-between align-items-start mb-2">
|
||||
<h5 className="card-title text-truncate flex-grow-1 me-2">{request.title}</h5>
|
||||
<span className={`badge bg-${getStatusColor(request.status)}`}>
|
||||
{request.status.charAt(0).toUpperCase() + request.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="card-text text-muted small mb-2">
|
||||
{request.description.length > 100
|
||||
? `${request.description.substring(0, 100)}...`
|
||||
: request.description
|
||||
}
|
||||
</p>
|
||||
|
||||
<div className="mb-2">
|
||||
<small className="text-muted">
|
||||
<i className="bi bi-geo-alt me-1"></i>
|
||||
{getLocationString()}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
<small className="text-muted">
|
||||
<i className="bi bi-person me-1"></i>
|
||||
Requested by {request.requester?.firstName || 'Unknown'}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{(request.maxPricePerDay || request.maxPricePerHour) && (
|
||||
<div className="mb-2">
|
||||
<small className="text-muted">
|
||||
<i className="bi bi-currency-dollar me-1"></i>
|
||||
Budget:
|
||||
{request.maxPricePerDay && ` $${request.maxPricePerDay}/day`}
|
||||
{request.maxPricePerHour && ` $${request.maxPricePerHour}/hour`}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3">
|
||||
<small className="text-muted">
|
||||
<i className="bi bi-calendar me-1"></i>
|
||||
Dates: {formatDate(request.preferredStartDate)} - {formatDate(request.preferredEndDate)}
|
||||
{request.isFlexibleDates && ' (Flexible)'}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
<small className="text-muted">
|
||||
{request.responseCount || 0} response{request.responseCount !== 1 ? 's' : ''}
|
||||
</small>
|
||||
<small className="text-muted">
|
||||
{new Date(request.createdAt).toLocaleDateString()}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{showActions && (
|
||||
<div className="d-grid gap-2 mt-3">
|
||||
<Link
|
||||
to={`/item-requests/${request.id}`}
|
||||
className="btn btn-outline-primary btn-sm"
|
||||
>
|
||||
View Details
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ItemRequestCard;
|
||||
@@ -253,9 +253,9 @@ const Navbar: React.FC = () => {
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link className="dropdown-item" to="/my-requests">
|
||||
<i className="bi bi-clipboard-check me-2"></i>
|
||||
Looking For
|
||||
<Link className="dropdown-item" to="/forum">
|
||||
<i className="bi bi-chat-dots me-2"></i>
|
||||
Forum
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
|
||||
31
frontend/src/components/PostStatusBadge.tsx
Normal file
31
frontend/src/components/PostStatusBadge.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
|
||||
interface PostStatusBadgeProps {
|
||||
status: "open" | "solved" | "closed";
|
||||
}
|
||||
|
||||
const PostStatusBadge: React.FC<PostStatusBadgeProps> = ({ status }) => {
|
||||
const getStatusConfig = (stat: string) => {
|
||||
switch (stat) {
|
||||
case 'open':
|
||||
return { label: 'Open', color: 'success', icon: 'bi-circle' };
|
||||
case 'solved':
|
||||
return { label: 'Solved', color: 'info', icon: 'bi-check-circle' };
|
||||
case 'closed':
|
||||
return { label: 'Closed', color: 'secondary', icon: 'bi-x-circle' };
|
||||
default:
|
||||
return { label: status, color: 'secondary', icon: 'bi-circle' };
|
||||
}
|
||||
};
|
||||
|
||||
const config = getStatusConfig(status);
|
||||
|
||||
return (
|
||||
<span className={`badge bg-${config.color}`}>
|
||||
<i className={`bi ${config.icon} me-1`}></i>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostStatusBadge;
|
||||
@@ -1,295 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { itemRequestAPI, itemAPI } from "../services/api";
|
||||
import { ItemRequest, Item } from "../types";
|
||||
|
||||
interface RequestResponseModalProps {
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
request: ItemRequest | null;
|
||||
onResponseSubmitted: () => void;
|
||||
}
|
||||
|
||||
const RequestResponseModal: React.FC<RequestResponseModalProps> = ({
|
||||
show,
|
||||
onHide,
|
||||
request,
|
||||
onResponseSubmitted,
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [userItems, setUserItems] = useState<Item[]>([]);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
message: "",
|
||||
offerPricePerHour: "",
|
||||
offerPricePerDay: "",
|
||||
availableStartDate: "",
|
||||
availableEndDate: "",
|
||||
existingItemId: "",
|
||||
contactInfo: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (show && user) {
|
||||
fetchUserItems();
|
||||
resetForm();
|
||||
}
|
||||
}, [show, user]);
|
||||
|
||||
const fetchUserItems = async () => {
|
||||
try {
|
||||
const response = await itemAPI.getItems({ owner: user?.id });
|
||||
setUserItems(response.data.items || []);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch user items:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
message: "",
|
||||
offerPricePerHour: "",
|
||||
offerPricePerDay: "",
|
||||
availableStartDate: "",
|
||||
availableEndDate: "",
|
||||
existingItemId: "",
|
||||
contactInfo: "",
|
||||
});
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<
|
||||
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
||||
>
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!request || !user) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const responseData = {
|
||||
...formData,
|
||||
offerPricePerHour: formData.offerPricePerHour
|
||||
? parseFloat(formData.offerPricePerHour)
|
||||
: null,
|
||||
offerPricePerDay: formData.offerPricePerDay
|
||||
? parseFloat(formData.offerPricePerDay)
|
||||
: null,
|
||||
existingItemId: formData.existingItemId || null,
|
||||
availableStartDate: formData.availableStartDate || null,
|
||||
availableEndDate: formData.availableEndDate || null,
|
||||
};
|
||||
|
||||
await itemRequestAPI.respondToRequest(request.id, responseData);
|
||||
onResponseSubmitted();
|
||||
onHide();
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || "Failed to submit response");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`modal fade ${show ? "show d-block" : ""}`}
|
||||
tabIndex={-1}
|
||||
style={{ backgroundColor: show ? "rgba(0,0,0,0.5)" : "transparent" }}
|
||||
>
|
||||
<div className="modal-dialog modal-lg">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">Respond to Request</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close"
|
||||
onClick={onHide}
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<div className="mb-3 p-3 bg-light rounded">
|
||||
<h6>{request.title}</h6>
|
||||
<p className="text-muted small mb-0">{request.description}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-3">
|
||||
<label htmlFor="message" className="form-label">
|
||||
Your Message *
|
||||
</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
id="message"
|
||||
name="message"
|
||||
rows={4}
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
placeholder="Explain how you can help, availability, condition of the item, etc."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{userItems.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<label htmlFor="existingItemId" className="form-label">
|
||||
Do you have an existing listing for this item?
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="existingItemId"
|
||||
name="existingItemId"
|
||||
value={formData.existingItemId}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="">No existing listing</option>
|
||||
{userItems.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name} - ${item.pricePerDay}/day
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="form-text">
|
||||
If you have an existing listing that matches this request,
|
||||
select it here.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="row mb-3">
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="offerPricePerDay" className="form-label">
|
||||
Your Price per Day
|
||||
</label>
|
||||
<div className="input-group">
|
||||
<span className="input-group-text">$</span>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
id="offerPricePerDay"
|
||||
name="offerPricePerDay"
|
||||
value={formData.offerPricePerDay}
|
||||
onChange={handleChange}
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="offerPricePerHour" className="form-label">
|
||||
Your Price per Hour
|
||||
</label>
|
||||
<div className="input-group">
|
||||
<span className="input-group-text">$</span>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
id="offerPricePerHour"
|
||||
name="offerPricePerHour"
|
||||
value={formData.offerPricePerHour}
|
||||
onChange={handleChange}
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row mb-3">
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="availableStartDate" className="form-label">
|
||||
Available From
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className="form-control"
|
||||
id="availableStartDate"
|
||||
name="availableStartDate"
|
||||
value={formData.availableStartDate}
|
||||
onChange={handleChange}
|
||||
min={new Date().toLocaleDateString()}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="availableEndDate" className="form-label">
|
||||
Available Until
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className="form-control"
|
||||
id="availableEndDate"
|
||||
name="availableEndDate"
|
||||
value={formData.availableEndDate}
|
||||
onChange={handleChange}
|
||||
min={
|
||||
formData.availableStartDate ||
|
||||
new Date().toLocaleDateString()
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="contactInfo" className="form-label">
|
||||
Contact Information
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="contactInfo"
|
||||
name="contactInfo"
|
||||
value={formData.contactInfo}
|
||||
onChange={handleChange}
|
||||
placeholder="Phone number, email, or preferred contact method"
|
||||
/>
|
||||
<div className="form-text">
|
||||
How should the requester contact you if they're interested?
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={onHide}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !formData.message.trim()}
|
||||
>
|
||||
{loading ? "Submitting..." : "Submit Response"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RequestResponseModal;
|
||||
154
frontend/src/components/TagInput.tsx
Normal file
154
frontend/src/components/TagInput.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { forumAPI } from '../services/api';
|
||||
|
||||
interface TagInputProps {
|
||||
selectedTags: string[];
|
||||
onChange: (tags: string[]) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const TagInput: React.FC<TagInputProps> = ({
|
||||
selectedTags,
|
||||
onChange,
|
||||
placeholder = 'Add tags...',
|
||||
}) => {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [activeSuggestion, setActiveSuggestion] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const suggestionsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSuggestions = async () => {
|
||||
if (inputValue.length >= 2) {
|
||||
try {
|
||||
const response = await forumAPI.getTags({ search: inputValue });
|
||||
const tags = response.data.map((tag: any) => tag.tagName);
|
||||
setSuggestions(tags.filter((tag: string) => !selectedTags.includes(tag)));
|
||||
setShowSuggestions(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tag suggestions:', error);
|
||||
}
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const debounceTimer = setTimeout(fetchSuggestions, 300);
|
||||
return () => clearTimeout(debounceTimer);
|
||||
}, [inputValue, selectedTags]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
suggestionsRef.current &&
|
||||
!suggestionsRef.current.contains(event.target as Node) &&
|
||||
inputRef.current &&
|
||||
!inputRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const addTag = (tag: string) => {
|
||||
const normalizedTag = tag.toLowerCase().trim();
|
||||
if (normalizedTag && !selectedTags.includes(normalizedTag)) {
|
||||
onChange([...selectedTags, normalizedTag]);
|
||||
setInputValue('');
|
||||
setSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
setActiveSuggestion(0);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (tagToRemove: string) => {
|
||||
onChange(selectedTags.filter((tag) => tag !== tagToRemove));
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (showSuggestions && suggestions.length > 0 && activeSuggestion < suggestions.length) {
|
||||
addTag(suggestions[activeSuggestion]);
|
||||
} else if (inputValue.trim()) {
|
||||
addTag(inputValue);
|
||||
}
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveSuggestion((prev) => Math.min(prev + 1, suggestions.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveSuggestion((prev) => Math.max(prev - 1, 0));
|
||||
} else if (e.key === 'Escape') {
|
||||
setShowSuggestions(false);
|
||||
} else if (e.key === 'Backspace' && !inputValue && selectedTags.length > 0) {
|
||||
removeTag(selectedTags[selectedTags.length - 1]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tag-input-container">
|
||||
<div className="form-control d-flex flex-wrap gap-1 p-2" style={{ minHeight: '45px' }}>
|
||||
{selectedTags.map((tag) => (
|
||||
<span key={tag} className="badge bg-primary d-flex align-items-center gap-1">
|
||||
#{tag}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close btn-close-white"
|
||||
style={{ fontSize: '0.6rem' }}
|
||||
onClick={() => removeTag(tag)}
|
||||
aria-label="Remove tag"
|
||||
></button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="border-0 flex-grow-1"
|
||||
style={{ outline: 'none', minWidth: '150px' }}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => {
|
||||
if (suggestions.length > 0) setShowSuggestions(true);
|
||||
}}
|
||||
placeholder={selectedTags.length === 0 ? placeholder : ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<div
|
||||
ref={suggestionsRef}
|
||||
className="list-group position-absolute w-100 shadow-sm"
|
||||
style={{ zIndex: 1000, maxHeight: '200px', overflowY: 'auto' }}
|
||||
>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
className={`list-group-item list-group-item-action ${
|
||||
index === activeSuggestion ? 'active' : ''
|
||||
}`}
|
||||
onClick={() => addTag(suggestion)}
|
||||
onMouseEnter={() => setActiveSuggestion(index)}
|
||||
>
|
||||
#{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<small className="form-text text-muted">
|
||||
Press Enter to add a tag. Use letters, numbers, and hyphens only.
|
||||
</small>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagInput;
|
||||
Reference in New Issue
Block a user