feat(frontend): invitations page with project and workspace support
- Add /invitations route with dedicated page and sidebar button - Display both project invitations (accept/decline) and workspace invitations (accept only) - Merge and sort both invitation types by most recent first - Export new SDK functions: workspaceMyInvitations, workspaceAcceptInvitationBySlug
This commit is contained in:
parent
9b9c12ffc8
commit
0cccec33b2
@ -57,6 +57,8 @@ import {SettingsPreferences} from '@/app/settings/preferences';
|
|||||||
import {SettingsActivity} from '@/app/settings/activity';
|
import {SettingsActivity} from '@/app/settings/activity';
|
||||||
import NotifyLayout from '@/app/notify/layout';
|
import NotifyLayout from '@/app/notify/layout';
|
||||||
import NotifyPage from '@/app/notify/page';
|
import NotifyPage from '@/app/notify/page';
|
||||||
|
import InvitationsLayout from '@/app/invitations/layout';
|
||||||
|
import InvitationsPage from '@/app/invitations/page';
|
||||||
import LandingPage from '@/app/page';
|
import LandingPage from '@/app/page';
|
||||||
import SearchPage from '@/app/search/page';
|
import SearchPage from '@/app/search/page';
|
||||||
import PricingPage from '@/app/pricing/page';
|
import PricingPage from '@/app/pricing/page';
|
||||||
@ -143,6 +145,10 @@ function App() {
|
|||||||
<Route index element={<NotifyPage/>}/>
|
<Route index element={<NotifyPage/>}/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
<Route path="/invitations" element={<InvitationsLayout/>}>
|
||||||
|
<Route index element={<InvitationsPage/>}/>
|
||||||
|
</Route>
|
||||||
|
|
||||||
<Route path="/repository/:namespace/:repoName" element={<RepoLayout/>}>
|
<Route path="/repository/:namespace/:repoName" element={<RepoLayout/>}>
|
||||||
<Route index element={<RepoOverview/>}/>
|
<Route index element={<RepoOverview/>}/>
|
||||||
<Route path="branches" element={<RepoBranches/>}/>
|
<Route path="branches" element={<RepoBranches/>}/>
|
||||||
|
|||||||
94
src/app/invitations/layout.tsx
Normal file
94
src/app/invitations/layout.tsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { Bell, ChevronLeft, UserPlus } from "lucide-react";
|
||||||
|
import { Outlet, useNavigate } from "react-router-dom";
|
||||||
|
import { SidebarSystem } from "@/components/layout/sidebar-system";
|
||||||
|
import { SidebarUser } from "@/components/layout/sidebar-user";
|
||||||
|
import { useSidebarCollapse } from "@/hooks/use-sidebar-collapse";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
|
||||||
|
function InvitationsSidebar() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { collapsed, setCollapsed } = useSidebarCollapse();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
"hidden md:flex h-full flex-col border-r bg-background transition-all duration-200",
|
||||||
|
collapsed ? "w-16" : "w-64",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn("border-b h-12 flex items-center", collapsed ? "justify-center" : "justify-between")}
|
||||||
|
>
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="flex min-w-0 items-center gap-3 overflow-hidden px-3">
|
||||||
|
<div className="bg-muted rounded-full w-8 h-8 flex items-center justify-center">
|
||||||
|
<UserPlus className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 truncate text-sm font-semibold">
|
||||||
|
My Invitations
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-md hover:bg-muted cursor-pointer bg-transparent border-0"
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 p-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate("/invitations")}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full h-9 justify-start items-center rounded-md font-medium hover:bg-muted cursor-pointer bg-transparent border-0 text-left text-sm",
|
||||||
|
location.pathname === "/invitations" && "bg-primary/10 text-primary",
|
||||||
|
collapsed ? "justify-center px-0" : "justify-start px-2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn("flex h-6 items-center shrink-0", collapsed ? "w-6 justify-center" : "w-6")}>
|
||||||
|
<UserPlus className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
{!collapsed && "Invitations"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate("/notify")}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full h-9 justify-start items-center rounded-md font-medium hover:bg-muted cursor-pointer bg-transparent border-0 text-left text-sm",
|
||||||
|
location.pathname === "/notify" && "bg-primary/10 text-primary",
|
||||||
|
collapsed ? "justify-center px-0" : "justify-start px-2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn("flex h-6 items-center shrink-0", collapsed ? "w-6 justify-center" : "w-6")}>
|
||||||
|
<Bell className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
{!collapsed && "Notifications"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="border-t flex flex-col justify-end">
|
||||||
|
<SidebarSystem collapsed={collapsed} />
|
||||||
|
<SidebarUser collapsed={collapsed} />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InvitationsLayout() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-full bg-background">
|
||||||
|
<InvitationsSidebar />
|
||||||
|
<main className="flex-1 h-screen overflow-auto">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
316
src/app/invitations/page.tsx
Normal file
316
src/app/invitations/page.tsx
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
Check,
|
||||||
|
FolderKanban,
|
||||||
|
Loader2,
|
||||||
|
UserPlus,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
projectMyInvitations,
|
||||||
|
projectAcceptInvitation,
|
||||||
|
projectRejectInvitation,
|
||||||
|
workspaceMyInvitations,
|
||||||
|
workspaceAcceptInvitationBySlug,
|
||||||
|
} from "@/client";
|
||||||
|
import type { InvitationResponse, MyWorkspaceInvitation } from "@/client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { getApiErrorMessage } from "@/lib/api-error";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
function formatTime(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - d.getTime();
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
if (minutes < 1) return "just now";
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days < 7) return `${days}d ago`;
|
||||||
|
return d.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
type UnifiedInvitation =
|
||||||
|
| ({ type: "project" } & InvitationResponse)
|
||||||
|
| ({ type: "workspace" } & MyWorkspaceInvitation);
|
||||||
|
|
||||||
|
function ProjectInvitationItem({
|
||||||
|
inv,
|
||||||
|
onAccept,
|
||||||
|
onReject,
|
||||||
|
isAccepting,
|
||||||
|
isRejecting,
|
||||||
|
}: {
|
||||||
|
inv: InvitationResponse;
|
||||||
|
onAccept: () => void;
|
||||||
|
onReject: () => void;
|
||||||
|
isAccepting: boolean;
|
||||||
|
isRejecting: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3 px-4 py-4 border-b last:border-b-0 hover:bg-muted/50 transition-colors">
|
||||||
|
<div className="flex-shrink-0 mt-0.5 h-8 w-8 rounded-full border bg-purple-500/10 text-purple-600 border-purple-500/20 flex items-center justify-center">
|
||||||
|
<FolderKanban className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-semibold truncate">Project Invitation</p>
|
||||||
|
<Badge variant="outline" className="text-xs bg-purple-500/10 text-purple-600 border-purple-500/20">
|
||||||
|
{inv.scope}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
You've been invited to join{" "}
|
||||||
|
<span className="font-medium text-foreground">{inv.project_name}</span>
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
|
{inv.invited_by_username && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
by {inv.invited_by_username}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatTime(inv.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0 flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 text-xs gap-1 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={onReject}
|
||||||
|
disabled={isAccepting || isRejecting}
|
||||||
|
>
|
||||||
|
{isRejecting ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
Decline
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
className="h-8 text-xs gap-1"
|
||||||
|
onClick={onAccept}
|
||||||
|
disabled={isAccepting || isRejecting}
|
||||||
|
>
|
||||||
|
{isAccepting ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
Accept
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkspaceInvitationItem({
|
||||||
|
inv,
|
||||||
|
onAccept,
|
||||||
|
isAccepting,
|
||||||
|
}: {
|
||||||
|
inv: MyWorkspaceInvitation;
|
||||||
|
onAccept: () => void;
|
||||||
|
isAccepting: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3 px-4 py-4 border-b last:border-b-0 hover:bg-muted/50 transition-colors">
|
||||||
|
<div className="flex-shrink-0 mt-0.5 h-8 w-8 rounded-full border bg-blue-500/10 text-blue-600 border-blue-500/20 flex items-center justify-center">
|
||||||
|
<Building2 className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-semibold truncate">Workspace Invitation</p>
|
||||||
|
<Badge variant="outline" className="text-xs bg-blue-500/10 text-blue-600 border-blue-500/20">
|
||||||
|
{inv.role}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
You've been invited to join workspace{" "}
|
||||||
|
<span className="font-medium text-foreground">{inv.workspace_name}</span>
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
|
{inv.invited_by_username && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
by {inv.invited_by_username}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatTime(inv.invited_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
className="h-8 text-xs gap-1"
|
||||||
|
onClick={onAccept}
|
||||||
|
disabled={isAccepting}
|
||||||
|
>
|
||||||
|
{isAccepting ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
Accept
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InvitationsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { data: projectData, isLoading: projectLoading } = useQuery({
|
||||||
|
queryKey: ["projectInvitations"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const resp = await projectMyInvitations();
|
||||||
|
return resp.data?.data ?? null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: workspaceData, isLoading: workspaceLoading } = useQuery({
|
||||||
|
queryKey: ["workspaceInvitations"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const resp = await workspaceMyInvitations();
|
||||||
|
return resp.data?.data ?? [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const acceptProjectMutation = useMutation({
|
||||||
|
mutationFn: async (projectName: string) => {
|
||||||
|
await projectAcceptInvitation({ path: { project_name: projectName } });
|
||||||
|
},
|
||||||
|
onSuccess: (_data, projectName) => {
|
||||||
|
toast.success(`You've joined project: ${projectName}`);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projectInvitations"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||||
|
navigate(`/project/${projectName}`);
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
toast.error(getApiErrorMessage(err, "Failed to accept invitation"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rejectProjectMutation = useMutation({
|
||||||
|
mutationFn: async (projectName: string) => {
|
||||||
|
await projectRejectInvitation({ path: { project_name: projectName } });
|
||||||
|
},
|
||||||
|
onSuccess: (_data, projectName) => {
|
||||||
|
toast.success(`Invitation to ${projectName} declined`);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projectInvitations"] });
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
toast.error(getApiErrorMessage(err, "Failed to decline invitation"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const acceptWorkspaceMutation = useMutation({
|
||||||
|
mutationFn: async (slug: string) => {
|
||||||
|
await workspaceAcceptInvitationBySlug({
|
||||||
|
body: { slug },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("You've joined the workspace");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["workspaceInvitations"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
toast.error(getApiErrorMessage(err, "Failed to accept invitation"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectInvitations: InvitationResponse[] = projectData?.invitations ?? [];
|
||||||
|
const workspaceInvitations: MyWorkspaceInvitation[] = workspaceData ?? [];
|
||||||
|
const total = projectInvitations.length + workspaceInvitations.length;
|
||||||
|
|
||||||
|
const unified: UnifiedInvitation[] = [
|
||||||
|
...projectInvitations.map((inv) => ({ type: "project" as const, ...inv })),
|
||||||
|
...workspaceInvitations.map((inv) => ({ type: "workspace" as const, ...inv })),
|
||||||
|
].sort((a, b) => {
|
||||||
|
const aTime = a.type === "project" ? a.created_at : a.invited_at;
|
||||||
|
const bTime = b.type === "project" ? b.created_at : b.invited_at;
|
||||||
|
return new Date(bTime).getTime() - new Date(aTime).getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLoading = projectLoading || workspaceLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold flex items-center gap-2">
|
||||||
|
<UserPlus className="h-5 w-5" />
|
||||||
|
My Invitations
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
{total > 0
|
||||||
|
? `${total} pending invitation${total !== 1 ? "s" : ""}`
|
||||||
|
: "No pending invitations"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded-lg bg-card overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-48">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : unified.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground">
|
||||||
|
<UserPlus className="h-10 w-10 mb-3 opacity-40" />
|
||||||
|
<p className="font-medium">No pending invitations</p>
|
||||||
|
<p className="text-sm mt-1">
|
||||||
|
Project and workspace invitations will appear here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
unified.map((inv) => {
|
||||||
|
if (inv.type === "project") {
|
||||||
|
return (
|
||||||
|
<ProjectInvitationItem
|
||||||
|
key={`project-${inv.project_uid}`}
|
||||||
|
inv={inv}
|
||||||
|
onAccept={() => acceptProjectMutation.mutate(inv.project_name)}
|
||||||
|
onReject={() => rejectProjectMutation.mutate(inv.project_name)}
|
||||||
|
isAccepting={acceptProjectMutation.isPending}
|
||||||
|
isRejecting={rejectProjectMutation.isPending}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<WorkspaceInvitationItem
|
||||||
|
key={`workspace-${inv.workspace_id}`}
|
||||||
|
inv={inv}
|
||||||
|
onAccept={() => acceptWorkspaceMutation.mutate(inv.workspace_slug)}
|
||||||
|
isAccepting={acceptWorkspaceMutation.isPending}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { Bell, ChevronLeft } from "lucide-react";
|
import { Bell, ChevronLeft, UserPlus } from "lucide-react";
|
||||||
import { Outlet, useNavigate } from "react-router-dom";
|
import { Outlet, useNavigate } from "react-router-dom";
|
||||||
import { SidebarSystem } from "@/components/layout/sidebar-system";
|
import { SidebarSystem } from "@/components/layout/sidebar-system";
|
||||||
import { SidebarUser } from "@/components/layout/sidebar-user";
|
import { SidebarUser } from "@/components/layout/sidebar-user";
|
||||||
@ -43,6 +43,20 @@ function NotifySidebar() {
|
|||||||
|
|
||||||
<nav className="flex-1 p-2">
|
<nav className="flex-1 p-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate("/invitations")}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full h-9 justify-start items-center rounded-md font-medium hover:bg-muted cursor-pointer bg-transparent border-0 text-left text-sm",
|
||||||
|
location.pathname === "/invitations" && "bg-primary/10 text-primary",
|
||||||
|
collapsed ? "justify-center px-0" : "justify-start px-2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn("flex h-6 items-center shrink-0", collapsed ? "w-6 justify-center" : "w-6")}>
|
||||||
|
<UserPlus className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
{!collapsed && "Invitations"}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate("/notify")}
|
onClick={() => navigate("/notify")}
|
||||||
@ -55,7 +69,7 @@ function NotifySidebar() {
|
|||||||
<span className={cn("flex h-6 items-center shrink-0", collapsed ? "w-6 justify-center" : "w-6")}>
|
<span className={cn("flex h-6 items-center shrink-0", collapsed ? "w-6 justify-center" : "w-6")}>
|
||||||
<Bell className="h-4 w-4" />
|
<Bell className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
{!collapsed && "All Notifications"}
|
{!collapsed && "Notifications"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -3310,8 +3310,10 @@ export type InvitationListResponse = {
|
|||||||
|
|
||||||
export type InvitationResponse = {
|
export type InvitationResponse = {
|
||||||
project_uid: string;
|
project_uid: string;
|
||||||
|
project_name: string;
|
||||||
user_uid: string;
|
user_uid: string;
|
||||||
invited_by: string;
|
invited_by: string;
|
||||||
|
invited_by_username?: string | null;
|
||||||
scope: string;
|
scope: string;
|
||||||
accepted: boolean;
|
accepted: boolean;
|
||||||
accepted_at?: string | null;
|
accepted_at?: string | null;
|
||||||
@ -3814,6 +3816,16 @@ export type PendingInvitationInfo = {
|
|||||||
expires_at?: string | null;
|
expires_at?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MyWorkspaceInvitation = {
|
||||||
|
workspace_id: string;
|
||||||
|
workspace_slug: string;
|
||||||
|
workspace_name: string;
|
||||||
|
role: string;
|
||||||
|
invited_by_username?: string | null;
|
||||||
|
invited_at: string;
|
||||||
|
expires_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type PrCommitResponse = {
|
export type PrCommitResponse = {
|
||||||
oid: string;
|
oid: string;
|
||||||
short_oid: string;
|
short_oid: string;
|
||||||
@ -5127,6 +5139,10 @@ export type WorkspaceInviteParams = {
|
|||||||
role?: string | null;
|
role?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WorkspaceAcceptBySlugParams = {
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkspaceListItem = {
|
export type WorkspaceListItem = {
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
@ -18237,6 +18253,66 @@ export type WorkspaceAcceptInvitationResponses = {
|
|||||||
|
|
||||||
export type WorkspaceAcceptInvitationResponse = WorkspaceAcceptInvitationResponses[keyof WorkspaceAcceptInvitationResponses];
|
export type WorkspaceAcceptInvitationResponse = WorkspaceAcceptInvitationResponses[keyof WorkspaceAcceptInvitationResponses];
|
||||||
|
|
||||||
|
export type WorkspaceMyInvitationsData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/api/workspaces/me/invitations';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceMyInvitationsErrors = {
|
||||||
|
/**
|
||||||
|
* Unauthorized
|
||||||
|
*/
|
||||||
|
401: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiResponseVecMyWorkspaceInvitation = {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data?: Array<MyWorkspaceInvitation>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceMyInvitationsResponses = {
|
||||||
|
/**
|
||||||
|
* List my workspace invitations
|
||||||
|
*/
|
||||||
|
200: ApiResponseVecMyWorkspaceInvitation;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceMyInvitationsResponse = WorkspaceMyInvitationsResponses[keyof WorkspaceMyInvitationsResponses];
|
||||||
|
|
||||||
|
export type WorkspaceAcceptInvitationBySlugData = {
|
||||||
|
body: WorkspaceAcceptBySlugParams;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/api/workspaces/invitations/accept-by-slug';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceAcceptInvitationBySlugErrors = {
|
||||||
|
/**
|
||||||
|
* Unauthorized
|
||||||
|
*/
|
||||||
|
401: unknown;
|
||||||
|
/**
|
||||||
|
* Invitation not found
|
||||||
|
*/
|
||||||
|
404: unknown;
|
||||||
|
/**
|
||||||
|
* Already accepted
|
||||||
|
*/
|
||||||
|
409: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceAcceptInvitationBySlugResponses = {
|
||||||
|
/**
|
||||||
|
* Accept invitation
|
||||||
|
*/
|
||||||
|
200: ApiResponseWorkspaceInfoResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceAcceptInvitationBySlugResponse = WorkspaceAcceptInvitationBySlugResponses[keyof WorkspaceAcceptInvitationBySlugResponses];
|
||||||
|
|
||||||
export type WorkspaceListData = {
|
export type WorkspaceListData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import {useUser} from '@/contexts';
|
import {useUser} from '@/contexts';
|
||||||
import {cn} from '@/lib/utils';
|
import {cn} from '@/lib/utils';
|
||||||
import {Mail} from 'lucide-react';
|
import {Mail, UserPlus} from 'lucide-react';
|
||||||
import {useNavigate} from 'react-router-dom';
|
import {useNavigate} from 'react-router-dom';
|
||||||
|
|
||||||
const btnClass = 'flex w-full h-9 justify-start items-center rounded-md font-medium hover:bg-muted cursor-pointer bg-transparent border-0 text-left text-sm';
|
const btnClass = 'flex w-full h-9 justify-start items-center rounded-md font-medium hover:bg-muted cursor-pointer bg-transparent border-0 text-left text-sm';
|
||||||
@ -21,6 +21,14 @@ export function SidebarUser({collapsed}: { collapsed: boolean }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full mb-2">
|
<div className="w-full mb-2">
|
||||||
|
<button type="button" className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')}
|
||||||
|
onClick={() => navigate('/invitations')}>
|
||||||
|
<span className="flex h-6 items-center shrink-0 w-6">
|
||||||
|
<UserPlus className="h-4 w-4"/>
|
||||||
|
</span>
|
||||||
|
{!collapsed && <span className="text-sm leading-none">Invitations</span>}
|
||||||
|
</button>
|
||||||
|
|
||||||
<button type="button" className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')}
|
<button type="button" className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')}
|
||||||
onClick={() => navigate('/notify')}>
|
onClick={() => navigate('/notify')}>
|
||||||
<span className="relative flex h-6 items-center shrink-0 w-6">
|
<span className="relative flex h-6 items-center shrink-0 w-6">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user