782 lines
36 KiB
TypeScript
782 lines
36 KiB
TypeScript
import {
|
|
ArrowLeftIcon,
|
|
ChevronRightIcon,
|
|
Clock3Icon,
|
|
LoaderCircleIcon,
|
|
LogOutIcon,
|
|
PlusIcon,
|
|
ShieldCheckIcon,
|
|
Trash2Icon,
|
|
UserPlusIcon,
|
|
} from "lucide-react";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import toast from "react-hot-toast";
|
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
|
import CreateSpaceDialog from "@/components/CreateSpaceDialog";
|
|
import InviteSpaceMemberDialog from "@/components/Settings/InviteSpaceMemberDialog";
|
|
import SettingSection from "@/components/Settings/SettingSection";
|
|
import SpaceIconPicker from "@/components/SpaceIconPicker";
|
|
import SpaceMark from "@/components/SpaceMark";
|
|
import SpaceRoleBadge from "@/components/SpaceRoleBadge";
|
|
import UserAvatar from "@/components/UserAvatar";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
|
import {
|
|
useAcceptSpaceInvitation,
|
|
useDeclineSpaceInvitation,
|
|
useDeleteSpace,
|
|
useDeleteSpaceInvitation,
|
|
useDeleteSpaceMember,
|
|
useSpaceInvitations,
|
|
useSpaceMembers,
|
|
useSpaces,
|
|
useUpdateSpace,
|
|
useUpdateSpaceMember,
|
|
useUserSpaceInvitations,
|
|
} from "@/hooks/useSpaceQueries";
|
|
import { useUsersByUsernames } from "@/hooks/useUserQueries";
|
|
import { customIconsEqual } from "@/lib/custom-icons";
|
|
import { handleError } from "@/lib/error";
|
|
import { extractUsernameFromName } from "@/lib/resource-names";
|
|
import { extractSpaceUidFromName } from "@/lib/space-display";
|
|
import { ROUTES } from "@/router/routes";
|
|
import { type Space, type SpaceInvitation, type SpaceMember, SpaceMember_Role } from "@/types/proto/api/v1/space_service_pb";
|
|
import type { User } from "@/types/proto/api/v1/user_service_pb";
|
|
import { useTranslate } from "@/utils/i18n";
|
|
|
|
type DetailTab = "general" | "members";
|
|
|
|
const getSpacesSettingsLocation = (spaceName?: string) => ({
|
|
pathname: ROUTES.SETTING,
|
|
search: spaceName ? `?${new URLSearchParams({ space: spaceName }).toString()}` : "",
|
|
hash: "#spaces",
|
|
});
|
|
|
|
const MemberIdentity = ({ member, user, isCurrentUser }: { member: SpaceMember; user?: User; isCurrentUser?: boolean }) => {
|
|
const t = useTranslate();
|
|
const username = user?.username || extractUsernameFromName(member.user);
|
|
const displayName = user?.displayName || `@${username}`;
|
|
|
|
return (
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<UserAvatar className="size-10 rounded-xl" avatarUrl={user?.avatarUrl} />
|
|
<div className="min-w-0">
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<p className="truncate text-sm font-medium">{displayName}</p>
|
|
{isCurrentUser ? <span className="text-xs text-muted-foreground">{t("setting.spaces.you")}</span> : null}
|
|
</div>
|
|
<p className="mt-0.5 truncate text-xs text-muted-foreground">@{username}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const InvitationIdentity = ({ invitation, user }: { invitation: SpaceInvitation; user?: User }) => {
|
|
const username = user?.username || extractUsernameFromName(invitation.invitee);
|
|
const displayName = user?.displayName || `@${username}`;
|
|
|
|
return (
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<UserAvatar className="size-10 rounded-xl" avatarUrl={user?.avatarUrl} />
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-medium">{displayName}</p>
|
|
<p className="mt-0.5 truncate text-xs text-muted-foreground">@{username}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const SpacesSection = () => {
|
|
const t = useTranslate();
|
|
const navigate = useNavigate();
|
|
const [searchParams] = useSearchParams();
|
|
const currentUser = useCurrentUser();
|
|
const viewerName = currentUser?.name ?? "";
|
|
const managedSpaceName = searchParams.get("space") ?? "";
|
|
const spacesQuery = useSpaces(viewerName);
|
|
const receivedInvitationsQuery = useUserSpaceInvitations(viewerName);
|
|
const spaces = spacesQuery.data ?? [];
|
|
const receivedInvitations = receivedInvitationsQuery.data ?? [];
|
|
const managedSpace = spaces.find((space) => space.name === managedSpaceName);
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const acceptInvitation = useAcceptSpaceInvitation(viewerName);
|
|
const declineInvitation = useDeclineSpaceInvitation(viewerName);
|
|
|
|
useEffect(() => {
|
|
if (managedSpaceName && spacesQuery.isSuccess && !managedSpace) {
|
|
navigate(getSpacesSettingsLocation(), { replace: true });
|
|
}
|
|
}, [managedSpace, managedSpaceName, navigate, spacesQuery.isSuccess]);
|
|
|
|
const handleOpenSpace = (spaceName: string) => {
|
|
navigate(getSpacesSettingsLocation(spaceName));
|
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
};
|
|
|
|
const handleAcceptInvitation = async (invitation: SpaceInvitation) => {
|
|
try {
|
|
await acceptInvitation.mutateAsync({ name: invitation.name });
|
|
const spaceLabel = invitation.space
|
|
? `${invitation.space.title} (${extractSpaceUidFromName(invitation.space.name)})`
|
|
: t("setting.spaces.label");
|
|
toast.success(t("setting.spaces.accept-success", { space: spaceLabel }));
|
|
} catch (error) {
|
|
handleError(error, toast.error, { context: "Accept space invitation" });
|
|
}
|
|
};
|
|
|
|
const handleDeclineInvitation = async (invitation: SpaceInvitation) => {
|
|
try {
|
|
await declineInvitation.mutateAsync({ name: invitation.name });
|
|
toast.success(t("setting.spaces.decline-success"));
|
|
} catch (error) {
|
|
handleError(error, toast.error, { context: "Decline space invitation" });
|
|
}
|
|
};
|
|
|
|
if (managedSpaceName && !managedSpace && spacesQuery.isLoading) {
|
|
return (
|
|
<SettingSection title={t("setting.spaces.title")} description={t("setting.spaces.description")}>
|
|
<div className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
|
|
<LoaderCircleIcon className="size-4 animate-spin" />
|
|
{t("setting.spaces.loading")}
|
|
</div>
|
|
</SettingSection>
|
|
);
|
|
}
|
|
|
|
return managedSpace ? (
|
|
<SpaceDetail
|
|
key={managedSpace.name}
|
|
space={managedSpace}
|
|
viewerName={viewerName}
|
|
onBack={() => navigate(getSpacesSettingsLocation())}
|
|
/>
|
|
) : (
|
|
<>
|
|
<SettingSection
|
|
title={t("setting.spaces.title")}
|
|
description={t("setting.spaces.description")}
|
|
actions={
|
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
|
<PlusIcon data-icon="inline-start" />
|
|
{t("setting.spaces.create-space")}
|
|
</Button>
|
|
}
|
|
className="gap-5"
|
|
>
|
|
<div data-critical-surface="spaces-overview" className="flex min-w-0 flex-col gap-5">
|
|
{receivedInvitations.length > 0 ? (
|
|
<section aria-labelledby="space-invitations-heading" className="space-y-2.5">
|
|
<div className="flex items-center justify-between">
|
|
<h4 id="space-invitations-heading" className="text-sm font-medium">
|
|
{t("setting.spaces.invitations")}
|
|
</h4>
|
|
<span className="text-xs text-muted-foreground">
|
|
{t("setting.spaces.pending-count", { count: receivedInvitations.length })}
|
|
</span>
|
|
</div>
|
|
<div className="divide-y divide-border overflow-hidden rounded-lg border border-border bg-background">
|
|
{receivedInvitations.map((invitation) => {
|
|
const uid = invitation.space ? extractSpaceUidFromName(invitation.space.name) : "";
|
|
const title = invitation.space?.title || t("setting.spaces.untitled");
|
|
|
|
return (
|
|
<div key={invitation.name} className="flex min-w-0 flex-col gap-3 px-3 py-3 sm:flex-row sm:items-center">
|
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
|
<SpaceMark icon={invitation.space?.icon} size="lg" />
|
|
<div className="min-w-0">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<p className="truncate text-sm font-medium">{title}</p>
|
|
<SpaceRoleBadge role={invitation.role} />
|
|
</div>
|
|
{uid ? (
|
|
<p className="mt-0.5 flex min-w-0 items-baseline gap-1 text-[11px] text-muted-foreground">
|
|
<span className="shrink-0">{t("space.custom-id-label")}:</span>
|
|
<span title={uid} className="min-w-0 break-all font-mono">
|
|
{uid}
|
|
</span>
|
|
</p>
|
|
) : null}
|
|
<p className="mt-1 truncate text-xs leading-5 text-muted-foreground">
|
|
{invitation.space?.description || t("setting.spaces.invited-to-join")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex shrink-0 items-center justify-end gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
aria-label={uid ? `${t("setting.spaces.decline")} ${title} (${uid})` : undefined}
|
|
disabled={acceptInvitation.isPending || declineInvitation.isPending}
|
|
onClick={() => void handleDeclineInvitation(invitation)}
|
|
>
|
|
{t("setting.spaces.decline")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
aria-label={uid ? `${t("setting.spaces.accept")} ${title} (${uid})` : undefined}
|
|
disabled={acceptInvitation.isPending || declineInvitation.isPending}
|
|
onClick={() => void handleAcceptInvitation(invitation)}
|
|
>
|
|
{t("setting.spaces.accept")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
|
|
<section aria-labelledby="your-spaces-heading" className="space-y-2.5">
|
|
<div className="flex items-center justify-between">
|
|
<h4 id="your-spaces-heading" className="text-sm font-medium">
|
|
{t("setting.spaces.your-spaces")}
|
|
</h4>
|
|
{spaces.length > 0 ? (
|
|
<span className="text-xs text-muted-foreground">
|
|
{spaces.length === 1
|
|
? t("setting.spaces.space-count", { count: spaces.length })
|
|
: t("setting.spaces.spaces-count", { count: spaces.length })}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
{spacesQuery.isLoading ? (
|
|
<div className="flex items-center justify-center gap-2 rounded-lg border border-border py-10 text-sm text-muted-foreground">
|
|
<LoaderCircleIcon className="size-4 animate-spin" />
|
|
{t("setting.spaces.loading")}
|
|
</div>
|
|
) : spacesQuery.isError ? (
|
|
<div className="rounded-lg border border-border px-3 py-8 text-center text-sm text-muted-foreground">
|
|
{t("setting.spaces.load-error")}
|
|
</div>
|
|
) : spaces.length === 0 ? (
|
|
<div className="rounded-lg border border-dashed border-border px-4 py-9 text-center">
|
|
<p className="text-sm font-medium">{t("setting.spaces.no-spaces")}</p>
|
|
<p className="mx-auto mt-1 max-w-md text-xs leading-5 text-muted-foreground">{t("setting.spaces.no-spaces-description")}</p>
|
|
<Button variant="outline" size="sm" className="mt-4" onClick={() => setCreateOpen(true)}>
|
|
<PlusIcon data-icon="inline-start" />
|
|
{t("setting.spaces.create-space")}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-border overflow-hidden rounded-lg border border-border bg-background">
|
|
{spaces.map((space) => {
|
|
const uid = extractSpaceUidFromName(space.name);
|
|
const manageLabel = t("setting.spaces.manage-space", { space: space.title });
|
|
|
|
return (
|
|
<button
|
|
key={space.name}
|
|
type="button"
|
|
aria-label={uid ? `${manageLabel} (${uid})` : manageLabel}
|
|
onClick={() => handleOpenSpace(space.name)}
|
|
className="group flex w-full min-w-0 items-center gap-3 px-3 py-3 text-left transition-colors hover:bg-muted/35 focus-visible:bg-muted/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/45"
|
|
>
|
|
<SpaceMark icon={space.icon} size="lg" />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
<p className="truncate text-sm font-medium">{space.title}</p>
|
|
<SpaceRoleBadge role={space.currentUserRole} />
|
|
</div>
|
|
<p className="mt-0.5 flex min-w-0 items-baseline gap-1 text-[11px] text-muted-foreground">
|
|
<span className="shrink-0">{t("space.custom-id-label")}:</span>
|
|
<span title={uid} className="min-w-0 break-all font-mono">
|
|
{uid}
|
|
</span>
|
|
</p>
|
|
<p className="mt-1 truncate text-xs text-muted-foreground">
|
|
{space.description || t("setting.spaces.no-description")}
|
|
</p>
|
|
</div>
|
|
<span className="hidden shrink-0 text-xs text-muted-foreground sm:inline">
|
|
{space.memberCount === 1
|
|
? t("setting.spaces.member-count", { count: space.memberCount })
|
|
: t("setting.spaces.members-count", { count: space.memberCount })}
|
|
</span>
|
|
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground/65 transition-transform group-hover:translate-x-0.5" />
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</SettingSection>
|
|
|
|
<CreateSpaceDialog
|
|
open={createOpen}
|
|
onOpenChange={setCreateOpen}
|
|
onCreated={(space) => navigate(getSpacesSettingsLocation(space.name))}
|
|
note={t("setting.spaces.create-scope-note")}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
interface SpaceDetailProps {
|
|
space: Space;
|
|
viewerName: string;
|
|
onBack: () => void;
|
|
}
|
|
|
|
const SpaceDetail = ({ space, viewerName, onBack }: SpaceDetailProps) => {
|
|
const t = useTranslate();
|
|
const membersQuery = useSpaceMembers(viewerName, space.name);
|
|
const members = membersQuery.data ?? [];
|
|
const currentMember = members.find((member) => member.user === viewerName);
|
|
const currentRole = currentMember?.role ?? space.currentUserRole;
|
|
const isAdmin = currentRole === SpaceMember_Role.ADMIN;
|
|
const invitationsQuery = useSpaceInvitations(viewerName, space.name, { enabled: isAdmin });
|
|
const invitations = invitationsQuery.data ?? [];
|
|
const usernames = useMemo(
|
|
() =>
|
|
Array.from(
|
|
new Set(
|
|
[...members.map((member) => member.user), ...invitations.map((invitation) => invitation.invitee)].map(extractUsernameFromName),
|
|
),
|
|
),
|
|
[invitations, members],
|
|
);
|
|
const usersQuery = useUsersByUsernames(usernames);
|
|
const usersByUsername = usersQuery.data ?? new Map<string, User | undefined>();
|
|
const [tab, setTab] = useState<DetailTab>("general");
|
|
const [title, setTitle] = useState(space.title);
|
|
const [description, setDescription] = useState(space.description);
|
|
const [icon, setIcon] = useState(space.icon);
|
|
const [inviteOpen, setInviteOpen] = useState(false);
|
|
const [leaveOpen, setLeaveOpen] = useState(false);
|
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
const [removeTarget, setRemoveTarget] = useState<SpaceMember>();
|
|
const [cancelInvitationTarget, setCancelInvitationTarget] = useState<SpaceInvitation>();
|
|
const updateSpace = useUpdateSpace(viewerName);
|
|
const deleteSpace = useDeleteSpace(viewerName);
|
|
const updateMember = useUpdateSpaceMember(viewerName);
|
|
const deleteMember = useDeleteSpaceMember(viewerName);
|
|
const deleteInvitation = useDeleteSpaceInvitation(viewerName);
|
|
const activeAdminCount = members.filter((member) => member.role === SpaceMember_Role.ADMIN).length;
|
|
const isLastAdmin = currentRole === SpaceMember_Role.ADMIN && activeAdminCount === 1;
|
|
const memberUserNames = useMemo(() => new Set(members.map((member) => member.user)), [members]);
|
|
const pendingInviteeNames = useMemo(() => new Set(invitations.map((invitation) => invitation.invitee)), [invitations]);
|
|
const roleOptions = [
|
|
{ value: String(SpaceMember_Role.USER), label: t("setting.spaces.space-user") },
|
|
{ value: String(SpaceMember_Role.ADMIN), label: t("setting.spaces.space-admin") },
|
|
];
|
|
const iconChanged = !customIconsEqual(icon, space.icon);
|
|
const detailsChanged = title.trim() !== space.title || description.trim() !== space.description || iconChanged;
|
|
const spaceUid = extractSpaceUidFromName(space.name);
|
|
const disambiguatedSpaceTitle = `${space.title} (${spaceUid})`;
|
|
|
|
useEffect(() => {
|
|
setTitle(space.title);
|
|
setDescription(space.description);
|
|
setIcon(space.icon);
|
|
}, [space.description, space.title, space.icon]);
|
|
|
|
const handleSave = async () => {
|
|
const trimmedTitle = title.trim();
|
|
if (!trimmedTitle || !isAdmin || updateSpace.isPending) return;
|
|
try {
|
|
await updateSpace.mutateAsync({
|
|
space: { name: space.name, title: trimmedTitle, description: description.trim(), ...(iconChanged ? { icon } : {}) },
|
|
updateMask: ["title", "description", ...(iconChanged ? ["icon"] : [])],
|
|
});
|
|
toast.success(t("setting.spaces.save-success"));
|
|
} catch (error) {
|
|
handleError(error, toast.error, { context: "Update space" });
|
|
}
|
|
};
|
|
|
|
const handleRoleChange = async (member: SpaceMember, role: SpaceMember_Role) => {
|
|
if (member.role === role || updateMember.isPending) return;
|
|
try {
|
|
await updateMember.mutateAsync({ spaceMember: { name: member.name, role }, updateMask: ["role"] });
|
|
toast.success(t("setting.spaces.role-update-success"));
|
|
} catch (error) {
|
|
handleError(error, toast.error, { context: "Update space member" });
|
|
}
|
|
};
|
|
|
|
const handleRemoveMember = async () => {
|
|
if (!removeTarget) return;
|
|
try {
|
|
await deleteMember.mutateAsync({ name: removeTarget.name });
|
|
toast.success(t("setting.spaces.remove-success", { username: extractUsernameFromName(removeTarget.user) }));
|
|
setRemoveTarget(undefined);
|
|
} catch (error) {
|
|
handleError(error, toast.error, { context: "Remove space member" });
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const handleCancelInvitation = async () => {
|
|
if (!cancelInvitationTarget) return;
|
|
try {
|
|
await deleteInvitation.mutateAsync({ name: cancelInvitationTarget.name });
|
|
toast.success(t("setting.spaces.cancel-invitation-success"));
|
|
setCancelInvitationTarget(undefined);
|
|
} catch (error) {
|
|
handleError(error, toast.error, { context: "Cancel space invitation" });
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const handleLeave = async () => {
|
|
if (!currentMember) return;
|
|
try {
|
|
await deleteMember.mutateAsync({ name: currentMember.name });
|
|
toast.success(t("setting.spaces.leave-success", { space: disambiguatedSpaceTitle }));
|
|
onBack();
|
|
} catch (error) {
|
|
handleError(error, toast.error, { context: "Leave space" });
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const handleDeleteSpace = async () => {
|
|
try {
|
|
await deleteSpace.mutateAsync({ name: space.name });
|
|
toast.success(t("setting.spaces.delete-success", { space: disambiguatedSpaceTitle }));
|
|
onBack();
|
|
} catch (error) {
|
|
handleError(error, toast.error, { context: "Delete space" });
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<section className="min-w-0">
|
|
<button
|
|
type="button"
|
|
onClick={onBack}
|
|
className="mb-4 inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
|
>
|
|
<ArrowLeftIcon className="size-4" />
|
|
{t("setting.spaces.label")}
|
|
</button>
|
|
|
|
<header className="flex min-w-0 flex-col gap-3 border-b border-border/70 pb-4 sm:flex-row sm:items-center">
|
|
<SpaceMark icon={space.icon} size="xl" />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
<h3 className="truncate text-lg font-semibold tracking-tight">{space.title}</h3>
|
|
<SpaceRoleBadge role={currentRole} />
|
|
</div>
|
|
<p className="mt-1 flex min-w-0 items-baseline gap-1 text-xs text-muted-foreground">
|
|
<span className="shrink-0">{t("space.custom-id-label")}:</span>
|
|
<span title={spaceUid} className="min-w-0 break-all font-mono">
|
|
{spaceUid}
|
|
</span>
|
|
</p>
|
|
<p className="mt-1 truncate text-sm text-muted-foreground">{space.description || t("setting.spaces.no-description")}</p>
|
|
</div>
|
|
</header>
|
|
|
|
<Tabs value={tab} onValueChange={(value) => setTab(value as DetailTab)} variant="underline" className="mt-2">
|
|
<TabsList className="w-full justify-start border-b border-border/70">
|
|
<TabsTrigger value="general" className="flex-none px-3">
|
|
{t("setting.spaces.general")}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="members" className="flex-none px-3">
|
|
{t("setting.spaces.members")}
|
|
<span className="text-xs font-normal text-muted-foreground">{members.length || space.memberCount}</span>
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
</Tabs>
|
|
|
|
{tab === "general" ? (
|
|
<div className="space-y-5 pt-3">
|
|
<section className="space-y-2.5">
|
|
<div>
|
|
<h4 className="text-sm font-medium">{t("setting.spaces.space-details")}</h4>
|
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t("setting.spaces.space-details-description")}</p>
|
|
</div>
|
|
<div className="overflow-hidden rounded-lg border border-border bg-background">
|
|
<div className="grid gap-2 border-b border-border px-3 py-3 sm:grid-cols-[170px_1fr] sm:items-center">
|
|
<span className="text-sm font-medium">{t("space.custom-id-label")}</span>
|
|
<code className="min-w-0 break-all rounded-md bg-muted/35 px-3 py-2 font-mono text-xs leading-5 text-foreground/85">
|
|
{spaceUid}
|
|
</code>
|
|
</div>
|
|
<div className="grid gap-2 border-b border-border px-3 py-3 sm:grid-cols-[170px_1fr] sm:items-center">
|
|
<Label htmlFor="space-settings-title">{t("common.name")}</Label>
|
|
<div className="flex items-center gap-2">
|
|
{isAdmin ? (
|
|
<SpaceIconPicker value={icon} onChange={setIcon} disabled={updateSpace.isPending} />
|
|
) : (
|
|
<SpaceMark icon={icon} size="lg" className="size-8" />
|
|
)}
|
|
<Input
|
|
id="space-settings-title"
|
|
value={title}
|
|
onChange={(event) => setTitle(event.target.value)}
|
|
readOnly={!isAdmin}
|
|
className={!isAdmin ? "bg-muted/25" : undefined}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-2 px-3 py-3 sm:grid-cols-[170px_1fr] sm:items-start">
|
|
<div>
|
|
<Label htmlFor="space-settings-description">{t("common.description")}</Label>
|
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t("setting.spaces.description-help")}</p>
|
|
</div>
|
|
<Textarea
|
|
id="space-settings-description"
|
|
value={description}
|
|
onChange={(event) => setDescription(event.target.value)}
|
|
readOnly={!isAdmin}
|
|
className={isAdmin ? "min-h-20 resize-none" : "min-h-20 resize-none bg-muted/25"}
|
|
/>
|
|
</div>
|
|
{isAdmin ? (
|
|
<div className="flex justify-end border-t border-border bg-muted/20 px-3 py-2.5">
|
|
<Button size="sm" disabled={!title.trim() || !detailsChanged || updateSpace.isPending} onClick={() => void handleSave()}>
|
|
{t("setting.spaces.save-changes")}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="space-y-2.5">
|
|
<h4 className="text-sm font-medium">{t("setting.spaces.your-access")}</h4>
|
|
<div className="flex flex-col gap-3 rounded-lg border border-border px-3 py-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex items-start gap-2">
|
|
<ShieldCheckIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
|
<div>
|
|
<p className="text-sm font-medium">{t("setting.spaces.role-in-space")}</p>
|
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
|
{isAdmin ? t("setting.spaces.admin-role-description") : t("setting.spaces.user-role-description")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<SpaceRoleBadge role={currentRole} />
|
|
</div>
|
|
</section>
|
|
|
|
<section className="rounded-xl border border-destructive/30 bg-destructive/5 p-4">
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
<div className="flex min-w-0 items-start gap-3">
|
|
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
|
<LogOutIcon className="size-4" />
|
|
</span>
|
|
<div>
|
|
<h4 className="text-sm font-medium">{t("setting.spaces.leave-space")}</h4>
|
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
|
{isLastAdmin ? t("setting.spaces.last-admin-leave-description") : t("setting.spaces.leave-description")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={!currentMember || isLastAdmin}
|
|
className="border-destructive/35 text-destructive hover:bg-destructive/10"
|
|
onClick={() => setLeaveOpen(true)}
|
|
>
|
|
{t("setting.spaces.leave-space")}
|
|
</Button>
|
|
</div>
|
|
{isAdmin ? (
|
|
<div className="mt-4 flex flex-col gap-4 border-t border-destructive/20 pt-4 sm:flex-row sm:items-start sm:justify-between">
|
|
<div className="flex min-w-0 items-start gap-3">
|
|
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
|
<Trash2Icon className="size-4" />
|
|
</span>
|
|
<div>
|
|
<h4 className="text-sm font-medium">{t("setting.spaces.delete-space")}</h4>
|
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t("setting.spaces.delete-description")}</p>
|
|
</div>
|
|
</div>
|
|
<Button variant="destructive" size="sm" onClick={() => setDeleteOpen(true)}>
|
|
{t("setting.spaces.delete-space")}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
</div>
|
|
) : (
|
|
<div data-critical-surface="space-members" className="space-y-5 pt-3">
|
|
<section className="space-y-2.5">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
|
<div>
|
|
<h4 className="text-sm font-medium">{t("setting.spaces.members")}</h4>
|
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t("setting.spaces.members-description")}</p>
|
|
</div>
|
|
{isAdmin ? (
|
|
<Button size="sm" onClick={() => setInviteOpen(true)}>
|
|
<UserPlusIcon data-icon="inline-start" />
|
|
{t("setting.spaces.invite-member")}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="divide-y divide-border overflow-hidden rounded-lg border border-border bg-background">
|
|
{membersQuery.isLoading ? (
|
|
<div className="flex items-center justify-center gap-2 px-3 py-8 text-sm text-muted-foreground">
|
|
<LoaderCircleIcon className="size-4 animate-spin" />
|
|
{t("setting.spaces.loading-members")}
|
|
</div>
|
|
) : membersQuery.isError ? (
|
|
<div className="px-3 py-8 text-center text-sm text-muted-foreground">{t("setting.spaces.members-load-error")}</div>
|
|
) : (
|
|
members.map((member) => {
|
|
const isCurrentUser = member.user === viewerName;
|
|
return (
|
|
<div key={member.name} className="grid min-w-0 gap-3 px-3 py-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
|
<MemberIdentity
|
|
member={member}
|
|
user={usersByUsername.get(extractUsernameFromName(member.user))}
|
|
isCurrentUser={isCurrentUser}
|
|
/>
|
|
<div className="flex min-w-0 flex-wrap items-center justify-end gap-2">
|
|
{isAdmin && !isCurrentUser ? (
|
|
<Select
|
|
value={String(member.role)}
|
|
items={roleOptions}
|
|
disabled={updateMember.isPending}
|
|
onValueChange={(value) => void handleRoleChange(member, Number(value) as SpaceMember_Role)}
|
|
>
|
|
<SelectTrigger
|
|
size="sm"
|
|
className="w-[126px] rounded-md"
|
|
aria-label={t("setting.spaces.change-role", { username: extractUsernameFromName(member.user) })}
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent align="end">
|
|
<SelectItem value={String(SpaceMember_Role.USER)}>{t("setting.spaces.space-user")}</SelectItem>
|
|
<SelectItem value={String(SpaceMember_Role.ADMIN)}>{t("setting.spaces.space-admin")}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<SpaceRoleBadge role={member.role} />
|
|
)}
|
|
{isAdmin && !isCurrentUser ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-muted-foreground hover:text-destructive"
|
|
onClick={() => setRemoveTarget(member)}
|
|
>
|
|
{t("setting.spaces.remove")}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
{isAdmin ? (
|
|
<section className="space-y-2.5">
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="text-sm font-medium">{t("setting.spaces.pending-invitations")}</h4>
|
|
<span className="text-xs text-muted-foreground">{invitations.length}</span>
|
|
</div>
|
|
<div className="divide-y divide-border overflow-hidden rounded-lg border border-border bg-background">
|
|
{invitationsQuery.isLoading ? (
|
|
<div className="flex items-center justify-center gap-2 px-3 py-5 text-sm text-muted-foreground">
|
|
<LoaderCircleIcon className="size-4 animate-spin" />
|
|
{t("setting.spaces.loading-invitations")}
|
|
</div>
|
|
) : invitations.length === 0 ? (
|
|
<div className="px-3 py-5 text-center text-sm text-muted-foreground">{t("setting.spaces.no-pending-invitations")}</div>
|
|
) : (
|
|
invitations.map((invitation) => (
|
|
<div key={invitation.name} className="grid min-w-0 gap-3 px-3 py-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
|
<InvitationIdentity invitation={invitation} user={usersByUsername.get(extractUsernameFromName(invitation.invitee))} />
|
|
<div className="flex min-w-0 flex-wrap items-center justify-end gap-2">
|
|
<Badge variant="warning" shape="pill" className="h-5 px-2 font-normal">
|
|
<Clock3Icon data-icon="inline-start" />
|
|
{t("setting.spaces.invitation-pending")}
|
|
</Badge>
|
|
<SpaceRoleBadge role={invitation.role} />
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-muted-foreground hover:text-destructive"
|
|
onClick={() => setCancelInvitationTarget(invitation)}
|
|
>
|
|
{t("setting.spaces.cancel-invitation")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
|
|
<InviteSpaceMemberDialog
|
|
open={inviteOpen}
|
|
onOpenChange={setInviteOpen}
|
|
space={space}
|
|
viewerName={viewerName}
|
|
memberUserNames={memberUserNames}
|
|
pendingInviteeNames={pendingInviteeNames}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={leaveOpen}
|
|
onOpenChange={setLeaveOpen}
|
|
title={
|
|
<span className="[overflow-wrap:anywhere]">{t("setting.spaces.leave-confirm-title", { space: disambiguatedSpaceTitle })}</span>
|
|
}
|
|
description={t("setting.spaces.leave-confirm-description")}
|
|
confirmLabel={t("setting.spaces.leave")}
|
|
cancelLabel={t("common.cancel")}
|
|
onConfirm={handleLeave}
|
|
confirmVariant="destructive"
|
|
/>
|
|
<ConfirmDialog
|
|
open={deleteOpen}
|
|
onOpenChange={setDeleteOpen}
|
|
title={
|
|
<span className="[overflow-wrap:anywhere]">{t("setting.spaces.delete-confirm-title", { space: disambiguatedSpaceTitle })}</span>
|
|
}
|
|
description={t("setting.spaces.delete-confirm-description")}
|
|
confirmLabel={t("common.delete")}
|
|
cancelLabel={t("common.cancel")}
|
|
onConfirm={handleDeleteSpace}
|
|
confirmVariant="destructive"
|
|
/>
|
|
<ConfirmDialog
|
|
open={Boolean(removeTarget)}
|
|
onOpenChange={(open) => !open && setRemoveTarget(undefined)}
|
|
title={t("setting.spaces.remove-confirm-title", { username: extractUsernameFromName(removeTarget?.user ?? "") })}
|
|
description={t("setting.spaces.remove-confirm-description")}
|
|
confirmLabel={t("setting.spaces.remove")}
|
|
cancelLabel={t("common.cancel")}
|
|
onConfirm={handleRemoveMember}
|
|
confirmVariant="destructive"
|
|
/>
|
|
<ConfirmDialog
|
|
open={Boolean(cancelInvitationTarget)}
|
|
onOpenChange={(open) => !open && setCancelInvitationTarget(undefined)}
|
|
title={t("setting.spaces.cancel-invitation-confirm-title", {
|
|
username: extractUsernameFromName(cancelInvitationTarget?.invitee ?? ""),
|
|
})}
|
|
description={t("setting.spaces.cancel-invitation-confirm-description")}
|
|
confirmLabel={t("setting.spaces.cancel-invitation")}
|
|
cancelLabel={t("common.cancel")}
|
|
onConfirm={handleCancelInvitation}
|
|
confirmVariant="destructive"
|
|
/>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export default SpacesSection;
|