sozsoft-platform/ui/src/views/intranet/widgets/EventModal.tsx

431 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { motion, AnimatePresence } from 'framer-motion'
import {
FaTimes,
FaChevronLeft,
FaChevronRight,
FaMapMarkerAlt,
FaCalendarAlt,
FaUsers,
FaExpand,
FaCommentAlt,
FaPaperPlane,
FaHeart,
} from 'react-icons/fa'
import { Dialog } from '@/components/ui'
import { useDialogContext } from '@/components/ui/Dialog/Dialog'
import { EventCommentDto, EventDto } from '@/proxy/intranet/models'
import useLocale from '@/utils/hooks/useLocale'
import { currentLocalDate } from '@/utils/dateUtils'
import Avatar from '@/components/ui/Avatar/Avatar'
import { AVATAR_URL } from '@/constants/app.constant'
import { intranetService } from '@/services/intranet.service'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { useLocalization } from '@/utils/hooks/useLocalization'
dayjs.extend(relativeTime)
interface EventModalProps {
event: EventDto
onClose: () => void
}
const imgSrc = (img: string) => {
if (
img.startsWith('data:') ||
img.startsWith('http://') ||
img.startsWith('https://') ||
img.startsWith('/')
)
return img
return `data:image/jpeg;base64,${img}`
}
function EventModalContent({ event, onClose }: EventModalProps) {
const currentLocale = useLocale()
const photos = (event.photos || '').split('|').filter(Boolean)
const { translate } = useLocalization()
const { isMaximized } = useDialogContext()
// Photo slider state
const [activePhoto, setActivePhoto] = useState(0)
const [lightboxOpen, setLightboxOpen] = useState(false)
const [lightboxIndex, setLightboxIndex] = useState(0)
// Comments state
const [comments, setComments] = useState<EventCommentDto[]>(event.comments || [])
const [commentText, setCommentText] = useState('')
const [submitting, setSubmitting] = useState(false)
const commentInputRef = useRef<HTMLTextAreaElement>(null)
const commentsEndRef = useRef<HTMLDivElement>(null)
// Likes state
const [likes, setLikes] = useState(event.likes)
const [isLiked, setIsLiked] = useState(event.isLiked)
const [liking, setLiking] = useState(false)
useEffect(() => {
// Refresh comments from API on open
intranetService.getEventComments(event.id).then((res) => {
if (res.data) setComments(res.data)
})
}, [event.id])
const scrollToBottom = () => {
commentsEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
const handleSubmitComment = async () => {
if (!commentText.trim() || submitting) return
setSubmitting(true)
try {
const res = await intranetService.createEventComment(event.id, commentText.trim())
if (res.data) {
setComments((prev) => [...prev, res.data!])
setCommentText('')
setTimeout(scrollToBottom, 100)
}
} finally {
setSubmitting(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmitComment()
}
}
const handleLike = async () => {
if (liking) return
setLiking(true)
// Optimistic update
setIsLiked((prev) => !prev)
setLikes((prev) => (isLiked ? Math.max(0, prev - 1) : prev + 1))
try {
const res = await intranetService.likeEvent(event.id)
if (res.data) {
setLikes(res.data.likes)
setIsLiked(res.data.isLiked)
}
} catch {
// Revert on error
setIsLiked((prev) => !prev)
setLikes((prev) => (isLiked ? prev + 1 : Math.max(0, prev - 1)))
} finally {
setLiking(false)
}
}
const openLightbox = (idx: number) => {
setLightboxIndex(idx)
setLightboxOpen(true)
}
const closeLightbox = () => setLightboxOpen(false)
const prevLightbox = (e: React.MouseEvent) => {
e.stopPropagation()
setLightboxIndex((i) => (i - 1 + photos.length) % photos.length)
}
const nextLightbox = (e: React.MouseEvent) => {
e.stopPropagation()
setLightboxIndex((i) => (i + 1) % photos.length)
}
return (
<>
<Dialog.Body className="flex flex-col min-h-0">
{/* Header */}
<div className="pb-3 pr-16 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<h2 className="text-xl font-bold text-gray-900 dark:text-white leading-tight">
{event.name}
</h2>
<div className="flex flex-wrap items-center gap-4 mt-2 text-sm text-gray-600 dark:text-gray-400">
<span className="flex items-center gap-1">
<FaCalendarAlt className="w-3.5 h-3.5 text-green-500" />
{currentLocalDate(event.date, currentLocale || 'tr')}
</span>
{event.place && (
<span className="flex items-center gap-1">
<FaMapMarkerAlt className="w-3.5 h-3.5 text-red-500" />
{event.place}
</span>
)}
{event.participantsCount > 0 && (
<span className="flex items-center gap-1">
<FaUsers className="w-3.5 h-3.5 text-blue-500" />
{event.participantsCount}
</span>
)}
<div>
<button
onClick={handleLike}
disabled={liking}
className={`flex items-center gap-2 border-0 transition-colors text-xs font-xs border ${
isLiked
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/40'
: 'bg-white dark:bg-gray-700 border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
<FaHeart className={`w-4 h-4 ${isLiked ? 'text-red-500' : ''}`} />
{likes > 0 && likes}
</button>
</div>
</div>
{event.user && (
<div className="flex items-center gap-2 mt-3">
<Avatar
size={28}
shape="circle"
src={AVATAR_URL(event.user.id, event.user.tenantId)}
/>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{event.user.fullName}
</span>
</div>
)}
</div>
</div>
</div>
{/* Scrollable body */}
<div className={`overflow-y-auto ${isMaximized ? 'flex-1 min-h-0' : 'max-h-[62vh]'}`}>
{/* Photo Gallery */}
{photos.length > 0 && (
<div className="m-3 border-b border-gray-200 dark:border-gray-700">
{/* Main photo */}
<div className="relative w-full rounded-lg overflow-hidden dark:bg-gray-900 mb-2 mx-auto">
<img
src={imgSrc(photos[activePhoto])}
alt={`${event.name} ${activePhoto + 1}`}
className="w-full h-56 object-contain cursor-zoom-in"
onClick={() => openLightbox(activePhoto)}
/>
<button
onClick={() => openLightbox(activePhoto)}
className="absolute top-2 right-2 p-1.5 bg-black/50 hover:bg-black/70 rounded-lg text-white transition-colors"
title="Tam ekran"
>
<FaExpand className="w-4 h-4" />
</button>
{photos.length > 1 && (
<>
<button
onClick={(e) => {
e.stopPropagation()
setActivePhoto((i) => (i - 1 + photos.length) % photos.length)
}}
className="absolute left-2 top-1/2 -translate-y-1/2 p-2 bg-black/50 hover:bg-black/70 rounded-full text-white transition-colors"
>
<FaChevronLeft className="w-4 h-4" />
</button>
<button
onClick={(e) => {
e.stopPropagation()
setActivePhoto((i) => (i + 1) % photos.length)
}}
className="absolute right-2 top-1/2 -translate-y-1/2 p-2 bg-black/50 hover:bg-black/70 rounded-full text-white transition-colors"
>
<FaChevronRight className="w-4 h-4" />
</button>
</>
)}
</div>
{/* Thumbnail strip */}
{photos.length > 1 && (
<div className="flex gap-2 overflow-x-auto pb-1">
{photos.map((photo, idx) => (
<button
key={idx}
onClick={() => setActivePhoto(idx)}
className={`flex-shrink-0 w-16 h-16 rounded-lg overflow-hidden border-2 transition-all ${
idx === activePhoto
? 'border-green-500 opacity-100'
: 'border-transparent opacity-60 hover:opacity-90'
}`}
>
<img
src={imgSrc(photo)}
alt={`Thumbnail ${idx + 1}`}
className="w-full h-full object-cover"
/>
</button>
))}
</div>
)}
</div>
)}
{/* Description */}
{event.description && (
<div className="pb-2 border-b border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-line">
{event.description}
</p>
</div>
)}
{/* Comments Section */}
<div className="py-3">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-2 mb-2">
<FaCommentAlt className="w-4 h-4 text-green-500" />
{translate('::App.Intranet.Events.Comments')} ({comments.length})
</h3>
{/* Comment List */}
<div className="max-h-64 overflow-y-auto">
{comments.length === 0 ? (
<p className="text-sm text-gray-400 dark:text-gray-500 text-center">
{translate('::App.Intranet.Events.NoComment')}
</p>
) : (
comments.map((comment) => (
<div key={comment.id} className="flex gap-3">
{comment.user && (
<Avatar
size={32}
shape="circle"
src={AVATAR_URL(comment.user.id, comment.user.tenantId)}
className="flex-shrink-0"
/>
)}
<div className="flex-1 min-w-0">
<div className="bg-gray-50 dark:bg-gray-700/60 rounded-xl px-3 py-2">
<p className="text-xs font-semibold text-gray-800 dark:text-gray-200">
{comment.user?.fullName ?? 'Kullanıcı'}
</p>
<p className="text-sm text-gray-700 dark:text-gray-300 mt-0.5 whitespace-pre-line">
{comment.content}
</p>
</div>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1 px-1">
{dayjs(comment.creationTime).fromNow()}
</p>
</div>
</div>
))
)}
<div ref={commentsEndRef} />
</div>
</div>
</div>
</Dialog.Body>
<Dialog.Footer className="p-1 pt-2 border-t border-gray-200 dark:border-gray-700 flex-shrink-0 flex items-end gap-3">
<div className="flex flex-1 gap-2 items-end">
<textarea
ref={commentInputRef}
value={commentText}
autoFocus
onChange={(e) => setCommentText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={translate('::App.Intranet.Events.EventAttendance')}
rows={1}
className="flex-1 resize-none px-3 py-2 text-sm rounded-xl border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 transition-colors"
/>
<button
type="button"
onClick={handleSubmitComment}
disabled={!commentText.trim() || submitting}
className="p-2.5 bg-green-500 hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-xl transition-colors flex-shrink-0"
>
<FaPaperPlane className="w-4 h-4" />
</button>
</div>
<button
onClick={onClose}
type="button"
className="px-2 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors text-sm font-medium flex-shrink-0"
>
{translate('::App.Platform.Close')}
</button>
</Dialog.Footer>
{/* Lightbox */}
{createPortal(
<AnimatePresence>
{lightboxOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/95 z-[1100]"
onClick={closeLightbox}
/>
<div className="fixed inset-0 z-[1110] flex items-center justify-center">
<button
onClick={closeLightbox}
className="absolute top-4 right-4 p-2 text-white hover:text-gray-300 transition-colors z-10"
>
<FaTimes className="w-8 h-8" />
</button>
{photos.length > 1 && (
<>
<button
onClick={prevLightbox}
className="absolute left-4 top-1/2 -translate-y-1/2 p-3 bg-black/50 hover:bg-black/70 text-white rounded-full transition-colors z-10"
>
<FaChevronLeft className="w-6 h-6" />
</button>
<button
onClick={nextLightbox}
className="absolute right-4 top-1/2 -translate-y-1/2 p-3 bg-black/50 hover:bg-black/70 text-white rounded-full transition-colors z-10"
>
<FaChevronRight className="w-6 h-6" />
</button>
</>
)}
<motion.img
key={lightboxIndex}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
src={imgSrc(photos[lightboxIndex])}
alt={`${event.name} ${lightboxIndex + 1}`}
className="max-w-[90vw] max-h-[90vh] object-contain rounded-lg"
onClick={(e) => e.stopPropagation()}
/>
{photos.length > 1 && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2">
{photos.map((_, idx) => (
<button
key={idx}
onClick={(e) => {
e.stopPropagation()
setLightboxIndex(idx)
}}
className={`w-2.5 h-2.5 rounded-full transition-all ${
idx === lightboxIndex ? 'bg-white' : 'bg-white/40 hover:bg-white/70'
}`}
/>
))}
</div>
)}
</div>
</>
)}
</AnimatePresence>,
document.body,
)}
</>
)
}
const EventModal: React.FC<EventModalProps> = ({ event, onClose }) => {
return (
<Dialog isOpen onClose={onClose} onRequestClose={onClose} width={768}>
<EventModalContent event={event} onClose={onClose} />
</Dialog>
)
}
export default EventModal