From 4322f36a7680ab5518c21eac292451ad0a4c9d8f Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Thu, 14 May 2026 23:14:21 +0800 Subject: [PATCH] feat: add project invitation and join pages Add MyInvitationsPage, ProjectInvitationPage, and ProjectJoinPage for handling project invitation flows. --- src/app/me/MyInvitationsPage.tsx | 250 +++++++++++++++++++++ src/app/project/ProjectInvitationPage.tsx | 151 +++++++++++++ src/app/project/ProjectJoinPage.tsx | 260 ++++++++++++++++++++++ 3 files changed, 661 insertions(+) create mode 100644 src/app/me/MyInvitationsPage.tsx create mode 100644 src/app/project/ProjectInvitationPage.tsx create mode 100644 src/app/project/ProjectJoinPage.tsx diff --git a/src/app/me/MyInvitationsPage.tsx b/src/app/me/MyInvitationsPage.tsx new file mode 100644 index 0000000..feb64ba --- /dev/null +++ b/src/app/me/MyInvitationsPage.tsx @@ -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(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 ( +
+
+
+

Invitations

+

+ Review project invitations and track your join requests. +

+
+ + {message && ( + + {message.text} + + )} + + + + Pending Invitations + Invitations sent by project admins. + + + {isLoading ? ( +
+ +
+ ) : invitations.length === 0 ? ( + + + + + + No pending invitations + New project invitations will appear here. + + + ) : ( + invitations.map((invitation) => { + const loading = actionKey === `invite:${invitation.project_name}`; + return ( + + + + + {invitation.project_name} + + {invitation.scope} + + + Invited by {invitation.invited_by_username ?? "Unknown"} on{" "} + {formatDate(invitation.created_at)} + + + + + + + + ); + }) + )} +
+
+ + + + Join Requests + Your project membership applications. + + + {isLoading ? ( +
+ +
+ ) : requests.length === 0 ? ( + + + No join requests + Projects you apply to join will be tracked here. + + + ) : ( + requests.map((request) => { + const loading = actionKey === `request:${request.id}`; + return ( + + + + + {request.username} + + {request.status} + + + Submitted on {formatDate(request.created_at)} + {request.reject_reason ? ` · ${request.reject_reason}` : ""} + + {request.status === "pending" && ( + + + + )} + + + ); + }) + )} +
+
+
+
+ ); +} + +export default MyInvitationsPage; diff --git a/src/app/project/ProjectInvitationPage.tsx b/src/app/project/ProjectInvitationPage.tsx new file mode 100644 index 0000000..dc43bba --- /dev/null +++ b/src/app/project/ProjectInvitationPage.tsx @@ -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 ( +
+
+ {message && ( + + {message.text} + + )} + + + + Project Invitation + Accept or reject your invitation to join this project. + + + {isLoading ? ( +
+ +
+ ) : !invitation ? ( + + + + + + No pending invitation + + This invitation may have already been processed. You can review all invitations from your profile. + + + + + ) : ( + + + + {invitation.project_name} + {invitation.scope} + + + Invited by {invitation.invited_by_username ?? "Unknown"} on {formatDate(invitation.created_at)} + + + + + + + + )} +
+
+
+
+ ); +} + +export default ProjectInvitationPage; diff --git a/src/app/project/ProjectJoinPage.tsx b/src/app/project/ProjectJoinPage.tsx new file mode 100644 index 0000000..4167829 --- /dev/null +++ b/src/app/project/ProjectJoinPage.tsx @@ -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>({}); + 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 => { + 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 ( +
+
+ {feedback && ( + + {feedback.text} + + )} + + + + Join {projectQuery.data?.display_name || projectName} + + {projectQuery.data?.description || "Submit a request to become a project member."} + + {projectQuery.data && ( + + + {projectQuery.data.is_public ? "Public" : "Private"} + + + )} + + + {isLoading ? ( +
+ +
+ ) : isMember ? ( +
+ + You are already a member of this project. + + +
+ ) : existingRequest && existingRequest.status !== "cancelled" ? ( + + + + Current request + {existingRequest.status} + + + Submitted on {formatDate(existingRequest.created_at)} + {existingRequest.reject_reason ? ` · ${existingRequest.reject_reason}` : ""} + + {existingRequest.status === "pending" && ( + + + + )} + + + ) : ( + + {!canJoinWithoutReason && ( + + Message +