commit pre-existing group settings content edits

This commit is contained in:
Nico 2026-02-14 00:52:11 -08:00
parent 828bb301d6
commit 1b7e4b94b5

View File

@ -2,16 +2,18 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import useTags from "@/hooks/use-tags"; import useTags from "@/features/tags/hooks/use-tags";
import useGroupSettings from "@/hooks/use-group-settings"; import useGroupSettings from "@/features/groups/hooks/use-group-settings";
import useGroupMembers from "@/hooks/use-group-members"; import useGroupMembers from "@/features/groups/hooks/use-group-members";
import useGroupInvites from "@/hooks/use-group-invites"; import useGroupInvites from "@/features/groups/hooks/use-group-invites";
import useGroupAudit from "@/hooks/use-group-audit"; import useGroupAudit from "@/features/groups/hooks/use-group-audit";
import { useGroupsContext } from "@/hooks/groups-context"; import { useGroupsContext } from "@/hooks/groups-context";
import TagInput from "@/components/tag-input"; import TagInput from "@/components/tag-input";
import { useNotificationsContext } from "@/hooks/notifications-context"; import { useNotificationsContext } from "@/hooks/notifications-context";
import ConfirmSlideModal from "@/components/confirm-slide-modal"; import ConfirmSlideModal from "@/components/confirm-slide-modal";
import ConfirmRetypeModal from "@/components/confirm-retype-modal";
import { groupsDelete, groupsRename } from "@/lib/client/groups"; import { groupsDelete, groupsRename } from "@/lib/client/groups";
import ToggleButtonGroup from "@/components/toggle-button-group";
export default function GroupSettingsContent({ groupId }: { groupId: number }) { export default function GroupSettingsContent({ groupId }: { groupId: number }) {
const router = useRouter(); const router = useRouter();
@ -267,6 +269,24 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
return { userId: top, count: topCount, name, searchValue }; return { userId: top, count: topCount, name, searchValue };
})(); })();
const mostActiveCount = mostActiveUser?.count ?? 0; const mostActiveCount = mostActiveUser?.count ?? 0;
const renameDirty = Boolean(group && renameValue.trim() !== group.name);
const memberCount = members.length;
const showLeaveGroup = role !== "GROUP_OWNER" && !isLastAdmin;
useEffect(() => {
if (!renameModalOpen && !tagModalOpen) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
if (tagModalOpen) setTagModalOpen(false);
if (renameModalOpen) handleCloseRenameModal();
}
if (event.key === "Enter" && !event.shiftKey && !event.defaultPrevented) {
if (renameModalOpen && renameDirty && isAdmin && renameValue.trim()) setConfirmRenameOpen(true);
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [renameModalOpen, tagModalOpen, renameDirty, isAdmin, renameValue, handleCloseRenameModal]);
async function handleDeleteGroup() { async function handleDeleteGroup() {
const result = await groupsDelete(); const result = await groupsDelete();
@ -288,30 +308,9 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
); );
} }
const renameDirty = renameValue.trim() !== group.name;
const visibleTags = showAllTags ? tags : tags.slice(0, 5); const visibleTags = showAllTags ? tags : tags.slice(0, 5);
const hasMoreTags = tags.length > 5; const hasMoreTags = tags.length > 5;
const tagsScrollable = showAllTags && tags.length > 15; const tagsScrollable = showAllTags && tags.length > 15;
const memberCount = members.length;
const showLeaveGroup = role !== "GROUP_OWNER" && !isLastAdmin;
useEffect(() => {
if (!renameModalOpen && !tagModalOpen && !confirmDeleteGroupOpen) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
if (confirmDeleteGroupOpen) setConfirmDeleteGroupOpen(false);
if (tagModalOpen) setTagModalOpen(false);
if (renameModalOpen) handleCloseRenameModal();
}
if (event.key === "Enter" && !event.shiftKey && !event.defaultPrevented) {
if (renameModalOpen && renameDirty && isAdmin && renameValue.trim()) setConfirmRenameOpen(true);
// if (tagModalOpen && pendingTags.length && canManageTags) handleSaveTags();
if (confirmDeleteGroupOpen && deleteConfirmText.trim().toUpperCase() === "DELETE") handleDeleteGroup();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [renameModalOpen, tagModalOpen, confirmDeleteGroupOpen, renameDirty, isAdmin, renameValue, pendingTags.length, canManageTags, deleteConfirmText, handleDeleteGroup, handleSaveTags, handleCloseRenameModal]);
return ( return (
<> <>
@ -425,23 +424,21 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-sm font-semibold">Join policy</div> <div className="text-sm font-semibold">Join policy</div>
<div className="flex flex-wrap gap-2"> <ToggleButtonGroup
{[ value={localJoinPolicy}
onChange={policy => handleJoinPolicyChange(policy)}
ariaLabel="Join policy"
className="flex flex-wrap gap-2"
buttonBaseClassName="rounded-lg border"
sizeClassName="px-3 py-1.5 text-xs font-semibold transition"
activeClassName="border-accent bg-accent-soft"
inactiveClassName="border-accent-weak bg-panel hover:border-accent"
options={[
{ value: "NOT_ACCEPTING", label: "Disabled" }, { value: "NOT_ACCEPTING", label: "Disabled" },
{ value: "AUTO_ACCEPT", label: "Auto" }, { value: "AUTO_ACCEPT", label: "Auto" },
{ value: "APPROVAL_REQUIRED", label: "Manual" } { value: "APPROVAL_REQUIRED", label: "Manual" }
].map(option => ( ]}
<button />
key={option.value}
type="button"
className={`rounded-lg border px-3 py-1.5 text-xs font-semibold transition ${localJoinPolicy === option.value ? "border-accent bg-accent-soft" : "border-accent-weak bg-panel hover:border-accent"}`}
onClick={() => handleJoinPolicyChange(option.value as "NOT_ACCEPTING" | "AUTO_ACCEPT" | "APPROVAL_REQUIRED")}
aria-pressed={localJoinPolicy === option.value}
>
{option.label}
</button>
))}
</div>
</div> </div>
</div> </div>
<div className="divider" /> <div className="divider" />
@ -518,42 +515,55 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
className={`rounded-lg border px-3 py-2 text-sm ${link.revokedAt || isInviteExpired(link.expiresAt) || (link.singleUse && link.usedAt) ? "border-red-400/60 bg-red-500/5" : "border-accent-weak bg-panel"}`} className={`rounded-lg border px-3 py-2 text-sm ${link.revokedAt || isInviteExpired(link.expiresAt) || (link.singleUse && link.usedAt) ? "border-red-400/60 bg-red-500/5" : "border-accent-weak bg-panel"}`}
> >
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs text-soft">Token: {link.token.slice(0, 6)}{link.token.slice(-4)}</div> <div className="text-xs text-soft">Token: {link.token.slice(0, 6)}...{link.token.slice(-4)}</div>
<div className="flex items-center gap-2"> {(() => {
<button const showRevive = link.revokedAt || isInviteExpired(link.expiresAt) || (link.singleUse && link.usedAt);
type="button" const showRevoke = !showRevive && !link.revokedAt && !(link.singleUse && link.usedAt) && !isInviteExpired(link.expiresAt);
className="rounded-lg btn-outline-accent px-2 py-1 text-xs" const options = [
onClick={() => handleCopyInvite(link.token)} {
disabled={localJoinPolicy === "NOT_ACCEPTING"} value: "COPY",
> label: "Copy link",
Copy link className: "btn-outline-accent",
</button> disabled: localJoinPolicy === "NOT_ACCEPTING",
{(link.revokedAt || isInviteExpired(link.expiresAt) || (link.singleUse && link.usedAt)) ? ( onClick: () => handleCopyInvite(link.token)
<button },
type="button" ...(showRevive
className="rounded-lg btn-outline-accent px-2 py-1 text-xs" ? [{
onClick={() => reviveInvite(link.id, inviteTtlDays)} value: "REVIVE",
disabled={localJoinPolicy === "NOT_ACCEPTING"} label: "Revive",
> className: "btn-outline-accent",
Revive disabled: localJoinPolicy === "NOT_ACCEPTING",
</button> onClick: () => reviveInvite(link.id, inviteTtlDays)
) : (!link.revokedAt && !(link.singleUse && link.usedAt) && !isInviteExpired(link.expiresAt)) ? ( }]
<button : showRevoke
type="button" ? [{
className="rounded-lg border border-red-400/60 bg-red-500/10 px-2 py-1 text-xs text-red-200" value: "REVOKE",
onClick={() => revokeInvite(link.id)} label: "Revoke",
> className: "border border-red-400/60 bg-red-500/10 text-red-200",
Revoke onClick: () => revokeInvite(link.id)
</button> }]
) : null} : []),
<button {
type="button" value: "DELETE",
className="rounded-lg border border-red-400/60 bg-red-500/10 px-2 py-1 text-xs text-red-200" label: "Delete",
onClick={() => setConfirmDeleteInvite({ id: link.id, token: link.token })} className: "border border-red-400/60 bg-red-500/10 text-red-200",
> onClick: () => setConfirmDeleteInvite({ id: link.id, token: link.token })
Delete }
</button> ];
</div>
return (
<ToggleButtonGroup
value={null}
ariaLabel="Invite actions"
className="flex items-center gap-2"
buttonBaseClassName="rounded-lg"
sizeClassName="px-2 py-1 text-xs"
activeClassName=""
inactiveClassName=""
options={options}
/>
);
})()}
</div> </div>
<div className="mt-2 flex flex-wrap gap-3 text-[11px] text-soft"> <div className="mt-2 flex flex-wrap gap-3 text-[11px] text-soft">
<span>Expires {formatInviteExpiry(link.expiresAt)}</span> <span>Expires {formatInviteExpiry(link.expiresAt)}</span>
@ -577,10 +587,10 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
{members.map(member => { {members.map(member => {
const isSelf = member.userId === currentUserId; const isSelf = member.userId === currentUserId;
const privilegeLabel = member.role === "GROUP_OWNER" const privilegeLabel = member.role === "GROUP_OWNER"
? "👑 Owner · Full control" ? "Owner - Full control"
: member.role === "GROUP_ADMIN" : member.role === "GROUP_ADMIN"
? "🛡️ Admin · Manage members" ? "Admin - Manage members"
: "👤 Member · Entries only"; : "Member - Entries only";
return ( return (
<div <div
key={member.userId} key={member.userId}
@ -806,7 +816,7 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
onClick={handleCloseRenameModal} onClick={handleCloseRenameModal}
aria-label="Close" aria-label="Close"
> >
x
</button> </button>
<div className="text-lg font-semibold">Change group name</div> <div className="text-lg font-semibold">Change group name</div>
<input <input
@ -867,9 +877,7 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-lg font-semibold">Edit tags</div> <div className="text-lg font-semibold">Edit tags</div>
<button type="button" className="rounded-lg btn-outline-accent px-2 py-1 text-sm" onClick={() => setTagModalOpen(false)} aria-label="Close"> <button type="button" className="rounded-lg btn-outline-accent px-2 py-1 text-sm" onClick={() => setTagModalOpen(false)} aria-label="Close">x</button>
</button>
</div> </div>
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
<TagInput <TagInput
@ -938,7 +946,7 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
<ConfirmSlideModal <ConfirmSlideModal
isOpen={Boolean(confirmDeleteInvite)} isOpen={Boolean(confirmDeleteInvite)}
title="Delete invite link" title="Delete invite link"
description={confirmDeleteInvite ? `Delete invite ${confirmDeleteInvite.token.slice(0, 6)}${confirmDeleteInvite.token.slice(-4)}?` : ""} description={confirmDeleteInvite ? `Delete invite ${confirmDeleteInvite.token.slice(0, 6)}...${confirmDeleteInvite.token.slice(-4)}?` : ""}
confirmLabel="Delete link" confirmLabel="Delete link"
onClose={() => setConfirmDeleteInvite(null)} onClose={() => setConfirmDeleteInvite(null)}
onConfirm={async () => { onConfirm={async () => {
@ -1000,46 +1008,17 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
if (ok) notify({ title: "Ownership transferred", message: target.name }); if (ok) notify({ title: "Ownership transferred", message: target.name });
}} }}
/> />
{confirmDeleteGroupOpen ? ( <ConfirmRetypeModal
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-4 !mt-0" onClick={() => setConfirmDeleteGroupOpen(false)}> isOpen={confirmDeleteGroupOpen}
<div title="Delete group"
className="w-full max-w-sm rounded-2xl border border-accent-weak bg-panel p-5" description="Type DELETE to confirm. This cannot be undone."
onClick={event => event.stopPropagation()} expectedText="DELETE"
onKeyDown={event => { value={deleteConfirmText}
if (event.key === "Escape") setConfirmDeleteGroupOpen(false); onChange={setDeleteConfirmText}
if (event.key === "Enter" && deleteConfirmText.trim().toUpperCase() === "DELETE") handleDeleteGroup(); confirmLabel="Delete"
}} onClose={() => setConfirmDeleteGroupOpen(false)}
role="dialog" onConfirm={handleDeleteGroup}
tabIndex={-1} />
>
<div className="text-lg font-semibold text-red-200">Delete group</div>
<p className="mt-2 text-sm text-muted">Type DELETE to confirm. This cannot be undone.</p>
<input
className={`mt-4 w-full input-base px-3 py-2 text-sm ${deleteConfirmText.trim().toUpperCase() === "DELETE" ? "" : "border-red-400/70"}`}
value={deleteConfirmText}
onChange={e => setDeleteConfirmText(e.target.value)}
placeholder="DELETE"
/>
<div className="mt-4 flex items-center gap-2">
<button
type="button"
className="flex-1 rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold"
onClick={() => setConfirmDeleteGroupOpen(false)}
>
Cancel
</button>
<button
type="button"
className="flex-1 rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-2 text-sm font-semibold text-red-200 disabled:opacity-40"
disabled={deleteConfirmText.trim().toUpperCase() !== "DELETE"}
onClick={handleDeleteGroup}
>
Delete
</button>
</div>
</div>
</div>
) : null}
</div> </div>
</> </>
); );