looood / src /components /group-info-panel.tsx
looda3131's picture
Clean push without any binary history
cc276cc
"use client";
import { useState, useEffect, useRef, useMemo } from 'react';
import { Group, GroupMember, User, GroupSendingMode } from '@/lib/types';
import { Button } from './ui/button';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { useAuth } from '@/contexts/auth-context';
import { useFirebase } from '@/contexts/firebase-context';
import { useGroups } from '@/contexts/groups-context';
import { useSettings } from '@/contexts/settings-context';
import { ArrowLeft, Edit, ShieldCheck, User as UserIcon, Users, X, Check, Loader2, MoreVertical, UserPlus, MessageSquare, Settings, MicOff, Crown } from 'lucide-react';
import { ScrollArea } from './ui/scroll-area';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from './ui/alert-dialog';
import { Badge } from './ui/badge';
import { doc, getDoc } from 'firebase/firestore';
import { Input } from './ui/input';
import { Textarea } from './ui/textarea';
import { Label } from './ui/label';
import { RadioGroup, RadioGroupItem } from './ui/radio-group';
import { Capacitor } from '@capacitor/core';
interface GroupInfoPanelProps {
isOpen: boolean;
onClose: () => void;
group: Group;
onAddMembers: () => void;
}
export function GroupInfoPanel({ isOpen, onClose, group, onAddMembers }: GroupInfoPanelProps) {
const { currentUser } = useAuth();
const { db } = useFirebase();
const { leaveGroup, removeMemberFromGroup, toggleGroupAdmin, updateGroupInfo, updateGroupSendingMode, toggleMuteMember } = useGroups();
const { playSound, t, addToast } = useSettings();
const [confirmAction, setConfirmAction] = useState<{ type: 'leave' | 'remove', member?: GroupMember } | null>(null);
const [members, setMembers] = useState<GroupMember[]>([]);
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [editedName, setEditedName] = useState(group.info.name);
const [editedPhoto, setEditedPhoto] = useState(group.info.photoURL);
const [editedDesc, setEditedDesc] = useState(group.info.description || '');
const initialGroupState = useRef(group.info);
const fileInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (!group) return;
const fetchMembersData = async () => {
const memberUids = Object.keys(group.members);
const memberPromises = memberUids.map(uid => getDoc(doc(db, 'users', uid)));
const memberDocs = await Promise.all(memberPromises);
const memberDetails = memberDocs
.filter(doc => doc.exists())
.map(doc => {
const userData = doc.data() as User;
return {
...userData,
uid: doc.id,
isAdmin: !!group.admins[doc.id],
isMuted: !!group.info.mutedMembers?.[doc.id]
} as GroupMember;
})
.sort((a, b) => { // Admins first, then by name
if (a.isAdmin !== b.isAdmin) return a.isAdmin ? -1 : 1;
return a.displayName.localeCompare(b.displayName);
});
setMembers(memberDetails);
};
fetchMembersData();
}, [group, db]);
useEffect(() => {
// Reset fields if group changes or panel is opened/closed
setEditedName(group.info.name);
setEditedPhoto(group.info.photoURL);
setEditedDesc(group.info.description || '');
setIsEditing(false);
initialGroupState.current = group.info;
}, [isOpen, group]);
const isCurrentUserAdmin = useMemo(() => {
return !!(currentUser && group.admins[currentUser.uid]);
}, [currentUser, group]);
const isCurrentUserOwner = useMemo(() => {
return currentUser?.uid === group.info.createdBy;
}, [currentUser, group]);
const handleEditToggle = () => {
playSound('touch');
if (isEditing) {
// Cancel edit, reset to initial state
setEditedName(initialGroupState.current.name);
setEditedPhoto(initialGroupState.current.photoURL);
setEditedDesc(initialGroupState.current.description || '');
}
setIsEditing(!isEditing);
};
const handleSaveChanges = async () => {
playSound('touch');
if (!isEditing) return;
setIsSaving(true);
try {
await updateGroupInfo(group.id, {
name: editedName,
photoURL: editedPhoto,
description: editedDesc,
});
setIsEditing(false);
} catch (error) {
console.error("Failed to save group info:", error);
} finally {
setIsSaving(false);
}
};
const handleLeaveGroup = async () => {
if (!currentUser) return;
playSound('touch');
// This function in context will handle closing the chat window after leaving.
await leaveGroup(group.id);
onClose(); // Explicitly close the panel and chat
setConfirmAction(null);
};
const handleRemoveMember = async () => {
if (confirmAction?.type !== 'remove' || !confirmAction.member) return;
playSound('touch');
await removeMemberFromGroup(group.id, confirmAction.member.uid, confirmAction.member.displayName);
setConfirmAction(null);
};
const handleToggleAdmin = async (member: GroupMember) => {
playSound('touch');
await toggleGroupAdmin(group.id, member.uid, member.isAdmin);
// Optimistic UI update
setMembers(prev => prev.map(m => m.uid === member.uid ? { ...m, isAdmin: !m.isAdmin } : m));
}
const handleToggleMute = async (member: GroupMember) => {
playSound('touch');
await toggleMuteMember(group.id, member.uid, member.displayName, !!member.isMuted);
// Optimistic UI update
setMembers(prev => prev.map(m => m.uid === member.uid ? { ...m, isMuted: !m.isMuted } : m));
};
const handleSendingModeChange = (mode: GroupSendingMode) => {
playSound('touch');
updateGroupSendingMode(group.id, mode);
}
const handleAvatarClick = () => {
if (isEditing) {
fileInputRef.current?.click();
}
};
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) { // 5MB limit
addToast("File is too large. Please select an image under 5MB.", { variant: 'destructive' });
return;
}
setIsUploading(true);
try {
const compressedFile = await new Promise<File>((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (e) => {
const img = new Image();
img.src = e.target?.result as string;
img.onload = () => {
const canvas = document.createElement('canvas');
const MAX_WIDTH = 256;
const MAX_HEIGHT = 256;
let width = img.width;
let height = img.height;
if (width > height) {
if (width > MAX_WIDTH) {
height *= MAX_WIDTH / width;
width = MAX_WIDTH;
}
} else {
if (height > MAX_HEIGHT) {
width *= MAX_HEIGHT / height;
height = MAX_HEIGHT;
}
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx?.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {
if (blob) {
resolve(new File([blob], file.name, { type: 'image/jpeg', lastModified: Date.now() }));
} else {
reject(new Error('Canvas to Blob conversion failed'));
}
}, 'image/jpeg', 0.8);
};
img.onerror = reject;
};
reader.onerror = reject;
});
const formData = new FormData();
formData.append('file', compressedFile);
const isNative = Capacitor.isNativePlatform();
const baseUrl = isNative ? process.env.NEXT_PUBLIC_API_BASE_URL : '';
const uploadUrl = `${baseUrl}/api/media`;
const uploadResponse = await fetch(uploadUrl, {
method: 'POST',
body: formData,
});
if (!uploadResponse.ok) {
throw new Error('Upload to storage failed.');
}
const { fileKey } = await uploadResponse.json();
const publicUrl = `${baseUrl}/api/media?fileKey=${fileKey}`;
setEditedPhoto(publicUrl);
addToast("Group picture updated!");
} catch (error) {
console.error("Failed to upload group picture:", error);
addToast("Failed to upload image. Please try again.", { variant: 'destructive' });
} finally {
setIsUploading(false);
}
};
if (!isOpen || !currentUser) return null;
const actionToConfirm = () => {
if (!confirmAction) return;
if (confirmAction.type === 'leave') handleLeaveGroup();
if (confirmAction.type === 'remove') handleRemoveMember();
}
const sendingMode = group.info.settings?.sendingMode || 'everyone';
const groupType = group.info.type || 'general';
return (
<aside className="absolute top-0 right-0 h-full w-full bg-card border-l z-50 flex flex-col group-info-anim md:w-80 lg:w-96">
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/png, image/jpeg, image/gif"
onChange={handleFileSelect}
/>
<header className="flex-shrink-0 flex items-center p-4 bg-card border-b">
<Button onClick={onClose} variant="ghost" size="icon" className="mr-4">
<X />
</Button>
<h2 className="text-xl font-bold">{t('groupInfoTitle')}</h2>
</header>
<ScrollArea className="flex-1">
<div className="flex flex-col items-center text-center p-6 border-b">
<div className="relative">
<Avatar className="w-24 h-24 mb-4 cursor-pointer" onClick={handleAvatarClick}>
<AvatarImage src={isEditing ? editedPhoto : group.info.photoURL} alt={group.info.name} />
<AvatarFallback>{(isEditing ? editedName : group.info.name).charAt(0)}</AvatarFallback>
</Avatar>
{isUploading && (
<div className="absolute inset-0 mb-4 bg-black/60 rounded-full flex items-center justify-center">
<Loader2 className="w-8 w-8 animate-spin text-white" />
</div>
)}
</div>
<div className="relative w-full">
{isEditing ? (
<Input value={editedName} onChange={(e) => setEditedName(e.target.value)} className="text-2xl font-bold text-center h-12 mb-2" />
) : (
<h3 className="text-2xl font-bold">{group.info.name}</h3>
)}
{isCurrentUserAdmin && (
<Button variant="ghost" size="icon" onClick={handleEditToggle} className="absolute -right-1 top-1 h-8 w-8">
{isEditing ? <X className="h-4 w-4" /> : <Edit className="h-4 w-4" />}
</Button>
)}
</div>
{isEditing ? (
<Textarea value={editedDesc} onChange={(e) => setEditedDesc(e.target.value)} placeholder={t('groupDescriptionPlaceholder')} className="text-center mt-1" />
) : (
<p className="text-sm text-muted-foreground mt-1">{group.info.description || t('noDescription')}</p>
)}
<Badge variant="outline" className="mt-3 capitalize">{t(groupType)}</Badge>
{isEditing && (
<>
<Button onClick={handleSaveChanges} disabled={isSaving || isUploading} className="mt-4 w-full">
{isSaving ? <Loader2 className="animate-spin" /> : t('saveChanges')}
</Button>
</>
)}
</div>
{isCurrentUserAdmin && (
<div className="p-4 border-b">
<h4 className="font-bold flex items-center gap-2 text-muted-foreground mb-3">
<Settings className="h-5 w-5"/> {t('adminSettings')}
</h4>
<RadioGroup value={sendingMode} onValueChange={handleSendingModeChange} className="space-y-2">
<Label className="font-medium flex items-center gap-2"><MessageSquare className="h-4 w-4" /> {t('whoCanSend')}</Label>
<div className="flex items-center space-x-2 p-2 bg-muted rounded-md">
<RadioGroupItem value="everyone" id="r-everyone" />
<Label htmlFor="r-everyone" className="flex-1 cursor-pointer">{t('everyone')}</Label>
</div>
<div className="flex items-center space-x-2 p-2 bg-muted rounded-md">
<RadioGroupItem value="admins" id="r-admins" />
<Label htmlFor="r-admins" className="flex-1 cursor-pointer">{t('adminsOnlyOption')}</Label>
</div>
{isCurrentUserOwner && (
<div className="flex items-center space-x-2 p-2 bg-muted rounded-md">
<RadioGroupItem value="owner" id="r-owner" />
<Label htmlFor="r-owner" className="flex-1 cursor-pointer">{t('ownerOnlyOption')}</Label>
</div>
)}
</RadioGroup>
</div>
)}
<div className="p-4">
<div className="flex items-center justify-between mb-2">
<h4 className="font-bold flex items-center gap-2 text-muted-foreground">
<Users className="h-5 w-5"/> {t('members', { count: members.length })}
</h4>
{isCurrentUserAdmin && (
<Button variant="outline" size="sm" onClick={onAddMembers}>
<UserPlus className="mr-2 h-4 w-4" />
{t('add')}
</Button>
)}
</div>
<div className="space-y-2">
{members.map(member => (
<div key={member.uid} className="flex items-center justify-between p-2 rounded-lg hover:bg-muted group">
<div className="flex items-center gap-3">
<Avatar>
<AvatarImage src={member.photoURL} alt={member.displayName} />
<AvatarFallback>{member.displayName.charAt(0)}</AvatarFallback>
</Avatar>
<div>
<p className="font-bold flex items-center gap-1.5">
{member.displayName}
{member.uid === currentUser.uid && <span className="text-xs text-muted-foreground">{t('you')}</span>}
</p>
<div className="flex items-center gap-1.5">
{member.uid === group.info.createdBy && <Badge variant="secondary" className="px-1.5 text-xs h-5 flex items-center gap-1"><Crown className="h-3 w-3 text-amber-500" /> {t('owner')}</Badge>}
{member.isAdmin && member.uid !== group.info.createdBy && <Badge variant="secondary" className="px-1.5 text-xs h-5">{t('admin')}</Badge>}
{member.isMuted && <Badge variant="destructive" className="px-1.5 text-xs h-5 flex items-center gap-1"><MicOff className="h-3 w-3" /> {t('muted')}</Badge>}
</div>
</div>
</div>
{isCurrentUserAdmin && member.uid !== currentUser.uid && (
<DropdownMenu onOpenChange={() => playSound('touch')}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 opacity-0 group-hover:opacity-100">
<MoreVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{isCurrentUserOwner && member.uid !== group.info.createdBy && (
<DropdownMenuItem onSelect={() => handleToggleAdmin(member)}>
{member.isAdmin ? t('demoteToMember') : t('promoteToAdmin')}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => handleToggleMute(member)}>
{member.isMuted ? t('unmuteMember') : t('muteMember')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setConfirmAction({ type: 'remove', member })} className="text-destructive">
{t('removeFromGroup')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
))}
</div>
</div>
</ScrollArea>
<footer className="p-4 border-t">
<Button variant="destructive" className="w-full" onClick={() => setConfirmAction({ type: 'leave' })}>
{t('leaveGroup')}
</Button>
</footer>
<AlertDialog open={!!confirmAction} onOpenChange={(isOpen) => !isOpen && setConfirmAction(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('areYouSure')}</AlertDialogTitle>
<AlertDialogDescription>
{confirmAction?.type === 'leave' && t('leaveGroupConfirm')}
{confirmAction?.type === 'remove' && t('removeMemberConfirm', { name: confirmAction.member?.displayName || '' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => playSound('touch')}>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={actionToConfirm} className="bg-destructive hover:bg-destructive/90">
{t('confirm')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</aside>
);
}