| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391 |
- "use client";
- import { useState, useCallback, useEffect, useRef } from "react";
- import { useRouter } from "next/navigation";
- 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";
- }
- interface Member {
- user_id: string;
- role: "admin" | "member";
- users: { display_name: string; avatar_color: string | null } | null;
- }
- interface MembersResponse {
- members: Member[];
- currentUserRole: "admin" | "member";
- }
- export function SettingsPanel({ groupId }: { groupId: string }) {
- const queryClient = useQueryClient();
- const router = useRouter();
- const [newName, setNewName] = useState("");
- const [copied, setCopied] = useState(false);
- const [transferTarget, setTransferTarget] = useState<{
- userId: string;
- displayName: string;
- } | null>(null);
- const [deleteState, setDeleteState] = useState<"idle" | "armed" | "choosing">("idle");
- const disarmTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
- const arm = useCallback(() => {
- setDeleteState("armed");
- if (disarmTimer.current) clearTimeout(disarmTimer.current);
- disarmTimer.current = setTimeout(() => setDeleteState("idle"), 4000);
- }, []);
- useEffect(() => {
- return () => {
- if (disarmTimer.current) clearTimeout(disarmTimer.current);
- };
- }, []);
- 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"] });
- router.push("/");
- },
- });
- 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"] });
- router.push("/");
- },
- });
- const transferAndLeave = useMutation({
- mutationFn: async (newAdminId: string) => {
- const tRes = await fetch(`/api/groups/${groupId}/transfer`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ new_admin_id: newAdminId }),
- });
- if (!tRes.ok) {
- const body = await tRes.json();
- throw new Error(body.error || "Failed to transfer ownership");
- }
- const lRes = await fetch(`/api/groups/${groupId}/leave`, { method: "POST" });
- if (!lRes.ok) {
- const body = await lRes.json();
- throw new Error(body.error || "Failed to leave group");
- }
- return lRes.json();
- },
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["groups"] });
- router.push("/");
- },
- });
- const membersQuery = useQuery<MembersResponse>({
- queryKey: ["groups", groupId, "members"],
- queryFn: async () => {
- const res = await fetch(`/api/groups/${groupId}/members`);
- if (!res.ok) throw new Error("Failed to fetch members");
- return res.json();
- },
- staleTime: 30_000,
- });
- const memberCount = membersQuery.data?.members.length ?? 0;
- const successors = (membersQuery.data?.members ?? []).filter((m) => m.role !== "admin");
- const isSoloAdmin = memberCount <= 1;
- 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-base text-gray-500">Loading...</p>;
- if (error) return <p className="text-base text-red-500">Failed to load group</p>;
- if (!data) return null;
- const isAdmin = data.role === "admin";
- return (
- <div className="space-y-6">
- {/* Rename (admin only) */}
- {isAdmin && (
- <div>
- <h3 className="text-base font-medium mb-2">Rename List</h3>
- <form
- onSubmit={(e) => {
- e.preventDefault();
- const trimmed = newName.trim();
- if (trimmed) rename.mutate(trimmed);
- }}
- className="flex flex-col gap-2 sm:flex-row"
- >
- <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-base 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-base font-medium text-white hover:bg-blue-700 disabled:opacity-50"
- >
- {rename.isPending ? "Saving..." : "Save name"}
- </button>
- </form>
- {rename.error && <p className="mt-1 text-base text-red-500">{rename.error.message}</p>}
- <div className="mt-3 flex flex-wrap items-center gap-3 text-sm">
- <button
- type="button"
- onClick={handleCopyCode}
- className="text-foreground/70 underline-offset-4 hover:text-foreground hover:underline"
- aria-label="Copy invite code"
- >
- {copied ? "Code copied" : "Copy invite code"}
- </button>
- {isAdmin && (
- <button
- type="button"
- onClick={() => regenerateCode.mutate()}
- disabled={regenerateCode.isPending}
- className="text-foreground/70 underline-offset-4 hover:text-foreground hover:underline disabled:opacity-50"
- aria-label="Regenerate invite code"
- >
- {regenerateCode.isPending ? "Regenerating..." : "Regenerate invite code"}
- </button>
- )}
- </div>
- {regenerateCode.error && (
- <p className="mt-1 text-base text-red-500">{regenerateCode.error.message}</p>
- )}
- </div>
- )}
- {!isAdmin && (
- <div className="flex flex-wrap items-center gap-3 text-sm">
- <button
- type="button"
- onClick={handleCopyCode}
- className="text-foreground/70 underline-offset-4 hover:text-foreground hover:underline"
- aria-label="Copy invite code"
- >
- {copied ? "Code copied" : "Copy invite code"}
- </button>
- </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">
- {deleteState !== "choosing" && (
- <button
- onClick={() => {
- if (deleteGroup.isPending || transferAndLeave.isPending) return;
- if (deleteState === "idle") {
- arm();
- return;
- }
- // armed: second click
- if (isSoloAdmin) {
- deleteGroup.mutate();
- } else {
- setDeleteState("choosing");
- }
- }}
- disabled={deleteGroup.isPending}
- className={`w-full rounded-md px-4 py-2 text-base font-medium text-white disabled:opacity-50 ${
- deleteState === "armed"
- ? "animate-shake bg-red-700 hover:bg-red-800"
- : "bg-red-600 hover:bg-red-700"
- }`}
- aria-live="polite"
- >
- {deleteGroup.isPending
- ? "Deleting..."
- : deleteState === "armed"
- ? isSoloAdmin
- ? "Click again to permanently delete"
- : "Click again to choose successor"
- : "Delete List"}
- </button>
- )}
- {deleteState === "choosing" && (
- <div className="space-y-2 rounded-md border border-red-300 p-3 dark:border-red-800">
- <p className="text-base font-medium text-red-700 dark:text-red-300">
- Choose a new admin. You will leave the list once ownership transfers.
- </p>
- {membersQuery.isLoading ? (
- <p className="text-base text-gray-500">Loading members...</p>
- ) : successors.length === 0 ? (
- <p className="text-base text-gray-500">No other members available.</p>
- ) : (
- <ul className="space-y-1">
- {successors.map((m) => (
- <li key={m.user_id}>
- <button
- onClick={() => transferAndLeave.mutate(m.user_id)}
- disabled={transferAndLeave.isPending}
- 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"
- >
- <span
- className="inline-block h-5 w-5 rounded-full"
- style={{ backgroundColor: m.users?.avatar_color ?? "#6b7280" }}
- aria-hidden="true"
- />
- <span>{m.users?.display_name ?? "Unknown"}</span>
- </button>
- </li>
- ))}
- </ul>
- )}
- <button
- onClick={() => setDeleteState("idle")}
- disabled={transferAndLeave.isPending}
- className="text-sm text-gray-600 hover:underline dark:text-gray-400"
- >
- Cancel
- </button>
- {transferAndLeave.error && (
- <p className="text-base text-red-500">{transferAndLeave.error.message}</p>
- )}
- </div>
- )}
- {deleteGroup.error && (
- <p className="text-base text-red-500">{deleteGroup.error.message}</p>
- )}
- </div>
- ) : (
- <div className="space-y-2">
- <button
- onClick={() => {
- if (leaveGroup.isPending) return;
- if (deleteState === "idle") {
- arm();
- return;
- }
- leaveGroup.mutate();
- }}
- disabled={leaveGroup.isPending}
- className={`w-full rounded-md border px-4 py-2 text-base font-medium disabled:opacity-50 ${
- deleteState === "armed"
- ? "animate-shake border-red-500 bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-300"
- : "border-red-300 text-red-600 hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20"
- }`}
- aria-live="polite"
- >
- {leaveGroup.isPending
- ? "Leaving..."
- : deleteState === "armed"
- ? "Click again to confirm"
- : "Leave List"}
- </button>
- {leaveGroup.error && (
- <p className="text-base 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>
- );
- }
|