settings-panel.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. "use client";
  2. import { useState, useCallback, useEffect, useRef } from "react";
  3. import { useRouter } from "next/navigation";
  4. import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
  5. import { MemberList } from "./member-list";
  6. import { TransferOwnershipModal } from "./transfer-ownership-modal";
  7. import { GROUP_NAME_MAX_LENGTH } from "@/lib/constants";
  8. interface GroupData {
  9. group: {
  10. id: string;
  11. name: string;
  12. invite_code: string;
  13. created_by: string;
  14. created_at: string;
  15. };
  16. role: "admin" | "member";
  17. }
  18. interface Member {
  19. user_id: string;
  20. role: "admin" | "member";
  21. users: { display_name: string; avatar_color: string | null } | null;
  22. }
  23. interface MembersResponse {
  24. members: Member[];
  25. currentUserRole: "admin" | "member";
  26. }
  27. export function SettingsPanel({ groupId }: { groupId: string }) {
  28. const queryClient = useQueryClient();
  29. const router = useRouter();
  30. const [newName, setNewName] = useState("");
  31. const [copied, setCopied] = useState(false);
  32. const [transferTarget, setTransferTarget] = useState<{
  33. userId: string;
  34. displayName: string;
  35. } | null>(null);
  36. const [deleteState, setDeleteState] = useState<"idle" | "armed" | "choosing">("idle");
  37. const disarmTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
  38. const arm = useCallback(() => {
  39. setDeleteState("armed");
  40. if (disarmTimer.current) clearTimeout(disarmTimer.current);
  41. disarmTimer.current = setTimeout(() => setDeleteState("idle"), 4000);
  42. }, []);
  43. useEffect(() => {
  44. return () => {
  45. if (disarmTimer.current) clearTimeout(disarmTimer.current);
  46. };
  47. }, []);
  48. const { data, isLoading, error } = useQuery<GroupData>({
  49. queryKey: ["groups", groupId],
  50. queryFn: async () => {
  51. const res = await fetch(`/api/groups/${groupId}`);
  52. if (!res.ok) throw new Error("Failed to fetch group");
  53. return res.json();
  54. },
  55. staleTime: 30_000,
  56. });
  57. const rename = useMutation({
  58. mutationFn: async (name: string) => {
  59. const res = await fetch(`/api/groups/${groupId}`, {
  60. method: "PATCH",
  61. headers: { "Content-Type": "application/json" },
  62. body: JSON.stringify({ name }),
  63. });
  64. if (!res.ok) {
  65. const body = await res.json();
  66. throw new Error(body.error || "Failed to rename");
  67. }
  68. return res.json();
  69. },
  70. onSuccess: () => {
  71. queryClient.invalidateQueries({ queryKey: ["groups", groupId] });
  72. setNewName("");
  73. },
  74. });
  75. const regenerateCode = useMutation({
  76. mutationFn: async () => {
  77. const res = await fetch(`/api/groups/${groupId}/invite`, {
  78. method: "POST",
  79. });
  80. if (!res.ok) {
  81. const body = await res.json();
  82. throw new Error(body.error || "Failed to regenerate code");
  83. }
  84. return res.json();
  85. },
  86. onSuccess: () => {
  87. queryClient.invalidateQueries({ queryKey: ["groups", groupId] });
  88. },
  89. });
  90. const deleteGroup = useMutation({
  91. mutationFn: async () => {
  92. const res = await fetch(`/api/groups/${groupId}`, { method: "DELETE" });
  93. if (!res.ok) {
  94. const body = await res.json();
  95. throw new Error(body.error || "Failed to delete group");
  96. }
  97. return res.json();
  98. },
  99. onSuccess: () => {
  100. queryClient.invalidateQueries({ queryKey: ["groups"] });
  101. router.push("/");
  102. },
  103. });
  104. const leaveGroup = useMutation({
  105. mutationFn: async () => {
  106. const res = await fetch(`/api/groups/${groupId}/leave`, {
  107. method: "POST",
  108. });
  109. if (!res.ok) {
  110. const body = await res.json();
  111. throw new Error(body.error || "Failed to leave group");
  112. }
  113. return res.json();
  114. },
  115. onSuccess: () => {
  116. queryClient.invalidateQueries({ queryKey: ["groups"] });
  117. router.push("/");
  118. },
  119. });
  120. const transferAndLeave = useMutation({
  121. mutationFn: async (newAdminId: string) => {
  122. const tRes = await fetch(`/api/groups/${groupId}/transfer`, {
  123. method: "POST",
  124. headers: { "Content-Type": "application/json" },
  125. body: JSON.stringify({ new_admin_id: newAdminId }),
  126. });
  127. if (!tRes.ok) {
  128. const body = await tRes.json();
  129. throw new Error(body.error || "Failed to transfer ownership");
  130. }
  131. const lRes = await fetch(`/api/groups/${groupId}/leave`, { method: "POST" });
  132. if (!lRes.ok) {
  133. const body = await lRes.json();
  134. throw new Error(body.error || "Failed to leave group");
  135. }
  136. return lRes.json();
  137. },
  138. onSuccess: () => {
  139. queryClient.invalidateQueries({ queryKey: ["groups"] });
  140. router.push("/");
  141. },
  142. });
  143. const membersQuery = useQuery<MembersResponse>({
  144. queryKey: ["groups", groupId, "members"],
  145. queryFn: async () => {
  146. const res = await fetch(`/api/groups/${groupId}/members`);
  147. if (!res.ok) throw new Error("Failed to fetch members");
  148. return res.json();
  149. },
  150. staleTime: 30_000,
  151. });
  152. const memberCount = membersQuery.data?.members.length ?? 0;
  153. const successors = (membersQuery.data?.members ?? []).filter((m) => m.role !== "admin");
  154. const isSoloAdmin = memberCount <= 1;
  155. const inviteCode = data?.group.invite_code;
  156. const handleCopyCode = useCallback(async () => {
  157. if (!inviteCode) return;
  158. try {
  159. await navigator.clipboard.writeText(inviteCode);
  160. setCopied(true);
  161. setTimeout(() => setCopied(false), 2000);
  162. } catch {
  163. // Fallback: select text for manual copy
  164. }
  165. }, [inviteCode]);
  166. const handleTransferRequest = useCallback((userId: string, displayName: string) => {
  167. setTransferTarget({ userId, displayName });
  168. }, []);
  169. if (isLoading) return <p className="text-base text-gray-500">Loading...</p>;
  170. if (error) return <p className="text-base text-red-500">Failed to load group</p>;
  171. if (!data) return null;
  172. const isAdmin = data.role === "admin";
  173. return (
  174. <div className="space-y-6">
  175. {/* Rename (admin only) */}
  176. {isAdmin && (
  177. <div>
  178. <h3 className="text-base font-medium mb-2">Rename List</h3>
  179. <form
  180. onSubmit={(e) => {
  181. e.preventDefault();
  182. const trimmed = newName.trim();
  183. if (trimmed) rename.mutate(trimmed);
  184. }}
  185. className="flex flex-col gap-2 sm:flex-row"
  186. >
  187. <input
  188. type="text"
  189. value={newName}
  190. onChange={(e) => setNewName(e.target.value)}
  191. maxLength={GROUP_NAME_MAX_LENGTH}
  192. placeholder={data.group.name}
  193. className="flex-1 rounded-md border border-gray-300 bg-transparent px-3 py-2 text-base focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-700"
  194. />
  195. <button
  196. type="submit"
  197. disabled={rename.isPending || !newName.trim()}
  198. className="rounded-md bg-blue-600 px-4 py-2 text-base font-medium text-white hover:bg-blue-700 disabled:opacity-50"
  199. >
  200. {rename.isPending ? "Saving..." : "Save name"}
  201. </button>
  202. </form>
  203. {rename.error && <p className="mt-1 text-base text-red-500">{rename.error.message}</p>}
  204. <div className="mt-3 flex flex-wrap items-center gap-3 text-sm">
  205. <button
  206. type="button"
  207. onClick={handleCopyCode}
  208. className="text-foreground/70 underline-offset-4 hover:text-foreground hover:underline"
  209. aria-label="Copy invite code"
  210. >
  211. {copied ? "Code copied" : "Copy invite code"}
  212. </button>
  213. {isAdmin && (
  214. <button
  215. type="button"
  216. onClick={() => regenerateCode.mutate()}
  217. disabled={regenerateCode.isPending}
  218. className="text-foreground/70 underline-offset-4 hover:text-foreground hover:underline disabled:opacity-50"
  219. aria-label="Regenerate invite code"
  220. >
  221. {regenerateCode.isPending ? "Regenerating..." : "Regenerate invite code"}
  222. </button>
  223. )}
  224. </div>
  225. {regenerateCode.error && (
  226. <p className="mt-1 text-base text-red-500">{regenerateCode.error.message}</p>
  227. )}
  228. </div>
  229. )}
  230. {!isAdmin && (
  231. <div className="flex flex-wrap items-center gap-3 text-sm">
  232. <button
  233. type="button"
  234. onClick={handleCopyCode}
  235. className="text-foreground/70 underline-offset-4 hover:text-foreground hover:underline"
  236. aria-label="Copy invite code"
  237. >
  238. {copied ? "Code copied" : "Copy invite code"}
  239. </button>
  240. </div>
  241. )}
  242. {/* Members */}
  243. <MemberList groupId={groupId} onTransferRequest={handleTransferRequest} />
  244. {/* Leave / Delete */}
  245. <div className="border-t border-gray-200 pt-4 dark:border-gray-700">
  246. {isAdmin ? (
  247. <div className="space-y-2">
  248. {deleteState !== "choosing" && (
  249. <button
  250. onClick={() => {
  251. if (deleteGroup.isPending || transferAndLeave.isPending) return;
  252. if (deleteState === "idle") {
  253. arm();
  254. return;
  255. }
  256. // armed: second click
  257. if (isSoloAdmin) {
  258. deleteGroup.mutate();
  259. } else {
  260. setDeleteState("choosing");
  261. }
  262. }}
  263. disabled={deleteGroup.isPending}
  264. className={`w-full rounded-md px-4 py-2 text-base font-medium text-white disabled:opacity-50 ${
  265. deleteState === "armed"
  266. ? "animate-shake bg-red-700 hover:bg-red-800"
  267. : "bg-red-600 hover:bg-red-700"
  268. }`}
  269. aria-live="polite"
  270. >
  271. {deleteGroup.isPending
  272. ? "Deleting..."
  273. : deleteState === "armed"
  274. ? isSoloAdmin
  275. ? "Click again to permanently delete"
  276. : "Click again to choose successor"
  277. : "Delete List"}
  278. </button>
  279. )}
  280. {deleteState === "choosing" && (
  281. <div className="space-y-2 rounded-md border border-red-300 p-3 dark:border-red-800">
  282. <p className="text-base font-medium text-red-700 dark:text-red-300">
  283. Choose a new admin. You will leave the list once ownership transfers.
  284. </p>
  285. {membersQuery.isLoading ? (
  286. <p className="text-base text-gray-500">Loading members...</p>
  287. ) : successors.length === 0 ? (
  288. <p className="text-base text-gray-500">No other members available.</p>
  289. ) : (
  290. <ul className="space-y-1">
  291. {successors.map((m) => (
  292. <li key={m.user_id}>
  293. <button
  294. onClick={() => transferAndLeave.mutate(m.user_id)}
  295. disabled={transferAndLeave.isPending}
  296. className="flex w-full items-center gap-2 rounded-md border border-gray-200 px-3 py-2 text-base hover:bg-gray-50 disabled:opacity-50 dark:border-gray-700 dark:hover:bg-gray-800"
  297. >
  298. <span
  299. className="inline-block h-5 w-5 rounded-full"
  300. style={{ backgroundColor: m.users?.avatar_color ?? "#6b7280" }}
  301. aria-hidden="true"
  302. />
  303. <span>{m.users?.display_name ?? "Unknown"}</span>
  304. </button>
  305. </li>
  306. ))}
  307. </ul>
  308. )}
  309. <button
  310. onClick={() => setDeleteState("idle")}
  311. disabled={transferAndLeave.isPending}
  312. className="text-sm text-gray-600 hover:underline dark:text-gray-400"
  313. >
  314. Cancel
  315. </button>
  316. {transferAndLeave.error && (
  317. <p className="text-base text-red-500">{transferAndLeave.error.message}</p>
  318. )}
  319. </div>
  320. )}
  321. {deleteGroup.error && (
  322. <p className="text-base text-red-500">{deleteGroup.error.message}</p>
  323. )}
  324. </div>
  325. ) : (
  326. <div className="space-y-2">
  327. <button
  328. onClick={() => {
  329. if (leaveGroup.isPending) return;
  330. if (deleteState === "idle") {
  331. arm();
  332. return;
  333. }
  334. leaveGroup.mutate();
  335. }}
  336. disabled={leaveGroup.isPending}
  337. className={`w-full rounded-md border px-4 py-2 text-base font-medium disabled:opacity-50 ${
  338. deleteState === "armed"
  339. ? "animate-shake border-red-500 bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-300"
  340. : "border-red-300 text-red-600 hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20"
  341. }`}
  342. aria-live="polite"
  343. >
  344. {leaveGroup.isPending
  345. ? "Leaving..."
  346. : deleteState === "armed"
  347. ? "Click again to confirm"
  348. : "Leave List"}
  349. </button>
  350. {leaveGroup.error && (
  351. <p className="text-base text-red-500">{leaveGroup.error.message}</p>
  352. )}
  353. </div>
  354. )}
  355. </div>
  356. {/* Transfer modal */}
  357. {transferTarget && (
  358. <TransferOwnershipModal
  359. groupId={groupId}
  360. targetUserId={transferTarget.userId}
  361. targetDisplayName={transferTarget.displayName}
  362. onClose={() => setTransferTarget(null)}
  363. />
  364. )}
  365. </div>
  366. );
  367. }