301 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|