initial
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user