| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240 |
- "use client";
- import { useState, useCallback } from "react";
- import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
- import { MemberList } from "./member-list";
- import { TransferOwnershipModal } from "./transfer-ownership-modal";
- import { GROUP_NAME_MAX_LENGTH } from "@/lib/constants";
- interface GroupData {
- group: {
- id: string;
- name: string;
- invite_code: string;
- created_by: string;
- created_at: string;
- };
- role: "admin" | "member";
- }
- export function SettingsPanel({ groupId }: { groupId: string }) {
- const queryClient = useQueryClient();
- const [newName, setNewName] = useState("");
- const [copied, setCopied] = useState(false);
- const [transferTarget, setTransferTarget] = useState<{
- userId: string;
- displayName: string;
- } | null>(null);
- const { data, isLoading, error } = useQuery<GroupData>({
- queryKey: ["groups", groupId],
- queryFn: async () => {
- const res = await fetch(`/api/groups/${groupId}`);
- if (!res.ok) throw new Error("Failed to fetch group");
- return res.json();
- },
- staleTime: 30_000,
- });
- const rename = useMutation({
- mutationFn: async (name: string) => {
- const res = await fetch(`/api/groups/${groupId}`, {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ name }),
- });
- if (!res.ok) {
- const body = await res.json();
- throw new Error(body.error || "Failed to rename");
- }
- return res.json();
- },
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["groups", groupId] });
- setNewName("");
- },
- });
- const regenerateCode = useMutation({
- mutationFn: async () => {
- const res = await fetch(`/api/groups/${groupId}/invite`, {
- method: "POST",
- });
- if (!res.ok) {
- const body = await res.json();
- throw new Error(body.error || "Failed to regenerate code");
- }
- return res.json();
- },
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["groups", groupId] });
- },
- });
- const deleteGroup = useMutation({
- mutationFn: async () => {
- const res = await fetch(`/api/groups/${groupId}`, { method: "DELETE" });
- if (!res.ok) {
- const body = await res.json();
- throw new Error(body.error || "Failed to delete group");
- }
- return res.json();
- },
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["groups"] });
- },
- });
- const leaveGroup = useMutation({
- mutationFn: async () => {
- const res = await fetch(`/api/groups/${groupId}/leave`, {
- method: "POST",
- });
- if (!res.ok) {
- const body = await res.json();
- throw new Error(body.error || "Failed to leave group");
- }
- return res.json();
- },
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["groups"] });
- },
- });
- const inviteCode = data?.group.invite_code;
- const handleCopyCode = useCallback(async () => {
- if (!inviteCode) return;
- try {
- await navigator.clipboard.writeText(inviteCode);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- } catch {
- // Fallback: select text for manual copy
- }
- }, [inviteCode]);
- const handleTransferRequest = useCallback((userId: string, displayName: string) => {
- setTransferTarget({ userId, displayName });
- }, []);
- if (isLoading) return <p className="text-sm text-gray-500">Loading...</p>;
- if (error) return <p className="text-sm text-red-500">Failed to load group</p>;
- if (!data) return null;
- const isAdmin = data.role === "admin";
- return (
- <div className="space-y-6">
- {/* Invite Code */}
- <div>
- <h3 className="text-sm font-medium mb-2">Invite Code</h3>
- <div className="flex items-center gap-2">
- <code className="rounded bg-gray-100 px-3 py-2 font-mono text-lg dark:bg-gray-800">
- {data.group.invite_code}
- </code>
- <button
- onClick={handleCopyCode}
- 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"
- aria-label="Copy invite code"
- >
- {copied ? "Copied!" : "Copy"}
- </button>
- {isAdmin && (
- <button
- onClick={() => regenerateCode.mutate()}
- disabled={regenerateCode.isPending}
- 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"
- >
- {regenerateCode.isPending ? "..." : "Regenerate"}
- </button>
- )}
- </div>
- {regenerateCode.error && (
- <p className="mt-1 text-sm text-red-500">{regenerateCode.error.message}</p>
- )}
- </div>
- {/* Rename (admin only) */}
- {isAdmin && (
- <div>
- <h3 className="text-sm font-medium mb-2">Rename Group</h3>
- <form
- onSubmit={(e) => {
- e.preventDefault();
- const trimmed = newName.trim();
- if (trimmed) rename.mutate(trimmed);
- }}
- className="flex gap-2"
- >
- <input
- type="text"
- value={newName}
- onChange={(e) => setNewName(e.target.value)}
- maxLength={GROUP_NAME_MAX_LENGTH}
- placeholder={data.group.name}
- 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"
- />
- <button
- type="submit"
- disabled={rename.isPending || !newName.trim()}
- className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
- >
- {rename.isPending ? "..." : "Rename"}
- </button>
- </form>
- {rename.error && <p className="mt-1 text-sm text-red-500">{rename.error.message}</p>}
- </div>
- )}
- {/* Members */}
- <MemberList groupId={groupId} onTransferRequest={handleTransferRequest} />
- {/* Leave / Delete */}
- <div className="border-t border-gray-200 pt-4 dark:border-gray-700">
- {isAdmin ? (
- <div className="space-y-2">
- <button
- onClick={() => {
- if (confirm("Are you sure you want to delete this group? This cannot be undone.")) {
- deleteGroup.mutate();
- }
- }}
- disabled={deleteGroup.isPending}
- 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"
- >
- {deleteGroup.isPending ? "Deleting..." : "Delete Group"}
- </button>
- {deleteGroup.error && (
- <p className="text-sm text-red-500">{deleteGroup.error.message}</p>
- )}
- </div>
- ) : (
- <div className="space-y-2">
- <button
- onClick={() => {
- if (confirm("Are you sure you want to leave this group?")) {
- leaveGroup.mutate();
- }
- }}
- disabled={leaveGroup.isPending}
- 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"
- >
- {leaveGroup.isPending ? "Leaving..." : "Leave Group"}
- </button>
- {leaveGroup.error && <p className="text-sm text-red-500">{leaveGroup.error.message}</p>}
- </div>
- )}
- </div>
- {/* Transfer modal */}
- {transferTarget && (
- <TransferOwnershipModal
- groupId={groupId}
- targetUserId={transferTarget.userId}
- targetDisplayName={transferTarget.displayName}
- onClose={() => setTransferTarget(null)}
- />
- )}
- </div>
- );
- }
|