1047 lines
61 KiB
TypeScript
1047 lines
61 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import useTags from "@/hooks/use-tags";
|
|
import useGroupSettings from "@/hooks/use-group-settings";
|
|
import useGroupMembers from "@/hooks/use-group-members";
|
|
import useGroupInvites from "@/hooks/use-group-invites";
|
|
import useGroupAudit from "@/hooks/use-group-audit";
|
|
import { useGroupsContext } from "@/hooks/groups-context";
|
|
import TagInput from "@/components/tag-input";
|
|
import { useNotificationsContext } from "@/hooks/notifications-context";
|
|
import ConfirmSlideModal from "@/components/confirm-slide-modal";
|
|
import { groupsDelete, groupsRename } from "@/lib/client/groups";
|
|
|
|
export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
|
const router = useRouter();
|
|
const { groups, activeGroupId, setActiveGroup } = useGroupsContext();
|
|
const { notify } = useNotificationsContext();
|
|
const group = useMemo(() => groups.find(item => item.id === groupId) || null, [groups, groupId]);
|
|
const role = group?.role;
|
|
const isAdmin = role === "GROUP_ADMIN" || role === "GROUP_OWNER";
|
|
const isOwner = role === "GROUP_OWNER";
|
|
const canViewAudit = isAdmin;
|
|
const hasSyncedActiveRef = useRef(false);
|
|
const lastGroupIdRef = useRef(groupId);
|
|
|
|
useEffect(() => {
|
|
if (lastGroupIdRef.current !== groupId) {
|
|
lastGroupIdRef.current = groupId;
|
|
hasSyncedActiveRef.current = false;
|
|
}
|
|
if (!groupId || hasSyncedActiveRef.current) return;
|
|
if (activeGroupId !== groupId) setActiveGroup(groupId);
|
|
hasSyncedActiveRef.current = true;
|
|
}, [activeGroupId, groupId, setActiveGroup]);
|
|
|
|
const { tags, addTags, removeTag } = useTags(activeGroupId === groupId ? groupId : null);
|
|
const { settings, updateSettings } = useGroupSettings(activeGroupId === groupId ? groupId : null);
|
|
const { members, requests, currentUserId, approve, deny, promote, demote, kick, transferOwner, leave } = useGroupMembers(activeGroupId === groupId ? groupId : null);
|
|
const { links, create: createInvite, revoke: revokeInvite, revive: reviveInvite, remove: deleteInvite } = useGroupInvites(activeGroupId === groupId ? groupId : null);
|
|
const { events } = useGroupAudit(activeGroupId === groupId && canViewAudit ? groupId : null);
|
|
const [pendingTags, setPendingTags] = useState<string[]>([]);
|
|
const [toggleRemoveTags, setToggleRemoveTags] = useState<string[]>([]);
|
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
|
const [renameValue, setRenameValue] = useState(group?.name || "");
|
|
const [renameModalOpen, setRenameModalOpen] = useState(false);
|
|
const [confirmRenameOpen, setConfirmRenameOpen] = useState(false);
|
|
const [confirmLeaveOpen, setConfirmLeaveOpen] = useState(false);
|
|
const [confirmKick, setConfirmKick] = useState<{ userId: number; name: string } | null>(null);
|
|
const [confirmTransfer, setConfirmTransfer] = useState<{ userId: number; name: string } | null>(null);
|
|
const [confirmDeleteGroupOpen, setConfirmDeleteGroupOpen] = useState(false);
|
|
const [deleteConfirmText, setDeleteConfirmText] = useState("");
|
|
const [tagModalOpen, setTagModalOpen] = useState(false);
|
|
const [showAllTags, setShowAllTags] = useState(false);
|
|
const [inviteTtlDays, setInviteTtlDays] = useState(7);
|
|
const [inviteMaxUses, setInviteMaxUses] = useState<"UNLIMITED" | "SINGLE">("UNLIMITED");
|
|
const [auditOpen, setAuditOpen] = useState(false);
|
|
const [confirmDeleteInvite, setConfirmDeleteInvite] = useState<{ id: number; token: string } | null>(null);
|
|
const [auditFilters, setAuditFilters] = useState(["members", "tags", "settings", "entries"] as Array<"members" | "tags" | "settings" | "entries">);
|
|
const [auditQuery, setAuditQuery] = useState("");
|
|
const [auditFrom, setAuditFrom] = useState("");
|
|
const [auditTo, setAuditTo] = useState("");
|
|
const [auditLimit, setAuditLimit] = useState(10);
|
|
const [localAllowMemberTagManage, setLocalAllowMemberTagManage] = useState(settings.allowMemberTagManage);
|
|
const [localJoinPolicy, setLocalJoinPolicy] = useState(settings.joinPolicy);
|
|
|
|
const canManageTags = role === "GROUP_ADMIN" || role === "GROUP_OWNER" || localAllowMemberTagManage;
|
|
const adminCount = members.filter(member => member.role === "GROUP_ADMIN" || member.role === "GROUP_OWNER").length;
|
|
const isLastAdmin = isAdmin && adminCount <= 1;
|
|
|
|
useEffect(() => {
|
|
setRenameValue(group?.name || "");
|
|
}, [group?.name]);
|
|
|
|
useEffect(() => {
|
|
setLocalAllowMemberTagManage(settings.allowMemberTagManage);
|
|
setLocalJoinPolicy(settings.joinPolicy);
|
|
}, [settings.allowMemberTagManage, settings.joinPolicy]);
|
|
|
|
async function handleSaveTags() {
|
|
if (!pendingTags.length) return;
|
|
const ok = await addTags(pendingTags);
|
|
if (ok) {
|
|
notify({ title: "Tags updated", message: pendingTags.join(", "), tone: "success" });
|
|
setPendingTags([]);
|
|
}
|
|
}
|
|
|
|
async function handleRemoveTag(tag: string) {
|
|
const ok = await removeTag(tag);
|
|
if (ok) notify({ title: "Tag removed", message: `#${tag}`, tone: "danger" });
|
|
}
|
|
|
|
async function handleConfirmDelete() {
|
|
if (!toggleRemoveTags.length) return;
|
|
await Promise.all(toggleRemoveTags.map(tag => removeTag(tag)));
|
|
notify({ title: "Tags removed", message: toggleRemoveTags.join(", "), tone: "danger" });
|
|
setToggleRemoveTags([]);
|
|
}
|
|
|
|
async function handleToggleAllowMembers(value: boolean) {
|
|
setLocalAllowMemberTagManage(value);
|
|
const ok = await updateSettings(value, localJoinPolicy);
|
|
if (ok) {
|
|
notify({ title: "Tag permissions updated" });
|
|
return;
|
|
}
|
|
setLocalAllowMemberTagManage(settings.allowMemberTagManage);
|
|
notify({ title: "Update failed", message: "Could not save tag permissions.", tone: "danger" });
|
|
}
|
|
|
|
async function handleJoinPolicyChange(nextPolicy: "NOT_ACCEPTING" | "AUTO_ACCEPT" | "APPROVAL_REQUIRED") {
|
|
setLocalJoinPolicy(nextPolicy);
|
|
const ok = await updateSettings(localAllowMemberTagManage, nextPolicy);
|
|
if (ok) {
|
|
notify({ title: "Join policy updated" });
|
|
return;
|
|
}
|
|
setLocalJoinPolicy(settings.joinPolicy);
|
|
notify({ title: "Update failed", message: "Could not save join policy.", tone: "danger" });
|
|
}
|
|
|
|
function handleOpenRenameModal() {
|
|
setRenameValue(group?.name || "");
|
|
setRenameModalOpen(true);
|
|
}
|
|
|
|
function handleCloseRenameModal() {
|
|
setRenameModalOpen(false);
|
|
setRenameValue(group?.name || "");
|
|
}
|
|
|
|
async function handleRenameGroup() {
|
|
const name = renameValue.trim();
|
|
if (!name) return;
|
|
const result = await groupsRename({ name });
|
|
if ("error" in result) {
|
|
notify({ title: "Rename failed", message: result.error.message, tone: "danger" });
|
|
return;
|
|
}
|
|
notify({ title: "Group renamed", message: name });
|
|
setConfirmRenameOpen(false);
|
|
setRenameModalOpen(false);
|
|
}
|
|
|
|
async function handleRoleChange(targetUserId: number, nextRole: "MEMBER" | "GROUP_ADMIN" | "GROUP_OWNER") {
|
|
if (!isAdmin) return;
|
|
const member = members.find(item => item.userId === targetUserId);
|
|
if (!member) return;
|
|
if (member.role === nextRole) return;
|
|
if (nextRole === "GROUP_OWNER") {
|
|
if (!isOwner) return;
|
|
await transferOwner(targetUserId);
|
|
return;
|
|
}
|
|
if (nextRole === "GROUP_ADMIN") {
|
|
await promote(targetUserId);
|
|
return;
|
|
}
|
|
await demote(targetUserId);
|
|
}
|
|
|
|
function getInviteLinkUrl(token: string) {
|
|
if (typeof window === "undefined") return token;
|
|
return `${window.location.origin}/invite/${token}`;
|
|
}
|
|
|
|
async function handleCopyInvite(token: string) {
|
|
try {
|
|
await navigator.clipboard.writeText(getInviteLinkUrl(token));
|
|
notify({ title: "Invite link copied", message: "Link copied to clipboard" });
|
|
} catch {
|
|
notify({ title: "Copy failed", tone: "danger" });
|
|
}
|
|
}
|
|
|
|
function formatInviteExpiry(expiresAt: string) {
|
|
const expiry = new Date(expiresAt).getTime();
|
|
if (Number.isNaN(expiry)) return "Unknown";
|
|
const diffMs = expiry - Date.now();
|
|
const absMs = Math.abs(diffMs);
|
|
const minute = 60 * 1000;
|
|
const hour = 60 * minute;
|
|
const day = 24 * hour;
|
|
const pick = (value: number, unit: string) => `${value} ${unit}${value === 1 ? "" : "s"}`;
|
|
let label = "";
|
|
|
|
if (absMs < hour) {
|
|
const value = Math.max(1, Math.round(absMs / minute));
|
|
label = pick(value, "minute");
|
|
} else if (absMs < day) {
|
|
const value = Math.max(1, Math.round(absMs / hour));
|
|
label = pick(value, "hour");
|
|
} else {
|
|
const value = Math.max(1, Math.round(absMs / day));
|
|
label = pick(value, "day");
|
|
}
|
|
|
|
return diffMs >= 0 ? `in ${label}` : `${label} ago`;
|
|
}
|
|
|
|
function isInviteExpired(expiresAt: string) {
|
|
const expiry = new Date(expiresAt).getTime();
|
|
if (Number.isNaN(expiry)) return false;
|
|
return Date.now() > expiry;
|
|
}
|
|
|
|
function eventCategory(eventType: string) {
|
|
const upper = eventType.toUpperCase();
|
|
if (upper.includes("SPENDING") || upper.includes("ENTRY")) return "entries" as const;
|
|
if (upper.includes("TAG")) return "tags" as const;
|
|
if (upper.includes("SETTING") || upper.includes("RENAMED")) return "settings" as const;
|
|
return "members" as const;
|
|
}
|
|
|
|
const memberLookup = useMemo(() => {
|
|
return new Map(members.map(member => [member.userId, { name: member.displayName || member.email, email: member.email }]));
|
|
}, [members]);
|
|
|
|
function getMemberLabel(userId?: number | null) {
|
|
if (!userId) return "System";
|
|
const member = memberLookup.get(userId);
|
|
return member?.name || `User ${userId}`;
|
|
}
|
|
|
|
const filteredEvents = events.filter(event => {
|
|
const category = eventCategory(event.eventType);
|
|
if (!auditFilters.includes(category)) return false;
|
|
if (auditQuery) {
|
|
const query = auditQuery.toLowerCase();
|
|
const actor = event.actorUserId ? memberLookup.get(event.actorUserId) : null;
|
|
const haystack = `${event.eventType} ${event.actorUserId ?? ""} ${actor?.name || ""} ${actor?.email || ""} ${event.requestId}`.toLowerCase();
|
|
if (!haystack.includes(query)) return false;
|
|
}
|
|
if (auditFrom) {
|
|
const from = new Date(auditFrom).getTime();
|
|
if (!Number.isNaN(from) && new Date(event.createdAt).getTime() < from) return false;
|
|
}
|
|
if (auditTo) {
|
|
const to = new Date(auditTo).getTime();
|
|
if (!Number.isNaN(to) && new Date(event.createdAt).getTime() > to + 24 * 60 * 60 * 1000 - 1) return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const todayKey = new Date().toISOString().slice(0, 10);
|
|
const logsToday = events.filter(event => event.createdAt?.slice(0, 10) === todayKey).length;
|
|
const mostActiveUser = (() => {
|
|
const counts = new Map<number, number>();
|
|
for (const event of events) {
|
|
if (!event.actorUserId) continue;
|
|
counts.set(event.actorUserId, (counts.get(event.actorUserId) ?? 0) + 1);
|
|
}
|
|
let top: number | null = null;
|
|
let topCount = 0;
|
|
counts.forEach((count, userId) => {
|
|
if (count > topCount) {
|
|
top = userId;
|
|
topCount = count;
|
|
}
|
|
});
|
|
if (!top) return null;
|
|
const member = memberLookup.get(top);
|
|
const name = member?.name || `User ${top}`;
|
|
const searchValue = member?.name || member?.email || String(top);
|
|
return { userId: top, count: topCount, name, searchValue };
|
|
})();
|
|
const mostActiveCount = mostActiveUser?.count ?? 0;
|
|
|
|
async function handleDeleteGroup() {
|
|
const result = await groupsDelete();
|
|
if ("error" in result) {
|
|
notify({ title: "Delete failed", message: result.error.message, tone: "danger" });
|
|
return;
|
|
}
|
|
setConfirmDeleteGroupOpen(false);
|
|
setDeleteConfirmText("");
|
|
notify({ title: "Group deleted", tone: "danger" });
|
|
router.push("/");
|
|
}
|
|
|
|
if (!group) {
|
|
return (
|
|
<div className="panel panel-accent p-4">
|
|
<div className="text-sm text-muted">Loading group settings...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const renameDirty = renameValue.trim() !== group.name;
|
|
const visibleTags = showAllTags ? tags : tags.slice(0, 5);
|
|
const hasMoreTags = tags.length > 5;
|
|
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 (
|
|
<>
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold">{group.name} settings</h1>
|
|
<p className="text-sm text-muted">Manage tags and permissions for this group.</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="rounded-lg btn-outline-accent px-3 py-1.5 text-sm"
|
|
onClick={() => router.push("/")}
|
|
>
|
|
Back
|
|
</button>
|
|
</div>
|
|
<div className="panel panel-accent p-4 space-y-4">
|
|
<div className="card-header">
|
|
<div className="card-title">General Settings</div>
|
|
</div>
|
|
<div className="space-y-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<div className="text-xs text-soft">Group name</div>
|
|
<div className="text-base font-semibold">{group.name}</div>
|
|
</div>
|
|
{isAdmin ? (
|
|
<button
|
|
type="button"
|
|
className="rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold"
|
|
onClick={handleOpenRenameModal}
|
|
>
|
|
Change
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
{!isAdmin ? (
|
|
<div className="text-xs text-soft">Only admins can rename the group.</div>
|
|
) : null}
|
|
<div className="divider" />
|
|
{isAdmin ? (
|
|
<>
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="text-sm font-semibold">Allow members to manage tags</div>
|
|
<button
|
|
type="button"
|
|
aria-pressed={localAllowMemberTagManage}
|
|
className={`relative h-7 w-12 rounded-full border transition ${localAllowMemberTagManage ? "border-emerald-400 bg-emerald-500/20" : "border-red-400/60 bg-red-500/10"}`}
|
|
onClick={() => handleToggleAllowMembers(!localAllowMemberTagManage)}
|
|
>
|
|
<span
|
|
className={`absolute left-0.5 top-1/2 h-5 w-5 -translate-y-1/2 rounded-full transition ${localAllowMemberTagManage ? "translate-x-[22px] bg-emerald-200" : "translate-x-0 bg-red-200"}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
<div className="divider" />
|
|
</>
|
|
) : null}
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<div className="text-sm font-semibold">Group Tags ({tags.length})</div>
|
|
<div className="flex items-center gap-2">
|
|
{showAllTags ? (
|
|
<button
|
|
type="button"
|
|
className="rounded-lg btn-outline-accent px-2 py-1 text-xs"
|
|
onClick={() => setShowAllTags(false)}
|
|
>
|
|
Show less
|
|
</button>
|
|
) : null}
|
|
{canManageTags ? (
|
|
<button
|
|
type="button"
|
|
className="rounded-lg btn-accent px-2 py-1 text-xs font-semibold"
|
|
onClick={() => setTagModalOpen(true)}
|
|
>
|
|
Edit tags
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<div className={tagsScrollable ? "max-h-60 overflow-auto resize-y pr-1" : ""}>
|
|
<div className="flex flex-wrap gap-2">
|
|
{visibleTags.map(tag => (
|
|
<span key={tag} className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-xs text-[color:var(--color-text)]">
|
|
#{tag}
|
|
</span>
|
|
))}
|
|
{!showAllTags && hasMoreTags ? (
|
|
<button
|
|
type="button"
|
|
className="rounded-full border border-accent-weak bg-panel px-2 py-0.5 text-xs text-soft"
|
|
onClick={() => setShowAllTags(true)}
|
|
aria-label="Show all tags"
|
|
>
|
|
more . . .
|
|
</button>
|
|
) : null}
|
|
{!tags.length ? <div className="text-xs text-soft">No tags yet.</div> : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{isAdmin ? (
|
|
<div className="panel panel-accent p-4 space-y-4">
|
|
<div className="card-header">
|
|
<div className="card-title">Join and Invites</div>
|
|
</div>
|
|
<div className="space-y-3">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="text-sm font-semibold">Join policy</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{[
|
|
{ value: "NOT_ACCEPTING", label: "Disabled" },
|
|
{ value: "AUTO_ACCEPT", label: "Auto" },
|
|
{ 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 className="divider" />
|
|
<div className="space-y-2">
|
|
<div className="text-sm font-semibold">Join Requests ({requests.length})</div>
|
|
{!requests.length ? (
|
|
<div className="text-xs text-soft">No pending requests.</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{requests.map(request => (
|
|
<div key={request.id} className="flex items-center justify-between rounded-lg border border-accent-weak bg-panel px-3 py-2 text-sm">
|
|
<div>
|
|
<div className="font-medium">{request.displayName || request.email}</div>
|
|
<div className="text-xs text-soft">{request.email}</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
className="rounded-lg btn-outline-accent px-3 py-1.5 text-xs"
|
|
onClick={() => approve(request.userId, request.id)}
|
|
>
|
|
Approve
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-1.5 text-xs text-red-200"
|
|
onClick={() => deny(request.userId)}
|
|
>
|
|
Deny
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="divider" />
|
|
<div className="space-y-3">
|
|
<div className="text-sm font-semibold">Invite links</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<select
|
|
className="input-base px-2 py-1.5 text-xs"
|
|
value={inviteTtlDays}
|
|
onChange={e => setInviteTtlDays(Number(e.target.value))}
|
|
>
|
|
{[1, 2, 3, 4, 5, 6, 7].map(days => (
|
|
<option key={days} value={days}>{days} day{days === 1 ? "" : "s"}</option>
|
|
))}
|
|
</select>
|
|
<select
|
|
className="input-base px-2 py-1.5 text-xs"
|
|
value={inviteMaxUses}
|
|
onChange={e => setInviteMaxUses(e.target.value as "UNLIMITED" | "SINGLE")}
|
|
>
|
|
<option value="UNLIMITED">Unlimited</option>
|
|
<option value="SINGLE">1 use</option>
|
|
</select>
|
|
<button
|
|
type="button"
|
|
className="rounded-lg btn-accent px-3 py-1.5 text-xs font-semibold"
|
|
onClick={() => createInvite({ policy: localJoinPolicy, singleUse: inviteMaxUses === "SINGLE", ttlDays: inviteTtlDays })}
|
|
disabled={localJoinPolicy === "NOT_ACCEPTING"}
|
|
>
|
|
Create link
|
|
</button>
|
|
</div>
|
|
{!links.length ? (
|
|
<div className="text-xs text-soft">No invite links yet.</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{links.map(link => (
|
|
<div
|
|
key={link.id}
|
|
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="text-xs text-soft">Token: {link.token.slice(0, 6)}…{link.token.slice(-4)}</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
className="rounded-lg btn-outline-accent px-2 py-1 text-xs"
|
|
onClick={() => handleCopyInvite(link.token)}
|
|
disabled={localJoinPolicy === "NOT_ACCEPTING"}
|
|
>
|
|
Copy link
|
|
</button>
|
|
{(link.revokedAt || isInviteExpired(link.expiresAt) || (link.singleUse && link.usedAt)) ? (
|
|
<button
|
|
type="button"
|
|
className="rounded-lg btn-outline-accent px-2 py-1 text-xs"
|
|
onClick={() => reviveInvite(link.id, inviteTtlDays)}
|
|
disabled={localJoinPolicy === "NOT_ACCEPTING"}
|
|
>
|
|
Revive
|
|
</button>
|
|
) : (!link.revokedAt && !(link.singleUse && link.usedAt) && !isInviteExpired(link.expiresAt)) ? (
|
|
<button
|
|
type="button"
|
|
className="rounded-lg border border-red-400/60 bg-red-500/10 px-2 py-1 text-xs text-red-200"
|
|
onClick={() => revokeInvite(link.id)}
|
|
>
|
|
Revoke
|
|
</button>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
className="rounded-lg border border-red-400/60 bg-red-500/10 px-2 py-1 text-xs text-red-200"
|
|
onClick={() => setConfirmDeleteInvite({ id: link.id, token: link.token })}
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="mt-2 flex flex-wrap gap-3 text-[11px] text-soft">
|
|
<span>Expires {formatInviteExpiry(link.expiresAt)}</span>
|
|
<span>Uses: {link.singleUse ? "1 use" : "Unlimited"}</span>
|
|
<span>Status: {link.revokedAt ? "Revoked" : link.singleUse && link.usedAt ? "Used" : isInviteExpired(link.expiresAt) ? "Expired" : "Active"}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="panel panel-accent p-4 space-y-3">
|
|
<div className="card-header">
|
|
<div className="card-title">Members</div>
|
|
<div className="text-xs text-soft">{memberCount} total</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{members.map(member => {
|
|
const isSelf = member.userId === currentUserId;
|
|
const privilegeLabel = member.role === "GROUP_OWNER"
|
|
? "👑 Owner · Full control"
|
|
: member.role === "GROUP_ADMIN"
|
|
? "🛡️ Admin · Manage members"
|
|
: "👤 Member · Entries only";
|
|
return (
|
|
<div
|
|
key={member.userId}
|
|
className={`flex flex-wrap items-center justify-between gap-3 rounded-lg border border-accent-weak px-3 py-2 text-sm ${isSelf ? "bg-accent-soft" : "bg-panel"}`}
|
|
>
|
|
<div>
|
|
<div className={`font-medium ${isSelf ? "text-[color:var(--color-text)]" : ""}`}>
|
|
{member.displayName || member.email}
|
|
{isSelf ? <span className="ml-2 rounded-full border border-accent-weak px-2 py-0.5 text-[10px] text-soft">You</span> : null}
|
|
</div>
|
|
<div className="text-xs text-soft">{member.email}</div>
|
|
<div className="mt-1 text-[11px] text-soft">{privilegeLabel}</div>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{isAdmin ? (
|
|
<select
|
|
className="input-base px-2 py-1 text-xs"
|
|
value={member.role}
|
|
onChange={e => handleRoleChange(member.userId, e.target.value as "MEMBER" | "GROUP_ADMIN" | "GROUP_OWNER")}
|
|
disabled={member.role === "GROUP_OWNER"}
|
|
>
|
|
<option value="MEMBER">Member</option>
|
|
<option value="GROUP_ADMIN">Admin</option>
|
|
{isOwner ? <option value="GROUP_OWNER">Owner</option> : null}
|
|
</select>
|
|
) : (
|
|
<span className="text-xs text-soft">{member.role}</span>
|
|
)}
|
|
{isAdmin && member.role !== "GROUP_OWNER" ? (
|
|
<button
|
|
type="button"
|
|
className="rounded-lg border border-red-400/60 bg-red-500/10 px-2 py-1 text-xs text-red-200"
|
|
onClick={() => setConfirmKick({ userId: member.userId, name: member.displayName || member.email })}
|
|
>
|
|
Remove
|
|
</button>
|
|
) : null}
|
|
{isOwner && member.role !== "GROUP_OWNER" ? (
|
|
<button
|
|
type="button"
|
|
className="rounded-lg btn-accent px-2 py-1 text-xs"
|
|
onClick={() => setConfirmTransfer({ userId: member.userId, name: member.displayName || member.email })}
|
|
>
|
|
Make owner
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="panel panel-accent p-4 space-y-3">
|
|
<div className="card-header">
|
|
<div className="card-title text-red-200">Danger zone</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{showLeaveGroup ? (
|
|
<button
|
|
type="button"
|
|
className="w-full rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold"
|
|
onClick={() => setConfirmLeaveOpen(true)}
|
|
>
|
|
Leave group
|
|
</button>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
className="w-full rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-2 text-sm font-semibold text-red-200"
|
|
onClick={() => setConfirmDeleteGroupOpen(true)}
|
|
disabled={!isOwner}
|
|
>
|
|
Delete group
|
|
</button>
|
|
</div>
|
|
{!isOwner ? <div className="text-xs text-soft">Only the owner can delete this group.</div> : null}
|
|
</div>
|
|
|
|
{canViewAudit ? (
|
|
<div className="panel panel-accent p-4 space-y-3">
|
|
<div className="card-header">
|
|
<div className="card-title">Audit log</div>
|
|
<button
|
|
type="button"
|
|
className="rounded-lg btn-outline-accent px-2 py-1 text-xs"
|
|
onClick={() => setAuditOpen(prev => !prev)}
|
|
>
|
|
{auditOpen ? "Collapse" : "Expand"}
|
|
</button>
|
|
</div>
|
|
{auditOpen ? (
|
|
<>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<div className="rounded-lg border border-accent-weak bg-panel px-3 py-2">
|
|
<div className="text-[11px] text-soft">Total logs</div>
|
|
<div className="text-base font-semibold">{events.length}</div>
|
|
</div>
|
|
<div className="rounded-lg border border-accent-weak bg-panel px-3 py-2">
|
|
<div className="text-[11px] text-soft">Logs today</div>
|
|
<div className="text-base font-semibold">{logsToday}</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="rounded-lg border border-accent-weak bg-panel px-3 py-2 text-left disabled:opacity-60"
|
|
onClick={() => mostActiveUser ? setAuditQuery(mostActiveUser.searchValue) : null}
|
|
disabled={!mostActiveUser}
|
|
>
|
|
<div className="text-[11px] text-soft">Most Active User ({mostActiveCount})</div>
|
|
<div className="text-base font-semibold">
|
|
{mostActiveUser ? `${mostActiveUser.name}` : "-"}
|
|
</div>
|
|
</button>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{([
|
|
{ key: "entries", label: "Entries" },
|
|
{ key: "members", label: "Members" },
|
|
{ key: "tags", label: "Tags" },
|
|
{ key: "settings", label: "Settings" }
|
|
] as const).map(filter => (
|
|
<button
|
|
key={filter.key}
|
|
type="button"
|
|
className={`rounded-lg border px-2 py-1 text-xs font-semibold ${auditFilters.includes(filter.key) ? "border-accent bg-accent-soft" : "border-accent-weak bg-panel"}`}
|
|
onClick={() => setAuditFilters(prev => prev.includes(filter.key) ? prev.filter(item => item !== filter.key) : [...prev, filter.key])}
|
|
>
|
|
{filter.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<div className="relative min-w-[220px] flex-1">
|
|
<svg className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-soft" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="11" cy="11" r="8" />
|
|
<path d="m21 21-4.3-4.3" />
|
|
</svg>
|
|
<input
|
|
className="input-base w-full px-9 py-2 text-sm"
|
|
placeholder="Search by user, action, or request id"
|
|
value={auditQuery}
|
|
onChange={e => setAuditQuery(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="flex min-w-[220px] flex-1 items-center gap-2 flex-nowrap">
|
|
<div className="relative flex-1">
|
|
<svg className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-soft" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
|
<line x1="16" y1="2" x2="16" y2="6" />
|
|
<line x1="8" y1="2" x2="8" y2="6" />
|
|
<line x1="3" y1="10" x2="21" y2="10" />
|
|
</svg>
|
|
<input
|
|
type="date"
|
|
className="no-date-icon input-base date-input w-full pl-9 pr-3 py-2 text-sm"
|
|
value={auditFrom}
|
|
onChange={e => setAuditFrom(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="relative flex-1">
|
|
<svg className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-soft" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
|
<line x1="16" y1="2" x2="16" y2="6" />
|
|
<line x1="8" y1="2" x2="8" y2="6" />
|
|
<line x1="3" y1="10" x2="21" y2="10" />
|
|
</svg>
|
|
<input
|
|
type="date"
|
|
className="no-date-icon input-base date-input w-full pl-9 pr-3 py-2 text-sm"
|
|
value={auditTo}
|
|
onChange={e => setAuditTo(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{!filteredEvents.length ? (
|
|
<div className="text-xs text-soft">No audit events match your filters.</div>
|
|
) : (
|
|
<div className="max-h-72 space-y-2 overflow-auto rounded-lg border border-accent-weak bg-panel p-2">
|
|
{filteredEvents.slice(0, auditLimit).map(event => (
|
|
<div key={event.id} className="rounded-lg border border-accent-weak bg-panel px-3 py-2 text-xs text-soft">
|
|
<div className="text-sm font-semibold text-[color:var(--color-text)]">{event.eventType}</div>
|
|
<div className="mt-1 flex flex-wrap gap-2 text-[11px] text-soft">
|
|
<span>Actor: {getMemberLabel(event.actorUserId)}</span>
|
|
<span>Role: {event.actorRole ?? "-"}</span>
|
|
<span>{new Date(event.createdAt).toLocaleString()}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{filteredEvents.length > auditLimit ? (
|
|
<button
|
|
type="button"
|
|
className="w-full rounded-lg btn-outline-accent px-2 py-1 text-xs"
|
|
onClick={() => setAuditLimit(prev => prev + 10)}
|
|
>
|
|
Load more
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="text-xs text-soft">Audit log is collapsed.</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
|
|
{renameModalOpen ? (
|
|
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-4 !mt-0" onClick={handleCloseRenameModal}>
|
|
<div
|
|
className="relative w-full max-w-sm rounded-2xl border border-accent-weak bg-panel p-5"
|
|
onClick={event => event.stopPropagation()}
|
|
onKeyDown={event => {
|
|
if (event.key === "Escape") handleCloseRenameModal();
|
|
if (event.key === "Enter" && renameDirty && isAdmin && renameValue.trim()) setConfirmRenameOpen(true);
|
|
}}
|
|
role="dialog"
|
|
tabIndex={-1}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="absolute right-3 top-3 rounded-lg btn-outline-accent px-2 py-1 text-sm"
|
|
onClick={handleCloseRenameModal}
|
|
aria-label="Close"
|
|
>
|
|
✕
|
|
</button>
|
|
<div className="text-lg font-semibold">Change group name</div>
|
|
<input
|
|
className={`mt-4 w-full input-base px-3 py-2 text-sm ${renameValue.trim() ? "" : "border-red-400/70"}`}
|
|
value={renameValue}
|
|
onChange={e => setRenameValue(e.target.value)}
|
|
placeholder="Group name"
|
|
/>
|
|
{renameDirty ? (
|
|
<div className="mt-3 rounded-lg border border-yellow-400/60 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-200">
|
|
You have unsaved changes.
|
|
</div>
|
|
) : null}
|
|
<div className="mt-4 flex items-center gap-2">
|
|
{renameDirty ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="flex-1 rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold"
|
|
onClick={handleCloseRenameModal}
|
|
>
|
|
Discard changes
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="flex-1 rounded-lg btn-accent px-3 py-2 text-sm font-semibold disabled:opacity-60"
|
|
onClick={() => setConfirmRenameOpen(true)}
|
|
disabled={!isAdmin || !renameValue.trim()}
|
|
>
|
|
Rename
|
|
</button>
|
|
</>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="flex-1 rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold"
|
|
onClick={handleCloseRenameModal}
|
|
>
|
|
Dismiss
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{tagModalOpen ? (
|
|
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-4 !mt-0" onClick={() => setTagModalOpen(false)}>
|
|
<div
|
|
className="w-full max-w-xl rounded-2xl border border-accent-weak bg-panel p-5"
|
|
onClick={event => event.stopPropagation()}
|
|
onKeyDown={event => {
|
|
if (event.key === "Escape") setTagModalOpen(false);
|
|
// if (event.key === "Enter" && !event.shiftKey && pendingTags.length && canManageTags) handleSaveTags();
|
|
}}
|
|
role="dialog"
|
|
tabIndex={-1}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<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>
|
|
</div>
|
|
<div className="mt-4 space-y-3">
|
|
<TagInput
|
|
label="Add tags"
|
|
tags={pendingTags}
|
|
suggestions={tags}
|
|
enableBackspaceRemove
|
|
onToggleTag={tag => setPendingTags(prev => prev.filter(item => item !== tag))}
|
|
onAddTag={tag => setPendingTags(prev => prev.includes(tag) ? prev : [...prev, tag])}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="rounded-lg btn-accent px-3 py-2 text-sm font-semibold disabled:opacity-60"
|
|
onClick={handleSaveTags}
|
|
disabled={!pendingTags.length || !canManageTags}
|
|
>
|
|
Save tags
|
|
</button>
|
|
{!canManageTags ? (
|
|
<div className="text-xs text-soft">Only admins can add new tags.</div>
|
|
) : null}
|
|
<div className="divider" />
|
|
<div className="text-sm font-semibold">Existing tags</div>
|
|
<div className="max-h-60 overflow-auto resize-y pr-1">
|
|
<div className="flex flex-wrap gap-2">
|
|
{tags.map(tag => (
|
|
<button
|
|
key={tag}
|
|
type="button"
|
|
className={`rounded-full border px-2 py-0.5 text-xs text-[color:var(--color-text)] ${toggleRemoveTags.includes(tag) ? "border-red-400/60 text-red-200 bg-red-500/10" : "border-accent-weak bg-accent-soft hover:border-accent"}`}
|
|
onClick={() => {
|
|
if (!canManageTags) return;
|
|
setToggleRemoveTags(prev => prev.includes(tag) ? prev.filter(item => item !== tag) : [...prev, tag]);
|
|
}}
|
|
>
|
|
#{tag}
|
|
</button>
|
|
))}
|
|
{!tags.length ? <div className="text-xs text-soft">No tags yet.</div> : null}
|
|
</div>
|
|
</div>
|
|
{toggleRemoveTags.length ? (
|
|
<button
|
|
type="button"
|
|
className="rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-2 text-sm font-semibold text-red-200"
|
|
onClick={() => setConfirmDeleteOpen(true)}
|
|
>
|
|
Delete selected ({toggleRemoveTags.length})
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
<ConfirmSlideModal
|
|
isOpen={confirmDeleteOpen}
|
|
title="Delete selected tags"
|
|
description="Tags will be removed from this group and any entries using them."
|
|
confirmLabel="Delete tags"
|
|
onClose={() => setConfirmDeleteOpen(false)}
|
|
onConfirm={() => {
|
|
setConfirmDeleteOpen(false);
|
|
handleConfirmDelete();
|
|
}}
|
|
/>
|
|
<ConfirmSlideModal
|
|
isOpen={Boolean(confirmDeleteInvite)}
|
|
title="Delete invite link"
|
|
description={confirmDeleteInvite ? `Delete invite ${confirmDeleteInvite.token.slice(0, 6)}…${confirmDeleteInvite.token.slice(-4)}?` : ""}
|
|
confirmLabel="Delete link"
|
|
onClose={() => setConfirmDeleteInvite(null)}
|
|
onConfirm={async () => {
|
|
if (!confirmDeleteInvite) return;
|
|
const target = confirmDeleteInvite;
|
|
setConfirmDeleteInvite(null);
|
|
const ok = await deleteInvite(target.id);
|
|
if (ok) notify({ title: "Invite deleted", tone: "danger" });
|
|
}}
|
|
/>
|
|
<ConfirmSlideModal
|
|
isOpen={confirmRenameOpen}
|
|
title="Rename group"
|
|
description={`Change group name to "${renameValue.trim()}"?`}
|
|
confirmLabel="Rename"
|
|
onClose={() => setConfirmRenameOpen(false)}
|
|
onConfirm={handleRenameGroup}
|
|
/>
|
|
<ConfirmSlideModal
|
|
isOpen={confirmLeaveOpen}
|
|
title="Leave group"
|
|
description="You will lose access to this group."
|
|
confirmLabel="Leave"
|
|
onClose={() => setConfirmLeaveOpen(false)}
|
|
onConfirm={async () => {
|
|
setConfirmLeaveOpen(false);
|
|
const ok = await leave();
|
|
if (ok) {
|
|
notify({ title: "Left group" });
|
|
router.push("/");
|
|
}
|
|
}}
|
|
/>
|
|
<ConfirmSlideModal
|
|
isOpen={Boolean(confirmKick)}
|
|
title="Kick member"
|
|
description={confirmKick ? `Remove ${confirmKick.name} from this group?` : ""}
|
|
confirmLabel="Kick"
|
|
onClose={() => setConfirmKick(null)}
|
|
onConfirm={async () => {
|
|
if (!confirmKick) return;
|
|
const target = confirmKick;
|
|
setConfirmKick(null);
|
|
const ok = await kick(target.userId);
|
|
if (ok) notify({ title: "Member removed", message: target.name, tone: "danger" });
|
|
}}
|
|
/>
|
|
<ConfirmSlideModal
|
|
isOpen={Boolean(confirmTransfer)}
|
|
title="Transfer ownership"
|
|
description={confirmTransfer ? `Make ${confirmTransfer.name} the new owner?` : ""}
|
|
confirmLabel="Transfer"
|
|
onClose={() => setConfirmTransfer(null)}
|
|
onConfirm={async () => {
|
|
if (!confirmTransfer) return;
|
|
const target = confirmTransfer;
|
|
setConfirmTransfer(null);
|
|
const ok = await transferOwner(target.userId);
|
|
if (ok) notify({ title: "Ownership transferred", message: target.name });
|
|
}}
|
|
/>
|
|
{confirmDeleteGroupOpen ? (
|
|
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-4 !mt-0" onClick={() => setConfirmDeleteGroupOpen(false)}>
|
|
<div
|
|
className="w-full max-w-sm rounded-2xl border border-accent-weak bg-panel p-5"
|
|
onClick={event => event.stopPropagation()}
|
|
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}
|
|
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>
|
|
</>
|
|
);
|
|
}
|