Compare commits
2 Commits
3f319bfdd0
...
5e01bb8cff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e01bb8cff | ||
|
|
55e08e14b8 |
@@ -1111,7 +1111,7 @@ router.post('/posts/:id/comments', authenticateToken, async (req, res, next) =>
|
|||||||
// PUT /api/forum/comments/:id - Edit comment
|
// PUT /api/forum/comments/:id - Edit comment
|
||||||
router.put('/comments/:id', authenticateToken, async (req, res, next) => {
|
router.put('/comments/:id', authenticateToken, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { content } = req.body;
|
const { content, imageFilenames: rawImageFilenames } = req.body;
|
||||||
const comment = await ForumComment.findByPk(req.params.id);
|
const comment = await ForumComment.findByPk(req.params.id);
|
||||||
|
|
||||||
if (!comment) {
|
if (!comment) {
|
||||||
@@ -1126,7 +1126,19 @@ router.put('/comments/:id', authenticateToken, async (req, res, next) => {
|
|||||||
return res.status(400).json({ error: 'Cannot edit deleted comment' });
|
return res.status(400).json({ error: 'Cannot edit deleted comment' });
|
||||||
}
|
}
|
||||||
|
|
||||||
await comment.update({ content });
|
const updateData = { content };
|
||||||
|
|
||||||
|
// Handle image filenames if provided
|
||||||
|
if (rawImageFilenames !== undefined) {
|
||||||
|
const imageFilenamesArray = Array.isArray(rawImageFilenames) ? rawImageFilenames : [];
|
||||||
|
const keyValidation = validateS3Keys(imageFilenamesArray, 'forum', { maxKeys: IMAGE_LIMITS.forum });
|
||||||
|
if (!keyValidation.valid) {
|
||||||
|
return res.status(400).json({ error: keyValidation.error });
|
||||||
|
}
|
||||||
|
updateData.imageFilenames = imageFilenamesArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
await comment.update(updateData);
|
||||||
|
|
||||||
const updatedComment = await ForumComment.findByPk(comment.id, {
|
const updatedComment = await ForumComment.findByPk(comment.id, {
|
||||||
include: [
|
include: [
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ router.get("/", async (req, res, next) => {
|
|||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: "owner",
|
as: "owner",
|
||||||
attributes: ["id", "firstName", "lastName"],
|
attributes: ["id", "firstName", "lastName", "imageFilename"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
limit: parseInt(limit),
|
limit: parseInt(limit),
|
||||||
@@ -188,7 +188,7 @@ router.get('/:id/reviews', async (req, res, next) => {
|
|||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: 'renter',
|
as: 'renter',
|
||||||
attributes: ['id', 'firstName', 'lastName']
|
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
order: [['createdAt', 'DESC']]
|
order: [['createdAt', 'DESC']]
|
||||||
@@ -228,7 +228,7 @@ router.get("/:id", optionalAuth, async (req, res, next) => {
|
|||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: "owner",
|
as: "owner",
|
||||||
attributes: ["id", "firstName", "lastName"],
|
attributes: ["id", "firstName", "lastName", "imageFilename"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ router.get("/renting", authenticateToken, async (req, res) => {
|
|||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: "owner",
|
as: "owner",
|
||||||
attributes: ["id", "firstName", "lastName"],
|
attributes: ["id", "firstName", "lastName", "imageFilename"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
order: [["createdAt", "DESC"]],
|
order: [["createdAt", "DESC"]],
|
||||||
@@ -94,7 +94,7 @@ router.get("/owning", authenticateToken, async (req, res) => {
|
|||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: "renter",
|
as: "renter",
|
||||||
attributes: ["id", "firstName", "lastName"],
|
attributes: ["id", "firstName", "lastName", "imageFilename"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
order: [["createdAt", "DESC"]],
|
order: [["createdAt", "DESC"]],
|
||||||
|
|||||||
@@ -159,6 +159,14 @@ const AppContent: React.FC = () => {
|
|||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/forum/:id/edit"
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<CreateForumPost />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/my-posts"
|
path="/my-posts"
|
||||||
element={
|
element={
|
||||||
|
|||||||
146
frontend/src/components/Avatar.tsx
Normal file
146
frontend/src/components/Avatar.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { getPublicImageUrl } from "../services/uploadService";
|
||||||
|
|
||||||
|
interface AvatarUser {
|
||||||
|
id?: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
imageFilename?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AvatarProps {
|
||||||
|
user: AvatarUser | null;
|
||||||
|
size?: "xs" | "sm" | "md" | "lg" | "xl" | "xxl" | "xxxl";
|
||||||
|
sizePx?: number;
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeMap: Record<string, number> = {
|
||||||
|
xs: 30,
|
||||||
|
sm: 32,
|
||||||
|
md: 35,
|
||||||
|
lg: 50,
|
||||||
|
xl: 60,
|
||||||
|
xxl: 120,
|
||||||
|
xxxl: 150,
|
||||||
|
};
|
||||||
|
|
||||||
|
const colors = [
|
||||||
|
"#6366f1", // Indigo
|
||||||
|
"#8b5cf6", // Violet
|
||||||
|
"#ec4899", // Pink
|
||||||
|
"#ef4444", // Red
|
||||||
|
"#f97316", // Orange
|
||||||
|
"#eab308", // Yellow
|
||||||
|
"#22c55e", // Green
|
||||||
|
"#14b8a6", // Teal
|
||||||
|
"#06b6d4", // Cyan
|
||||||
|
"#3b82f6", // Blue
|
||||||
|
];
|
||||||
|
|
||||||
|
const getAvatarColor = (
|
||||||
|
firstName: string,
|
||||||
|
lastName: string,
|
||||||
|
userId?: string
|
||||||
|
): string => {
|
||||||
|
const input = userId || `${firstName}${lastName}`;
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
hash = input.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
return colors[Math.abs(hash) % colors.length];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInitials = (firstName: string, lastName: string): string => {
|
||||||
|
const firstInitial = firstName?.charAt(0)?.toUpperCase() || "";
|
||||||
|
const lastInitial = lastName?.charAt(0)?.toUpperCase() || "";
|
||||||
|
return `${firstInitial}${lastInitial}` || "?";
|
||||||
|
};
|
||||||
|
|
||||||
|
const Avatar: React.FC<AvatarProps> = ({
|
||||||
|
user,
|
||||||
|
size = "md",
|
||||||
|
sizePx,
|
||||||
|
className = "",
|
||||||
|
onClick,
|
||||||
|
imageUrl: directImageUrl,
|
||||||
|
}) => {
|
||||||
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
|
// Reset error state when image URL changes
|
||||||
|
useEffect(() => {
|
||||||
|
setImageError(false);
|
||||||
|
}, [directImageUrl, user?.imageFilename]);
|
||||||
|
|
||||||
|
const pixelSize = sizePx || sizeMap[size];
|
||||||
|
const fontSize = Math.max(pixelSize * 0.4, 12);
|
||||||
|
|
||||||
|
// Handle missing user - show generic placeholder
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-circle bg-secondary d-flex align-items-center justify-content-center text-white ${className}`}
|
||||||
|
style={{
|
||||||
|
width: pixelSize,
|
||||||
|
height: pixelSize,
|
||||||
|
fontSize: fontSize,
|
||||||
|
cursor: onClick ? "pointer" : "default",
|
||||||
|
}}
|
||||||
|
onClick={onClick}
|
||||||
|
role="img"
|
||||||
|
aria-label="User avatar"
|
||||||
|
>
|
||||||
|
<i className="bi bi-person-fill"></i>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { firstName, lastName, imageFilename, id } = user;
|
||||||
|
// Use direct imageUrl if provided, otherwise construct from imageFilename
|
||||||
|
const imageUrl = directImageUrl || (imageFilename ? getPublicImageUrl(imageFilename) : null);
|
||||||
|
const hasValidImage = imageUrl && !imageError;
|
||||||
|
|
||||||
|
if (hasValidImage) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={`${firstName} ${lastName}`}
|
||||||
|
className={`rounded-circle ${className}`}
|
||||||
|
style={{
|
||||||
|
width: pixelSize,
|
||||||
|
height: pixelSize,
|
||||||
|
objectFit: "cover",
|
||||||
|
cursor: onClick ? "pointer" : "default",
|
||||||
|
}}
|
||||||
|
onClick={onClick}
|
||||||
|
onError={() => setImageError(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initials fallback
|
||||||
|
const bgColor = getAvatarColor(firstName, lastName, id);
|
||||||
|
const initials = getInitials(firstName, lastName);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-circle d-flex align-items-center justify-content-center text-white fw-bold ${className}`}
|
||||||
|
style={{
|
||||||
|
width: pixelSize,
|
||||||
|
height: pixelSize,
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
fontSize: fontSize,
|
||||||
|
cursor: onClick ? "pointer" : "default",
|
||||||
|
}}
|
||||||
|
onClick={onClick}
|
||||||
|
role="img"
|
||||||
|
aria-label={`${firstName} ${lastName}'s avatar`}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Avatar;
|
||||||
@@ -11,6 +11,7 @@ import { User, Message } from "../types";
|
|||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { useSocket } from "../contexts/SocketContext";
|
import { useSocket } from "../contexts/SocketContext";
|
||||||
import TypingIndicator from "./TypingIndicator";
|
import TypingIndicator from "./TypingIndicator";
|
||||||
|
import Avatar from "./Avatar";
|
||||||
|
|
||||||
interface ChatWindowProps {
|
interface ChatWindowProps {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
@@ -462,21 +463,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-primary text-white p-3 d-flex align-items-center justify-content-between flex-shrink-0">
|
<div className="bg-primary text-white p-3 d-flex align-items-center justify-content-between flex-shrink-0">
|
||||||
<div className="d-flex align-items-center">
|
<div className="d-flex align-items-center">
|
||||||
{recipient.imageFilename ? (
|
<Avatar user={recipient} size="md" className="me-2" />
|
||||||
<img
|
|
||||||
src={recipient.imageFilename}
|
|
||||||
alt={`${recipient.firstName} ${recipient.lastName}`}
|
|
||||||
className="rounded-circle me-2"
|
|
||||||
style={{ width: "35px", height: "35px", objectFit: "cover" }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="rounded-circle bg-white bg-opacity-25 d-flex align-items-center justify-content-center me-2"
|
|
||||||
style={{ width: "35px", height: "35px" }}
|
|
||||||
>
|
|
||||||
<i className="bi bi-person-fill text-white"></i>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
<div>
|
||||||
<h6 className="mb-0">
|
<h6 className="mb-0">
|
||||||
{recipient.firstName} {recipient.lastName}
|
{recipient.firstName} {recipient.lastName}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import ForumImageUpload from './ForumImageUpload';
|
import ForumImageUpload from './ForumImageUpload';
|
||||||
|
import { IMAGE_LIMITS } from '../config/imageLimits';
|
||||||
|
|
||||||
interface CommentFormProps {
|
interface CommentFormProps {
|
||||||
onSubmit: (content: string, images: File[]) => Promise<void>;
|
onSubmit: (content: string, images: File[]) => Promise<void>;
|
||||||
@@ -24,7 +25,7 @@ const CommentForm: React.FC<CommentFormProps> = ({
|
|||||||
|
|
||||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(e.target.files || []);
|
const files = Array.from(e.target.files || []);
|
||||||
const remainingSlots = 3 - imageFiles.length;
|
const remainingSlots = IMAGE_LIMITS.forum - imageFiles.length;
|
||||||
const filesToAdd = files.slice(0, remainingSlots);
|
const filesToAdd = files.slice(0, remainingSlots);
|
||||||
|
|
||||||
setImageFiles((prev) => [...prev, ...filesToAdd]);
|
setImageFiles((prev) => [...prev, ...filesToAdd]);
|
||||||
@@ -81,7 +82,6 @@ const CommentForm: React.FC<CommentFormProps> = ({
|
|||||||
/>
|
/>
|
||||||
{error && <div className="invalid-feedback">{error}</div>}
|
{error && <div className="invalid-feedback">{error}</div>}
|
||||||
</div>
|
</div>
|
||||||
{!isReply && (
|
|
||||||
<ForumImageUpload
|
<ForumImageUpload
|
||||||
imageFiles={imageFiles}
|
imageFiles={imageFiles}
|
||||||
imagePreviews={imagePreviews}
|
imagePreviews={imagePreviews}
|
||||||
@@ -89,7 +89,6 @@ const CommentForm: React.FC<CommentFormProps> = ({
|
|||||||
onRemoveImage={handleRemoveImage}
|
onRemoveImage={handleRemoveImage}
|
||||||
compact={true}
|
compact={true}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
<div className="d-flex gap-2">
|
<div className="d-flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { ForumComment } from "../types";
|
import { ForumComment } from "../types";
|
||||||
import CommentForm from "./CommentForm";
|
import CommentForm from "./CommentForm";
|
||||||
|
import ForumImageUpload from "./ForumImageUpload";
|
||||||
import { getPublicImageUrl } from "../services/uploadService";
|
import { getPublicImageUrl } from "../services/uploadService";
|
||||||
|
import { IMAGE_LIMITS } from "../config/imageLimits";
|
||||||
|
|
||||||
interface CommentThreadProps {
|
interface CommentThreadProps {
|
||||||
comment: ForumComment;
|
comment: ForumComment;
|
||||||
onReply: (commentId: string, content: string) => Promise<void>;
|
onReply: (commentId: string, content: string, images?: File[]) => Promise<void>;
|
||||||
onEdit?: (commentId: string, content: string) => Promise<void>;
|
onEdit?: (commentId: string, content: string, existingImageKeys: string[], newImageFiles: File[]) => Promise<void>;
|
||||||
onDelete?: (commentId: string) => Promise<void>;
|
onDelete?: (commentId: string) => Promise<void>;
|
||||||
onMarkAsAnswer?: (commentId: string) => Promise<void>;
|
onMarkAsAnswer?: (commentId: string) => Promise<void>;
|
||||||
currentUserId?: string;
|
currentUserId?: string;
|
||||||
@@ -37,6 +39,11 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
|||||||
const [editContent, setEditContent] = useState(comment.content);
|
const [editContent, setEditContent] = useState(comment.content);
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
|
||||||
|
// Image editing state
|
||||||
|
const [existingImageKeys, setExistingImageKeys] = useState<string[]>([]);
|
||||||
|
const [editImageFiles, setEditImageFiles] = useState<File[]>([]);
|
||||||
|
const [editImagePreviews, setEditImagePreviews] = useState<string[]>([]);
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -59,17 +66,67 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleReply = async (content: string, images: File[]) => {
|
const handleReply = async (content: string, images: File[]) => {
|
||||||
// Replies don't support images, so we ignore the images parameter
|
await onReply(comment.id, content, images);
|
||||||
await onReply(comment.id, content);
|
|
||||||
setShowReplyForm(false);
|
setShowReplyForm(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = async () => {
|
const startEditing = () => {
|
||||||
if (onEdit && editContent.trim() !== comment.content) {
|
setIsEditing(true);
|
||||||
await onEdit(comment.id, editContent);
|
setEditContent(comment.content);
|
||||||
|
const existingKeys = comment.imageFilenames || [];
|
||||||
|
setExistingImageKeys(existingKeys);
|
||||||
|
setEditImagePreviews(existingKeys.map((key) => getPublicImageUrl(key)));
|
||||||
|
setEditImageFiles([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEditing = () => {
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
|
setEditContent(comment.content);
|
||||||
|
setExistingImageKeys([]);
|
||||||
|
setEditImageFiles([]);
|
||||||
|
setEditImagePreviews([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(e.target.files || []);
|
||||||
|
const totalImages = existingImageKeys.length + editImageFiles.length;
|
||||||
|
const remainingSlots = IMAGE_LIMITS.forum - totalImages;
|
||||||
|
const filesToAdd = files.slice(0, remainingSlots);
|
||||||
|
|
||||||
|
setEditImageFiles((prev) => [...prev, ...filesToAdd]);
|
||||||
|
|
||||||
|
filesToAdd.forEach((file) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setEditImagePreviews((prev) => [...prev, reader.result as string]);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
e.target.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveEditImage = (index: number) => {
|
||||||
|
if (index < existingImageKeys.length) {
|
||||||
|
// Removing an existing S3 image
|
||||||
|
setExistingImageKeys((prev) => prev.filter((_, i) => i !== index));
|
||||||
} else {
|
} else {
|
||||||
|
// Removing a new upload
|
||||||
|
const newFileIndex = index - existingImageKeys.length;
|
||||||
|
setEditImageFiles((prev) => prev.filter((_, i) => i !== newFileIndex));
|
||||||
|
}
|
||||||
|
setEditImagePreviews((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = async () => {
|
||||||
|
if (onEdit && editContent.trim()) {
|
||||||
|
await onEdit(comment.id, editContent, existingImageKeys, editImageFiles);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
|
setExistingImageKeys([]);
|
||||||
|
setEditImageFiles([]);
|
||||||
|
setEditImagePreviews([]);
|
||||||
|
} else {
|
||||||
|
cancelEditing();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -188,7 +245,14 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
|||||||
value={editContent}
|
value={editContent}
|
||||||
onChange={(e) => setEditContent(e.target.value)}
|
onChange={(e) => setEditContent(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className="d-flex gap-2">
|
<ForumImageUpload
|
||||||
|
imageFiles={editImageFiles}
|
||||||
|
imagePreviews={editImagePreviews}
|
||||||
|
onImageChange={handleEditImageChange}
|
||||||
|
onRemoveImage={handleRemoveEditImage}
|
||||||
|
compact={true}
|
||||||
|
/>
|
||||||
|
<div className="d-flex gap-2 mt-2">
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm btn-primary"
|
className="btn btn-sm btn-primary"
|
||||||
onClick={handleEdit}
|
onClick={handleEdit}
|
||||||
@@ -198,10 +262,7 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm btn-secondary"
|
className="btn btn-sm btn-secondary"
|
||||||
onClick={() => {
|
onClick={cancelEditing}
|
||||||
setIsEditing(false);
|
|
||||||
setEditContent(comment.content);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -222,8 +283,8 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
|||||||
className="img-fluid rounded"
|
className="img-fluid rounded"
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100px",
|
maxHeight: "300px",
|
||||||
objectFit: "cover",
|
objectFit: "contain",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -267,7 +328,7 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
|||||||
{isAuthor && onEdit && !isEditing && (
|
{isAuthor && onEdit && !isEditing && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm btn-link text-decoration-none p-0"
|
className="btn btn-sm btn-link text-decoration-none p-0"
|
||||||
onClick={() => setIsEditing(true)}
|
onClick={startEditing}
|
||||||
>
|
>
|
||||||
<i className="bi bi-pencil me-1"></i>
|
<i className="bi bi-pencil me-1"></i>
|
||||||
Edit
|
Edit
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Rental } from "../types";
|
import { Rental } from "../types";
|
||||||
import { itemAPI } from "../services/api";
|
import { itemAPI } from "../services/api";
|
||||||
|
import Avatar from "./Avatar";
|
||||||
|
|
||||||
interface ItemReviewsProps {
|
interface ItemReviewsProps {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
@@ -85,28 +86,7 @@ const ItemReviews: React.FC<ItemReviewsProps> = ({ itemId }) => {
|
|||||||
onClick={() => rental.renter && navigate(`/users/${rental.renterId}`)}
|
onClick={() => rental.renter && navigate(`/users/${rental.renterId}`)}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
>
|
>
|
||||||
{rental.renter?.imageFilename ? (
|
<Avatar user={rental.renter || null} size="sm" />
|
||||||
<img
|
|
||||||
src={rental.renter.imageFilename}
|
|
||||||
alt={`${rental.renter.firstName} ${rental.renter.lastName}`}
|
|
||||||
className="rounded-circle"
|
|
||||||
style={{
|
|
||||||
width: "32px",
|
|
||||||
height: "32px",
|
|
||||||
objectFit: "cover",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center"
|
|
||||||
style={{ width: "32px", height: "32px" }}
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
className="bi bi-person-fill text-white"
|
|
||||||
style={{ fontSize: "0.8rem" }}
|
|
||||||
></i>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
<div>
|
||||||
<strong style={{ color: "#0d6efd" }}>
|
<strong style={{ color: "#0d6efd" }}>
|
||||||
{rental.renter?.firstName} {rental.renter?.lastName}
|
{rental.renter?.firstName} {rental.renter?.lastName}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState } from "react";
|
|||||||
import { rentalAPI } from "../services/api";
|
import { rentalAPI } from "../services/api";
|
||||||
import { Rental } from "../types";
|
import { Rental } from "../types";
|
||||||
import SuccessModal from "./SuccessModal";
|
import SuccessModal from "./SuccessModal";
|
||||||
|
import Avatar from "./Avatar";
|
||||||
|
|
||||||
interface ReviewItemModalProps {
|
interface ReviewItemModalProps {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
@@ -102,26 +103,7 @@ const ReviewItemModal: React.FC<ReviewItemModalProps> = ({
|
|||||||
{rental.owner && rental.item && (
|
{rental.owner && rental.item && (
|
||||||
<div className="mb-4 text-center">
|
<div className="mb-4 text-center">
|
||||||
<div className="d-flex justify-content-center mb-3">
|
<div className="d-flex justify-content-center mb-3">
|
||||||
{rental.owner.imageFilename ? (
|
<Avatar user={rental.owner} size="xl" />
|
||||||
<img
|
|
||||||
src={rental.owner.imageFilename}
|
|
||||||
alt={`${rental.owner.firstName} ${rental.owner.lastName}`}
|
|
||||||
className="rounded-circle"
|
|
||||||
style={{
|
|
||||||
width: "60px",
|
|
||||||
height: "60px",
|
|
||||||
objectFit: "cover",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="rounded-circle bg-primary d-flex align-items-center justify-content-center text-white fw-bold"
|
|
||||||
style={{ width: "60px", height: "60px" }}
|
|
||||||
>
|
|
||||||
{rental.owner.firstName[0]}
|
|
||||||
{rental.owner.lastName[0]}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<h6 className="mb-1">
|
<h6 className="mb-1">
|
||||||
{rental.owner.firstName} {rental.owner.lastName}
|
{rental.owner.firstName} {rental.owner.lastName}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState } from "react";
|
|||||||
import { rentalAPI } from "../services/api";
|
import { rentalAPI } from "../services/api";
|
||||||
import { Rental } from "../types";
|
import { Rental } from "../types";
|
||||||
import SuccessModal from "./SuccessModal";
|
import SuccessModal from "./SuccessModal";
|
||||||
|
import Avatar from "./Avatar";
|
||||||
|
|
||||||
interface ReviewRenterModalProps {
|
interface ReviewRenterModalProps {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
@@ -102,26 +103,7 @@ const ReviewRenterModal: React.FC<ReviewRenterModalProps> = ({
|
|||||||
{rental.renter && rental.item && (
|
{rental.renter && rental.item && (
|
||||||
<div className="mb-4 text-center">
|
<div className="mb-4 text-center">
|
||||||
<div className="d-flex justify-content-center mb-3">
|
<div className="d-flex justify-content-center mb-3">
|
||||||
{rental.renter.imageFilename ? (
|
<Avatar user={rental.renter} size="xl" />
|
||||||
<img
|
|
||||||
src={rental.renter.imageFilename}
|
|
||||||
alt={`${rental.renter.firstName} ${rental.renter.lastName}`}
|
|
||||||
className="rounded-circle"
|
|
||||||
style={{
|
|
||||||
width: "60px",
|
|
||||||
height: "60px",
|
|
||||||
objectFit: "cover",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="rounded-circle bg-primary d-flex align-items-center justify-content-center text-white fw-bold"
|
|
||||||
style={{ width: "60px", height: "60px" }}
|
|
||||||
>
|
|
||||||
{rental.renter.firstName[0]}
|
|
||||||
{rental.renter.lastName[0]}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<h6 className="mb-1">
|
<h6 className="mb-1">
|
||||||
{rental.renter.firstName} {rental.renter.lastName}
|
{rental.renter.firstName} {rental.renter.lastName}
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { useNavigate, Link } from "react-router-dom";
|
import { useNavigate, Link, useParams } from "react-router-dom";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { forumAPI, addressAPI } from "../services/api";
|
import { forumAPI, addressAPI } from "../services/api";
|
||||||
import { uploadFiles } from "../services/uploadService";
|
import { uploadFiles, getPublicImageUrl } from "../services/uploadService";
|
||||||
import TagInput from "../components/TagInput";
|
import TagInput from "../components/TagInput";
|
||||||
import ForumImageUpload from "../components/ForumImageUpload";
|
import ForumImageUpload from "../components/ForumImageUpload";
|
||||||
import { Address } from "../types";
|
import { Address, ForumPost } from "../types";
|
||||||
|
|
||||||
const CreateForumPost: React.FC = () => {
|
const CreateForumPost: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const isEditMode = !!id;
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [loading, setLoading] = useState(isEditMode);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [userAddresses, setUserAddresses] = useState<Address[]>([]);
|
const [userAddresses, setUserAddresses] = useState<Address[]>([]);
|
||||||
|
const [existingImageKeys, setExistingImageKeys] = useState<string[]>([]);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
title: "",
|
title: "",
|
||||||
@@ -33,7 +37,48 @@ const CreateForumPost: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUserAddresses();
|
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 () => {
|
const fetchUserAddresses = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -96,7 +141,8 @@ const CreateForumPost: React.FC = () => {
|
|||||||
|
|
||||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(e.target.files || []);
|
const files = Array.from(e.target.files || []);
|
||||||
const remainingSlots = 5 - imageFiles.length;
|
const totalImages = existingImageKeys.length + imageFiles.length;
|
||||||
|
const remainingSlots = 5 - totalImages;
|
||||||
const filesToAdd = files.slice(0, remainingSlots);
|
const filesToAdd = files.slice(0, remainingSlots);
|
||||||
|
|
||||||
setImageFiles((prev) => [...prev, ...filesToAdd]);
|
setImageFiles((prev) => [...prev, ...filesToAdd]);
|
||||||
@@ -115,7 +161,14 @@ const CreateForumPost: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveImage = (index: number) => {
|
const handleRemoveImage = (index: number) => {
|
||||||
setImageFiles((prev) => prev.filter((_, i) => i !== index));
|
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));
|
setImagePreviews((prev) => prev.filter((_, i) => i !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -123,24 +176,12 @@ const CreateForumPost: React.FC = () => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Validation
|
// Validation - character minimums shown inline, just prevent submission
|
||||||
if (!formData.title.trim()) {
|
if (!formData.title.trim() || formData.title.length < 10) {
|
||||||
setError("Title is required");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.title.length < 10) {
|
if (!formData.content.trim() || formData.content.length < 20) {
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,25 +231,58 @@ const CreateForumPost: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add S3 image keys
|
// Combine existing and new S3 image keys
|
||||||
if (imageFilenames.length > 0) {
|
const allImageKeys = [...existingImageKeys, ...imageFilenames];
|
||||||
postData.imageFilenames = 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);
|
const response = await forumAPI.createPost(postData);
|
||||||
navigate(`/forum/${response.data.id}`);
|
navigate(`/forum/${response.data.id}`);
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.error || err.message || "Failed to create post");
|
setError(err.response?.data?.error || err.message || `Failed to ${isEditMode ? 'update' : 'create'} post`);
|
||||||
setIsSubmitting(false);
|
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) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<div className="container mt-4">
|
<div className="container mt-4">
|
||||||
<div className="alert alert-warning" role="alert">
|
<div className="alert alert-warning" role="alert">
|
||||||
<i className="bi bi-exclamation-triangle me-2"></i>
|
<i className="bi bi-exclamation-triangle me-2"></i>
|
||||||
You must be logged in to create a post.
|
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>
|
</div>
|
||||||
<Link to="/forum" className="btn btn-secondary">
|
<Link to="/forum" className="btn btn-secondary">
|
||||||
<i className="bi bi-arrow-left me-2"></i>
|
<i className="bi bi-arrow-left me-2"></i>
|
||||||
@@ -225,15 +299,21 @@ const CreateForumPost: React.FC = () => {
|
|||||||
<li className="breadcrumb-item">
|
<li className="breadcrumb-item">
|
||||||
<Link to="/forum">Forum</Link>
|
<Link to="/forum">Forum</Link>
|
||||||
</li>
|
</li>
|
||||||
|
{isEditMode && (
|
||||||
|
<li className="breadcrumb-item">
|
||||||
|
<Link to={`/forum/${id}`}>Post</Link>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
<li className="breadcrumb-item active" aria-current="page">
|
<li className="breadcrumb-item active" aria-current="page">
|
||||||
Create Post
|
{isEditMode ? 'Edit' : 'Create Post'}
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-lg-8 mx-auto">
|
<div className="col-lg-8 mx-auto">
|
||||||
{/* Guidelines Card */}
|
{/* Guidelines Card - only show for new posts */}
|
||||||
|
{!isEditMode && (
|
||||||
<div className="card mb-3">
|
<div className="card mb-3">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<h6 className="mb-0">
|
<h6 className="mb-0">
|
||||||
@@ -252,10 +332,11 @@ const CreateForumPost: React.FC = () => {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<h3 className="mb-0">Create New Post</h3>
|
<h3 className="mb-0">{isEditMode ? 'Edit Post' : 'Create New Post'}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
{error && (
|
{error && (
|
||||||
@@ -272,7 +353,7 @@ const CreateForumPost: React.FC = () => {
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control"
|
className={`form-control ${formData.title.length > 0 && formData.title.length < 10 ? 'is-invalid' : ''}`}
|
||||||
id="title"
|
id="title"
|
||||||
name="title"
|
name="title"
|
||||||
value={formData.title}
|
value={formData.title}
|
||||||
@@ -282,9 +363,15 @@ const CreateForumPost: React.FC = () => {
|
|||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
required
|
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">
|
<div className="form-text">
|
||||||
{formData.title.length}/200 characters (minimum 10)
|
{formData.title.length}/200 characters (minimum 10)
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category */}
|
{/* Category */}
|
||||||
@@ -343,7 +430,7 @@ const CreateForumPost: React.FC = () => {
|
|||||||
Content <span className="text-danger">*</span>
|
Content <span className="text-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="form-control"
|
className={`form-control ${formData.content.length > 0 && formData.content.length < 20 ? 'is-invalid' : ''}`}
|
||||||
id="content"
|
id="content"
|
||||||
name="content"
|
name="content"
|
||||||
rows={10}
|
rows={10}
|
||||||
@@ -353,9 +440,15 @@ const CreateForumPost: React.FC = () => {
|
|||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
required
|
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">
|
<div className="form-text">
|
||||||
{formData.content.length} characters (minimum 20)
|
{formData.content.length} characters (minimum 20)
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
@@ -426,17 +519,17 @@ const CreateForumPost: React.FC = () => {
|
|||||||
role="status"
|
role="status"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></span>
|
></span>
|
||||||
Creating...
|
{isEditMode ? 'Saving...' : 'Creating...'}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<i className="bi bi-send me-2"></i>
|
<i className={`bi ${isEditMode ? 'bi-check-lg' : 'bi-send'} me-2`}></i>
|
||||||
Create Post
|
{isEditMode ? 'Save Changes' : 'Create Post'}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
to="/forum"
|
to={isEditMode ? `/forum/${id}` : '/forum'}
|
||||||
className={`btn btn-secondary ${
|
className={`btn btn-secondary ${
|
||||||
isSubmitting ? "disabled" : ""
|
isSubmitting ? "disabled" : ""
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -72,16 +72,24 @@ const ForumPostDetail: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReply = async (parentCommentId: string, content: string) => {
|
const handleReply = async (parentCommentId: string, content: string, images: File[] = []) => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
alert('Please log in to reply');
|
alert('Please log in to reply');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Upload images to S3 first (if any)
|
||||||
|
let imageFilenames: string[] = [];
|
||||||
|
if (images.length > 0) {
|
||||||
|
const uploadResults = await uploadFiles("forum", images);
|
||||||
|
imageFilenames = uploadResults.map((result) => result.key);
|
||||||
|
}
|
||||||
|
|
||||||
await forumAPI.createComment(id!, {
|
await forumAPI.createComment(id!, {
|
||||||
content,
|
content,
|
||||||
parentId: parentCommentId,
|
parentId: parentCommentId,
|
||||||
|
imageFilenames: imageFilenames.length > 0 ? imageFilenames : undefined,
|
||||||
});
|
});
|
||||||
await fetchPost(); // Refresh to get new reply
|
await fetchPost(); // Refresh to get new reply
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -89,9 +97,27 @@ const ForumPostDetail: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditComment = async (commentId: string, content: string) => {
|
const handleEditComment = async (
|
||||||
|
commentId: string,
|
||||||
|
content: string,
|
||||||
|
existingImageKeys: string[],
|
||||||
|
newImageFiles: File[]
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
await forumAPI.updateComment(commentId, { content });
|
// Upload new images to S3
|
||||||
|
let newImageFilenames: string[] = [];
|
||||||
|
if (newImageFiles.length > 0) {
|
||||||
|
const uploadResults = await uploadFiles("forum", newImageFiles);
|
||||||
|
newImageFilenames = uploadResults.map((result) => result.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine existing and new image keys
|
||||||
|
const allImageKeys = [...existingImageKeys, ...newImageFilenames];
|
||||||
|
|
||||||
|
await forumAPI.updateComment(commentId, {
|
||||||
|
content,
|
||||||
|
imageFilenames: allImageKeys,
|
||||||
|
});
|
||||||
await fetchPost(); // Refresh to get updated comment
|
await fetchPost(); // Refresh to get updated comment
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
throw new Error(err.response?.data?.error || 'Failed to update comment');
|
throw new Error(err.response?.data?.error || 'Failed to update comment');
|
||||||
@@ -354,7 +380,7 @@ const ForumPostDetail: React.FC = () => {
|
|||||||
src={getPublicImageUrl(image)}
|
src={getPublicImageUrl(image)}
|
||||||
alt={`Post image`}
|
alt={`Post image`}
|
||||||
className="img-fluid rounded"
|
className="img-fluid rounded"
|
||||||
style={{ width: '100%', height: '200px', objectFit: 'cover', cursor: 'pointer' }}
|
style={{ width: '100%', maxHeight: '400px', objectFit: 'contain', cursor: 'pointer' }}
|
||||||
onClick={() => window.open(getPublicImageUrl(image), '_blank')}
|
onClick={() => window.open(getPublicImageUrl(image), '_blank')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getPublicImageUrl } from "../services/uploadService";
|
|||||||
import GoogleMapWithRadius from "../components/GoogleMapWithRadius";
|
import GoogleMapWithRadius from "../components/GoogleMapWithRadius";
|
||||||
import ItemReviews from "../components/ItemReviews";
|
import ItemReviews from "../components/ItemReviews";
|
||||||
import ConfirmationModal from "../components/ConfirmationModal";
|
import ConfirmationModal from "../components/ConfirmationModal";
|
||||||
|
import Avatar from "../components/Avatar";
|
||||||
|
|
||||||
const ItemDetail: React.FC = () => {
|
const ItemDetail: React.FC = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -480,28 +481,7 @@ const ItemDetail: React.FC = () => {
|
|||||||
onClick={() => navigate(`/users/${item.ownerId}`)}
|
onClick={() => navigate(`/users/${item.ownerId}`)}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
>
|
>
|
||||||
{item.owner.imageFilename ? (
|
<Avatar user={item.owner} size="xs" className="me-2" />
|
||||||
<img
|
|
||||||
src={item.owner.imageFilename}
|
|
||||||
alt={`${item.owner.firstName} ${item.owner.lastName}`}
|
|
||||||
className="rounded-circle me-2"
|
|
||||||
style={{
|
|
||||||
width: "30px",
|
|
||||||
height: "30px",
|
|
||||||
objectFit: "cover",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center me-2"
|
|
||||||
style={{ width: "30px", height: "30px" }}
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
className="bi bi-person-fill text-white"
|
|
||||||
style={{ fontSize: "0.8rem" }}
|
|
||||||
></i>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span className="text-muted">
|
<span className="text-muted">
|
||||||
{item.owner.firstName} {item.owner.lastName}
|
{item.owner.firstName} {item.owner.lastName}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { messageAPI } from "../services/api";
|
|||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { useSocket } from "../contexts/SocketContext";
|
import { useSocket } from "../contexts/SocketContext";
|
||||||
import ChatWindow from "../components/ChatWindow";
|
import ChatWindow from "../components/ChatWindow";
|
||||||
|
import Avatar from "../components/Avatar";
|
||||||
|
|
||||||
const Messages: React.FC = () => {
|
const Messages: React.FC = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -230,25 +231,7 @@ const Messages: React.FC = () => {
|
|||||||
<div className="d-flex w-100 justify-content-between align-items-start">
|
<div className="d-flex w-100 justify-content-between align-items-start">
|
||||||
<div className="d-flex align-items-center flex-grow-1">
|
<div className="d-flex align-items-center flex-grow-1">
|
||||||
{/* Profile Picture */}
|
{/* Profile Picture */}
|
||||||
{conversation.partner.imageFilename ? (
|
<Avatar user={conversation.partner} size="lg" className="me-3" />
|
||||||
<img
|
|
||||||
src={conversation.partner.imageFilename}
|
|
||||||
alt={`${conversation.partner.firstName} ${conversation.partner.lastName}`}
|
|
||||||
className="rounded-circle me-3"
|
|
||||||
style={{
|
|
||||||
width: "50px",
|
|
||||||
height: "50px",
|
|
||||||
objectFit: "cover",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center me-3"
|
|
||||||
style={{ width: "50px", height: "50px" }}
|
|
||||||
>
|
|
||||||
<i className="bi bi-person-fill text-white"></i>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex-grow-1" style={{ minWidth: 0 }}>
|
<div className="flex-grow-1" style={{ minWidth: 0 }}>
|
||||||
{/* User Name and Unread Badge */}
|
{/* User Name and Unread Badge */}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import AvailabilitySettings from "../components/AvailabilitySettings";
|
|||||||
import ReviewItemModal from "../components/ReviewModal";
|
import ReviewItemModal from "../components/ReviewModal";
|
||||||
import ReviewRenterModal from "../components/ReviewRenterModal";
|
import ReviewRenterModal from "../components/ReviewRenterModal";
|
||||||
import ReviewDetailsModal from "../components/ReviewDetailsModal";
|
import ReviewDetailsModal from "../components/ReviewDetailsModal";
|
||||||
|
import Avatar from "../components/Avatar";
|
||||||
import {
|
import {
|
||||||
geocodingService,
|
geocodingService,
|
||||||
AddressComponents,
|
AddressComponents,
|
||||||
@@ -313,6 +314,11 @@ const Profile: React.FC = () => {
|
|||||||
|
|
||||||
// Update preview to use the S3 URL
|
// Update preview to use the S3 URL
|
||||||
setImagePreview(publicUrl);
|
setImagePreview(publicUrl);
|
||||||
|
|
||||||
|
// Save imageFilename to database immediately
|
||||||
|
const response = await userAPI.updateProfile({ imageFilename: key });
|
||||||
|
setProfileData(response.data);
|
||||||
|
updateUser(response.data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Image upload error:", err);
|
console.error("Image upload error:", err);
|
||||||
setError(err.message || "Failed to upload image");
|
setError(err.message || "Failed to upload image");
|
||||||
@@ -747,39 +753,20 @@ const Profile: React.FC = () => {
|
|||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="position-relative d-inline-block mb-3">
|
<div className="position-relative d-inline-block mb-3">
|
||||||
{imagePreview ? (
|
<Avatar
|
||||||
<img
|
user={user}
|
||||||
src={imagePreview}
|
size="xxl"
|
||||||
alt="Profile"
|
imageUrl={imagePreview}
|
||||||
className="rounded-circle"
|
|
||||||
style={{
|
|
||||||
width: "120px",
|
|
||||||
height: "120px",
|
|
||||||
objectFit: "cover",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center"
|
|
||||||
style={{ width: "120px", height: "120px" }}
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
className="bi bi-person-fill text-white"
|
|
||||||
style={{ fontSize: "2.5rem" }}
|
|
||||||
></i>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{editing && (
|
|
||||||
<label
|
<label
|
||||||
htmlFor="imageFilenameOverview"
|
htmlFor="imageFilenameOverview"
|
||||||
className="position-absolute bottom-0 end-0 btn btn-sm btn-primary rounded-circle"
|
className="position-absolute bottom-0 end-0 btn btn-sm btn-primary rounded-circle d-flex align-items-center justify-content-center"
|
||||||
style={{
|
style={{
|
||||||
width: "35px",
|
width: "35px",
|
||||||
height: "35px",
|
height: "35px",
|
||||||
padding: "0",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<i className="bi bi-camera-fill"></i>
|
<i className="bi bi-pencil-fill"></i>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
id="imageFilenameOverview"
|
id="imageFilenameOverview"
|
||||||
@@ -788,14 +775,12 @@ const Profile: React.FC = () => {
|
|||||||
className="d-none"
|
className="d-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h5>
|
<h5>
|
||||||
{profileData?.firstName} {profileData?.lastName}
|
{profileData?.firstName} {profileData?.lastName}
|
||||||
</h5>
|
</h5>
|
||||||
<p className="text-muted">@{profileData?.username}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { userAPI, itemAPI } from '../services/api';
|
|||||||
import { getPublicImageUrl } from '../services/uploadService';
|
import { getPublicImageUrl } from '../services/uploadService';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import ChatWindow from '../components/ChatWindow';
|
import ChatWindow from '../components/ChatWindow';
|
||||||
|
import Avatar from '../components/Avatar';
|
||||||
|
|
||||||
const PublicProfile: React.FC = () => {
|
const PublicProfile: React.FC = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -72,21 +73,7 @@ const PublicProfile: React.FC = () => {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<div className="text-center mb-4">
|
<div className="text-center mb-4">
|
||||||
{user.imageFilename ? (
|
<Avatar user={user} size="xxxl" className="mb-3 mx-auto" />
|
||||||
<img
|
|
||||||
src={user.imageFilename}
|
|
||||||
alt={`${user.firstName} ${user.lastName}`}
|
|
||||||
className="rounded-circle mb-3"
|
|
||||||
style={{ width: '150px', height: '150px', objectFit: 'cover' }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center mb-3 mx-auto"
|
|
||||||
style={{ width: '150px', height: '150px' }}
|
|
||||||
>
|
|
||||||
<i className="bi bi-person-fill text-white" style={{ fontSize: '3rem' }}></i>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<h3>{user.firstName} {user.lastName}</h3>
|
<h3>{user.firstName} {user.lastName}</h3>
|
||||||
{currentUser && currentUser.id !== user.id && (
|
{currentUser && currentUser.id !== user.id && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -266,6 +266,9 @@ export interface ForumPost {
|
|||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
acceptedAnswerId?: string;
|
acceptedAnswerId?: string;
|
||||||
imageFilenames?: string[];
|
imageFilenames?: string[];
|
||||||
|
zipCode?: string;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
isDeleted?: boolean;
|
isDeleted?: boolean;
|
||||||
deletedBy?: string;
|
deletedBy?: string;
|
||||||
deletedAt?: string;
|
deletedAt?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user