feat: add project invitation and join pages

Add MyInvitationsPage, ProjectInvitationPage, and ProjectJoinPage
for handling project invitation flows.
This commit is contained in:
ZhenYi 2026-05-14 23:14:21 +08:00
parent b737d19166
commit 4322f36a76
3 changed files with 661 additions and 0 deletions

View File

@ -0,0 +1,250 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Loader2, Mail, X } from "lucide-react";
import {
projectAcceptInvitation,
projectCancelJoinRequest,
projectMyInvitations,
projectMyJoinRequests,
projectRejectInvitation,
} from "@/client/api";
import type { InvitationResponse, JoinRequestResponse } from "@/client/model";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
type ActionKey = `invite:${string}` | `request:${number}` | null;
function formatDate(value?: string | null) {
if (!value) return "Unknown";
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
function statusVariant(status: string) {
if (status === "approved") return "default";
if (status === "rejected") return "destructive";
return "secondary";
}
export function MyInvitationsPage() {
const queryClient = useQueryClient();
const [actionKey, setActionKey] = useState<ActionKey>(null);
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
const invitationsQuery = useQuery({
queryKey: ["project-my-invitations"],
queryFn: async () => {
const res = await projectMyInvitations({ page: 1, per_page: 50 });
return (res.data?.data?.invitations ?? []) as InvitationResponse[];
},
staleTime: 30_000,
});
const requestsQuery = useQuery({
queryKey: ["project-my-join-requests"],
queryFn: async () => {
const res = await projectMyJoinRequests({ page: 1, per_page: 50 });
return (res.data?.data?.requests ?? []) as JoinRequestResponse[];
},
staleTime: 30_000,
});
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ["project-my-invitations"] });
queryClient.invalidateQueries({ queryKey: ["project-my-join-requests"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
};
const handleInvitation = async (projectName: string, accept: boolean) => {
setActionKey(`invite:${projectName}`);
setMessage(null);
try {
if (accept) {
await projectAcceptInvitation(projectName);
} else {
await projectRejectInvitation(projectName);
}
setMessage({ type: "success", text: accept ? "Invitation accepted." : "Invitation rejected." });
invalidate();
} catch {
setMessage({ type: "error", text: "Failed to process invitation." });
} finally {
setActionKey(null);
}
};
const handleCancelRequest = async (request: JoinRequestResponse) => {
setActionKey(`request:${request.id}`);
setMessage(null);
try {
await projectCancelJoinRequest(request.username, request.id);
setMessage({ type: "success", text: "Join request cancelled." });
invalidate();
} catch {
setMessage({ type: "error", text: "Failed to cancel join request." });
} finally {
setActionKey(null);
}
};
const invitations = invitationsQuery.data ?? [];
const requests = requestsQuery.data ?? [];
const isLoading = invitationsQuery.isLoading || requestsQuery.isLoading;
return (
<div className="h-full overflow-y-auto bg-background p-8">
<div className="mx-auto flex max-w-4xl flex-col gap-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">Invitations</h1>
<p className="text-sm text-muted-foreground">
Review project invitations and track your join requests.
</p>
</div>
{message && (
<Alert variant={message.type === "error" ? "destructive" : "default"}>
<AlertDescription>{message.text}</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle>Pending Invitations</CardTitle>
<CardDescription>Invitations sent by project admins.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{isLoading ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="animate-spin" />
</div>
) : invitations.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<Mail />
</EmptyMedia>
<EmptyTitle>No pending invitations</EmptyTitle>
<EmptyDescription>New project invitations will appear here.</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
invitations.map((invitation) => {
const loading = actionKey === `invite:${invitation.project_name}`;
return (
<Card key={invitation.project_uid} size="sm" className="bg-muted/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Link to={`/${invitation.project_name}`} className="hover:underline">
{invitation.project_name}
</Link>
<Badge variant="secondary">{invitation.scope}</Badge>
</CardTitle>
<CardDescription>
Invited by {invitation.invited_by_username ?? "Unknown"} on{" "}
{formatDate(invitation.created_at)}
</CardDescription>
<CardAction className="flex gap-2">
<Button
size="sm"
onClick={() => handleInvitation(invitation.project_name, true)}
disabled={loading}
>
{loading ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Check data-icon="inline-start" />}
Accept
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleInvitation(invitation.project_name, false)}
disabled={loading}
>
<X data-icon="inline-start" />
Reject
</Button>
</CardAction>
</CardHeader>
</Card>
);
})
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Join Requests</CardTitle>
<CardDescription>Your project membership applications.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{isLoading ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="animate-spin" />
</div>
) : requests.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyTitle>No join requests</EmptyTitle>
<EmptyDescription>Projects you apply to join will be tracked here.</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
requests.map((request) => {
const loading = actionKey === `request:${request.id}`;
return (
<Card key={request.id} size="sm" className="bg-muted/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Link to={`/${request.username}`} className="hover:underline">
{request.username}
</Link>
<Badge variant={statusVariant(request.status)}>{request.status}</Badge>
</CardTitle>
<CardDescription>
Submitted on {formatDate(request.created_at)}
{request.reject_reason ? ` · ${request.reject_reason}` : ""}
</CardDescription>
{request.status === "pending" && (
<CardAction>
<Button
size="sm"
variant="outline"
onClick={() => handleCancelRequest(request)}
disabled={loading}
>
{loading ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <X data-icon="inline-start" />}
Cancel
</Button>
</CardAction>
)}
</CardHeader>
</Card>
);
})
)}
</CardContent>
</Card>
</div>
</div>
);
}
export default MyInvitationsPage;

View File

@ -0,0 +1,151 @@
import { useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Loader2, Mail, X } from "lucide-react";
import {
projectAcceptInvitation,
projectMyInvitations,
projectRejectInvitation,
} from "@/client/api";
import type { InvitationResponse } from "@/client/model";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
function formatDate(value?: string | null) {
if (!value) return "Unknown";
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
export function ProjectInvitationPage() {
const { projectName } = useParams<{ projectName: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [isSubmitting, setIsSubmitting] = useState(false);
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
const { data: invitations = [], isLoading } = useQuery({
queryKey: ["project-my-invitations"],
queryFn: async () => {
const res = await projectMyInvitations({ page: 1, per_page: 50 });
return (res.data?.data?.invitations ?? []) as InvitationResponse[];
},
staleTime: 30_000,
});
const invitation = useMemo(
() => invitations.find((item) => item.project_name === projectName),
[invitations, projectName],
);
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ["project-my-invitations"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
};
const handleDecision = async (accept: boolean) => {
if (!projectName) return;
setIsSubmitting(true);
setMessage(null);
try {
if (accept) {
await projectAcceptInvitation(projectName);
} else {
await projectRejectInvitation(projectName);
}
invalidate();
if (accept) {
navigate(`/${projectName}`, { replace: true });
} else {
setMessage({ type: "success", text: "Invitation rejected." });
}
} catch {
setMessage({ type: "error", text: "Failed to process invitation." });
} finally {
setIsSubmitting(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-background p-6">
<div className="w-full max-w-xl">
{message && (
<Alert variant={message.type === "error" ? "destructive" : "default"} className="mb-4">
<AlertDescription>{message.text}</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle>Project Invitation</CardTitle>
<CardDescription>Accept or reject your invitation to join this project.</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="animate-spin" />
</div>
) : !invitation ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<Mail />
</EmptyMedia>
<EmptyTitle>No pending invitation</EmptyTitle>
<EmptyDescription>
This invitation may have already been processed. You can review all invitations from your profile.
</EmptyDescription>
</EmptyHeader>
<Button asChild variant="outline">
<Link to="/me/invitations">View invitations</Link>
</Button>
</Empty>
) : (
<Card size="sm" className="bg-muted/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
{invitation.project_name}
<Badge variant="secondary">{invitation.scope}</Badge>
</CardTitle>
<CardDescription>
Invited by {invitation.invited_by_username ?? "Unknown"} on {formatDate(invitation.created_at)}
</CardDescription>
<CardAction className="flex gap-2">
<Button onClick={() => handleDecision(true)} disabled={isSubmitting}>
{isSubmitting ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Check data-icon="inline-start" />}
Accept
</Button>
<Button variant="outline" onClick={() => handleDecision(false)} disabled={isSubmitting}>
<X data-icon="inline-start" />
Reject
</Button>
</CardAction>
</CardHeader>
</Card>
)}
</CardContent>
</Card>
</div>
</div>
);
}
export default ProjectInvitationPage;

View File

@ -0,0 +1,260 @@
import { useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Send, X } from "lucide-react";
import {
projectCancelJoinRequest,
projectInfo,
projectJoinSettings,
projectMyJoinRequests,
projectSubmitJoinRequest,
} from "@/client/api";
import type {
AnswerRequest,
JoinRequestResponse,
JoinSettingsResponse,
ProjectInfoRelational,
QuestionSchema,
} from "@/client/model";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
function parseQuestions(settings?: JoinSettingsResponse | null): QuestionSchema[] {
if (!settings?.require_questions) return [];
if (Array.isArray(settings.questions)) {
return settings.questions
.map((item) => {
if (typeof item === "string") return { question: item };
if (item && typeof item === "object" && "question" in item) {
return { question: String((item as { question: unknown }).question ?? "") };
}
return { question: "" };
})
.filter((item) => item.question.trim().length > 0);
}
return [];
}
function formatDate(value?: string | null) {
if (!value) return "Unknown";
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
function statusVariant(status: string) {
if (status === "approved") return "default";
if (status === "rejected") return "destructive";
return "secondary";
}
export function ProjectJoinPage() {
const { projectName } = useParams<{ projectName: string }>();
const queryClient = useQueryClient();
const [message, setMessage] = useState("");
const [answers, setAnswers] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [feedback, setFeedback] = useState<{ type: "success" | "error"; text: string } | null>(null);
const projectQuery = useQuery({
queryKey: ["project-info", projectName],
queryFn: async (): Promise<ProjectInfoRelational | null> => {
if (!projectName) return null;
const res = await projectInfo(projectName);
return res.data?.data ?? null;
},
enabled: !!projectName,
retry: false,
staleTime: 60_000,
});
const settingsQuery = useQuery({
queryKey: ["project-join-settings", projectName],
queryFn: async () => {
const res = await projectJoinSettings(projectName!);
return res.data?.data as JoinSettingsResponse;
},
enabled: !!projectName,
staleTime: 30_000,
});
const requestsQuery = useQuery({
queryKey: ["project-my-join-requests"],
queryFn: async () => {
const res = await projectMyJoinRequests({ page: 1, per_page: 50 });
return (res.data?.data?.requests ?? []) as JoinRequestResponse[];
},
staleTime: 30_000,
});
const questions = useMemo(() => parseQuestions(settingsQuery.data), [settingsQuery.data]);
const existingRequest = useMemo(
() => requestsQuery.data?.find((request) => request.username === projectName),
[projectName, requestsQuery.data],
);
const isMember = !!projectQuery.data?.role;
const isLoading = settingsQuery.isLoading || requestsQuery.isLoading;
const canJoinWithoutReason = !settingsQuery.data?.require_approval && questions.length === 0;
const missingRequiredAnswer = questions.some((item) => !answers[item.question]?.trim());
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ["project-my-join-requests"] });
queryClient.invalidateQueries({ queryKey: ["project-info", projectName] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
};
const handleSubmit = async () => {
if (!projectName || missingRequiredAnswer) return;
setIsSubmitting(true);
setFeedback(null);
try {
const payloadAnswers: AnswerRequest[] = questions.map((item) => ({
question: item.question,
answer: answers[item.question]?.trim() ?? "",
}));
await projectSubmitJoinRequest(projectName, {
message: message.trim() || null,
answers: payloadAnswers,
});
setMessage("");
setAnswers({});
setFeedback({ type: "success", text: "Join request submitted." });
invalidate();
} catch {
setFeedback({ type: "error", text: "Failed to submit join request." });
} finally {
setIsSubmitting(false);
}
};
const handleCancel = async () => {
if (!projectName || !existingRequest) return;
setIsSubmitting(true);
setFeedback(null);
try {
await projectCancelJoinRequest(projectName, existingRequest.id);
setFeedback({ type: "success", text: "Join request cancelled." });
invalidate();
} catch {
setFeedback({ type: "error", text: "Failed to cancel join request." });
} finally {
setIsSubmitting(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-background p-6">
<div className="w-full max-w-2xl">
{feedback && (
<Alert variant={feedback.type === "error" ? "destructive" : "default"} className="mb-4">
<AlertDescription>{feedback.text}</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle>Join {projectQuery.data?.display_name || projectName}</CardTitle>
<CardDescription>
{projectQuery.data?.description || "Submit a request to become a project member."}
</CardDescription>
{projectQuery.data && (
<CardAction>
<Badge variant={projectQuery.data.is_public ? "secondary" : "outline"}>
{projectQuery.data.is_public ? "Public" : "Private"}
</Badge>
</CardAction>
)}
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="animate-spin" />
</div>
) : isMember ? (
<div className="flex flex-col gap-4">
<Alert>
<AlertDescription>You are already a member of this project.</AlertDescription>
</Alert>
<Button asChild>
<Link to={`/${projectName}`}>Open project</Link>
</Button>
</div>
) : existingRequest && existingRequest.status !== "cancelled" ? (
<Card size="sm" className="bg-muted/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
Current request
<Badge variant={statusVariant(existingRequest.status)}>{existingRequest.status}</Badge>
</CardTitle>
<CardDescription>
Submitted on {formatDate(existingRequest.created_at)}
{existingRequest.reject_reason ? ` · ${existingRequest.reject_reason}` : ""}
</CardDescription>
{existingRequest.status === "pending" && (
<CardAction>
<Button variant="outline" onClick={handleCancel} disabled={isSubmitting}>
{isSubmitting ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <X data-icon="inline-start" />}
Cancel request
</Button>
</CardAction>
)}
</CardHeader>
</Card>
) : (
<FieldGroup>
{!canJoinWithoutReason && (
<Field>
<FieldLabel htmlFor="join-message">Message</FieldLabel>
<Textarea
id="join-message"
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder="Tell the admins why you want to join."
rows={4}
/>
<FieldDescription>Optional, but useful for private or approval-required projects.</FieldDescription>
</Field>
)}
{questions.map((item, index) => (
<Field key={`${item.question}-${index}`} data-invalid={!answers[item.question]?.trim()}>
<FieldLabel htmlFor={`join-answer-${index}`}>{item.question}</FieldLabel>
<Input
id={`join-answer-${index}`}
value={answers[item.question] ?? ""}
onChange={(event) =>
setAnswers((current) => ({ ...current, [item.question]: event.target.value }))
}
aria-invalid={!answers[item.question]?.trim()}
/>
</Field>
))}
<Button onClick={handleSubmit} disabled={isSubmitting || missingRequiredAnswer}>
{isSubmitting ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Send data-icon="inline-start" />}
{canJoinWithoutReason ? "Join project" : "Submit join request"}
</Button>
</FieldGroup>
)}
</CardContent>
</Card>
</div>
</div>
);
}
export default ProjectJoinPage;