Complete Phase 3: Add conversation management capabilities to AI chat
- Add conversation delete functionality with confirmation dialog - Add conversation rename functionality with input modal - Implement three-dot menu for each conversation item - Add proper authentication token key consistency - Fix JSX structure issues in floating chat component - Integrate full CRUD operations with conversation API 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,12 @@ import {
|
|||||||
Slide,
|
Slide,
|
||||||
Grow,
|
Grow,
|
||||||
Zoom,
|
Zoom,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import {
|
import {
|
||||||
Chat,
|
Chat,
|
||||||
@@ -31,8 +37,13 @@ import {
|
|||||||
Minimize,
|
Minimize,
|
||||||
Launch,
|
Launch,
|
||||||
History,
|
History,
|
||||||
|
Add,
|
||||||
|
Delete,
|
||||||
|
CircularProgress,
|
||||||
|
MoreVert,
|
||||||
|
Edit,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
import { useTranslations, useLocale } from 'next-intl'
|
import { useTranslations, useLocale } from 'next-intl'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
|
||||||
@@ -43,6 +54,16 @@ interface ChatMessage {
|
|||||||
timestamp: Date
|
timestamp: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Conversation {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
language: string
|
||||||
|
messageCount: number
|
||||||
|
lastMessage: ChatMessage | null
|
||||||
|
lastMessageAt: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export default function FloatingChat() {
|
export default function FloatingChat() {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const t = useTranslations('chat')
|
const t = useTranslations('chat')
|
||||||
@@ -63,6 +84,18 @@ export default function FloatingChat() {
|
|||||||
])
|
])
|
||||||
const [inputMessage, setInputMessage] = useState('')
|
const [inputMessage, setInputMessage] = useState('')
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
// Conversation management state
|
||||||
|
const [conversations, setConversations] = useState<Conversation[]>([])
|
||||||
|
const [activeConversationId, setActiveConversationId] = useState<string | null>(null)
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||||
|
const [authToken, setAuthToken] = useState<string | null>(null)
|
||||||
|
const [isLoadingConversations, setIsLoadingConversations] = useState(false)
|
||||||
|
const [menuAnchorEl, setMenuAnchorEl] = useState<HTMLElement | null>(null)
|
||||||
|
const [selectedConversationId, setSelectedConversationId] = useState<string | null>(null)
|
||||||
|
const [showRenameDialog, setShowRenameDialog] = useState(false)
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||||
|
const [newTitle, setNewTitle] = useState('')
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
@@ -91,6 +124,204 @@ export default function FloatingChat() {
|
|||||||
return () => window.removeEventListener('floating-chat:open', handler as EventListener)
|
return () => window.removeEventListener('floating-chat:open', handler as EventListener)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Check authentication status
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuthStatus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Load conversations when authenticated
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated && authToken) {
|
||||||
|
loadConversations()
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, authToken, locale])
|
||||||
|
|
||||||
|
const checkAuthStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('authToken')
|
||||||
|
if (token) {
|
||||||
|
// Verify token with the server
|
||||||
|
const response = await fetch('/api/auth/me', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setAuthToken(token)
|
||||||
|
setIsAuthenticated(true)
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('authToken')
|
||||||
|
setIsAuthenticated(false)
|
||||||
|
setAuthToken(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auth check failed:', error)
|
||||||
|
setIsAuthenticated(false)
|
||||||
|
setAuthToken(null)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadConversations = useCallback(async () => {
|
||||||
|
if (!authToken) return
|
||||||
|
|
||||||
|
setIsLoadingConversations(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/chat/conversations?language=${locale}&limit=20`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${authToken}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setConversations(data.conversations || [])
|
||||||
|
} else {
|
||||||
|
console.error('Failed to load conversations')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading conversations:', error)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingConversations(false)
|
||||||
|
}
|
||||||
|
}, [authToken, locale])
|
||||||
|
|
||||||
|
const loadConversation = useCallback(async (conversationId: string) => {
|
||||||
|
if (!authToken) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/chat/conversations/${conversationId}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${authToken}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
const conversation = data.conversation
|
||||||
|
|
||||||
|
// Convert messages to the expected format
|
||||||
|
const formattedMessages = conversation.messages.map((msg: any) => ({
|
||||||
|
id: msg.id,
|
||||||
|
role: msg.role,
|
||||||
|
content: msg.content,
|
||||||
|
timestamp: new Date(msg.timestamp)
|
||||||
|
}))
|
||||||
|
|
||||||
|
setMessages(formattedMessages)
|
||||||
|
setActiveConversationId(conversationId)
|
||||||
|
setShowHistory(false) // Close history panel
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading conversation:', error)
|
||||||
|
}
|
||||||
|
}, [authToken])
|
||||||
|
|
||||||
|
const createNewConversation = useCallback(() => {
|
||||||
|
// Reset to a new conversation
|
||||||
|
setMessages([
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
role: 'assistant',
|
||||||
|
content: locale === 'ro'
|
||||||
|
? 'Bună ziua! Sunt asistentul tău AI pentru întrebări biblice. Cum te pot ajuta astăzi să înțelegi mai bine Scriptura?'
|
||||||
|
: 'Hello! I am your AI assistant for biblical questions. How can I help you understand Scripture better today?',
|
||||||
|
timestamp: new Date(),
|
||||||
|
}
|
||||||
|
])
|
||||||
|
setActiveConversationId(null)
|
||||||
|
setShowHistory(false)
|
||||||
|
}, [locale])
|
||||||
|
|
||||||
|
const handleMenuOpen = useCallback((event: React.MouseEvent<HTMLElement>, conversationId: string) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
setMenuAnchorEl(event.currentTarget)
|
||||||
|
setSelectedConversationId(conversationId)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleMenuClose = useCallback(() => {
|
||||||
|
setMenuAnchorEl(null)
|
||||||
|
setSelectedConversationId(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleRenameClick = useCallback(() => {
|
||||||
|
const conversation = conversations.find(c => c.id === selectedConversationId)
|
||||||
|
if (conversation) {
|
||||||
|
setNewTitle(conversation.title)
|
||||||
|
setShowRenameDialog(true)
|
||||||
|
}
|
||||||
|
handleMenuClose()
|
||||||
|
}, [conversations, selectedConversationId])
|
||||||
|
|
||||||
|
const handleDeleteClick = useCallback(() => {
|
||||||
|
setShowDeleteDialog(true)
|
||||||
|
handleMenuClose()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleRenameSubmit = useCallback(async () => {
|
||||||
|
if (!selectedConversationId || !newTitle.trim()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('authToken')
|
||||||
|
if (!token) return
|
||||||
|
|
||||||
|
const response = await fetch(`/api/chat/conversations/${selectedConversationId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ title: newTitle.trim() })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setConversations(prev => prev.map(conv =>
|
||||||
|
conv.id === selectedConversationId
|
||||||
|
? { ...conv, title: newTitle.trim() }
|
||||||
|
: conv
|
||||||
|
))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error renaming conversation:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowRenameDialog(false)
|
||||||
|
setSelectedConversationId(null)
|
||||||
|
setNewTitle('')
|
||||||
|
}, [selectedConversationId, newTitle])
|
||||||
|
|
||||||
|
const handleDeleteConfirm = useCallback(async () => {
|
||||||
|
if (!selectedConversationId) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('authToken')
|
||||||
|
if (!token) return
|
||||||
|
|
||||||
|
const response = await fetch(`/api/chat/conversations/${selectedConversationId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setConversations(prev => prev.filter(conv => conv.id !== selectedConversationId))
|
||||||
|
|
||||||
|
// If we deleted the active conversation, clear the current chat
|
||||||
|
if (activeConversationId === selectedConversationId) {
|
||||||
|
setActiveConversationId(null)
|
||||||
|
setMessages([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting conversation:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowDeleteDialog(false)
|
||||||
|
setSelectedConversationId(null)
|
||||||
|
}, [selectedConversationId, activeConversationId])
|
||||||
|
|
||||||
const handleSendMessage = async () => {
|
const handleSendMessage = async () => {
|
||||||
if (!inputMessage.trim() || isLoading) return
|
if (!inputMessage.trim() || isLoading) return
|
||||||
|
|
||||||
@@ -106,14 +337,22 @@ export default function FloatingChat() {
|
|||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const headers: any = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add authentication if available
|
||||||
|
if (authToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${authToken}`
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch('/api/chat', {
|
const response = await fetch('/api/chat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers,
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
message: inputMessage,
|
message: inputMessage,
|
||||||
history: messages.slice(-5),
|
conversationId: activeConversationId,
|
||||||
|
history: messages.slice(-5), // Fallback for anonymous users
|
||||||
locale: locale,
|
locale: locale,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -134,6 +373,15 @@ export default function FloatingChat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setMessages(prev => [...prev, assistantMessage])
|
setMessages(prev => [...prev, assistantMessage])
|
||||||
|
|
||||||
|
// Update conversation state if we got a conversationId back
|
||||||
|
if (data.conversationId && isAuthenticated) {
|
||||||
|
if (!activeConversationId) {
|
||||||
|
setActiveConversationId(data.conversationId)
|
||||||
|
}
|
||||||
|
// Refresh conversations list to update last message
|
||||||
|
loadConversations()
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error sending message:', error)
|
console.error('Error sending message:', error)
|
||||||
const errorMessage: ChatMessage = {
|
const errorMessage: ChatMessage = {
|
||||||
@@ -287,29 +535,170 @@ export default function FloatingChat() {
|
|||||||
maxHeight: '300px',
|
maxHeight: '300px',
|
||||||
overflowY: 'auto'
|
overflowY: 'auto'
|
||||||
}}>
|
}}>
|
||||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
Chat History
|
<Typography variant="h6">
|
||||||
</Typography>
|
Chat History
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
|
||||||
<Typography variant="body2" color="text.secondary" textAlign="center" sx={{ py: 2 }}>
|
|
||||||
📝 Chat history will appear here
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" textAlign="center">
|
<Box>
|
||||||
✨ Sign in to save your conversations
|
<IconButton
|
||||||
</Typography>
|
size="small"
|
||||||
<Button
|
onClick={createNewConversation}
|
||||||
variant="outlined"
|
title="New Conversation"
|
||||||
size="small"
|
sx={{ mr: 0.5 }}
|
||||||
sx={{ mt: 1 }}
|
>
|
||||||
onClick={() => setShowHistory(false)}
|
<Add />
|
||||||
>
|
</IconButton>
|
||||||
Close History
|
<IconButton
|
||||||
</Button>
|
size="small"
|
||||||
|
onClick={() => setShowHistory(false)}
|
||||||
|
title="Close History"
|
||||||
|
>
|
||||||
|
<Close />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{!isAuthenticated ? (
|
||||||
|
<Box sx={{ textAlign: 'center', py: 3 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
✨ Sign in to save your conversations
|
||||||
|
</Typography>
|
||||||
|
<Button variant="outlined" size="small">
|
||||||
|
Sign In
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
) : isLoadingConversations ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}>
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
</Box>
|
||||||
|
) : conversations.length === 0 ? (
|
||||||
|
<Box sx={{ textAlign: 'center', py: 3 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
📝 No conversations yet
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Start chatting to create your first conversation!
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
{conversations.map((conversation) => (
|
||||||
|
<Paper
|
||||||
|
key={conversation.id}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: activeConversationId === conversation.id ? '2px solid' : '1px solid',
|
||||||
|
borderColor: activeConversationId === conversation.id ? 'primary.main' : 'divider',
|
||||||
|
'&:hover': { bgcolor: 'action.hover' },
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
onClick={() => loadConversation(conversation.id)}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 0.5, fontWeight: 'medium' }}>
|
||||||
|
{conversation.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
|
||||||
|
{conversation.messageCount} messages • {new Date(conversation.lastMessageAt).toLocaleDateString()}
|
||||||
|
</Typography>
|
||||||
|
{conversation.lastMessage && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 1,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
}}>
|
||||||
|
{conversation.lastMessage.content.substring(0, 60)}...
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => handleMenuOpen(e, conversation.id)}
|
||||||
|
sx={{ ml: 1, flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<MoreVert fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<>
|
{/* Conversation Menu */}
|
||||||
|
<Menu
|
||||||
|
anchorEl={menuAnchorEl}
|
||||||
|
open={Boolean(menuAnchorEl)}
|
||||||
|
onClose={handleMenuClose}
|
||||||
|
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||||
|
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||||
|
>
|
||||||
|
<MenuItem onClick={handleRenameClick}>
|
||||||
|
<Edit fontSize="small" sx={{ mr: 1 }} />
|
||||||
|
Rename
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={handleDeleteClick} sx={{ color: 'error.main' }}>
|
||||||
|
<Delete fontSize="small" sx={{ mr: 1 }} />
|
||||||
|
Delete
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
{/* Rename Dialog */}
|
||||||
|
<Dialog open={showRenameDialog} onClose={() => setShowRenameDialog(false)} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>Rename Conversation</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
label="Conversation Title"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
value={newTitle}
|
||||||
|
onChange={(e) => setNewTitle(e.target.value)}
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleRenameSubmit()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setShowRenameDialog(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleRenameSubmit}
|
||||||
|
variant="contained"
|
||||||
|
disabled={!newTitle.trim()}
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<Dialog open={showDeleteDialog} onClose={() => setShowDeleteDialog(false)}>
|
||||||
|
<DialogTitle>Delete Conversation</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography>
|
||||||
|
Are you sure you want to delete this conversation? This action cannot be undone.
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setShowDeleteDialog(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Suggested Questions */}
|
{/* Suggested Questions */}
|
||||||
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
|
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user