Compare commits
9 Commits
32bd760b77
...
3a1a7b97db
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a1a7b97db | ||
|
|
31e9bb68ac | ||
|
|
c015871024 | ||
|
|
8702312c32 | ||
|
|
c308fc044d | ||
|
|
f4653f2399 | ||
|
|
4322f36a76 | ||
|
|
b737d19166 | ||
|
|
110945e438 |
@ -22,6 +22,7 @@ const TwoFactorPage = lazy(() => import("@/app/auth/two-factor").then(m => ({ de
|
|||||||
const VerifyEmailPage = lazy(() => import("@/app/auth/verify-email").then(m => ({ default: m.VerifyEmailPage })));
|
const VerifyEmailPage = lazy(() => import("@/app/auth/verify-email").then(m => ({ default: m.VerifyEmailPage })));
|
||||||
const ChangePasswordPage = lazy(() => import("@/app/auth/change-password").then(m => ({ default: m.ChangePasswordPage })));
|
const ChangePasswordPage = lazy(() => import("@/app/auth/change-password").then(m => ({ default: m.ChangePasswordPage })));
|
||||||
const MePage = lazy(() => import("@/app/me").then(m => ({ default: m.MePage })));
|
const MePage = lazy(() => import("@/app/me").then(m => ({ default: m.MePage })));
|
||||||
|
const MyInvitationsPage = lazy(() => import("@/app/me/MyInvitationsPage"));
|
||||||
const ChatPage = lazy(() => import("@/app/chat").then(m => ({ default: m.ChatPage })));
|
const ChatPage = lazy(() => import("@/app/chat").then(m => ({ default: m.ChatPage })));
|
||||||
const ExplorePage = lazy(() => import("@/app/explore/ExplorePage").then(m => ({ default: m.ExplorePage })));
|
const ExplorePage = lazy(() => import("@/app/explore/ExplorePage").then(m => ({ default: m.ExplorePage })));
|
||||||
const MyAccountPage = lazy(() => import("@/app/settings").then(m => ({ default: m.MyAccountPage })));
|
const MyAccountPage = lazy(() => import("@/app/settings").then(m => ({ default: m.MyAccountPage })));
|
||||||
@ -44,6 +45,8 @@ const CommitDetailPage = lazy(() => import("@/app/project").then(m => ({ default
|
|||||||
const IssueDetailPage = lazy(() => import("@/app/project").then(m => ({ default: m.IssueDetailPage })));
|
const IssueDetailPage = lazy(() => import("@/app/project").then(m => ({ default: m.IssueDetailPage })));
|
||||||
const SkillDetailPage = lazy(() => import("@/app/project").then(m => ({ default: m.SkillDetailPage })));
|
const SkillDetailPage = lazy(() => import("@/app/project").then(m => ({ default: m.SkillDetailPage })));
|
||||||
const PullRequestDetailPage = lazy(() => import("@/app/project").then(m => ({ default: m.PullRequestDetailPage })));
|
const PullRequestDetailPage = lazy(() => import("@/app/project").then(m => ({ default: m.PullRequestDetailPage })));
|
||||||
|
const ProjectJoinPage = lazy(() => import("@/app/project").then(m => ({ default: m.ProjectJoinPage })));
|
||||||
|
const ProjectInvitationPage = lazy(() => import("@/app/project").then(m => ({ default: m.ProjectInvitationPage })));
|
||||||
const GeneralSettings = lazy(() => import("@/app/project").then(m => ({ default: m.GeneralSettings })));
|
const GeneralSettings = lazy(() => import("@/app/project").then(m => ({ default: m.GeneralSettings })));
|
||||||
const MembersSettings = lazy(() => import("@/app/project").then(m => ({ default: m.MembersSettings })));
|
const MembersSettings = lazy(() => import("@/app/project").then(m => ({ default: m.MembersSettings })));
|
||||||
const AccessSettings = lazy(() => import("@/app/project").then(m => ({ default: m.AccessSettings })));
|
const AccessSettings = lazy(() => import("@/app/project").then(m => ({ default: m.AccessSettings })));
|
||||||
@ -96,6 +99,7 @@ export default function App() {
|
|||||||
<Route path="/me/followers" element={<MePage />} />
|
<Route path="/me/followers" element={<MePage />} />
|
||||||
<Route path="/me/following" element={<MePage />} />
|
<Route path="/me/following" element={<MePage />} />
|
||||||
<Route path="/me/notify" element={<MePage />} />
|
<Route path="/me/notify" element={<MePage />} />
|
||||||
|
<Route path="/me/invitations" element={<MyInvitationsPage />} />
|
||||||
<Route path="/me/chat" element={<ChatPage scope="personal" />} />
|
<Route path="/me/chat" element={<ChatPage scope="personal" />} />
|
||||||
<Route path="/me/chat/:conversationId" element={<ChatPage scope="personal" />} />
|
<Route path="/me/chat/:conversationId" element={<ChatPage scope="personal" />} />
|
||||||
<Route path="/explore" element={<ExplorePage />} />
|
<Route path="/explore" element={<ExplorePage />} />
|
||||||
@ -117,6 +121,9 @@ export default function App() {
|
|||||||
{/* Channel-based routes if any */}
|
{/* Channel-based routes if any */}
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
<Route path="/projects/:projectName/invitations" element={<ProjectInvitationPage />} />
|
||||||
|
<Route path="/:projectName/join" element={<ProjectJoinPage />} />
|
||||||
|
|
||||||
<Route path="/:projectName" element={<ProjectLayout />}>
|
<Route path="/:projectName" element={<ProjectLayout />}>
|
||||||
<Route index element={<Navigate to="repos" replace />} />
|
<Route index element={<Navigate to="repos" replace />} />
|
||||||
<Route path="repos" element={<ReposPage />} />
|
<Route path="repos" element={<ReposPage />} />
|
||||||
@ -164,4 +171,4 @@ export default function App() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { useProjectInfo } from "@/hooks/useProjectInfo";
|
|||||||
import { useConversationQuery } from "@/hooks/useAiChatQuery";
|
import { useConversationQuery } from "@/hooks/useAiChatQuery";
|
||||||
import { CodePreviewPanel } from "@/components/chat/CodePreviewPanel";
|
import { CodePreviewPanel } from "@/components/chat/CodePreviewPanel";
|
||||||
import { CodePreviewProvider, type CodePreviewPayload } from "@/components/chat/CodePreviewContext";
|
import { CodePreviewProvider, type CodePreviewPayload } from "@/components/chat/CodePreviewContext";
|
||||||
|
import { ProjectJoinBanner, useProjectLayout } from "@/app/project/layout";
|
||||||
|
|
||||||
interface ChatPageProps {
|
interface ChatPageProps {
|
||||||
scope: "personal" | "project";
|
scope: "personal" | "project";
|
||||||
@ -27,6 +28,7 @@ export function ChatPage({ scope }: ChatPageProps) {
|
|||||||
const [userModel, setSelectedModel] = useState<SelectedModel | null>(null);
|
const [userModel, setSelectedModel] = useState<SelectedModel | null>(null);
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(true);
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(true);
|
||||||
const [activeCode, setActiveCode] = useState<CodePreviewPayload | null>(null);
|
const [activeCode, setActiveCode] = useState<CodePreviewPayload | null>(null);
|
||||||
|
const { isProjectPreview } = useProjectLayout();
|
||||||
|
|
||||||
const { data: conversation } = useConversationQuery(selectedConversationId || "");
|
const { data: conversation } = useConversationQuery(selectedConversationId || "");
|
||||||
|
|
||||||
@ -131,24 +133,36 @@ export function ChatPage({ scope }: ChatPageProps) {
|
|||||||
{selectedConversationId ? (
|
{selectedConversationId ? (
|
||||||
<>
|
<>
|
||||||
<ChatMessageList conversationId={selectedConversationId} setIsStreaming={setIsStreaming} />
|
<ChatMessageList conversationId={selectedConversationId} setIsStreaming={setIsStreaming} />
|
||||||
<ChatMessageInput
|
{scope === "project" && isProjectPreview ? (
|
||||||
conversationId={selectedConversationId}
|
<div className="shrink-0 px-4 pb-4">
|
||||||
isStreaming={isStreaming}
|
<div className="mx-auto max-w-3xl">
|
||||||
setIsStreaming={setIsStreaming}
|
<ProjectJoinBanner compact message="Join this project to start project chat." />
|
||||||
onSelectConversation={handleSelectConversation}
|
</div>
|
||||||
/>
|
</div>
|
||||||
|
) : (
|
||||||
|
<ChatMessageInput
|
||||||
|
conversationId={selectedConversationId}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
setIsStreaming={setIsStreaming}
|
||||||
|
onSelectConversation={handleSelectConversation}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center px-4 gap-4">
|
<div className="flex-1 flex flex-col items-center justify-center px-4 gap-4">
|
||||||
<div className="w-full max-w-3xl">
|
<div className="w-full max-w-3xl">
|
||||||
<ChatMessageList conversationId={null} setIsStreaming={setIsStreaming} />
|
<ChatMessageList conversationId={null} setIsStreaming={setIsStreaming} />
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<ChatMessageInput
|
{scope === "project" && isProjectPreview ? (
|
||||||
conversationId={null}
|
<ProjectJoinBanner compact message="Join this project to start project chat." />
|
||||||
isStreaming={isStreaming}
|
) : (
|
||||||
setIsStreaming={setIsStreaming}
|
<ChatMessageInput
|
||||||
onSelectConversation={handleSelectConversation}
|
conversationId={null}
|
||||||
/>
|
isStreaming={isStreaming}
|
||||||
|
setIsStreaming={setIsStreaming}
|
||||||
|
onSelectConversation={handleSelectConversation}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
250
src/app/me/MyInvitationsPage.tsx
Normal file
250
src/app/me/MyInvitationsPage.tsx
Normal 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;
|
||||||
@ -8,6 +8,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Bell,
|
Bell,
|
||||||
|
Mail,
|
||||||
PanelLeftClose
|
PanelLeftClose
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
@ -53,6 +54,11 @@ const ME_NAV_ITEMS: NavItem[] = [
|
|||||||
name: "Notifications",
|
name: "Notifications",
|
||||||
icon: Bell,
|
icon: Bell,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/me/invitations",
|
||||||
|
name: "Invitations",
|
||||||
|
icon: Mail,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/me/stars",
|
path: "/me/stars",
|
||||||
name: "Stars",
|
name: "Stars",
|
||||||
@ -206,4 +212,4 @@ export function MeSidebar({ onCollapse }: MeSidebarProps) {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
151
src/app/project/ProjectInvitationPage.tsx
Normal file
151
src/app/project/ProjectInvitationPage.tsx
Normal 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;
|
||||||
260
src/app/project/ProjectJoinPage.tsx
Normal file
260
src/app/project/ProjectJoinPage.tsx
Normal 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;
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { AlertCircle } from 'lucide-react';
|
import { AlertCircle, MessageSquare, Pin, X } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
useWsConnected,
|
useWsConnected,
|
||||||
getWsClient,
|
getWsClient,
|
||||||
@ -8,17 +8,18 @@ import {
|
|||||||
import {
|
import {
|
||||||
useRoom,
|
useRoom,
|
||||||
} from '@/contexts/room';
|
} from '@/contexts/room';
|
||||||
import { useProjectLayout } from '@/app/project/layout';
|
import { ProjectJoinBanner, useProjectLayout } from '@/app/project/layout';
|
||||||
import type { Message, ReactionGroup, Member, ThreadState } from '@/contexts/room';
|
import type { Message, ReactionGroup, Member, ThreadState } from '@/contexts/room';
|
||||||
import {
|
import {
|
||||||
ThreadPanel,
|
ThreadPanel,
|
||||||
|
PinPanel,
|
||||||
EditHistoryOverlay,
|
EditHistoryOverlay,
|
||||||
MessageList,
|
MessageList,
|
||||||
MessageInput,
|
MessageInput,
|
||||||
} from '@/components/channel';
|
} from '@/components/channel';
|
||||||
import { MentionBottomSheet } from '@/components/channel/mention';
|
import { MentionBottomSheet } from '@/components/channel/mention';
|
||||||
import type { MentionSelection, MentionEntityType } from '@/components/channel/mention/types';
|
import type { MentionSelection, MentionEntityType } from '@/components/channel/mention/types';
|
||||||
import { projectRepos, aiList, skillList } from '@/client/api';
|
import { projectRepos, aiList, skillList, threadCreate, threadMessages } from '@/client/api';
|
||||||
|
|
||||||
function safeGetClient() {
|
function safeGetClient() {
|
||||||
try { return getWsClient(); } catch { return null; }
|
try { return getWsClient(); } catch { return null; }
|
||||||
@ -31,6 +32,8 @@ function ChannelPageInner() {
|
|||||||
wsStatus,
|
wsStatus,
|
||||||
currentRoom,
|
currentRoom,
|
||||||
members,
|
members,
|
||||||
|
pinnedMessages,
|
||||||
|
threads,
|
||||||
messages,
|
messages,
|
||||||
isHistoryLoaded,
|
isHistoryLoaded,
|
||||||
isLoadingMore,
|
isLoadingMore,
|
||||||
@ -38,11 +41,13 @@ function ChannelPageInner() {
|
|||||||
sendMessage,
|
sendMessage,
|
||||||
editMessage,
|
editMessage,
|
||||||
revokeMessage,
|
revokeMessage,
|
||||||
|
removePin,
|
||||||
|
setThreads,
|
||||||
typingUsers,
|
typingUsers,
|
||||||
} = useRoom();
|
} = useRoom();
|
||||||
|
|
||||||
const isConnected = useWsConnected();
|
const isConnected = useWsConnected();
|
||||||
const { setCurrentRoomName } = useProjectLayout();
|
const { isProjectPreview, setCurrentRoomName } = useProjectLayout();
|
||||||
|
|
||||||
// Sync room name to layout Header
|
// Sync room name to layout Header
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -55,6 +60,7 @@ function ChannelPageInner() {
|
|||||||
const [replyToMessageId, setReplyToMessageId] = useState<string | null>(null);
|
const [replyToMessageId, setReplyToMessageId] = useState<string | null>(null);
|
||||||
const [emojiPickerMessageId, setEmojiPickerMessageId] = useState<string | null>(null);
|
const [emojiPickerMessageId, setEmojiPickerMessageId] = useState<string | null>(null);
|
||||||
const [activeThread, setActiveThread] = useState<ThreadState | null>(null);
|
const [activeThread, setActiveThread] = useState<ThreadState | null>(null);
|
||||||
|
const [sidePanel, setSidePanel] = useState<'pins' | 'threads' | null>(null);
|
||||||
const [editHistoryMessageId, setEditHistoryMessageId] = useState<string | null>(null);
|
const [editHistoryMessageId, setEditHistoryMessageId] = useState<string | null>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
@ -75,7 +81,7 @@ function ChannelPageInner() {
|
|||||||
|
|
||||||
// Fetch AI agents, repos, and skills in parallel when room opens
|
// Fetch AI agents, repos, and skills in parallel when room opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!roomIdParam) return;
|
if (!roomIdParam || isProjectPreview) return;
|
||||||
const projectName = window.location.pathname.split('/')[1];
|
const projectName = window.location.pathname.split('/')[1];
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@ -100,7 +106,7 @@ function ChannelPageInner() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [roomIdParam]);
|
}, [isProjectPreview, roomIdParam]);
|
||||||
|
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
@ -113,10 +119,10 @@ function ChannelPageInner() {
|
|||||||
|
|
||||||
// Sync room name to layout Header
|
// Sync room name to layout Header
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (wsStatus === 'connected' && isConnected) {
|
if (!isProjectPreview && wsStatus === 'connected' && isConnected) {
|
||||||
loadHistory();
|
loadHistory();
|
||||||
}
|
}
|
||||||
}, [wsStatus, isConnected, loadHistory]);
|
}, [isProjectPreview, wsStatus, isConnected, loadHistory]);
|
||||||
|
|
||||||
// Load older messages when scrolling to top
|
// Load older messages when scrolling to top
|
||||||
const handleStartReached = useCallback(() => {
|
const handleStartReached = useCallback(() => {
|
||||||
@ -137,6 +143,7 @@ function ChannelPageInner() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (isProjectPreview) return;
|
||||||
const currentValue = e.target.value;
|
const currentValue = e.target.value;
|
||||||
setInputValue(currentValue);
|
setInputValue(currentValue);
|
||||||
|
|
||||||
@ -200,7 +207,7 @@ function ChannelPageInner() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSendMessage = () => {
|
const handleSendMessage = () => {
|
||||||
if (!inputValue.trim()) return;
|
if (isProjectPreview || !inputValue.trim()) return;
|
||||||
const content = resolveContent(inputValue);
|
const content = resolveContent(inputValue);
|
||||||
if (editingMessageId) {
|
if (editingMessageId) {
|
||||||
editMessage(editingMessageId, content);
|
editMessage(editingMessageId, content);
|
||||||
@ -346,35 +353,98 @@ function ChannelPageInner() {
|
|||||||
|
|
||||||
// ── Thread ──
|
// ── Thread ──
|
||||||
|
|
||||||
const openThread = useCallback(
|
const normalizeThreadMessages = useCallback((parent: Message | null, threadMsgs: Message[]) => {
|
||||||
async (msg: Message) => {
|
const map = new Map<string, Message>();
|
||||||
if (!roomIdParam || !msg.thread) return;
|
if (parent) map.set(parent.id, parent);
|
||||||
|
for (const msg of threadMsgs) map.set(msg.id, msg);
|
||||||
|
return [...map.values()].sort((a, b) => a.seq - b.seq);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openThreadByState = useCallback(
|
||||||
|
async (thread: ThreadState, parentMessage?: Message | null) => {
|
||||||
|
if (isProjectPreview || !roomIdParam) return;
|
||||||
try {
|
try {
|
||||||
const { threadMessages } = await import('@/client/api');
|
const res = await threadMessages(roomIdParam, thread.id, { limit: 100 });
|
||||||
const res = await threadMessages(roomIdParam, msg.thread);
|
const threadMsgs: Message[] = (res.data?.data?.messages ?? []).map((r) => ({
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
...r,
|
||||||
const threadMsgs: Message[] = (res.data?.data?.messages ?? []).map((r: any) => ({
|
_localReactions: [],
|
||||||
...r, _localReactions: [], is_streaming: false, isOptimistic: false, isOptimisticError: false, thinking_content: null,
|
is_streaming: false,
|
||||||
|
isOptimistic: false,
|
||||||
|
isOptimisticError: false,
|
||||||
|
thinking_content: null,
|
||||||
}));
|
}));
|
||||||
|
const parent = parentMessage ?? messages.find((m) => m.seq === thread.parent) ?? null;
|
||||||
setActiveThread({
|
setActiveThread({
|
||||||
id: msg.thread, parent: msg.seq, created_by: '', participants: [],
|
...thread,
|
||||||
last_message_at: new Date().toISOString(), last_message_preview: null,
|
messages: normalizeThreadMessages(parent, threadMsgs),
|
||||||
created_at: '', messages: threadMsgs, isOpen: true,
|
isOpen: true,
|
||||||
});
|
});
|
||||||
|
setSidePanel(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ChannelPage] failed to open thread:', err);
|
console.error('[ChannelPage] failed to open thread:', err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[roomIdParam],
|
[isProjectPreview, messages, normalizeThreadMessages, roomIdParam],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const openThread = useCallback(
|
||||||
|
async (msg: Message) => {
|
||||||
|
if (isProjectPreview || !roomIdParam) return;
|
||||||
|
const existing = threads.find((t) => t.id === msg.thread || t.parent === msg.seq);
|
||||||
|
if (existing) {
|
||||||
|
await openThreadByState(existing, msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await threadCreate(roomIdParam, { parent_seq: msg.seq });
|
||||||
|
const data = res.data?.data;
|
||||||
|
if (!data) return;
|
||||||
|
const nextThread: ThreadState = {
|
||||||
|
id: data.id,
|
||||||
|
parent: data.parent,
|
||||||
|
created_by: data.created_by,
|
||||||
|
participants: data.participants,
|
||||||
|
last_message_at: data.last_message_at,
|
||||||
|
last_message_preview: data.last_message_preview ?? null,
|
||||||
|
created_at: data.created_at,
|
||||||
|
messages: [msg],
|
||||||
|
isOpen: true,
|
||||||
|
};
|
||||||
|
setThreads((prev) => (prev.some((t) => t.id === nextThread.id) ? prev : [...prev, nextThread]));
|
||||||
|
setActiveThread(nextThread);
|
||||||
|
setSidePanel(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ChannelPage] failed to create thread:', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isProjectPreview, openThreadByState, roomIdParam, setThreads, threads],
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayedThread = useMemo(() => {
|
||||||
|
if (!activeThread) return null;
|
||||||
|
const parent = messages.find((m) => m.seq === activeThread.parent) ?? null;
|
||||||
|
const liveThreadMessages = messages.filter((m) => m.thread === activeThread.id);
|
||||||
|
const merged = normalizeThreadMessages(parent, [...activeThread.messages, ...liveThreadMessages]);
|
||||||
|
return { ...activeThread, messages: merged };
|
||||||
|
}, [activeThread, messages, normalizeThreadMessages]);
|
||||||
|
|
||||||
const closeThread = () => setActiveThread(null);
|
const closeThread = () => setActiveThread(null);
|
||||||
|
|
||||||
|
const gotoMessage = useCallback((messageId: string) => {
|
||||||
|
setSidePanel(null);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const escape = window.CSS?.escape ?? ((value: string) => value.replace(/"/g, '\\"'));
|
||||||
|
const el = document.querySelector(`[data-message-id="${escape(messageId)}"]`);
|
||||||
|
el?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// ── File upload ──
|
// ── File upload ──
|
||||||
|
|
||||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = e.target.files;
|
const files = e.target.files;
|
||||||
if (!files || files.length === 0 || !roomIdParam) return;
|
if (isProjectPreview || !files || files.length === 0 || !roomIdParam) return;
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@ -424,6 +494,45 @@ function ChannelPageInner() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, padding: '8px 16px', borderBottom: '1px solid var(--border-subtle)' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => { setActiveThread(null); setSidePanel((prev) => (prev === 'pins' ? null : 'pins')); }}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
padding: '5px 10px',
|
||||||
|
border: '1px solid var(--border-default)',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: sidePanel === 'pins' ? 'var(--surface-elevated)' : 'transparent',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontSize: 12,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pin className="w-3 h-3" />
|
||||||
|
Pins {pinnedMessages.length}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setActiveThread(null); setSidePanel((prev) => (prev === 'threads' ? null : 'threads')); }}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
padding: '5px 10px',
|
||||||
|
border: '1px solid var(--border-default)',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: sidePanel === 'threads' ? 'var(--surface-elevated)' : 'transparent',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontSize: 12,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-3 h-3" />
|
||||||
|
Threads {threads.length}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<MessageList
|
<MessageList
|
||||||
messages={messages}
|
messages={messages}
|
||||||
isLoadingHistory={isLoadingMore}
|
isLoadingHistory={isLoadingMore}
|
||||||
@ -438,6 +547,7 @@ function ChannelPageInner() {
|
|||||||
onShowEditHistory={setEditHistoryMessageId}
|
onShowEditHistory={setEditHistoryMessageId}
|
||||||
onStartReached={handleStartReached}
|
onStartReached={handleStartReached}
|
||||||
roomId={roomIdParam}
|
roomId={roomIdParam}
|
||||||
|
readOnly={isProjectPreview}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{mentionOpen && (
|
{mentionOpen && (
|
||||||
@ -463,36 +573,98 @@ function ChannelPageInner() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MessageInput
|
{isProjectPreview ? (
|
||||||
value={inputValue}
|
<div className="shrink-0 p-4">
|
||||||
roomName={currentRoom?.room_name ?? roomIdParam}
|
<ProjectJoinBanner compact message="Join this project to send messages in channels." />
|
||||||
isConnected={isConnected}
|
</div>
|
||||||
isEditing={!!editingMessageId}
|
) : (
|
||||||
replyToMessageId={replyToMessageId}
|
<MessageInput
|
||||||
uploading={uploading}
|
value={inputValue}
|
||||||
inputRef={inputRef}
|
roomName={currentRoom?.room_name ?? roomIdParam}
|
||||||
fileInputRef={fileInputRef}
|
isConnected={isConnected}
|
||||||
onChange={handleInputChange}
|
isEditing={!!editingMessageId}
|
||||||
onKeyDown={handleKeyDown}
|
replyToMessageId={replyToMessageId}
|
||||||
onSend={handleSendMessage}
|
uploading={uploading}
|
||||||
onFileSelect={handleFileSelect}
|
inputRef={inputRef}
|
||||||
onCancelReply={() => setReplyToMessageId(null)}
|
fileInputRef={fileInputRef}
|
||||||
onCancelEdit={() => { setEditingMessageId(null); setInputValue(''); pendingMentionsRef.current.clear(); }}
|
onChange={handleInputChange}
|
||||||
onMention={handleOpenMention}
|
onKeyDown={handleKeyDown}
|
||||||
/>
|
onSend={handleSendMessage}
|
||||||
|
onFileSelect={handleFileSelect}
|
||||||
|
onCancelReply={() => setReplyToMessageId(null)}
|
||||||
|
onCancelEdit={() => { setEditingMessageId(null); setInputValue(''); pendingMentionsRef.current.clear(); }}
|
||||||
|
onMention={handleOpenMention}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeThread && (
|
{activeThread && (
|
||||||
<ThreadPanel
|
<ThreadPanel
|
||||||
thread={activeThread}
|
thread={displayedThread ?? activeThread}
|
||||||
typingUsers={typingUsersList}
|
typingUsers={typingUsersList}
|
||||||
onClose={closeThread}
|
onClose={closeThread}
|
||||||
sendMessage={(content: string, opts?: { contentType?: string; thread?: string; inReplyTo?: string; attachmentIds?: string[] }) => sendMessage(content, opts)}
|
sendMessage={(content: string, opts?: { contentType?: string; thread?: string; inReplyTo?: string; attachmentIds?: string[] }) => sendMessage(content, opts)}
|
||||||
onTypingStart={() => { const c = safeGetClient(); if (c) c.sendTypingStart(roomIdParam); }}
|
onTypingStart={() => { const c = safeGetClient(); if (c) c.sendTypingStart(roomIdParam); }}
|
||||||
onTypingStop={() => { const c = safeGetClient(); if (c) c.sendTypingStop(roomIdParam); }}
|
onTypingStop={() => { const c = safeGetClient(); if (c) c.sendTypingStop(roomIdParam); }}
|
||||||
|
readOnly={isProjectPreview}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!activeThread && sidePanel === 'pins' && (
|
||||||
|
<PinPanel
|
||||||
|
pins={pinnedMessages}
|
||||||
|
messages={messages}
|
||||||
|
onClose={() => setSidePanel(null)}
|
||||||
|
onGotoMessage={gotoMessage}
|
||||||
|
onUnpin={isProjectPreview ? undefined : (messageId) => {
|
||||||
|
removePin(messageId).catch((err) => console.error('[ChannelPage] failed to unpin message:', err));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!activeThread && sidePanel === 'threads' && (
|
||||||
|
<div className="thread-panel">
|
||||||
|
<div className="thread-panel-header">
|
||||||
|
<div className="thread-panel-title">
|
||||||
|
<MessageSquare className="w-4 h-4" style={{ color: 'var(--text-primary)' }} />
|
||||||
|
<span style={{ color: 'var(--text-primary)', fontWeight: 600, fontSize: 14 }}>Threads</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setSidePanel(null)} className="thread-close-btn" title="Close Threads">
|
||||||
|
<X className="w-4 h-4" style={{ color: 'var(--text-muted)' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="thread-messages">
|
||||||
|
{threads.length === 0 ? (
|
||||||
|
<div style={{ color: 'var(--text-muted)', fontSize: 13, padding: 12 }}>
|
||||||
|
No threads yet. Use the message action menu to start one.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
threads.map((thread) => {
|
||||||
|
const parent = messages.find((msg) => msg.seq === thread.parent) ?? null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={thread.id}
|
||||||
|
onClick={() => openThreadByState(thread, parent)}
|
||||||
|
className="thread-list-item"
|
||||||
|
disabled={isProjectPreview}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600 }}>
|
||||||
|
{parent?.display_name ?? `Message #${thread.parent}`}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--text-secondary)', fontSize: 12, lineHeight: 1.4 }}>
|
||||||
|
{thread.last_message_preview ?? parent?.content ?? 'Thread has no replies yet.'}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--text-muted)', fontSize: 11 }}>
|
||||||
|
Updated {new Date(thread.last_message_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{editHistoryMessageId && (
|
{editHistoryMessageId && (
|
||||||
<EditHistoryOverlay
|
<EditHistoryOverlay
|
||||||
messageId={editHistoryMessageId}
|
messageId={editHistoryMessageId}
|
||||||
|
|||||||
@ -15,3 +15,5 @@ export { AccessSettings } from "./settings/AccessSettings";
|
|||||||
export { LabelsSettings } from "./settings/LabelsSettings";
|
export { LabelsSettings } from "./settings/LabelsSettings";
|
||||||
export { BillingSettings } from "./settings/BillingSettings";
|
export { BillingSettings } from "./settings/BillingSettings";
|
||||||
export { ProjectCreateMenuModal } from "./components/ProjectCreateMenuModal";
|
export { ProjectCreateMenuModal } from "./components/ProjectCreateMenuModal";
|
||||||
|
export { ProjectJoinPage } from "./ProjectJoinPage";
|
||||||
|
export { ProjectInvitationPage } from "./ProjectInvitationPage";
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import { memo, useState, useMemo, useDeferredValue, useRef, useCallback } from "
|
|||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { stripMarkdown, truncate } from "@/lib/utils";
|
import { stripMarkdown, truncate } from "@/lib/utils";
|
||||||
import type { IssueResponse, IssueLabelResponse } from "@/client/model";
|
import type { IssueResponse, IssueLabelResponse } from "@/client/model";
|
||||||
|
import { useProjectLayout } from "@/app/project/layout";
|
||||||
|
|
||||||
interface IssueRowProps {
|
interface IssueRowProps {
|
||||||
issue: IssueResponse;
|
issue: IssueResponse;
|
||||||
@ -108,6 +109,7 @@ const OVERSCAN = 5;
|
|||||||
export function IssuesPage() {
|
export function IssuesPage() {
|
||||||
const { projectName } = useParams<{ projectName: string }>();
|
const { projectName } = useParams<{ projectName: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { isProjectPreview } = useProjectLayout();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'open' | 'closed'>('open');
|
const [activeTab, setActiveTab] = useState<'open' | 'closed'>('open');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@ -199,13 +201,15 @@ export function IssuesPage() {
|
|||||||
<h1 className={ISSUES_PAGE.pageTitle}>Issues</h1>
|
<h1 className={ISSUES_PAGE.pageTitle}>Issues</h1>
|
||||||
<p className={ISSUES_PAGE.pageSub}>Track and manage project tasks and bugs</p>
|
<p className={ISSUES_PAGE.pageSub}>Track and manage project tasks and bugs</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
{!isProjectPreview && (
|
||||||
onClick={() => navigate(`/${projectName}/issues/new`)}
|
<button
|
||||||
className={ISSUES_PAGE.newBtn}
|
onClick={() => navigate(`/${projectName}/issues/new`)}
|
||||||
>
|
className={ISSUES_PAGE.newBtn}
|
||||||
<Plus className="w-4 h-4" />
|
>
|
||||||
New issue
|
<Plus className="w-4 h-4" />
|
||||||
</button>
|
New issue
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
@ -268,7 +272,7 @@ export function IssuesPage() {
|
|||||||
<p className="text-sm text-muted-foreground mt-1 mb-6 text-center max-w-[300px]">
|
<p className="text-sm text-muted-foreground mt-1 mb-6 text-center max-w-[300px]">
|
||||||
{searchQuery ? "Try adjusting your search or filters to find what you're looking for." : "You're all caught up! Create an issue to track new tasks."}
|
{searchQuery ? "Try adjusting your search or filters to find what you're looking for." : "You're all caught up! Create an issue to track new tasks."}
|
||||||
</p>
|
</p>
|
||||||
{!searchQuery && activeTab === 'open' && (
|
{!isProjectPreview && !searchQuery && activeTab === 'open' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/${projectName}/issues/new`)}
|
onClick={() => navigate(`/${projectName}/issues/new`)}
|
||||||
className={ISSUES_PAGE.newBtn}
|
className={ISSUES_PAGE.newBtn}
|
||||||
@ -307,4 +311,4 @@ export function IssuesPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default IssuesPage;
|
export default IssuesPage;
|
||||||
|
|||||||
@ -11,10 +11,12 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Info
|
Info
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { ProjectJoinBanner, useProjectLayout } from "@/app/project/layout";
|
||||||
|
|
||||||
export function NewIssuePage() {
|
export function NewIssuePage() {
|
||||||
const { projectName } = useParams<{ projectName: string }>();
|
const { projectName } = useParams<{ projectName: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { isProjectPreview } = useProjectLayout();
|
||||||
|
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [body, setBody] = useState("");
|
const [body, setBody] = useState("");
|
||||||
@ -22,6 +24,16 @@ export function NewIssuePage() {
|
|||||||
|
|
||||||
const createMutation = useCreateIssueMutation(projectName);
|
const createMutation = useCreateIssueMutation(projectName);
|
||||||
|
|
||||||
|
if (isProjectPreview) {
|
||||||
|
return (
|
||||||
|
<div className={ISSUES_PAGE.container}>
|
||||||
|
<div className="max-w-[800px] w-full mx-auto mt-8">
|
||||||
|
<ProjectJoinBanner message="Join this project before creating issues." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!title.trim()) {
|
if (!title.trim()) {
|
||||||
|
|||||||
@ -1,19 +1,24 @@
|
|||||||
import { Outlet, useMatch, useParams } from "react-router-dom";
|
import { Link, Outlet, useMatch, useParams } from "react-router-dom";
|
||||||
import { useState } from "react";
|
import { createContext, useContext, useState } from "react";
|
||||||
import { PanelLeftOpen } from "lucide-react";
|
import { Lock, PanelLeftOpen } from "lucide-react";
|
||||||
import { ServerIconRail } from "@/components/layout/ServerIconRail";
|
import { ServerIconRail } from "@/components/layout/ServerIconRail";
|
||||||
import { ChannelSidebar } from "@/components/layout/ChannelSidebar";
|
import { ChannelSidebar } from "@/components/layout/ChannelSidebar";
|
||||||
import { Header } from "@/components/layout/Header";
|
import { Header } from "@/components/layout/Header";
|
||||||
import { MemberList } from "@/components/layout/MemberList";
|
import { MemberList } from "@/components/layout/MemberList";
|
||||||
import { RoomProvider } from "@/contexts/room";
|
import { RoomProvider } from "@/contexts/room";
|
||||||
import { createContext, useContext } from "react";
|
|
||||||
import { useIsMobile, useIsTablet } from "@/hooks/use-mobile";
|
import { useIsMobile, useIsTablet } from "@/hooks/use-mobile";
|
||||||
|
import { useProjectInfo } from "@/hooks/useProjectInfo";
|
||||||
|
import type { ProjectInfoRelational } from "@/client/model";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
interface ProjectContextType {
|
interface ProjectContextType {
|
||||||
showMembers: boolean;
|
showMembers: boolean;
|
||||||
setShowMembers: (v: boolean) => void;
|
setShowMembers: (v: boolean) => void;
|
||||||
currentRoomName: string | null;
|
currentRoomName: string | null;
|
||||||
setCurrentRoomName: (name: string | null) => void;
|
setCurrentRoomName: (name: string | null) => void;
|
||||||
|
projectInfo: ProjectInfoRelational | null;
|
||||||
|
isProjectMember: boolean;
|
||||||
|
isProjectPreview: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProjectContext = createContext<ProjectContextType>({
|
const ProjectContext = createContext<ProjectContextType>({
|
||||||
@ -21,29 +26,78 @@ const ProjectContext = createContext<ProjectContextType>({
|
|||||||
setShowMembers: () => {},
|
setShowMembers: () => {},
|
||||||
currentRoomName: null,
|
currentRoomName: null,
|
||||||
setCurrentRoomName: () => {},
|
setCurrentRoomName: () => {},
|
||||||
|
projectInfo: null,
|
||||||
|
isProjectMember: false,
|
||||||
|
isProjectPreview: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const useProjectLayout = () => useContext(ProjectContext);
|
export const useProjectLayout = () => useContext(ProjectContext);
|
||||||
|
|
||||||
|
export function ProjectJoinBanner({
|
||||||
|
compact = false,
|
||||||
|
message = "Join this project to participate and use project tools.",
|
||||||
|
}: {
|
||||||
|
compact?: boolean;
|
||||||
|
message?: string;
|
||||||
|
}) {
|
||||||
|
const { projectInfo } = useProjectLayout();
|
||||||
|
const projectName = projectInfo?.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex ${compact ? "items-center justify-between gap-3 rounded-lg px-4 py-3" : "items-start justify-between gap-4 px-6 py-4"} border bg-muted/30`}
|
||||||
|
style={{ borderColor: "var(--border-subtle)" }}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
|
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||||
|
<Lock className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-foreground">Preview mode</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{projectName && (
|
||||||
|
<Button asChild size={compact ? "sm" : "default"} className="shrink-0">
|
||||||
|
<Link to={`/${projectName}/join`}>Apply to join</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ProjectLayout() {
|
export function ProjectLayout() {
|
||||||
const [showMembers, setShowMembers] = useState(false);
|
const [showMembers, setShowMembers] = useState(false);
|
||||||
const [currentRoomName, setCurrentRoomName] = useState<string | null>(null);
|
const [currentRoomName, setCurrentRoomName] = useState<string | null>(null);
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||||
const { projectName } = useParams<{ projectName: string }>();
|
const { projectName } = useParams<{ projectName: string }>();
|
||||||
|
const { data: projectInfo = null } = useProjectInfo(projectName);
|
||||||
const channelMatch = useMatch("/:projectName/channel/:roomId");
|
const channelMatch = useMatch("/:projectName/channel/:roomId");
|
||||||
const chatMatch = useMatch("/:projectName/chat/*");
|
const chatMatch = useMatch("/:projectName/chat/*");
|
||||||
const roomId = channelMatch?.params.roomId ?? null;
|
const roomId = channelMatch?.params.roomId ?? null;
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const isTablet = useIsTablet();
|
const isTablet = useIsTablet();
|
||||||
|
|
||||||
const canShowMembers = !isMobile && !isTablet;
|
const isProjectMember = !!projectInfo?.role;
|
||||||
|
const isProjectPreview = !!projectInfo && !projectInfo.role;
|
||||||
|
const canShowMembers = !isMobile && !isTablet && isProjectMember;
|
||||||
|
|
||||||
const mainShouldOwnScroll = !channelMatch && !chatMatch;
|
const mainShouldOwnScroll = !channelMatch && !chatMatch;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProjectContext.Provider value={{ showMembers, setShowMembers, currentRoomName, setCurrentRoomName }}>
|
<ProjectContext.Provider
|
||||||
<RoomProvider roomId={roomId} projectName={projectName}>
|
value={{
|
||||||
|
showMembers,
|
||||||
|
setShowMembers,
|
||||||
|
currentRoomName,
|
||||||
|
setCurrentRoomName,
|
||||||
|
projectInfo,
|
||||||
|
isProjectMember,
|
||||||
|
isProjectPreview,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RoomProvider roomId={isProjectMember ? roomId : null} projectName={projectName}>
|
||||||
<div className="flex h-screen overflow-hidden" style={{ backgroundColor: "var(--surface-ground)" }}>
|
<div className="flex h-screen overflow-hidden" style={{ backgroundColor: "var(--surface-ground)" }}>
|
||||||
{!isMobile && <ServerIconRail />}
|
{!isMobile && <ServerIconRail />}
|
||||||
|
|
||||||
@ -89,6 +143,16 @@ export function ProjectLayout() {
|
|||||||
style={{ backgroundColor: "var(--surface-ground)" }}
|
style={{ backgroundColor: "var(--surface-ground)" }}
|
||||||
>
|
>
|
||||||
<Header />
|
<Header />
|
||||||
|
{isProjectPreview && (
|
||||||
|
<ProjectJoinBanner
|
||||||
|
compact
|
||||||
|
message={
|
||||||
|
projectInfo?.is_public
|
||||||
|
? "This public project is read-only until you join."
|
||||||
|
: "You need to join before using project actions."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<main
|
<main
|
||||||
className={mainShouldOwnScroll ? "flex-1 overflow-y-auto" : "flex-1 overflow-hidden min-h-0"}
|
className={mainShouldOwnScroll ? "flex-1 overflow-y-auto" : "flex-1 overflow-hidden min-h-0"}
|
||||||
style={{ backgroundColor: "var(--surface-ground)" }}
|
style={{ backgroundColor: "var(--surface-ground)" }}
|
||||||
|
|||||||
@ -1,192 +1,560 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Check, Copy, Loader2, Mail, Plus, Shield, Trash2, User, X } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
projectInvitations, projectInviteUser, projectCancelInvitation,
|
projectCancelInvitation,
|
||||||
projectJoinSettings, projectUpdateJoinSettings,
|
projectInvitations,
|
||||||
projectJoinRequests, projectProcessJoinRequest,
|
projectInviteUser,
|
||||||
|
projectJoinAnswers,
|
||||||
|
projectJoinRequests,
|
||||||
|
projectJoinSettings,
|
||||||
|
projectProcessJoinRequest,
|
||||||
|
projectUpdateJoinSettings,
|
||||||
} from "@/client/api";
|
} from "@/client/api";
|
||||||
import type { InvitationResponse, JoinSettingsResponse, JoinRequestResponse, MemberRole, QuestionSchema } from "@/client/model";
|
import type {
|
||||||
|
InvitationResponse,
|
||||||
|
JoinAnswerResponse,
|
||||||
|
JoinRequestResponse,
|
||||||
|
JoinSettingsResponse,
|
||||||
|
MemberRole,
|
||||||
|
QuestionSchema,
|
||||||
|
} from "@/client/model";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
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";
|
||||||
|
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Loader2, Mail, X, Check, Shield, User, EyeOff } from "lucide-react";
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
type ActionKey = `invite:${string}` | `request:${number}:${"approve" | "reject"}` | "settings" | null;
|
||||||
|
|
||||||
|
function parseQuestions(settings?: JoinSettingsResponse | null): QuestionSchema[] {
|
||||||
|
if (!settings || !Array.isArray(settings.questions)) return [];
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value?: string | null) {
|
||||||
|
if (!value) return "Unknown";
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInvitationUrl(projectName?: string) {
|
||||||
|
if (!projectName) return "";
|
||||||
|
return `${window.location.origin}/projects/${projectName}/invitations`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function JoinRequestAnswers({ projectName, requestId }: { projectName: string; requestId: number }) {
|
||||||
|
const { data = [], isLoading } = useQuery({
|
||||||
|
queryKey: ["project-join-answers", projectName, requestId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await projectJoinAnswers(projectName, requestId);
|
||||||
|
return (res.data?.data?.answers ?? []) as JoinAnswerResponse[];
|
||||||
|
},
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
Loading answers...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 rounded-lg bg-muted/30 p-3">
|
||||||
|
{data.map((answer) => (
|
||||||
|
<div key={`${answer.question}-${answer.created_at}`} className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">{answer.question}</span>
|
||||||
|
<span className="text-sm">{answer.answer}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AccessSettings() {
|
export function AccessSettings() {
|
||||||
const { projectName } = useParams<{ projectName: string }>();
|
const { projectName } = useParams<{ projectName: string }>();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [inviteForm, setInviteForm] = useState({ email: "", scope: "Member" as MemberRole });
|
||||||
|
const [requestRoles, setRequestRoles] = useState<Record<number, MemberRole>>({});
|
||||||
|
const [rejectReasons, setRejectReasons] = useState<Record<number, string>>({});
|
||||||
|
const [settingsDraft, setSettingsDraft] = useState<{
|
||||||
|
require_approval: boolean;
|
||||||
|
require_questions: boolean;
|
||||||
|
questions: QuestionSchema[];
|
||||||
|
} | null>(null);
|
||||||
|
const [actionKey, setActionKey] = useState<ActionKey>(null);
|
||||||
|
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||||
|
|
||||||
// Invitations
|
const invitationsQuery = useQuery({
|
||||||
const { data: invData, isLoading: invLoading } = useQuery({
|
|
||||||
queryKey: ["project-invitations", projectName],
|
queryKey: ["project-invitations", projectName],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await projectInvitations(projectName!, {});
|
const res = await projectInvitations(projectName!, { page: 1, per_page: 50 });
|
||||||
return (res.data?.data?.invitations ?? []) as InvitationResponse[];
|
return (res.data?.data?.invitations ?? []) as InvitationResponse[];
|
||||||
},
|
},
|
||||||
enabled: !!projectName, staleTime: 30_000,
|
enabled: !!projectName,
|
||||||
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Join settings
|
const settingsQuery = useQuery({
|
||||||
const { data: joinSettings, isLoading: jsLoading } = useQuery({
|
|
||||||
queryKey: ["project-join-settings", projectName],
|
queryKey: ["project-join-settings", projectName],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await projectJoinSettings(projectName!);
|
const res = await projectJoinSettings(projectName!);
|
||||||
return res.data?.data as JoinSettingsResponse;
|
return res.data?.data as JoinSettingsResponse;
|
||||||
},
|
},
|
||||||
enabled: !!projectName, staleTime: 30_000,
|
enabled: !!projectName,
|
||||||
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Join requests
|
const requestsQuery = useQuery({
|
||||||
const { data: joinReqs } = useQuery({
|
|
||||||
queryKey: ["project-join-requests", projectName],
|
queryKey: ["project-join-requests", projectName],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await projectJoinRequests(projectName!, { status: "pending" });
|
const res = await projectJoinRequests(projectName!, { status: "pending", page: 1, per_page: 50 });
|
||||||
return (res.data?.data?.requests ?? []) as JoinRequestResponse[];
|
return (res.data?.data?.requests ?? []) as JoinRequestResponse[];
|
||||||
},
|
},
|
||||||
enabled: !!projectName, staleTime: 30_000,
|
enabled: !!projectName,
|
||||||
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [inviteForm, setInviteForm] = useState({ email: "", scope: "Member" as MemberRole });
|
const currentSettings = useMemo(() => {
|
||||||
const [sending, setSending] = useState(false);
|
if (!settingsQuery.data) return null;
|
||||||
const [actionLoading, setActionLoading] = useState<string | number | null>(null);
|
return {
|
||||||
const [jsSaving, setJsSaving] = useState(false);
|
require_approval: settingsQuery.data.require_approval,
|
||||||
const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
require_questions: settingsQuery.data.require_questions,
|
||||||
|
questions: parseQuestions(settingsQuery.data),
|
||||||
|
};
|
||||||
|
}, [settingsQuery.data]);
|
||||||
|
|
||||||
|
const draft = settingsDraft ?? currentSettings;
|
||||||
|
const pendingInvitations = (invitationsQuery.data ?? []).filter((item) => !item.accepted && !item.rejected);
|
||||||
|
const pendingRequests = requestsQuery.data ?? [];
|
||||||
|
|
||||||
const invalidateAll = () => {
|
const invalidateAll = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["project-invitations", projectName] });
|
queryClient.invalidateQueries({ queryKey: ["project-invitations", projectName] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["project-join-settings", projectName] });
|
queryClient.invalidateQueries({ queryKey: ["project-join-settings", projectName] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["project-join-requests", projectName] });
|
queryClient.invalidateQueries({ queryKey: ["project-join-requests", projectName] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["project-members-grouped", projectName] });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInvite = async () => {
|
const handleInvite = async () => {
|
||||||
if (!inviteForm.email.trim()) return;
|
if (!projectName || !inviteForm.email.trim()) return;
|
||||||
try { setSending(true); setMsg(null); await projectInviteUser(projectName!, { email: inviteForm.email.trim(), scope: inviteForm.scope }); setMsg({ type: "success", text: "Invitation sent" }); setInviteForm({ email: "", scope: "Member" }); invalidateAll(); }
|
setActionKey("settings");
|
||||||
catch { setMsg({ type: "error", text: "Failed to send invitation" }); }
|
setMessage(null);
|
||||||
finally { setSending(false); }
|
try {
|
||||||
|
await projectInviteUser(projectName, {
|
||||||
|
email: inviteForm.email.trim(),
|
||||||
|
scope: inviteForm.scope,
|
||||||
|
});
|
||||||
|
setInviteForm({ email: "", scope: "Member" });
|
||||||
|
setMessage({ type: "success", text: "Invitation sent." });
|
||||||
|
invalidateAll();
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: "error", text: "Failed to send invitation." });
|
||||||
|
} finally {
|
||||||
|
setActionKey(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelInvite = async (userId: string) => {
|
const handleCancelInvite = async (userId: string) => {
|
||||||
try { setActionLoading(userId); await projectCancelInvitation(projectName!, userId); invalidateAll(); }
|
if (!projectName) return;
|
||||||
catch { setMsg({ type: "error", text: "Failed to cancel invitation" }); }
|
setActionKey(`invite:${userId}`);
|
||||||
finally { setActionLoading(null); }
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
await projectCancelInvitation(projectName, userId);
|
||||||
|
setMessage({ type: "success", text: "Invitation cancelled." });
|
||||||
|
invalidateAll();
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: "error", text: "Failed to cancel invitation." });
|
||||||
|
} finally {
|
||||||
|
setActionKey(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProcessRequest = async (reqId: number, approve: boolean) => {
|
const handleProcessRequest = async (request: JoinRequestResponse, approve: boolean) => {
|
||||||
try { setActionLoading(reqId); await projectProcessJoinRequest(projectName!, reqId, { approve, scope: "Member", reject_reason: approve ? null : "Rejected" }); setMsg({ type: "success", text: approve ? "Request approved" : "Request rejected" }); invalidateAll(); }
|
if (!projectName) return;
|
||||||
catch { setMsg({ type: "error", text: "Failed to process request" }); }
|
setActionKey(`request:${request.id}:${approve ? "approve" : "reject"}`);
|
||||||
finally { setActionLoading(null); }
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
await projectProcessJoinRequest(projectName, request.id, {
|
||||||
|
approve,
|
||||||
|
scope: requestRoles[request.id] ?? "Member",
|
||||||
|
reject_reason: approve ? null : rejectReasons[request.id]?.trim() || "Rejected",
|
||||||
|
});
|
||||||
|
setMessage({ type: "success", text: approve ? "Join request approved." : "Join request rejected." });
|
||||||
|
invalidateAll();
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: "error", text: "Failed to process join request." });
|
||||||
|
} finally {
|
||||||
|
setActionKey(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleApproval = async () => {
|
const handleSaveSettings = async () => {
|
||||||
if (!joinSettings) return;
|
if (!projectName || !draft) return;
|
||||||
try { setJsSaving(true); await projectUpdateJoinSettings(projectName!, { require_approval: !joinSettings.require_approval, require_questions: joinSettings.require_questions, questions: (joinSettings.questions as QuestionSchema[]) || [] }); setMsg({ type: "success", text: "Join settings updated" }); invalidateAll(); }
|
const questions = draft.require_questions
|
||||||
catch { setMsg({ type: "error", text: "Failed to update join settings" }); }
|
? draft.questions.map((item) => ({ question: item.question.trim() })).filter((item) => item.question)
|
||||||
finally { setJsSaving(false); }
|
: [];
|
||||||
|
setActionKey("settings");
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
await projectUpdateJoinSettings(projectName, {
|
||||||
|
require_approval: draft.require_approval,
|
||||||
|
require_questions: draft.require_questions,
|
||||||
|
questions,
|
||||||
|
});
|
||||||
|
setSettingsDraft(null);
|
||||||
|
setMessage({ type: "success", text: "Join settings saved." });
|
||||||
|
invalidateAll();
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: "error", text: "Failed to save join settings." });
|
||||||
|
} finally {
|
||||||
|
setActionKey(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCopyInviteLink = async () => {
|
||||||
|
if (!projectName) return;
|
||||||
|
await navigator.clipboard.writeText(getInvitationUrl(projectName));
|
||||||
|
setMessage({ type: "success", text: "Invitation link copied." });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSettingsDirty = settingsDraft !== null;
|
||||||
|
const isSettingsSaving = actionKey === "settings";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="flex flex-col gap-6">
|
||||||
{msg && <div className="text-[13px]" style={{ color: msg.type === "success" ? "var(--success)" : "var(--destructive)" }}>{msg.text}</div>}
|
{message && (
|
||||||
|
<Alert variant={message.type === "error" ? "destructive" : "default"}>
|
||||||
{/* Invite */}
|
<AlertDescription>{message.text}</AlertDescription>
|
||||||
<section className="p-4 rounded-lg" style={{ backgroundColor: "var(--surface-elevated)", border: "1px solid var(--border-default)" }}>
|
</Alert>
|
||||||
<h2 className="text-[14px] font-semibold mb-3" style={{ color: "var(--text-primary)" }}>Invite Member</h2>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
value={inviteForm.email}
|
|
||||||
onChange={e => setInviteForm(f => ({ ...f, email: e.target.value }))}
|
|
||||||
placeholder="user@example.com"
|
|
||||||
className="text-[14px] flex-1"
|
|
||||||
style={{ backgroundColor: "var(--surface-ground)", borderColor: "var(--border-default)", color: "var(--text-primary)" }}
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
value={inviteForm.scope}
|
|
||||||
onChange={e => setInviteForm(f => ({ ...f, scope: e.target.value as MemberRole }))}
|
|
||||||
className="text-[14px] px-3 rounded-md"
|
|
||||||
style={{ backgroundColor: "var(--surface-ground)", border: "1px solid var(--border-default)", color: "var(--text-primary)" }}
|
|
||||||
>
|
|
||||||
<option value="Admin">Admin</option>
|
|
||||||
<option value="Member">Member</option>
|
|
||||||
</select>
|
|
||||||
<Button size="sm" onClick={handleInvite} disabled={sending || !inviteForm.email.trim()} style={{ backgroundColor: "var(--accent)", color: "var(--accent-fg)" }}>
|
|
||||||
{sending ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Mail className="w-3.5 h-3.5 mr-1" />}
|
|
||||||
Invite
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Pending Invitations */}
|
|
||||||
<section className="p-4 rounded-lg" style={{ backgroundColor: "var(--surface-elevated)", border: "1px solid var(--border-default)" }}>
|
|
||||||
<h2 className="text-[14px] font-semibold mb-3" style={{ color: "var(--text-primary)" }}>
|
|
||||||
Pending Invitations {invData ? `(${invData.filter(i => !i.accepted && !i.rejected).length})` : ""}
|
|
||||||
</h2>
|
|
||||||
{invLoading ? <Loader2 className="w-4 h-4 animate-spin" /> :
|
|
||||||
!invData?.length ? <p className="text-[13px]" style={{ color: "var(--text-muted)" }}>No pending invitations</p> :
|
|
||||||
<div className="space-y-1">
|
|
||||||
{invData.filter(i => !i.accepted && !i.rejected).map(inv => (
|
|
||||||
<div key={inv.user_uid} className="flex items-center justify-between py-2 px-3 rounded" style={{ backgroundColor: "var(--surface-ground)" }}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{inv.scope === "Admin" ? <Shield className="w-4 h-4" style={{ color: "var(--role-orange)" }} /> : <User className="w-4 h-4" style={{ color: "var(--role-blue)" }} />}
|
|
||||||
<span className="text-[13px]" style={{ color: "var(--text-primary)" }}>{inv.user_uid}</span>
|
|
||||||
<span className="text-[11px] px-1.5 py-0.5 rounded" style={{ backgroundColor: "var(--hover-bg)", color: "var(--text-muted)" }}>{inv.scope}</span>
|
|
||||||
</div>
|
|
||||||
<Button size="sm" variant="ghost" className="h-7 text-[12px]" style={{ color: "var(--destructive)" }} onClick={() => handleCancelInvite(inv.user_uid)} disabled={actionLoading === inv.user_uid}>
|
|
||||||
{actionLoading === inv.user_uid ? <Loader2 className="w-3 h-3 animate-spin" /> : <X className="w-3 h-3" />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Join Settings */}
|
|
||||||
<section className="p-4 rounded-lg" style={{ backgroundColor: "var(--surface-elevated)", border: "1px solid var(--border-default)" }}>
|
|
||||||
<h2 className="text-[14px] font-semibold mb-3" style={{ color: "var(--text-primary)" }}>Join Settings</h2>
|
|
||||||
{jsLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : joinSettings ? (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-[14px] font-medium" style={{ color: "var(--text-primary)" }}>
|
|
||||||
{joinSettings.require_approval ? "Approval required" : "Open access"}
|
|
||||||
</p>
|
|
||||||
<p className="text-[12px]" style={{ color: "var(--text-muted)" }}>
|
|
||||||
{joinSettings.require_approval ? "New members must be approved by an admin" : "Anyone can join without approval"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button size="sm" variant="outline" onClick={handleToggleApproval} disabled={jsSaving}>
|
|
||||||
{jsSaving ? <Loader2 className="w-3.5 h-3.5 mr-2 animate-spin" /> : joinSettings.require_approval ? <EyeOff className="w-3.5 h-3.5 mr-2" /> : <Check className="w-3.5 h-3.5 mr-2" />}
|
|
||||||
{joinSettings.require_approval ? "Disable Approval" : "Require Approval"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Pending Join Requests */}
|
|
||||||
{joinSettings?.require_approval && (
|
|
||||||
<section className="p-4 rounded-lg" style={{ backgroundColor: "var(--surface-elevated)", border: "1px solid var(--border-default)" }}>
|
|
||||||
<h2 className="text-[14px] font-semibold mb-3" style={{ color: "var(--text-primary)" }}>
|
|
||||||
Pending Join Requests {joinReqs ? `(${joinReqs.length})` : ""}
|
|
||||||
</h2>
|
|
||||||
{!joinReqs?.length ? <p className="text-[13px]" style={{ color: "var(--text-muted)" }}>No pending requests</p> :
|
|
||||||
<div className="space-y-1">
|
|
||||||
{joinReqs.map(req => (
|
|
||||||
<div key={req.id} className="flex items-center justify-between py-2 px-3 rounded" style={{ backgroundColor: "var(--surface-ground)" }}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-[13px]" style={{ color: "var(--text-primary)" }}>{req.username}</span>
|
|
||||||
{req.message && <span className="text-[12px] truncate max-w-[200px]" style={{ color: "var(--text-muted)" }}>{req.message}</span>}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button size="sm" variant="ghost" className="h-7 text-[12px]" style={{ color: "var(--success)" }} onClick={() => handleProcessRequest(req.id, true)} disabled={actionLoading === req.id}>
|
|
||||||
{actionLoading === req.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="ghost" className="h-7 text-[12px]" style={{ color: "var(--destructive)" }} onClick={() => handleProcessRequest(req.id, false)} disabled={actionLoading === req.id}>
|
|
||||||
{actionLoading === req.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <X className="w-3.5 h-3.5" />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Invite Member</CardTitle>
|
||||||
|
<CardDescription>Invite an existing user by email. The invitee can accept or reject from their invitations page.</CardDescription>
|
||||||
|
<CardAction>
|
||||||
|
<Button size="sm" variant="outline" onClick={handleCopyInviteLink}>
|
||||||
|
<Copy data-icon="inline-start" />
|
||||||
|
Copy invite link
|
||||||
|
</Button>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="invite-email">Email</FieldLabel>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Input
|
||||||
|
id="invite-email"
|
||||||
|
value={inviteForm.email}
|
||||||
|
onChange={(event) => setInviteForm((current) => ({ ...current, email: event.target.value }))}
|
||||||
|
placeholder="user@example.com"
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={inviteForm.scope}
|
||||||
|
onValueChange={(value) => setInviteForm((current) => ({ ...current, scope: value as MemberRole }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full sm:w-36">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="Member">Member</SelectItem>
|
||||||
|
<SelectItem value="Admin">Admin</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button onClick={handleInvite} disabled={isSettingsSaving || !inviteForm.email.trim()}>
|
||||||
|
{isSettingsSaving ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Mail data-icon="inline-start" />}
|
||||||
|
Invite
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<FieldDescription>Copied invitation links open the invite decision page for this project.</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Join Settings</CardTitle>
|
||||||
|
<CardDescription>Control whether users can apply to join and whether they must answer questions.</CardDescription>
|
||||||
|
<CardAction>
|
||||||
|
<Button size="sm" onClick={handleSaveSettings} disabled={!isSettingsDirty || isSettingsSaving}>
|
||||||
|
{isSettingsSaving ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Check data-icon="inline-start" />}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{settingsQuery.isLoading || !draft ? (
|
||||||
|
<div className="flex items-center justify-center py-10">
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FieldGroup>
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<Switch
|
||||||
|
checked={draft.require_approval}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setSettingsDraft({ ...draft, require_approval: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<FieldLabel>Require admin approval</FieldLabel>
|
||||||
|
<FieldDescription>Submitted join requests remain pending until an admin approves them.</FieldDescription>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<Switch
|
||||||
|
checked={draft.require_questions}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setSettingsDraft({
|
||||||
|
...draft,
|
||||||
|
require_questions: checked,
|
||||||
|
questions: checked && draft.questions.length === 0 ? [{ question: "" }] : draft.questions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<FieldLabel>Require answers</FieldLabel>
|
||||||
|
<FieldDescription>Ask applicants to answer project-specific questions.</FieldDescription>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{draft.require_questions && (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Questions</FieldLabel>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{draft.questions.map((item, index) => (
|
||||||
|
<div key={index} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={item.question}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextQuestions = [...draft.questions];
|
||||||
|
nextQuestions[index] = { question: event.target.value };
|
||||||
|
setSettingsDraft({ ...draft, questions: nextQuestions });
|
||||||
|
}}
|
||||||
|
placeholder="What do you want to ask?"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() =>
|
||||||
|
setSettingsDraft({
|
||||||
|
...draft,
|
||||||
|
questions: draft.questions.filter((_, questionIndex) => questionIndex !== index),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setSettingsDraft({ ...draft, questions: [...draft.questions, { question: "" }] })}
|
||||||
|
>
|
||||||
|
<Plus data-icon="inline-start" />
|
||||||
|
Add question
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</FieldGroup>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Pending Invitations</CardTitle>
|
||||||
|
<CardDescription>Invitations that have not been accepted or rejected.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
{invitationsQuery.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-10">
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : pendingInvitations.length === 0 ? (
|
||||||
|
<Empty>
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyMedia variant="icon">
|
||||||
|
<Mail />
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle>No pending invitations</EmptyTitle>
|
||||||
|
<EmptyDescription>Invitations you send will be listed here.</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
</Empty>
|
||||||
|
) : (
|
||||||
|
pendingInvitations.map((invitation) => {
|
||||||
|
const loading = actionKey === `invite:${invitation.user_uid}`;
|
||||||
|
return (
|
||||||
|
<Card key={invitation.user_uid} size="sm" className="bg-muted/20">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
{invitation.scope === "Admin" ? <Shield /> : <User />}
|
||||||
|
<span className="font-mono text-sm">{invitation.user_uid}</span>
|
||||||
|
<Badge variant="secondary">{invitation.scope}</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Invited by {invitation.invited_by_username ?? "Unknown"} on {formatDate(invitation.created_at)}
|
||||||
|
</CardDescription>
|
||||||
|
<CardAction>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleCancelInvite(invitation.user_uid)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <X data-icon="inline-start" />}
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Join Requests</CardTitle>
|
||||||
|
<CardDescription>Approve or reject users who requested project access.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
{requestsQuery.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-10">
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : pendingRequests.length === 0 ? (
|
||||||
|
<Empty>
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyTitle>No pending requests</EmptyTitle>
|
||||||
|
<EmptyDescription>User applications will appear here.</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
</Empty>
|
||||||
|
) : (
|
||||||
|
pendingRequests.map((request) => {
|
||||||
|
const approveLoading = actionKey === `request:${request.id}:approve`;
|
||||||
|
const rejectLoading = actionKey === `request:${request.id}:reject`;
|
||||||
|
return (
|
||||||
|
<Card key={request.id} size="sm" className="bg-muted/20">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
{request.username}
|
||||||
|
<Badge variant="secondary">pending</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Requested on {formatDate(request.created_at)}
|
||||||
|
{request.message ? ` · ${request.message}` : ""}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
{projectName && <JoinRequestAnswers projectName={projectName} requestId={request.id} />}
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-end">
|
||||||
|
<Field className="md:max-w-40">
|
||||||
|
<FieldLabel>Approval role</FieldLabel>
|
||||||
|
<Select
|
||||||
|
value={requestRoles[request.id] ?? "Member"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setRequestRoles((current) => ({ ...current, [request.id]: value as MemberRole }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="Member">Member</SelectItem>
|
||||||
|
<SelectItem value="Admin">Admin</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field className="flex-1">
|
||||||
|
<FieldLabel>Reject reason</FieldLabel>
|
||||||
|
<Textarea
|
||||||
|
value={rejectReasons[request.id] ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
setRejectReasons((current) => ({ ...current, [request.id]: event.target.value }))
|
||||||
|
}
|
||||||
|
rows={2}
|
||||||
|
placeholder="Optional reason shown in request history."
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleProcessRequest(request, true)}
|
||||||
|
disabled={approveLoading || rejectLoading}
|
||||||
|
>
|
||||||
|
{approveLoading ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Check data-icon="inline-start" />}
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleProcessRequest(request, false)}
|
||||||
|
disabled={approveLoading || rejectLoading}
|
||||||
|
>
|
||||||
|
{rejectLoading ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <X data-icon="inline-start" />}
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,11 +48,19 @@ export const {
|
|||||||
projectRemoveMember,
|
projectRemoveMember,
|
||||||
projectInvitations,
|
projectInvitations,
|
||||||
projectInviteUser,
|
projectInviteUser,
|
||||||
|
projectMyInvitations,
|
||||||
|
projectAcceptInvitation,
|
||||||
|
projectRejectInvitation,
|
||||||
projectCancelInvitation,
|
projectCancelInvitation,
|
||||||
projectJoinSettings,
|
projectJoinSettings,
|
||||||
projectUpdateJoinSettings,
|
projectUpdateJoinSettings,
|
||||||
projectJoinRequests,
|
projectJoinRequests,
|
||||||
|
projectMyJoinRequests,
|
||||||
|
projectSubmitJoinRequest,
|
||||||
|
projectCancelJoinRequest,
|
||||||
projectProcessJoinRequest,
|
projectProcessJoinRequest,
|
||||||
|
projectJoinAnswers,
|
||||||
|
projectSubmitJoinAnswers,
|
||||||
projectRolePriorities,
|
projectRolePriorities,
|
||||||
projectUpsertRolePriority,
|
projectUpsertRolePriority,
|
||||||
projectDeleteRolePriority,
|
projectDeleteRolePriority,
|
||||||
@ -156,6 +164,8 @@ export const {
|
|||||||
categoryList,
|
categoryList,
|
||||||
categoryCreate,
|
categoryCreate,
|
||||||
participantList,
|
participantList,
|
||||||
|
pinAdd,
|
||||||
|
pinRemove,
|
||||||
pinList,
|
pinList,
|
||||||
threadList,
|
threadList,
|
||||||
threadCreate,
|
threadCreate,
|
||||||
|
|||||||
@ -21,6 +21,7 @@ interface Props {
|
|||||||
onSetEmojiPicker: (msgId: string | null) => void;
|
onSetEmojiPicker: (msgId: string | null) => void;
|
||||||
onShowEditHistory: (msgId: string) => void;
|
onShowEditHistory: (msgId: string) => void;
|
||||||
roomId: string;
|
roomId: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMMON_EMOJIS = ['👍', '❤️', '😄', '😭', '😡', '🎉', '🚀', '👀'];
|
const COMMON_EMOJIS = ['👍', '❤️', '😄', '😭', '😡', '🎉', '🚀', '👀'];
|
||||||
@ -37,14 +38,16 @@ export function MessageItem({
|
|||||||
onOpenThread,
|
onOpenThread,
|
||||||
onSetEmojiPicker,
|
onSetEmojiPicker,
|
||||||
onShowEditHistory,
|
onShowEditHistory,
|
||||||
|
readOnly = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const isRevoked = !!msg.revoked;
|
const isRevoked = !!msg.revoked;
|
||||||
const { addPin, removePin, pinnedMessages } = useRoom();
|
const { addPin, removePin, pinnedMessages } = useRoom();
|
||||||
const isPinned = pinnedMessages.some((p) => p.message === msg.id);
|
const isPinned = pinnedMessages.some((p) => p.message === msg.id);
|
||||||
const handleTogglePin = () => {
|
const handleTogglePin = () => {
|
||||||
if (isPinned) removePin(msg.id);
|
if (readOnly) return;
|
||||||
else addPin(msg.id);
|
const action = isPinned ? removePin(msg.id) : addPin(msg.id);
|
||||||
|
action.catch((err) => console.error('[MessageItem] failed to toggle pin:', err));
|
||||||
};
|
};
|
||||||
const isEdited = !!msg.edited_at;
|
const isEdited = !!msg.edited_at;
|
||||||
const isStreaming = msg.is_streaming === true;
|
const isStreaming = msg.is_streaming === true;
|
||||||
@ -53,6 +56,7 @@ export function MessageItem({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
data-message-id={msg.id}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
padding: isCompact ? '1px 16px 1px' : '4px 16px 4px',
|
padding: isCompact ? '1px 16px 1px' : '4px 16px 4px',
|
||||||
@ -160,7 +164,7 @@ export function MessageItem({
|
|||||||
{reactions.map((reaction) => (
|
{reactions.map((reaction) => (
|
||||||
<button
|
<button
|
||||||
key={reaction.emoji}
|
key={reaction.emoji}
|
||||||
onClick={() => onReaction(msg.id, reaction.emoji)}
|
onClick={readOnly ? undefined : () => onReaction(msg.id, reaction.emoji)}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@ -169,7 +173,7 @@ export function MessageItem({
|
|||||||
background: reaction.reacted_by_me ? 'var(--accent-muted)' : 'var(--surface-elevated)',
|
background: reaction.reacted_by_me ? 'var(--accent-muted)' : 'var(--surface-elevated)',
|
||||||
border: `1px solid ${reaction.reacted_by_me ? 'var(--accent)' : 'transparent'}`,
|
border: `1px solid ${reaction.reacted_by_me ? 'var(--accent)' : 'transparent'}`,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
cursor: 'pointer',
|
cursor: readOnly ? 'default' : 'pointer',
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
transition: 'all 0.1s',
|
transition: 'all 0.1s',
|
||||||
}}
|
}}
|
||||||
@ -179,13 +183,13 @@ export function MessageItem({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
onClick={() => onSetEmojiPicker(showPicker ? null : msg.id)}
|
onClick={readOnly ? undefined : () => onSetEmojiPicker(showPicker ? null : msg.id)}
|
||||||
style={{
|
style={{
|
||||||
padding: '2px 6px',
|
padding: '2px 6px',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: '1px dashed var(--border-default)',
|
border: '1px dashed var(--border-default)',
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
cursor: 'pointer',
|
cursor: readOnly ? 'default' : 'pointer',
|
||||||
color: 'var(--text-muted)',
|
color: 'var(--text-muted)',
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
}}
|
}}
|
||||||
@ -230,7 +234,7 @@ export function MessageItem({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Thread badge */}
|
{/* Thread badge */}
|
||||||
{msg.thread && (
|
{msg.thread && !readOnly && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onOpenThread(msg)}
|
onClick={() => onOpenThread(msg)}
|
||||||
style={{
|
style={{
|
||||||
@ -254,7 +258,7 @@ export function MessageItem({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hover actions — Discord-style: button center aligns with message top edge */}
|
{/* Hover actions — Discord-style: button center aligns with message top edge */}
|
||||||
{isHovered && !isRevoked && (
|
{isHovered && !isRevoked && !readOnly && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@ -285,6 +289,13 @@ export function MessageItem({
|
|||||||
>
|
>
|
||||||
<Reply className="w-3 h-3" />
|
<Reply className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onOpenThread(msg)}
|
||||||
|
title={msg.thread ? 'Open thread' : 'Start thread'}
|
||||||
|
className={MESSAGE_ITEM.actionButton}
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
{msg.sender_type !== 'ai' && (
|
{msg.sender_type !== 'ai' && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@ -302,16 +313,16 @@ export function MessageItem({
|
|||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={handleTogglePin}
|
|
||||||
title={isPinned ? 'Unpin' : 'Pin'}
|
|
||||||
className={MESSAGE_ITEM.actionButton}
|
|
||||||
style={{ color: isPinned ? 'var(--text-primary)' : undefined }}
|
|
||||||
>
|
|
||||||
<Pin className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleTogglePin}
|
||||||
|
title={isPinned ? 'Unpin' : 'Pin'}
|
||||||
|
className={MESSAGE_ITEM.actionButton}
|
||||||
|
style={{ color: isPinned ? 'var(--text-primary)' : undefined }}
|
||||||
|
>
|
||||||
|
<Pin className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -41,6 +41,7 @@ interface Props {
|
|||||||
onShowEditHistory: (msgId: string) => void;
|
onShowEditHistory: (msgId: string) => void;
|
||||||
onStartReached?: () => void;
|
onStartReached?: () => void;
|
||||||
roomId: string;
|
roomId: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageList({
|
export function MessageList({
|
||||||
@ -57,6 +58,7 @@ export function MessageList({
|
|||||||
onShowEditHistory,
|
onShowEditHistory,
|
||||||
onStartReached,
|
onStartReached,
|
||||||
roomId,
|
roomId,
|
||||||
|
readOnly = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||||
const initialScrollDoneRef = useRef(false);
|
const initialScrollDoneRef = useRef(false);
|
||||||
@ -208,6 +210,7 @@ export function MessageList({
|
|||||||
onSetEmojiPicker={onSetEmojiPicker}
|
onSetEmojiPicker={onSetEmojiPicker}
|
||||||
onShowEditHistory={onShowEditHistory}
|
onShowEditHistory={onShowEditHistory}
|
||||||
roomId={roomId}
|
roomId={roomId}
|
||||||
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -1,14 +1,18 @@
|
|||||||
import { Pin, X } from 'lucide-react';
|
import { Pin, PinOff, X } from 'lucide-react';
|
||||||
import type { PinnedMessage } from '@/contexts/room';
|
import type { Message, PinnedMessage } from '@/contexts/room';
|
||||||
import { formatRelativeTime } from '@/contexts/room';
|
import { formatRelativeTime } from '@/contexts/room';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pins: PinnedMessage[];
|
pins: PinnedMessage[];
|
||||||
|
messages: Message[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onGotoMessage: (messageId: string) => void;
|
onGotoMessage: (messageId: string) => void;
|
||||||
|
onUnpin?: (messageId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PinPanel({ pins, onClose, onGotoMessage }: Props) {
|
export function PinPanel({ pins, messages, onClose, onGotoMessage, onUnpin }: Props) {
|
||||||
|
const messageById = new Map(messages.map((msg) => [msg.id, msg]));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pin-panel">
|
<div className="pin-panel">
|
||||||
<div className="pin-panel-header">
|
<div className="pin-panel-header">
|
||||||
@ -28,23 +32,60 @@ export function PinPanel({ pins, onClose, onGotoMessage }: Props) {
|
|||||||
No pinned messages yet.
|
No pinned messages yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
pins.map((pin) => (
|
pins.map((pin) => {
|
||||||
<button
|
const msg = messageById.get(pin.message);
|
||||||
key={pin.message}
|
return (
|
||||||
className="pin-item"
|
<div key={pin.message} className="pin-item" style={{ alignItems: 'flex-start', gap: 8 }}>
|
||||||
onClick={() => onGotoMessage(pin.message)}
|
<button
|
||||||
>
|
onClick={() => onGotoMessage(pin.message)}
|
||||||
<Pin className="w-3 h-3" style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
style={{
|
||||||
<span style={{ color: 'var(--text-muted)', fontSize: 12 }}>
|
flex: 1,
|
||||||
#{pin.message.slice(0, 8)}
|
minWidth: 0,
|
||||||
</span>
|
display: 'flex',
|
||||||
<span style={{ color: 'var(--text-muted)', fontSize: 11, marginLeft: 'auto' }}>
|
flexDirection: 'column',
|
||||||
{formatRelativeTime(pin.pinned_at)}
|
gap: 4,
|
||||||
</span>
|
background: 'none',
|
||||||
</button>
|
border: 'none',
|
||||||
))
|
padding: 0,
|
||||||
|
textAlign: 'left',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--text-primary)', fontSize: 13, fontWeight: 600 }}>
|
||||||
|
{msg?.display_name ?? msg?.sender_id ?? `#${pin.message.slice(0, 8)}`}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: msg ? 'var(--text-secondary)' : 'var(--text-muted)',
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 3,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{msg?.content ?? 'Pinned message is not loaded in the current history window.'}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--text-muted)', fontSize: 11 }}>
|
||||||
|
Pinned {formatRelativeTime(pin.pinned_at)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{onUnpin && (
|
||||||
|
<button
|
||||||
|
onClick={() => onUnpin(pin.message)}
|
||||||
|
title="Unpin"
|
||||||
|
className="thread-close-btn"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<PinOff className="w-3 h-3" style={{ color: 'var(--text-muted)' }} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,9 @@ import type { Message } from '@/contexts/room';
|
|||||||
import { formatTime } from '@/contexts/room';
|
import { formatTime } from '@/contexts/room';
|
||||||
import { Avatar } from './Avatar';
|
import { Avatar } from './Avatar';
|
||||||
import { getWsClient } from '@/ws';
|
import { getWsClient } from '@/ws';
|
||||||
|
import { ProjectJoinBanner } from '@/app/project/layout';
|
||||||
|
import { IrRenderer } from '@/lib/ir/renderer';
|
||||||
|
import { extractIrNodes } from '@/lib/ir/parser';
|
||||||
|
|
||||||
function safeGetClient() {
|
function safeGetClient() {
|
||||||
try { return getWsClient(); } catch { return null; }
|
try { return getWsClient(); } catch { return null; }
|
||||||
@ -21,6 +24,7 @@ interface Props {
|
|||||||
sendMessage: (content: string, opts?: { thread?: string }) => void;
|
sendMessage: (content: string, opts?: { thread?: string }) => void;
|
||||||
onTypingStart: () => void;
|
onTypingStart: () => void;
|
||||||
onTypingStop: () => void;
|
onTypingStop: () => void;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMMON_EMOJIS = ['👍', '❤️', '😄', '😭', '😡', '🎉'];
|
const COMMON_EMOJIS = ['👍', '❤️', '😄', '😭', '😡', '🎉'];
|
||||||
@ -32,6 +36,7 @@ export function ThreadPanel({
|
|||||||
sendMessage,
|
sendMessage,
|
||||||
onTypingStart,
|
onTypingStart,
|
||||||
onTypingStop,
|
onTypingStop,
|
||||||
|
readOnly = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
const [showReactionPicker, setShowReactionPicker] = useState<string | null>(null);
|
const [showReactionPicker, setShowReactionPicker] = useState<string | null>(null);
|
||||||
@ -43,7 +48,7 @@ export function ThreadPanel({
|
|||||||
}, [thread.messages]);
|
}, [thread.messages]);
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
if (!inputValue.trim()) return;
|
if (readOnly || !inputValue.trim()) return;
|
||||||
sendMessage(inputValue.trim(), { thread: thread.id });
|
sendMessage(inputValue.trim(), { thread: thread.id });
|
||||||
setInputValue('');
|
setInputValue('');
|
||||||
onTypingStop();
|
onTypingStop();
|
||||||
@ -51,6 +56,7 @@ export function ThreadPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (readOnly) return;
|
||||||
setInputValue(e.target.value);
|
setInputValue(e.target.value);
|
||||||
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
|
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
|
||||||
onTypingStart();
|
onTypingStart();
|
||||||
@ -58,11 +64,13 @@ export function ThreadPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleResolve = () => {
|
const handleResolve = () => {
|
||||||
|
if (readOnly) return;
|
||||||
const c = safeGetClient();
|
const c = safeGetClient();
|
||||||
if (c) c.resolveThread(thread.id);
|
if (c) c.resolveThread(thread.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleArchive = () => {
|
const handleArchive = () => {
|
||||||
|
if (readOnly) return;
|
||||||
const c = safeGetClient();
|
const c = safeGetClient();
|
||||||
if (c) c.archiveThread(thread.id);
|
if (c) c.archiveThread(thread.id);
|
||||||
};
|
};
|
||||||
@ -118,7 +126,7 @@ export function ThreadPanel({
|
|||||||
{isRevoked ? (
|
{isRevoked ? (
|
||||||
<span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>Message deleted</span>
|
<span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>Message deleted</span>
|
||||||
) : (
|
) : (
|
||||||
msg.content
|
<IrRenderer nodes={extractIrNodes(msg.content)} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -136,6 +144,11 @@ export function ThreadPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{readOnly ? (
|
||||||
|
<div className="thread-input-area">
|
||||||
|
<ProjectJoinBanner compact message="Join this project to reply in threads." />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="thread-input-area">
|
<div className="thread-input-area">
|
||||||
<button
|
<button
|
||||||
className="thread-reaction-btn"
|
className="thread-reaction-btn"
|
||||||
@ -164,6 +177,7 @@ export function ThreadPanel({
|
|||||||
<Send className="w-4 h-4" />
|
<Send className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,8 +41,9 @@ interface ChannelSidebarProps {
|
|||||||
export const ChannelSidebar = memo(function ChannelSidebar({onCollapse}: ChannelSidebarProps) {
|
export const ChannelSidebar = memo(function ChannelSidebar({onCollapse}: ChannelSidebarProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const {projectName} = useParams<{ projectName: string }>();
|
const {projectName} = useParams<{ projectName: string }>();
|
||||||
const {data: roomsData, isLoading} = useRoomsQuery(projectName);
|
|
||||||
const {data: projectInfo} = useProjectInfo(projectName);
|
const {data: projectInfo} = useProjectInfo(projectName);
|
||||||
|
const isProjectMember = !!projectInfo?.role;
|
||||||
|
const {data: roomsData, isLoading} = useRoomsQuery(isProjectMember ? projectName : undefined);
|
||||||
const [isCreateMenuOpen, setIsCreateMenuOpen] = useState(false);
|
const [isCreateMenuOpen, setIsCreateMenuOpen] = useState(false);
|
||||||
|
|
||||||
const rooms = useMemo(() => roomsData?.rooms ?? [], [roomsData?.rooms]);
|
const rooms = useMemo(() => roomsData?.rooms ?? [], [roomsData?.rooms]);
|
||||||
@ -103,14 +104,16 @@ export const ChannelSidebar = memo(function ChannelSidebar({onCollapse}: Channel
|
|||||||
>
|
>
|
||||||
<Search className="w-[14px] h-[14px]"/>
|
<Search className="w-[14px] h-[14px]"/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
{isProjectMember && (
|
||||||
onClick={() => setIsCreateMenuOpen(true)}
|
<button
|
||||||
className={CHANNEL_SIDEBAR.iconButton}
|
onClick={() => setIsCreateMenuOpen(true)}
|
||||||
style={{color: "var(--text-secondary)"}}
|
className={CHANNEL_SIDEBAR.iconButton}
|
||||||
title="Create new..."
|
style={{color: "var(--text-secondary)"}}
|
||||||
>
|
title="Create new..."
|
||||||
<Plus className="w-[14px] h-[14px]"/>
|
>
|
||||||
</button>
|
<Plus className="w-[14px] h-[14px]"/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onCollapse && (
|
{onCollapse && (
|
||||||
<button
|
<button
|
||||||
onClick={onCollapse}
|
onClick={onCollapse}
|
||||||
@ -178,7 +181,7 @@ export const ChannelSidebar = memo(function ChannelSidebar({onCollapse}: Channel
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading ? (
|
{!isProjectMember ? null : isLoading ? (
|
||||||
<div className="px-4 py-2 text-[var(--text-muted)]">Loading channels...</div>
|
<div className="px-4 py-2 text-[var(--text-muted)]">Loading channels...</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -39,6 +39,7 @@ const ME_NAV_SIBLINGS: BreadcrumbSibling[] = [
|
|||||||
{ label: "Stars", path: "/me/stars" },
|
{ label: "Stars", path: "/me/stars" },
|
||||||
{ label: "Following", path: "/me/following" },
|
{ label: "Following", path: "/me/following" },
|
||||||
{ label: "Followers", path: "/me/followers" },
|
{ label: "Followers", path: "/me/followers" },
|
||||||
|
{ label: "Invitations", path: "/me/invitations" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function getProjectNavSiblings(projectName: string): BreadcrumbSibling[] {
|
function getProjectNavSiblings(projectName: string): BreadcrumbSibling[] {
|
||||||
@ -167,7 +168,7 @@ const TOOLBAR_ICONS = [
|
|||||||
export const Header = memo(function Header() {
|
export const Header = memo(function Header() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { segments, projects } = useBreadcrumbs();
|
const { segments, projects } = useBreadcrumbs();
|
||||||
const { showMembers, setShowMembers } = useProjectLayout();
|
const { isProjectMember, showMembers, setShowMembers } = useProjectLayout();
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
const roomContext = useOptionalRoom();
|
const roomContext = useOptionalRoom();
|
||||||
|
|
||||||
@ -254,7 +255,7 @@ export const Header = memo(function Header() {
|
|||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
{location.pathname.startsWith("/me") ? null : (
|
{location.pathname.startsWith("/me") ? null : (
|
||||||
<>
|
<>
|
||||||
{roomContext?.currentRoom && location.pathname.includes("/channel/") && (
|
{isProjectMember && roomContext?.currentRoom && location.pathname.includes("/channel/") && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSettings(true)}
|
onClick={() => setShowSettings(true)}
|
||||||
className="w-8 h-8 flex items-center justify-center rounded-[4px] transition-colors hover:bg-hover-bg"
|
className="w-8 h-8 flex items-center justify-center rounded-[4px] transition-colors hover:bg-hover-bg"
|
||||||
@ -264,32 +265,34 @@ export const Header = memo(function Header() {
|
|||||||
<Settings className="w-[18px] h-[18px]" />
|
<Settings className="w-[18px] h-[18px]" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
{isProjectMember && (
|
||||||
onClick={() => setShowMembers(!showMembers)}
|
<button
|
||||||
className="w-8 h-8 flex items-center justify-center rounded-[4px] transition-colors"
|
onClick={() => setShowMembers(!showMembers)}
|
||||||
style={
|
className="w-8 h-8 flex items-center justify-center rounded-[4px] transition-colors"
|
||||||
showMembers
|
style={
|
||||||
? {
|
showMembers
|
||||||
color: "var(--text-primary)",
|
? {
|
||||||
backgroundColor: "var(--hover-bg-strong)",
|
color: "var(--text-primary)",
|
||||||
}
|
backgroundColor: "var(--hover-bg-strong)",
|
||||||
: { color: "var(--text-secondary)" }
|
}
|
||||||
}
|
: { color: "var(--text-secondary)" }
|
||||||
>
|
}
|
||||||
<svg
|
|
||||||
className="w-[18px] h-[18px]"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
strokeLinecap="round"
|
className="w-[18px] h-[18px]"
|
||||||
strokeLinejoin="round"
|
fill="none"
|
||||||
strokeWidth={1.5}
|
stroke="currentColor"
|
||||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"
|
viewBox="0 0 24 24"
|
||||||
/>
|
>
|
||||||
</svg>
|
<path
|
||||||
</button>
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{TOOLBAR_ICONS.map((icon, i) => (
|
{TOOLBAR_ICONS.map((icon, i) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useWsEvent, useWsStatus, getWsClient, useRoomSubscription } from '@/ws';
|
import { useWsEvent, useWsStatus, getWsClient, useRoomSubscription } from '@/ws';
|
||||||
import { roomGet, participantList, pinList, threadList } from '@/client/api';
|
import { roomGet, participantList, pinAdd, pinRemove, pinList, threadList } from '@/client/api';
|
||||||
import type { AxiosResponse } from 'axios';
|
import type { AxiosResponse } from 'axios';
|
||||||
import type {
|
import type {
|
||||||
ApiResponseRoomResponse,
|
ApiResponseRoomResponse,
|
||||||
@ -74,8 +74,8 @@ export interface RoomContextValue {
|
|||||||
deleteAi: (agentId: string) => void;
|
deleteAi: (agentId: string) => void;
|
||||||
|
|
||||||
/** Pin management */
|
/** Pin management */
|
||||||
addPin: (messageId: string) => void;
|
addPin: (messageId: string) => Promise<void>;
|
||||||
removePin: (messageId: string) => void;
|
removePin: (messageId: string) => Promise<void>;
|
||||||
|
|
||||||
/** Member state */
|
/** Member state */
|
||||||
updateDoNotDisturb: (dnd: boolean) => Promise<void>;
|
updateDoNotDisturb: (dnd: boolean) => Promise<void>;
|
||||||
@ -199,14 +199,18 @@ export function RoomProvider({ roomId, projectName, children }: RoomProviderProp
|
|||||||
if (client && roomId) client.deleteAi(roomId, agentId);
|
if (client && roomId) client.deleteAi(roomId, agentId);
|
||||||
}, [roomId]);
|
}, [roomId]);
|
||||||
|
|
||||||
const addPin = useCallback((messageId: string) => {
|
const addPin = useCallback(async (messageId: string) => {
|
||||||
const client = safeGetClient();
|
if (!roomId) return;
|
||||||
if (client && roomId) client.emitRaw('pin_add', { room: roomId, message: messageId });
|
const res = await pinAdd(roomId, messageId);
|
||||||
|
const pin = res.data?.data;
|
||||||
|
if (!pin) return;
|
||||||
|
setPinnedMessages((prev) => (prev.some((p) => p.message === pin.message) ? prev : [...prev, pin]));
|
||||||
}, [roomId]);
|
}, [roomId]);
|
||||||
|
|
||||||
const removePin = useCallback((messageId: string) => {
|
const removePin = useCallback(async (messageId: string) => {
|
||||||
const client = safeGetClient();
|
if (!roomId) return;
|
||||||
if (client && roomId) client.emitRaw('pin_remove', { room: roomId, message: messageId });
|
await pinRemove(roomId, messageId);
|
||||||
|
setPinnedMessages((prev) => prev.filter((p) => p.message !== messageId));
|
||||||
}, [roomId]);
|
}, [roomId]);
|
||||||
|
|
||||||
const updateDoNotDisturb = useCallback(async (dnd: boolean) => {
|
const updateDoNotDisturb = useCallback(async (dnd: boolean) => {
|
||||||
@ -467,7 +471,11 @@ export function RoomProvider({ roomId, projectName, children }: RoomProviderProp
|
|||||||
useWsEvent('pin_added', (event) => {
|
useWsEvent('pin_added', (event) => {
|
||||||
if (event.room_id !== roomId) return;
|
if (event.room_id !== roomId) return;
|
||||||
const { room, message, pinned_by, pinned_at } = event.data;
|
const { room, message, pinned_by, pinned_at } = event.data;
|
||||||
setPinnedMessages((prev: PinnedMessage[]) => [...prev, { room, message, pinned_by, pinned_at }]);
|
setPinnedMessages((prev: PinnedMessage[]) => (
|
||||||
|
prev.some((p) => p.message === message)
|
||||||
|
? prev
|
||||||
|
: [...prev, { room, message, pinned_by, pinned_at }]
|
||||||
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
useWsEvent('pin_removed', (event) => {
|
useWsEvent('pin_removed', (event) => {
|
||||||
|
|||||||
@ -36,3 +36,254 @@
|
|||||||
align-self: center;
|
align-self: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.thread-panel,
|
||||||
|
.pin-panel {
|
||||||
|
width: min(380px, 42vw);
|
||||||
|
min-width: 320px;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, color-mix(in oklch, var(--surface-elevated) 92%, var(--accent-bg)), var(--surface-ground) 180px),
|
||||||
|
var(--surface-ground);
|
||||||
|
border-left: 1px solid var(--border-subtle);
|
||||||
|
box-shadow: -12px 0 24px oklch(0 0 0 / 6%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-panel-header,
|
||||||
|
.pin-panel-header {
|
||||||
|
min-height: 52px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 14px 0 16px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
background: color-mix(in oklch, var(--surface-elevated) 88%, transparent);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-panel-title,
|
||||||
|
.pin-panel-title {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-preview-text {
|
||||||
|
max-width: 180px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-close-btn {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-close-btn:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
border-color: var(--border-subtle);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-messages,
|
||||||
|
.pin-list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-message {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: background 120ms ease, border-color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-message:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
border-color: var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-list-item {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: color-mix(in oklch, var(--surface-elevated) 94%, var(--accent-bg));
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-list-item:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: var(--border-default);
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-list-item:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-message-content {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-message-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-typing {
|
||||||
|
padding: 0 14px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-input-area {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
background: color-mix(in oklch, var(--surface-elevated) 86%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-input {
|
||||||
|
min-height: 38px;
|
||||||
|
max-height: 120px;
|
||||||
|
flex: 1;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-input:focus {
|
||||||
|
border-color: var(--input-ring);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in oklch, var(--input-ring) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-reaction-btn,
|
||||||
|
.thread-send-btn {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--surface-ground);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-send-btn {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-send-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-emoji-row {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
bottom: 58px;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--surface-overlay);
|
||||||
|
box-shadow: 0 10px 30px oklch(0 0 0 / 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-quick-btn {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-quick-btn:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-item {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: color-mix(in oklch, var(--surface-elevated) 94%, var(--accent-bg));
|
||||||
|
box-shadow: 0 1px 0 oklch(1 0 0 / 35%) inset;
|
||||||
|
transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-item:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: var(--border-default);
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-empty {
|
||||||
|
min-height: 160px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.thread-panel,
|
||||||
|
.pin-panel {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 0 auto;
|
||||||
|
z-index: 30;
|
||||||
|
width: min(100%, 420px);
|
||||||
|
min-width: 0;
|
||||||
|
box-shadow: -18px 0 40px oklch(0 0 0 / 18%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -20,43 +20,6 @@ export default defineConfig({
|
|||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
entries: ["src/**/*.{ts,tsx}"],
|
entries: ["src/**/*.{ts,tsx}"],
|
||||||
},
|
},
|
||||||
build: {
|
|
||||||
rollupOptions: {
|
|
||||||
output: {
|
|
||||||
manualChunks(id: string) {
|
|
||||||
if (id.includes("node_modules")) {
|
|
||||||
// React + deps that import React — keep together to avoid circular deps
|
|
||||||
if (id.includes("react-dom") || id.includes("react-router-dom") || id.includes("scheduler") || id.includes("@tanstack/react-query")) {
|
|
||||||
return "vendor-react";
|
|
||||||
}
|
|
||||||
if (id.includes("react-markdown") || id.includes("remark-gfm") || id.includes("rehype-raw") || id.includes("rehype-sanitize")) {
|
|
||||||
return "vendor-markdown";
|
|
||||||
}
|
|
||||||
if (id.includes("lucide-react")) {
|
|
||||||
return "vendor-lucide";
|
|
||||||
}
|
|
||||||
if (id.includes("motion")) {
|
|
||||||
return "vendor-motion";
|
|
||||||
}
|
|
||||||
if (id.includes("recharts")) {
|
|
||||||
return "vendor-recharts";
|
|
||||||
}
|
|
||||||
// Streamdown + diagram deps — keep together to avoid circular deps
|
|
||||||
if (id.includes("streamdown") || id.includes("@streamdown") || id.includes("cytoscape") || id.includes("d3-") || id.includes("dagre")) {
|
|
||||||
return "vendor-streamdown";
|
|
||||||
}
|
|
||||||
if (id.includes("@tanstack/react-table")) {
|
|
||||||
return "vendor-table";
|
|
||||||
}
|
|
||||||
if (id.includes("radix-ui")) {
|
|
||||||
return "vendor-radix";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
chunkSizeWarningLimit: 400,
|
|
||||||
},
|
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user