153 lines
6.0 KiB
TypeScript
153 lines
6.0 KiB
TypeScript
import { createGroup } from '@chat-app/shared/chat';
|
|
import { extractErrorCode } from '@chat-app/shared/i18n';
|
|
import { useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useNavigate } from 'react-router-dom';
|
|
|
|
import { useFriendshipsContext } from '../context/FriendshipsContext';
|
|
import { supabase } from '../lib/supabase';
|
|
import { AlertIcon, CheckCircleIcon, SpinnerIcon, UsersIcon } from './icons';
|
|
import { Modal } from './Modal';
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function CreateGroupDialog({ open, onClose }: Props) {
|
|
const { t } = useTranslation(['app', 'errors']);
|
|
const navigate = useNavigate();
|
|
const { friendships } = useFriendshipsContext();
|
|
const acceptedFriends = useMemo(
|
|
() => friendships.filter((f) => f.status === 'accepted').map((f) => f.peer),
|
|
[friendships],
|
|
);
|
|
|
|
const [name, setName] = useState('');
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
function toggle(userId: string) {
|
|
setSelected((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(userId)) next.delete(userId);
|
|
else next.add(userId);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (busy || name.trim().length === 0) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const id = await createGroup({
|
|
client: supabase,
|
|
name,
|
|
memberUserIds: Array.from(selected),
|
|
});
|
|
onClose();
|
|
setName('');
|
|
setSelected(new Set());
|
|
navigate('/chats/' + id);
|
|
} catch (err: unknown) {
|
|
const code = extractErrorCode(err);
|
|
setError(
|
|
code
|
|
? t('errors:' + code, { defaultValue: t('errors:generic') })
|
|
: err instanceof Error
|
|
? err.message
|
|
: t('errors:generic'),
|
|
);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Modal open={open} onClose={onClose} title={t('app:group.create_title')}>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div className="space-y-1.5">
|
|
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
|
|
{t('app:group.create_name_label')}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
maxLength={64}
|
|
autoFocus
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder={t('app:group.create_name_placeholder')}
|
|
className="w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
|
|
{t('app:group.create_members_label')}{' '}
|
|
<span className="text-neutral-500">({selected.size})</span>
|
|
</label>
|
|
{acceptedFriends.length === 0 ? (
|
|
<div className="flex items-center gap-3 rounded-lg border border-white/5 bg-ink-800/60 p-4 text-xs text-neutral-400">
|
|
<UsersIcon className="h-4 w-4" />
|
|
<span>{t('app:group.create_members_empty')}</span>
|
|
</div>
|
|
) : (
|
|
<ul className="max-h-72 space-y-1 overflow-y-auto rounded-lg border border-white/5 bg-ink-800/40 p-1">
|
|
{acceptedFriends.map((f) => {
|
|
const active = selected.has(f.userId);
|
|
const letter =
|
|
(f.displayName ?? f.username ?? '?').trim().charAt(0).toUpperCase() || '?';
|
|
return (
|
|
<li key={f.userId}>
|
|
<button
|
|
type="button"
|
|
onClick={() => toggle(f.userId)}
|
|
aria-pressed={active}
|
|
className={
|
|
'flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
|
|
(active ? 'bg-brand-500/15 ring-1 ring-brand-400/30' : 'hover:bg-white/5')
|
|
}
|
|
>
|
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-xs font-semibold text-white ring-1 ring-brand-400/30">
|
|
{letter}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm text-white">{f.displayName}</p>
|
|
<p className="truncate text-xs text-neutral-500">@{f.username}</p>
|
|
</div>
|
|
{active && <CheckCircleIcon className="h-4 w-4 text-brand-300" />}
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
{error && (
|
|
<div
|
|
role="alert"
|
|
className="flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
|
|
>
|
|
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
|
|
<p className="min-w-0 flex-1 break-words">{error}</p>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={busy || name.trim().length === 0}
|
|
className="inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-brand-400 to-brand-600 px-4 py-2.5 text-sm font-semibold text-white shadow-glow transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/60 disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
{busy && <SpinnerIcon className="h-4 w-4" />}
|
|
<span>{t(busy ? 'app:group.create_cta_loading' : 'app:group.create_cta')}</span>
|
|
</button>
|
|
</form>
|
|
</Modal>
|
|
);
|
|
}
|