This commit is contained in:
2026-04-18 23:11:35 +02:00
commit f7cfd2a86e
196 changed files with 35538 additions and 0 deletions
@@ -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>
);
}