2
This commit is contained in:
parent
b6268d4dff
commit
b6364f50e2
@ -1,4 +1,3 @@
|
||||
|
||||
const db = require('../models');
|
||||
const FileDBApi = require('./file');
|
||||
const crypto = require('crypto');
|
||||
@ -332,6 +331,16 @@ module.exports = class ConversationsDBApi {
|
||||
model: db.file,
|
||||
as: 'group_image',
|
||||
},
|
||||
{
|
||||
model: db.conversation_members,
|
||||
as: 'conversation_members_conversation',
|
||||
include: [
|
||||
{
|
||||
model: db.users,
|
||||
as: 'user',
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
];
|
||||
|
||||
@ -496,5 +505,4 @@ module.exports = class ConversationsDBApi {
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
};
|
||||
@ -1,4 +1,3 @@
|
||||
|
||||
const db = require('../models');
|
||||
const FileDBApi = require('./file');
|
||||
const crypto = require('crypto');
|
||||
@ -322,6 +321,16 @@ module.exports = class MessagesDBApi {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -343,6 +352,11 @@ module.exports = class MessagesDBApi {
|
||||
});
|
||||
|
||||
|
||||
output.reply_to = await messages.getReply_to({
|
||||
transaction
|
||||
});
|
||||
|
||||
|
||||
output.images = await messages.getImages({
|
||||
transaction
|
||||
});
|
||||
@ -353,11 +367,6 @@ module.exports = class MessagesDBApi {
|
||||
});
|
||||
|
||||
|
||||
output.reply_to = await messages.getReply_to({
|
||||
transaction
|
||||
});
|
||||
|
||||
|
||||
|
||||
return output;
|
||||
}
|
||||
@ -524,10 +533,6 @@ module.exports = class MessagesDBApi {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (filter.createdAtRange) {
|
||||
@ -620,5 +625,4 @@ module.exports = class MessagesDBApi {
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
};
|
||||
@ -1,4 +1,3 @@
|
||||
|
||||
const db = require('../models');
|
||||
const FileDBApi = require('./file');
|
||||
const crypto = require('crypto');
|
||||
@ -421,6 +420,20 @@ module.exports = class PostsDBApi {
|
||||
model: db.file,
|
||||
as: 'video',
|
||||
},
|
||||
{
|
||||
model: db.post_reactions,
|
||||
as: 'post_reactions_post',
|
||||
},
|
||||
{
|
||||
model: db.post_comments,
|
||||
as: 'post_comments_post',
|
||||
include: [
|
||||
{
|
||||
model: db.users,
|
||||
as: 'author',
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
];
|
||||
|
||||
@ -610,5 +623,4 @@ module.exports = class PostsDBApi {
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
};
|
||||
167
frontend/src/components/Posts/CreatePost.tsx
Normal file
167
frontend/src/components/Posts/CreatePost.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { mdiImageOutline, mdiVideoOutline, mdiMapMarkerOutline, mdiClose, mdiLoading } from '@mdi/js';
|
||||
import BaseIcon from '../BaseIcon';
|
||||
import UserAvatar from '../UserAvatar';
|
||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||
import { create as createPost, setRefetch } from '../../stores/posts/postsSlice';
|
||||
import FileUploader from '../Uploaders/UploadService';
|
||||
|
||||
export default function CreatePost() {
|
||||
const dispatch = useAppDispatch();
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const [caption, setCaption] = useState('');
|
||||
const [files, setFiles] = useState<any[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isPosting, setIsPosting] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const authorName = currentUser ? `${currentUser.firstName} ${currentUser.lastName}` : 'Anonymous';
|
||||
const authorAvatar = currentUser ? currentUser.avatar : null;
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = e.target.files;
|
||||
if (!selectedFiles) return;
|
||||
|
||||
setIsUploading(true);
|
||||
const newFiles = [...files];
|
||||
|
||||
for (let i = 0; i < selectedFiles.length; i++) {
|
||||
const file = selectedFiles[i];
|
||||
try {
|
||||
const remoteFile = await FileUploader.upload('posts', file, { image: file.type.startsWith('image'), size: 10 * 1024 * 1024 });
|
||||
newFiles.push({
|
||||
...remoteFile,
|
||||
type: file.type.startsWith('image') ? 'image' : 'video'
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Upload failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
setFiles(newFiles);
|
||||
setIsUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles(files.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!caption && files.length === 0) return;
|
||||
|
||||
setIsPosting(true);
|
||||
const postData = {
|
||||
caption,
|
||||
post_type: 'post',
|
||||
published_at: new Date().toISOString(),
|
||||
author: currentUser.id,
|
||||
images: files.filter(f => f.type === 'image'),
|
||||
video: files.find(f => f.type === 'video') || null,
|
||||
comments_enabled: true,
|
||||
is_archived: false,
|
||||
};
|
||||
|
||||
try {
|
||||
await dispatch(createPost(postData)).unwrap();
|
||||
setCaption('');
|
||||
setFiles([]);
|
||||
dispatch(setRefetch(true));
|
||||
} catch (err) {
|
||||
console.error('Failed to create post', err);
|
||||
} finally {
|
||||
setIsPosting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-[#1a1d23] border border-gray-200 dark:border-gray-800 rounded-2xl p-4 mb-6 max-w-lg mx-auto shadow-sm">
|
||||
<div className="flex items-start space-x-3">
|
||||
<UserAvatar username={authorName} image={authorAvatar} className="w-10 h-10 mt-1" />
|
||||
<div className="flex-1">
|
||||
<textarea
|
||||
className="w-full bg-transparent border-none focus:ring-0 text-sm dark:text-white placeholder-gray-500 resize-none"
|
||||
placeholder="What's on your mind?"
|
||||
rows={3}
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Previews */}
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{files.map((file, idx) => (
|
||||
<div key={idx} className="relative w-20 h-20 rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700">
|
||||
{file.type === 'image' ? (
|
||||
<img src={file.publicUrl} alt="Preview" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
|
||||
<BaseIcon path={mdiVideoOutline} size={24} className="text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeFile(idx)}
|
||||
className="absolute top-1 right-1 bg-black/50 rounded-full p-0.5 hover:bg-black/70 transition-colors"
|
||||
>
|
||||
<BaseIcon path={mdiClose} size={14} className="text-white" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{isUploading && (
|
||||
<div className="w-20 h-20 rounded-lg border border-gray-200 dark:border-gray-700 flex items-center justify-center animate-spin">
|
||||
<BaseIcon path={mdiLoading} size={24} className="text-pavitra-blue" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between mt-4 pt-3 border-t border-gray-100 dark:border-gray-800">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
className="text-gray-500 hover:text-pavitra-blue transition-colors flex items-center space-x-1"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<BaseIcon path={mdiImageOutline} size={20} />
|
||||
<span className="text-xs font-medium">Photo</span>
|
||||
</button>
|
||||
<button
|
||||
className="text-gray-500 hover:text-pavitra-blue transition-colors flex items-center space-x-1"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<BaseIcon path={mdiVideoOutline} size={20} />
|
||||
<span className="text-xs font-medium">Video</span>
|
||||
</button>
|
||||
<button className="text-gray-500 hover:text-pavitra-blue transition-colors flex items-center space-x-1">
|
||||
<BaseIcon path={mdiMapMarkerOutline} size={20} />
|
||||
<span className="text-xs font-medium">Location</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={isPosting || isUploading || (!caption && files.length === 0)}
|
||||
onClick={handleSubmit}
|
||||
className={`
|
||||
px-6 py-1.5 rounded-full text-sm font-bold transition-all
|
||||
${isPosting || isUploading || (!caption && files.length === 0)
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed dark:bg-gray-800'
|
||||
: 'bg-[#00d4ff] hover:bg-[#00b4d8] text-white shadow-[0_0_15px_rgba(0,212,255,0.3)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isPosting ? 'Posting...' : 'Post'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,video/*"
|
||||
className="hidden"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,9 +1,15 @@
|
||||
import React from 'react';
|
||||
import { mdiHeart, mdiHeartOutline, mdiChatOutline, mdiSendOutline, mdiBookmarkOutline } from '@mdi/js';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { mdiHeart, mdiHeartOutline, mdiChatOutline, mdiSendOutline, mdiBookmarkOutline, mdiDotsHorizontal, mdiAlertOctagonOutline } from '@mdi/js';
|
||||
import BaseIcon from '../BaseIcon';
|
||||
import UserAvatar from '../UserAvatar';
|
||||
import dataFormatter from '../../helpers/dataFormatter';
|
||||
import Link from 'next/link';
|
||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||
import { create as createReaction, deleteItem as deleteReaction } from '../../stores/post_reactions/post_reactionsSlice';
|
||||
import { create as createComment } from '../../stores/post_comments/post_commentsSlice';
|
||||
import { setRefetch } from '../../stores/posts/postsSlice';
|
||||
import ReportModal from '../ReportModal';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import Button from '@mui/material/Button';
|
||||
|
||||
type Props = {
|
||||
post: any;
|
||||
@ -12,10 +18,85 @@ type Props = {
|
||||
};
|
||||
|
||||
export default function SocialPostCard({ post, onLike, onComment }: Props) {
|
||||
const dispatch = useAppDispatch();
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const [commentBody, setCommentBody] = useState('');
|
||||
const [isCommenting, setIsCommenting] = useState(false);
|
||||
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
|
||||
const imageUrl = post.images && post.images[0] ? post.images[0].publicUrl : null;
|
||||
const authorName = post.author ? `${post.author.firstName} ${post.author.lastName}` : 'Anonymous';
|
||||
const authorAvatar = post.author ? post.author.avatar : null;
|
||||
|
||||
const currentUserReaction = useMemo(() => {
|
||||
if (!currentUser || !post.post_reactions_post) return null;
|
||||
return post.post_reactions_post.find((r: any) => r.userId === currentUser.id);
|
||||
}, [currentUser, post.post_reactions_post]);
|
||||
|
||||
const isLiked = !!currentUserReaction;
|
||||
|
||||
const handleLikeToggle = async () => {
|
||||
if (!currentUser) return;
|
||||
|
||||
if (isLiked) {
|
||||
try {
|
||||
await dispatch(deleteReaction(currentUserReaction.id)).unwrap();
|
||||
dispatch(setRefetch(true));
|
||||
} catch (err) {
|
||||
console.error('Failed to unlike', err);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await dispatch(createReaction({
|
||||
reaction_type: 'like',
|
||||
reacted_at: new Date().toISOString(),
|
||||
post: post.id,
|
||||
user: currentUser.id,
|
||||
})).unwrap();
|
||||
dispatch(setRefetch(true));
|
||||
} catch (err) {
|
||||
console.error('Failed to like', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (onLike) onLike(post.id);
|
||||
};
|
||||
|
||||
const handleCommentSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!commentBody.trim() || !currentUser) return;
|
||||
|
||||
setIsCommenting(true);
|
||||
try {
|
||||
await dispatch(createComment({
|
||||
body: commentBody,
|
||||
commented_at: new Date().toISOString(),
|
||||
post: post.id,
|
||||
author: currentUser.id,
|
||||
})).unwrap();
|
||||
setCommentBody('');
|
||||
dispatch(setRefetch(true));
|
||||
} catch (err) {
|
||||
console.error('Failed to comment', err);
|
||||
} finally {
|
||||
setIsCommenting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenMenu = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleCloseMenu = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleReportClick = () => {
|
||||
handleCloseMenu();
|
||||
setIsReportModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-[#1a1d23] border border-gray-200 dark:border-gray-800 rounded-2xl overflow-hidden mb-6 max-w-lg mx-auto shadow-sm">
|
||||
{/* Header */}
|
||||
@ -27,9 +108,32 @@ export default function SocialPostCard({ post, onLike, onComment }: Props) {
|
||||
{post.location_name && <p className="text-xs text-gray-500 dark:text-gray-400">{post.location_name}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<button className="text-gray-500 dark:text-gray-400">
|
||||
<span className="text-xl font-bold">···</span>
|
||||
<button className="text-gray-500 dark:text-gray-400" onClick={handleOpenMenu}>
|
||||
<BaseIcon path={mdiDotsHorizontal} size={24} />
|
||||
</button>
|
||||
<Popover
|
||||
open={Boolean(anchorEl)}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleCloseMenu}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
>
|
||||
<div className="bg-white dark:bg-dark-800 p-1 min-w-[120px]">
|
||||
<Button
|
||||
onClick={handleReportClick}
|
||||
className="w-full text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 px-4 py-2 text-sm flex items-center justify-start space-x-2 normal-case"
|
||||
startIcon={<BaseIcon path={mdiAlertOctagonOutline} size={18} />}
|
||||
>
|
||||
Report
|
||||
</Button>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Image/Video */}
|
||||
@ -45,8 +149,12 @@ export default function SocialPostCard({ post, onLike, onComment }: Props) {
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button onClick={() => onLike && onLike(post.id)} className="hover:scale-110 transition-transform">
|
||||
<BaseIcon path={mdiHeartOutline} size={28} className="dark:text-white" />
|
||||
<button onClick={handleLikeToggle} className="hover:scale-110 transition-transform">
|
||||
<BaseIcon
|
||||
path={isLiked ? mdiHeart : mdiHeartOutline}
|
||||
size={28}
|
||||
className={isLiked ? "text-red-500" : "dark:text-white"}
|
||||
/>
|
||||
</button>
|
||||
<button onClick={() => onComment && onComment(post.id)} className="hover:scale-110 transition-transform">
|
||||
<BaseIcon path={mdiChatOutline} size={26} className="dark:text-white" />
|
||||
@ -62,7 +170,7 @@ export default function SocialPostCard({ post, onLike, onComment }: Props) {
|
||||
|
||||
{/* Likes Count */}
|
||||
<p className="text-sm font-bold dark:text-white">
|
||||
{post.post_reactions_count || 0} likes
|
||||
{post.post_reactions_post ? post.post_reactions_post.length : 0} likes
|
||||
</p>
|
||||
|
||||
{/* Caption */}
|
||||
@ -71,18 +179,54 @@ export default function SocialPostCard({ post, onLike, onComment }: Props) {
|
||||
<span className="dark:text-gray-300">{post.caption}</span>
|
||||
</div>
|
||||
|
||||
{/* View Comments */}
|
||||
{post.post_comments_count > 0 && (
|
||||
<button className="text-sm text-gray-500 dark:text-gray-400">
|
||||
View all {post.post_comments_count} comments
|
||||
</button>
|
||||
{/* Comments Section */}
|
||||
{post.post_comments_post && post.post_comments_post.length > 0 && (
|
||||
<div className="space-y-1 mt-2">
|
||||
{post.post_comments_post.slice(-3).map((comment: any) => (
|
||||
<div key={comment.id} className="text-sm">
|
||||
<span className="font-bold mr-2 dark:text-white">
|
||||
{comment.author ? `${comment.author.firstName}` : 'User'}
|
||||
</span>
|
||||
<span className="dark:text-gray-300">{comment.body}</span>
|
||||
</div>
|
||||
))}
|
||||
{post.post_comments_post.length > 3 && (
|
||||
<button className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
View all {post.post_comments_post.length} comments
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time */}
|
||||
<p className="text-[10px] text-gray-400 uppercase tracking-wider">
|
||||
<p className="text-[10px] text-gray-400 uppercase tracking-wider pt-1">
|
||||
{dataFormatter.dateTimeFormatter(post.published_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Comment Input */}
|
||||
<form onSubmit={handleCommentSubmit} className="border-t border-gray-100 dark:border-gray-800 p-4 flex items-center space-x-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add a comment..."
|
||||
className="flex-1 bg-transparent border-none focus:ring-0 text-sm dark:text-white placeholder-gray-500"
|
||||
value={commentBody}
|
||||
onChange={(e) => setCommentBody(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
disabled={!commentBody.trim() || isCommenting}
|
||||
className={`text-sm font-bold ${!commentBody.trim() || isCommenting ? 'text-gray-300' : 'text-[#00d4ff]'}`}
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<ReportModal
|
||||
isOpen={isReportModalOpen}
|
||||
onClose={() => setIsReportModalOpen(false)}
|
||||
targetType="post"
|
||||
targetRef={post.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
89
frontend/src/components/ReportModal.tsx
Normal file
89
frontend/src/components/ReportModal.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import React, { useState } from 'react';
|
||||
import CardBoxModal from './CardBoxModal';
|
||||
import FormField from './FormField';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
import { create as createReport } from '../stores/reports/reportsSlice';
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
targetType: 'user' | 'post' | 'comment' | 'reel' | 'message' | 'story';
|
||||
targetRef: string;
|
||||
};
|
||||
|
||||
const REASONS = [
|
||||
{ value: 'spam', label: 'Spam' },
|
||||
{ value: 'harassment', label: 'Harassment' },
|
||||
{ value: 'hate_speech', label: 'Hate Speech' },
|
||||
{ value: 'nudity', label: 'Nudity' },
|
||||
{ value: 'violence', label: 'Violence' },
|
||||
{ value: 'self_harm', label: 'Self Harm' },
|
||||
{ value: 'scam', label: 'Scam' },
|
||||
{ value: 'copyright', label: 'Copyright' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
|
||||
export default function ReportModal({ isOpen, onClose, targetType, targetRef }: Props) {
|
||||
const dispatch = useAppDispatch();
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const [reason, setReason] = useState('spam');
|
||||
const [details, setDetails] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!currentUser) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await dispatch(createReport({
|
||||
target_type: targetType,
|
||||
target_ref: targetRef,
|
||||
reason: reason,
|
||||
details: details,
|
||||
status: 'open',
|
||||
reported_at: new Date().toISOString(),
|
||||
reporter: currentUser.id,
|
||||
})).unwrap();
|
||||
onClose();
|
||||
alert('Report submitted successfully.');
|
||||
} catch (err) {
|
||||
console.error('Failed to submit report', err);
|
||||
alert('Failed to submit report.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CardBoxModal
|
||||
title="Report Item"
|
||||
buttonColor="danger"
|
||||
buttonLabel="Submit Report"
|
||||
isActive={isOpen}
|
||||
onConfirm={handleSubmit}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<FormField label="Reason for report">
|
||||
<select
|
||||
className="w-full dark:bg-dark-900 dark:text-white border-gray-300 dark:border-gray-700 rounded-md"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
>
|
||||
{REASONS.map((r) => (
|
||||
<option key={r.value} value={r.value}>{r.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Details (Optional)">
|
||||
<textarea
|
||||
className="w-full dark:bg-dark-900 dark:text-white border-gray-300 dark:border-gray-700 rounded-md"
|
||||
rows={3}
|
||||
value={details}
|
||||
onChange={(e) => setDetails(e.target.value)}
|
||||
placeholder="Provide more information..."
|
||||
/>
|
||||
</FormField>
|
||||
{isSubmitting && <p className="text-sm text-gray-500">Submitting...</p>}
|
||||
</CardBoxModal>
|
||||
);
|
||||
}
|
||||
80
frontend/src/components/Stories/StoriesList.tsx
Normal file
80
frontend/src/components/Stories/StoriesList.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||
import { fetch as fetchStories } from '../../stores/stories/storiesSlice';
|
||||
import UserAvatar from '../UserAvatar';
|
||||
import { mdiPlus } from '@mdi/js';
|
||||
import BaseIcon from '../BaseIcon';
|
||||
|
||||
export default function StoriesList() {
|
||||
const dispatch = useAppDispatch();
|
||||
const { stories, loading } = useAppSelector((state) => state.stories);
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchStories({ page: 1, limit: 20 }));
|
||||
}, [dispatch]);
|
||||
|
||||
// Group stories by author
|
||||
const storiesByAuthor = stories.reduce((acc: any, story: any) => {
|
||||
const authorId = story.author?.id;
|
||||
if (!authorId) return acc;
|
||||
if (!acc[authorId]) {
|
||||
acc[authorId] = {
|
||||
author: story.author,
|
||||
stories: []
|
||||
};
|
||||
}
|
||||
acc[authorId].stories.push(story);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const authorsWithStories = Object.values(storiesByAuthor);
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-4 overflow-x-auto pb-4 scrollbar-hide px-4">
|
||||
{/* Current User Story / Add Story */}
|
||||
<div className="flex flex-col items-center space-y-1 flex-shrink-0">
|
||||
<div className="relative p-[2px] rounded-full bg-gray-200 dark:bg-gray-800">
|
||||
<UserAvatar
|
||||
username={currentUser?.firstName || 'Me'}
|
||||
image={currentUser?.avatar}
|
||||
className="w-16 h-16 border-2 border-white dark:border-[#0b0e14]"
|
||||
/>
|
||||
<div className="absolute bottom-0 right-0 bg-[#00d4ff] rounded-full p-1 border-2 border-white dark:border-[#0b0e14]">
|
||||
<BaseIcon path={mdiPlus} size={12} className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] dark:text-gray-400 font-medium">Your Story</span>
|
||||
</div>
|
||||
|
||||
{/* Authors with Stories */}
|
||||
{authorsWithStories.map((item: any) => (
|
||||
<div key={item.author.id} className="flex flex-col items-center space-y-1 flex-shrink-0 cursor-pointer">
|
||||
<div className="p-[2px] rounded-full bg-gradient-to-tr from-[#f9ce34] via-[#ee2a7b] to-[#6228d7]">
|
||||
<div className="p-[2px] bg-white dark:bg-[#0b0e14] rounded-full">
|
||||
<UserAvatar
|
||||
username={item.author.firstName}
|
||||
image={item.author.avatar}
|
||||
className="w-16 h-16"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] dark:text-gray-400 font-medium truncate w-16 text-center">
|
||||
{item.author.firstName}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{loading && stories.length === 0 && (
|
||||
<div className="flex space-x-4 animate-pulse">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="flex flex-col items-center space-y-1">
|
||||
<div className="w-16 h-16 bg-gray-200 dark:bg-gray-800 rounded-full" />
|
||||
<div className="w-12 h-2 bg-gray-200 dark:bg-gray-800 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,239 +1,194 @@
|
||||
import * as icon from '@mdi/js';
|
||||
import {
|
||||
mdiAccountCircle,
|
||||
mdiMonitor,
|
||||
mdiSquareEditOutline,
|
||||
mdiTable,
|
||||
mdiViewList,
|
||||
mdiTelevisionGuide,
|
||||
mdiResponsive,
|
||||
mdiPalette,
|
||||
mdiAccountGroup,
|
||||
mdiAccountKey,
|
||||
mdiShape,
|
||||
mdiFolder,
|
||||
mdiCommentMultipleOutline,
|
||||
mdiHeart,
|
||||
mdiTag,
|
||||
mdiStar,
|
||||
mdiCamera,
|
||||
mdiBell,
|
||||
mdiAccountMultiple,
|
||||
mdiStore,
|
||||
mdiPlayBoxOutline,
|
||||
mdiClockOutline,
|
||||
mdiHistory,
|
||||
mdiMessageTextOutline,
|
||||
mdiRss,
|
||||
mdiAlertOctagonOutline
|
||||
} from '@mdi/js'
|
||||
import { MenuAsideItem } from './interfaces'
|
||||
|
||||
const menuAside: MenuAsideItem[] = [
|
||||
{
|
||||
href: '/feed',
|
||||
icon: icon.mdiHomeOutline,
|
||||
label: 'Feed',
|
||||
icon: mdiRss,
|
||||
},
|
||||
{
|
||||
href: '/messages',
|
||||
label: 'Messages',
|
||||
icon: mdiMessageTextOutline,
|
||||
},
|
||||
{
|
||||
href: '/dashboard',
|
||||
icon: icon.mdiViewDashboardOutline,
|
||||
icon: mdiMonitor,
|
||||
label: 'Dashboard',
|
||||
},
|
||||
|
||||
{
|
||||
href: '/users/users-list',
|
||||
label: 'Users',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiAccountGroup ?? icon.mdiTable,
|
||||
permissions: 'READ_USERS'
|
||||
icon: mdiAccountGroup,
|
||||
menu: [
|
||||
{
|
||||
href: '/users',
|
||||
label: 'Users',
|
||||
icon: mdiAccountCircle,
|
||||
},
|
||||
{
|
||||
href: '/roles',
|
||||
label: 'Roles',
|
||||
icon: mdiAccountKey,
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
href: '/roles/roles-list',
|
||||
label: 'Roles',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable,
|
||||
permissions: 'READ_ROLES'
|
||||
label: 'Social Media',
|
||||
icon: mdiShape,
|
||||
menu: [
|
||||
{
|
||||
href: '/posts',
|
||||
label: 'Posts',
|
||||
icon: mdiFolder,
|
||||
},
|
||||
{
|
||||
href: '/post_comments',
|
||||
label: 'Post Comments',
|
||||
icon: mdiCommentMultipleOutline,
|
||||
},
|
||||
{
|
||||
href: '/post_reactions',
|
||||
label: 'Post Reactions',
|
||||
icon: mdiHeart,
|
||||
},
|
||||
{
|
||||
href: '/post_hashtags',
|
||||
label: 'Post Hashtags',
|
||||
icon: mdiTag,
|
||||
},
|
||||
{
|
||||
href: '/post_saves',
|
||||
label: 'Post Saves',
|
||||
icon: mdiStar,
|
||||
},
|
||||
{
|
||||
href: '/hashtags',
|
||||
label: 'Hashtags',
|
||||
icon: mdiTag,
|
||||
},
|
||||
{
|
||||
href: '/stories',
|
||||
label: 'Stories',
|
||||
icon: mdiCamera,
|
||||
},
|
||||
{
|
||||
href: '/story_views',
|
||||
label: 'Story Views',
|
||||
icon: mdiClockOutline,
|
||||
},
|
||||
{
|
||||
href: '/reels',
|
||||
label: 'Reels',
|
||||
icon: mdiPlayBoxOutline,
|
||||
},
|
||||
{
|
||||
href: '/reel_comments',
|
||||
label: 'Reel Comments',
|
||||
icon: mdiCommentMultipleOutline,
|
||||
},
|
||||
{
|
||||
href: '/reel_reactions',
|
||||
label: 'Reel Reactions',
|
||||
icon: mdiHeart,
|
||||
},
|
||||
{
|
||||
href: '/highlights',
|
||||
label: 'Highlights',
|
||||
icon: mdiStar,
|
||||
},
|
||||
{
|
||||
href: '/highlight_items',
|
||||
label: 'Highlight Items',
|
||||
icon: mdiStar,
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
href: '/permissions/permissions-list',
|
||||
label: 'Permissions',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
|
||||
permissions: 'READ_PERMISSIONS'
|
||||
label: 'Communication',
|
||||
icon: mdiMessageTextOutline,
|
||||
menu: [
|
||||
{
|
||||
href: '/conversations',
|
||||
label: 'Conversations',
|
||||
icon: mdiAccountMultiple,
|
||||
},
|
||||
{
|
||||
href: '/conversation_members',
|
||||
label: 'Conversation Members',
|
||||
icon: mdiAccountMultiple,
|
||||
},
|
||||
{
|
||||
href: '/messages-admin',
|
||||
label: 'Messages Admin',
|
||||
icon: mdiMessageTextOutline,
|
||||
},
|
||||
{
|
||||
href: '/calls',
|
||||
label: 'Calls',
|
||||
icon: mdiCamera,
|
||||
},
|
||||
{
|
||||
href: '/notifications',
|
||||
label: 'Notifications',
|
||||
icon: mdiBell,
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
href: '/user_relationships/user_relationships-list',
|
||||
label: 'User relationships',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiAccountMultiple' in icon ? icon['mdiAccountMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_USER_RELATIONSHIPS'
|
||||
},
|
||||
{
|
||||
href: '/posts/posts-list',
|
||||
label: 'Posts',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiPost' in icon ? icon['mdiPost' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_POSTS'
|
||||
},
|
||||
{
|
||||
href: '/post_comments/post_comments-list',
|
||||
label: 'Post comments',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiCommentTextOutline' in icon ? icon['mdiCommentTextOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_POST_COMMENTS'
|
||||
},
|
||||
{
|
||||
href: '/post_reactions/post_reactions-list',
|
||||
label: 'Post reactions',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiHeartOutline' in icon ? icon['mdiHeartOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_POST_REACTIONS'
|
||||
},
|
||||
{
|
||||
href: '/post_saves/post_saves-list',
|
||||
label: 'Post saves',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiBookmarkOutline' in icon ? icon['mdiBookmarkOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_POST_SAVES'
|
||||
},
|
||||
{
|
||||
href: '/save_collections/save_collections-list',
|
||||
label: 'Save collections',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiFolderBookmark' in icon ? icon['mdiFolderBookmark' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_SAVE_COLLECTIONS'
|
||||
},
|
||||
{
|
||||
href: '/hashtags/hashtags-list',
|
||||
label: 'Hashtags',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiPound' in icon ? icon['mdiPound' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_HASHTAGS'
|
||||
},
|
||||
{
|
||||
href: '/post_hashtags/post_hashtags-list',
|
||||
label: 'Post hashtags',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiTagMultiple' in icon ? icon['mdiTagMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_POST_HASHTAGS'
|
||||
},
|
||||
{
|
||||
href: '/stories/stories-list',
|
||||
label: 'Stories',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiHistory' in icon ? icon['mdiHistory' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_STORIES'
|
||||
},
|
||||
{
|
||||
href: '/story_views/story_views-list',
|
||||
label: 'Story views',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiEyeOutline' in icon ? icon['mdiEyeOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_STORY_VIEWS'
|
||||
},
|
||||
{
|
||||
href: '/highlights/highlights-list',
|
||||
label: 'Highlights',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiStarOutline' in icon ? icon['mdiStarOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_HIGHLIGHTS'
|
||||
},
|
||||
{
|
||||
href: '/highlight_items/highlight_items-list',
|
||||
label: 'Highlight items',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiFormatListBulleted' in icon ? icon['mdiFormatListBulleted' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_HIGHLIGHT_ITEMS'
|
||||
},
|
||||
{
|
||||
href: '/reels/reels-list',
|
||||
label: 'Reels',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiMovieOpenPlay' in icon ? icon['mdiMovieOpenPlay' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_REELS'
|
||||
},
|
||||
{
|
||||
href: '/reel_reactions/reel_reactions-list',
|
||||
label: 'Reel reactions',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiHeart' in icon ? icon['mdiHeart' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_REEL_REACTIONS'
|
||||
},
|
||||
{
|
||||
href: '/reel_comments/reel_comments-list',
|
||||
label: 'Reel comments',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiCommentProcessingOutline' in icon ? icon['mdiCommentProcessingOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_REEL_COMMENTS'
|
||||
},
|
||||
{
|
||||
href: '/conversations/conversations-list',
|
||||
label: 'Conversations',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiChatOutline' in icon ? icon['mdiChatOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_CONVERSATIONS'
|
||||
},
|
||||
{
|
||||
href: '/conversation_members/conversation_members-list',
|
||||
label: 'Conversation members',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiAccountGroup' in icon ? icon['mdiAccountGroup' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_CONVERSATION_MEMBERS'
|
||||
},
|
||||
{
|
||||
href: '/messages/messages-list',
|
||||
label: 'Messages',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiMessageTextOutline' in icon ? icon['mdiMessageTextOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_MESSAGES'
|
||||
},
|
||||
{
|
||||
href: '/calls/calls-list',
|
||||
label: 'Calls',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiPhoneOutline' in icon ? icon['mdiPhoneOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_CALLS'
|
||||
},
|
||||
{
|
||||
href: '/notifications/notifications-list',
|
||||
label: 'Notifications',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiBellOutline' in icon ? icon['mdiBellOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_NOTIFICATIONS'
|
||||
},
|
||||
{
|
||||
href: '/reports/reports-list',
|
||||
label: 'Reports',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiAlertOctagonOutline' in icon ? icon['mdiAlertOctagonOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_REPORTS'
|
||||
},
|
||||
{
|
||||
href: '/products/products-list',
|
||||
label: 'Products',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiShoppingOutline' in icon ? icon['mdiShoppingOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_PRODUCTS'
|
||||
},
|
||||
{
|
||||
href: '/insight_snapshots/insight_snapshots-list',
|
||||
label: 'Insight snapshots',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiChartLine' in icon ? icon['mdiChartLine' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_INSIGHT_SNAPSHOTS'
|
||||
},
|
||||
{
|
||||
href: '/profile',
|
||||
label: 'Profile',
|
||||
icon: icon.mdiAccountCircle,
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
href: '/api-docs',
|
||||
target: '_blank',
|
||||
label: 'Swagger API',
|
||||
icon: icon.mdiFileCode,
|
||||
permissions: 'READ_API_DOCS'
|
||||
label: 'Admin',
|
||||
icon: mdiViewList,
|
||||
permissions: 'READ_REPORTS',
|
||||
menu: [
|
||||
{
|
||||
href: '/reports',
|
||||
label: 'Reports',
|
||||
icon: mdiAlertOctagonOutline,
|
||||
permissions: 'READ_REPORTS',
|
||||
},
|
||||
{
|
||||
href: '/user_relationships',
|
||||
label: 'User Relationships',
|
||||
icon: mdiAccountMultiple,
|
||||
},
|
||||
{
|
||||
href: '/products',
|
||||
label: 'Products',
|
||||
icon: mdiStore,
|
||||
},
|
||||
{
|
||||
href: '/insight_snapshots',
|
||||
label: 'Insight Snapshots',
|
||||
icon: mdiHistory,
|
||||
},
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@ -5,41 +5,58 @@ import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
import SectionMain from '../components/SectionMain';
|
||||
import { getPageTitle } from '../config';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
import { fetch as fetchPosts } from '../stores/posts/postsSlice';
|
||||
import { fetch as fetchPosts, setRefetch } from '../stores/posts/postsSlice';
|
||||
import SocialPostCard from '../components/Posts/SocialPostCard';
|
||||
import CreatePost from '../components/Posts/CreatePost';
|
||||
import StoriesList from '../components/Stories/StoriesList';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
|
||||
export default function FeedPage() {
|
||||
const dispatch = useAppDispatch();
|
||||
const { posts, loading } = useAppSelector((state) => state.posts);
|
||||
const { posts, loading, refetch } = useAppSelector((state) => state.posts);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchPosts({ page: 1, limit: 10 }));
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (refetch) {
|
||||
dispatch(fetchPosts({ page: 1, limit: 10 }));
|
||||
dispatch(setRefetch(false));
|
||||
}
|
||||
}, [refetch, dispatch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Feed')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<div className="max-w-2xl mx-auto py-8">
|
||||
<h1 className="text-3xl font-black mb-8 dark:text-white tracking-tight px-4">Your Feed</h1>
|
||||
<div className="max-w-2xl mx-auto py-4">
|
||||
<h1 className="text-3xl font-black mb-6 dark:text-white tracking-tight px-4 bg-gradient-to-r from-[#00d4ff] to-[#9d00ff] bg-clip-text text-transparent">Ultra Social</h1>
|
||||
|
||||
{loading && (
|
||||
{/* Stories */}
|
||||
<div className="mb-6">
|
||||
<StoriesList />
|
||||
</div>
|
||||
|
||||
{/* Create Post Area */}
|
||||
<div className="mb-8 px-4">
|
||||
<CreatePost />
|
||||
</div>
|
||||
|
||||
{loading && posts.length === 0 && (
|
||||
<div className="flex justify-center py-20">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && posts && Array.isArray(posts) && posts.length > 0 && (
|
||||
{posts && Array.isArray(posts) && posts.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{posts.map((post: any) => (
|
||||
<SocialPostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
onLike={(id) => console.log('Like', id)}
|
||||
onComment={(id) => console.log('Comment', id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
213
frontend/src/pages/messages.tsx
Normal file
213
frontend/src/pages/messages.tsx
Normal file
@ -0,0 +1,213 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
import SectionMain from '../components/SectionMain';
|
||||
import { getPageTitle } from '../config';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
import { fetch as fetchConversations } from '../stores/conversations/conversationsSlice';
|
||||
import { fetch as fetchMessages, create as createMessage } from '../stores/messages/messagesSlice';
|
||||
import UserAvatar from '../components/UserAvatar';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import { mdiSend, mdiImageOutline, mdiDotsVertical, mdiMagnify } from '@mdi/js';
|
||||
import dataFormatter from '../helpers/dataFormatter';
|
||||
|
||||
export default function MessagesPage() {
|
||||
const dispatch = useAppDispatch();
|
||||
const { conversations, loading: convLoading } = useAppSelector((state) => state.conversations);
|
||||
const { messages, loading: msgLoading } = useAppSelector((state) => state.messages);
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
|
||||
const [selectedConversation, setSelectedConversation] = useState<any>(null);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchConversations({ page: 1, limit: 20 }));
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedConversation) {
|
||||
dispatch(fetchMessages({ query: `?conversation=${selectedConversation.id}&limit=50&page=0` }));
|
||||
}
|
||||
}, [selectedConversation, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const handleSendMessage = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newMessage.trim() || !selectedConversation || !currentUser) return;
|
||||
|
||||
try {
|
||||
await dispatch(createMessage({
|
||||
body: newMessage,
|
||||
message_type: 'text',
|
||||
sent_at: new Date().toISOString(),
|
||||
conversation: selectedConversation.id,
|
||||
sender: currentUser.id,
|
||||
delivery_status: 'sent'
|
||||
})).unwrap();
|
||||
setNewMessage('');
|
||||
dispatch(fetchMessages({ query: `?conversation=${selectedConversation.id}&limit=50&page=0` }));
|
||||
} catch (err) {
|
||||
console.error('Failed to send message', err);
|
||||
}
|
||||
};
|
||||
|
||||
const getChatPartner = (conv: any) => {
|
||||
if (conv.conversation_type === 'group') return { name: conv.title, avatar: conv.group_image };
|
||||
const otherMember = conv.conversation_members_conversation?.find((m: any) => m.user?.id !== currentUser?.id);
|
||||
return {
|
||||
name: otherMember ? `${otherMember.user.firstName} ${otherMember.user.lastName}` : 'Chat',
|
||||
avatar: otherMember?.user?.avatar
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Messages')}</title>
|
||||
</Head>
|
||||
<SectionMain className="h-[calc(100vh-80px)] p-0">
|
||||
<div className="flex h-full overflow-hidden bg-white dark:bg-[#0b0e14]">
|
||||
{/* Sidebar: Chat List */}
|
||||
<div className="w-80 border-r border-gray-200 dark:border-gray-800 flex flex-col h-full bg-gray-50 dark:bg-[#1a1d23]/30">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<h2 className="text-xl font-black dark:text-white mb-4">Messages</h2>
|
||||
<div className="relative">
|
||||
<BaseIcon path={mdiMagnify} className="absolute left-3 top-2.5 text-gray-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search chats..."
|
||||
className="w-full bg-white dark:bg-[#0b0e14] border-gray-200 dark:border-gray-800 rounded-xl pl-10 text-sm py-2 focus:ring-[#00d4ff] focus:border-[#00d4ff]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{conversations.map((conv: any) => {
|
||||
const partner = getChatPartner(conv);
|
||||
const isActive = selectedConversation?.id === conv.id;
|
||||
return (
|
||||
<div
|
||||
key={conv.id}
|
||||
onClick={() => setSelectedConversation(conv)}
|
||||
className={`flex items-center p-4 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800/50 transition-colors ${isActive ? 'bg-white dark:bg-gray-800/80 border-l-4 border-[#00d4ff]' : ''}`}
|
||||
>
|
||||
<UserAvatar username={partner.name} image={partner.avatar} className="w-12 h-12 flex-shrink-0" />
|
||||
<div className="ml-3 flex-1 overflow-hidden">
|
||||
<div className="flex justify-between items-baseline">
|
||||
<h3 className="text-sm font-bold dark:text-white truncate">{partner.name}</h3>
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{conv.last_message_at ? dataFormatter.dateTimeFormatter(conv.last_message_at) : ''}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{conv.last_message_body || 'No messages yet'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Chat Area */}
|
||||
<div className="flex-1 flex flex-col h-full bg-white dark:bg-[#0b0e14]">
|
||||
{selectedConversation ? (
|
||||
<>
|
||||
{/* Chat Header */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-800 flex items-center justify-between bg-white dark:bg-[#0b0e14]">
|
||||
<div className="flex items-center">
|
||||
<UserAvatar
|
||||
username={getChatPartner(selectedConversation).name}
|
||||
image={getChatPartner(selectedConversation).avatar}
|
||||
className="w-10 h-10"
|
||||
/>
|
||||
<div className="ml-3">
|
||||
<h3 className="font-bold text-sm dark:text-white">{getChatPartner(selectedConversation).name}</h3>
|
||||
<p className="text-[10px] text-green-500 font-medium">Online</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="text-gray-400 hover:text-gray-600 dark:hover:text-white">
|
||||
<BaseIcon path={mdiDotsVertical} size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages Area */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-gray-50 dark:bg-[#0b0e14]">
|
||||
{messages.map((msg: any) => {
|
||||
const isMine = msg.sender?.id === currentUser?.id;
|
||||
return (
|
||||
<div key={msg.id} className={`flex ${isMine ? 'justify-end' : 'justify-start'}`}>
|
||||
{!isMine && (
|
||||
<UserAvatar
|
||||
username={msg.sender?.firstName || 'User'}
|
||||
image={msg.sender?.avatar}
|
||||
className="w-8 h-8 self-end mr-2 mb-1"
|
||||
/>
|
||||
)}
|
||||
<div className={`max-w-[70%] group`}>
|
||||
<div className={`p-3 rounded-2xl text-sm ${
|
||||
isMine
|
||||
? 'bg-[#00d4ff] text-white rounded-br-none'
|
||||
: 'bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700 rounded-bl-none'
|
||||
}`}>
|
||||
{msg.body}
|
||||
</div>
|
||||
<p className={`text-[9px] text-gray-400 mt-1 ${isMine ? 'text-right' : 'text-left'}`}>
|
||||
{dataFormatter.dateTimeFormatter(msg.sent_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Message Input */}
|
||||
<form onSubmit={handleSendMessage} className="p-4 border-t border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0b0e14]">
|
||||
<div className="flex items-center bg-gray-100 dark:bg-gray-800 rounded-2xl px-4 py-2">
|
||||
<button type="button" className="text-gray-400 hover:text-[#00d4ff]">
|
||||
<BaseIcon path={mdiImageOutline} size={24} />
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Type a message..."
|
||||
className="flex-1 bg-transparent border-none focus:ring-0 text-sm dark:text-white mx-2"
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newMessage.trim()}
|
||||
className="text-[#00d4ff] hover:scale-110 transition-transform disabled:opacity-50"
|
||||
>
|
||||
<BaseIcon path={mdiSend} size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center p-8">
|
||||
<div className="w-20 h-20 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mb-4">
|
||||
<BaseIcon path={mdiChatOutline} size={40} className="text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold dark:text-white">Your Messages</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-xs mt-2">
|
||||
Select a conversation from the sidebar to start chatting or create a new one.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
MessagesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user