settings-panel.tsx 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. "use client";
  2. import { useState, useCallback } from "react";
  3. import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
  4. import { MemberList } from "./member-list";
  5. import { TransferOwnershipModal } from "./transfer-ownership-modal";
  6. import { GROUP_NAME_MAX_LENGTH } from "@/lib/constants";
  7. interface GroupData {
  8. group: {
  9. id: string;
  10. name: string;
  11. invite_code: string;
  12. created_by: string;
  13. created_at: string;
  14. };
  15. role: "admin" | "member";
  16. }
  17. export function SettingsPanel({ groupId }: { groupId: string }) {
  18. const queryClient = useQueryClient();
  19. const [newName, setNewName] = useState("");
  20. const [copied, setCopied] = useState(false);
  21. const [transferTarget, setTransferTarget] = useState<{
  22. userId: string;
  23. displayName: string;
  24. } | null>(null);
  25. const { data, isLoading, error } = useQuery<GroupData>({
  26. queryKey: ["groups", groupId],
  27. queryFn: async () => {
  28. const res = await fetch(`/api/groups/${groupId}`);
  29. if (!res.ok) throw new Error("Failed to fetch group");
  30. return res.json();
  31. },
  32. staleTime: 30_000,
  33. });
  34. const rename = useMutation({
  35. mutationFn: async (name: string) => {
  36. const res = await fetch(`/api/groups/${groupId}`, {
  37. method: "PATCH",
  38. headers: { "Content-Type": "application/json" },
  39. body: JSON.stringify({ name }),
  40. });
  41. if (!res.ok) {
  42. const body = await res.json();
  43. throw new Error(body.error || "Failed to rename");
  44. }
  45. return res.json();
  46. },
  47. onSuccess: () => {
  48. queryClient.invalidateQueries({ queryKey: ["groups", groupId] });
  49. setNewName("");
  50. },
  51. });
  52. const regenerateCode = useMutation({
  53. mutationFn: async () => {
  54. const res = await fetch(`/api/groups/${groupId}/invite`, {
  55. method: "POST",
  56. });
  57. if (!res.ok) {
  58. const body = await res.json();
  59. throw new Error(body.error || "Failed to regenerate code");
  60. }
  61. return res.json();
  62. },
  63. onSuccess: () => {
  64. queryClient.invalidateQueries({ queryKey: ["groups", groupId] });
  65. },
  66. });
  67. const deleteGroup = useMutation({
  68. mutationFn: async () => {
  69. const res = await fetch(`/api/groups/${groupId}`, { method: "DELETE" });
  70. if (!res.ok) {
  71. const body = await res.json();
  72. throw new Error(body.error || "Failed to delete group");
  73. }
  74. return res.json();
  75. },
  76. onSuccess: () => {
  77. queryClient.invalidateQueries({ queryKey: ["groups"] });
  78. },
  79. });
  80. const leaveGroup = useMutation({
  81. mutationFn: async () => {
  82. const res = await fetch(`/api/groups/${groupId}/leave`, {
  83. method: "POST",
  84. });
  85. if (!res.ok) {
  86. const body = await res.json();
  87. throw new Error(body.error || "Failed to leave group");
  88. }
  89. return res.json();
  90. },
  91. onSuccess: () => {
  92. queryClient.invalidateQueries({ queryKey: ["groups"] });
  93. },
  94. });
  95. const inviteCode = data?.group.invite_code;
  96. const handleCopyCode = useCallback(async () => {
  97. if (!inviteCode) return;
  98. try {
  99. await navigator.clipboard.writeText(inviteCode);
  100. setCopied(true);
  101. setTimeout(() => setCopied(false), 2000);
  102. } catch {
  103. // Fallback: select text for manual copy
  104. }
  105. }, [inviteCode]);
  106. const handleTransferRequest = useCallback((userId: string, displayName: string) => {
  107. setTransferTarget({ userId, displayName });
  108. }, []);
  109. if (isLoading) return <p className="text-sm text-gray-500">Loading...</p>;
  110. if (error) return <p className="text-sm text-red-500">Failed to load group</p>;
  111. if (!data) return null;
  112. const isAdmin = data.role === "admin";
  113. return (
  114. <div className="space-y-6">
  115. {/* Invite Code */}
  116. <div>
  117. <h3 className="text-sm font-medium mb-2">Invite Code</h3>
  118. <div className="flex items-center gap-2">
  119. <code className="rounded bg-gray-100 px-3 py-2 font-mono text-lg dark:bg-gray-800">
  120. {data.group.invite_code}
  121. </code>
  122. <button
  123. onClick={handleCopyCode}
  124. className="rounded-md border border-gray-300 px-3 py-2 text-sm hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
  125. aria-label="Copy invite code"
  126. >
  127. {copied ? "Copied!" : "Copy"}
  128. </button>
  129. {isAdmin && (
  130. <button
  131. onClick={() => regenerateCode.mutate()}
  132. disabled={regenerateCode.isPending}
  133. className="rounded-md border border-gray-300 px-3 py-2 text-sm hover:bg-gray-50 disabled:opacity-50 dark:border-gray-700 dark:hover:bg-gray-800"
  134. >
  135. {regenerateCode.isPending ? "..." : "Regenerate"}
  136. </button>
  137. )}
  138. </div>
  139. {regenerateCode.error && (
  140. <p className="mt-1 text-sm text-red-500">{regenerateCode.error.message}</p>
  141. )}
  142. </div>
  143. {/* Rename (admin only) */}
  144. {isAdmin && (
  145. <div>
  146. <h3 className="text-sm font-medium mb-2">Rename Group</h3>
  147. <form
  148. onSubmit={(e) => {
  149. e.preventDefault();
  150. const trimmed = newName.trim();
  151. if (trimmed) rename.mutate(trimmed);
  152. }}
  153. className="flex gap-2"
  154. >
  155. <input
  156. type="text"
  157. value={newName}
  158. onChange={(e) => setNewName(e.target.value)}
  159. maxLength={GROUP_NAME_MAX_LENGTH}
  160. placeholder={data.group.name}
  161. className="flex-1 rounded-md border border-gray-300 bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-700"
  162. />
  163. <button
  164. type="submit"
  165. disabled={rename.isPending || !newName.trim()}
  166. className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
  167. >
  168. {rename.isPending ? "..." : "Rename"}
  169. </button>
  170. </form>
  171. {rename.error && <p className="mt-1 text-sm text-red-500">{rename.error.message}</p>}
  172. </div>
  173. )}
  174. {/* Members */}
  175. <MemberList groupId={groupId} onTransferRequest={handleTransferRequest} />
  176. {/* Leave / Delete */}
  177. <div className="border-t border-gray-200 pt-4 dark:border-gray-700">
  178. {isAdmin ? (
  179. <div className="space-y-2">
  180. <button
  181. onClick={() => {
  182. if (confirm("Are you sure you want to delete this group? This cannot be undone.")) {
  183. deleteGroup.mutate();
  184. }
  185. }}
  186. disabled={deleteGroup.isPending}
  187. className="w-full rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
  188. >
  189. {deleteGroup.isPending ? "Deleting..." : "Delete Group"}
  190. </button>
  191. {deleteGroup.error && (
  192. <p className="text-sm text-red-500">{deleteGroup.error.message}</p>
  193. )}
  194. </div>
  195. ) : (
  196. <div className="space-y-2">
  197. <button
  198. onClick={() => {
  199. if (confirm("Are you sure you want to leave this group?")) {
  200. leaveGroup.mutate();
  201. }
  202. }}
  203. disabled={leaveGroup.isPending}
  204. className="w-full rounded-md border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 disabled:opacity-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20"
  205. >
  206. {leaveGroup.isPending ? "Leaving..." : "Leave Group"}
  207. </button>
  208. {leaveGroup.error && <p className="text-sm text-red-500">{leaveGroup.error.message}</p>}
  209. </div>
  210. )}
  211. </div>
  212. {/* Transfer modal */}
  213. {transferTarget && (
  214. <TransferOwnershipModal
  215. groupId={groupId}
  216. targetUserId={transferTarget.userId}
  217. targetDisplayName={transferTarget.displayName}
  218. onClose={() => setTransferTarget(null)}
  219. />
  220. )}
  221. </div>
  222. );
  223. }