fiddy/apps/web/components/group-dropdown.tsx
2026-02-11 23:45:15 -08:00

301 lines
14 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useGroupsContext } from "@/hooks/groups-context";
import { useNotificationsContext } from "@/hooks/notifications-context";
type GroupDropdownProps = {
onInviteCode: (code: string) => void;
};
export default function GroupDropdown({ onInviteCode }: GroupDropdownProps) {
const { groups, activeGroupId, loading, error, createGroup, joinGroup, setActiveGroup } = useGroupsContext();
const { notify } = useNotificationsContext();
const router = useRouter();
const pathname = usePathname();
const [open, setOpen] = useState(false);
const [manageOpen, setManageOpen] = useState(false);
const [activeTab, setActiveTab] = useState<"create" | "join">("create");
const [name, setName] = useState("");
const [inviteCode, setInviteCode] = useState("");
const [localError, setLocalError] = useState("");
const dropdownRef = useRef<HTMLDivElement | null>(null);
const activeGroup = groups.find(group => group.id === activeGroupId) || null;
const groupLabel = loading ? "Loading" : activeGroup ? activeGroup.name : "Select group";
async function handleCreate() {
if (!name.trim()) {
setLocalError("Group name is required");
return;
}
setLocalError("");
const group = await createGroup({ name });
if (group?.inviteCode) onInviteCode(group.inviteCode);
if (group) {
setName("");
notify({ title: "Group created", message: group.name, tone: "success" });
setManageOpen(false);
setOpen(false);
router.push("/groups/settings");
}
}
async function handleJoin() {
if (!inviteCode.trim()) {
setLocalError("Invite code is required");
return;
}
setLocalError("");
const raw = inviteCode.trim();
const inviteTokenMatch = raw.match(/\/invite\/([a-zA-Z0-9]+)/);
if (inviteTokenMatch?.[1]) {
setInviteCode("");
setManageOpen(false);
setOpen(false);
router.push(`/invite/${inviteTokenMatch[1]}`);
return;
}
const group = await joinGroup({ inviteCode: raw });
if (group) {
setInviteCode("");
notify({ title: "Joined group", message: group.name, tone: "success" });
setManageOpen(false);
setOpen(false);
}
}
async function handleSelect(groupId: number) {
const ok = await setActiveGroup(groupId);
if (ok) {
setOpen(false);
const group = groups.find(item => item.id === groupId);
if (group) notify({ title: "Active group", message: group.name });
if (pathname.startsWith("/groups/")) router.push("/groups/settings");
}
}
function handleQuickEntries() {
setOpen(false);
router.push("/");
}
function roleIcon(role: string) {
if (role === "GROUP_OWNER") return "👑";
if (role === "GROUP_ADMIN") return "🛡️";
return "👤";
}
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (!open || !dropdownRef.current) return;
if (!dropdownRef.current.contains(event.target as Node))
setOpen(false);
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
useEffect(() => {
if (!manageOpen) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") setManageOpen(false);
if (event.key === "Enter" && !event.shiftKey) {
if (activeTab === "create") handleCreate();
else handleJoin();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [manageOpen, activeTab, handleCreate, handleJoin]);
return (
<div className="relative" ref={dropdownRef}>
<div className="inline-flex items-center">
<button
type="button"
aria-label="Back to entries"
onClick={handleQuickEntries}
className="flex h-9 w-9 items-center justify-center rounded-l-lg border border-accent-weak bg-panel text-sm hover:border-accent"
disabled={loading || !activeGroupId}
>
$
{/* <svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M15 6l-6 6 6 6" />
</svg> */}
</button>
<button
type="button"
onClick={() => setOpen(v => !v)}
className={`-ml-px flex h-9 w-44 items-center justify-between rounded-none border border-accent-weak bg-panel px-3 text-sm hover:border-accent sm:w-56 ${loading ? "animate-pulse" : ""}`}
>
<span className="truncate">{groupLabel}</span>
<span className="text-l font-bold text-soft"></span>
</button>
<button
type="button"
aria-label="Group settings"
onClick={() => {
if (!activeGroupId) return;
setOpen(false);
router.push("/groups/settings");
}}
disabled={loading || !activeGroupId}
className="-ml-px flex h-9 w-9 items-center justify-center rounded-r-lg border border-accent-weak bg-panel text-sm hover:border-accent disabled:opacity-60"
>
<span className="text-sm"></span>
</button>
</div>
{open ? (
<div className="absolute left-1/2 mt-2 w-64 -translate-x-1/2 rounded-xl border border-accent-weak bg-panel p-3 text-sm shadow-lg">
<div className="space-y-2">
{loading ? (
<div className="space-y-2">
{[0, 1, 2].map(row => (
<div key={row} className="flex items-center justify-between rounded-lg border border-accent-weak bg-panel px-2 py-2">
<div className="h-3 w-32 rounded bg-surface animate-pulse" />
<div className="h-3 w-10 rounded bg-surface animate-pulse" />
</div>
))}
</div>
) : groups.length ? (
groups.map(group => (
<div
key={group.id}
role="button"
tabIndex={0}
className={`flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left outline-none ${activeGroupId === group.id ? "bg-accent-soft" : "hover:bg-surface"}`}
onClick={() => handleSelect(group.id)}
onKeyDown={event => {
if (event.key === "Enter" || event.key === " ") handleSelect(group.id);
}}
>
<span className="truncate">{group.name}</span>
<span className="text-xs text-soft" aria-label={group.role}>
{roleIcon(group.role)}
</span>
</div>
))
) : (
<div className="text-soft">No groups yet</div>
)}
</div>
<div className="my-3 h-px divider" />
<div className="space-y-2">
<button
type="button"
className="w-full rounded-lg btn-accent px-2 py-2 text-sm font-semibold disabled:opacity-60"
onClick={() => {
setManageOpen(true);
setActiveTab("create");
setOpen(false);
}}
disabled={loading}
>
Create or join group
</button>
{localError || error ? (
<div className="text-xs text-red-400">{localError || error}</div>
) : null}
</div>
</div>
) : null}
{manageOpen ? (
<div className="fixed inset-0 z-[60] flex min-h-[100dvh] items-center justify-center bg-black/60 p-4" onClick={() => setManageOpen(false)}>
<div
className="w-full max-w-sm rounded-2xl border border-accent-weak bg-panel p-5"
onClick={event => event.stopPropagation()}
onKeyDown={event => {
if (event.key === "Escape") setManageOpen(false);
if (event.key === "Enter" && !event.shiftKey) {
if (activeTab === "create") handleCreate();
else handleJoin();
}
}}
role="dialog"
tabIndex={-1}
>
<div className="flex items-center justify-between">
<div className="text-lg font-semibold">Manage groups</div>
<button
type="button"
className="rounded-lg btn-outline-accent px-2 py-1 text-sm"
onClick={() => setManageOpen(false)}
>
Close
</button>
</div>
<div className="mt-4 flex items-center gap-2">
{([
{ key: "create", label: "Create" },
{ key: "join", label: "Join" }
] as const).map(tab => (
<button
key={tab.key}
type="button"
className={`flex-1 rounded-lg border px-3 py-2 text-sm font-semibold ${activeTab === tab.key ? "border-accent bg-accent-soft" : "border-accent-weak bg-panel"}`}
onClick={() => setActiveTab(tab.key)}
>
{tab.label}
</button>
))}
</div>
{activeTab === "create" ? (
<div className="mt-4 space-y-2">
<input
type="text"
placeholder="Group name"
className={`w-full input-base px-3 py-2 text-sm ${name.trim() ? "" : "border-red-400/70"}`}
value={name}
onChange={e => setName(e.target.value)}
disabled={loading}
/>
<button
type="button"
className="w-full rounded-lg btn-accent px-3 py-2 text-sm font-semibold disabled:opacity-60"
onClick={handleCreate}
disabled={loading}
>
Create group
</button>
</div>
) : (
<div className="mt-4 space-y-2">
<input
type="text"
placeholder="Invite link or code"
className={`w-full input-base px-3 py-2 text-sm ${inviteCode.trim() ? "" : "border-red-400/70"}`}
value={inviteCode}
onChange={e => setInviteCode(e.target.value)}
disabled={loading}
/>
<button
type="button"
className="w-full rounded-lg btn-accent px-3 py-2 text-sm font-semibold disabled:opacity-60"
onClick={handleJoin}
disabled={loading}
>
Join group
</button>
</div>
)}
{localError || error ? (
<div className="mt-3 text-xs text-red-400">{localError || error}</div>
) : null}
</div>
</div>
) : null}
</div>
);
}