commit pre-existing group settings content edits
This commit is contained in:
parent
828bb301d6
commit
1b7e4b94b5
@ -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 => {
|
|
||||||
if (event.key === "Escape") setConfirmDeleteGroupOpen(false);
|
|
||||||
if (event.key === "Enter" && deleteConfirmText.trim().toUpperCase() === "DELETE") handleDeleteGroup();
|
|
||||||
}}
|
|
||||||
role="dialog"
|
|
||||||
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}
|
value={deleteConfirmText}
|
||||||
onChange={e => setDeleteConfirmText(e.target.value)}
|
onChange={setDeleteConfirmText}
|
||||||
placeholder="DELETE"
|
confirmLabel="Delete"
|
||||||
|
onClose={() => setConfirmDeleteGroupOpen(false)}
|
||||||
|
onConfirm={handleDeleteGroup}
|
||||||
/>
|
/>
|
||||||
<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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user