Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions actions/contributors.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"use server";

import { db } from "@/lib/prisma";

export async function deleteContributor(contributorId: string, authorId: string, projectId: string) {
try {
if (!contributorId || !authorId || !projectId) {
throw new Error("Missing required parameters");
}

const project = await db.project.findUnique({
where: { id: projectId },
include: { contributors: true }
});

if (!project) {
throw new Error("Project not found");
}

if (project.authorId !== authorId) {
throw new Error("Unauthorized: Only the project author can remove contributors");
}

const contributer = project.contributors.find(c => c.id === contributorId);

if (!contributer) {
throw new Error("Contributor not found in this project");
}

await db.contributor.delete({
where: { id: contributorId }
});

await db.application.delete({
where: {
profileId_projectId: {
profileId: contributer.profileId,
projectId: projectId,
}
}
});

return { success: true, message: "Contributor removed successfully" };
} catch (error) {
console.error('Error removing contributor:', error);
return {
success: false,
message: 'Failed to remove contributor',
};
}
}

export async function leaveProject(contributorId: string, userId: string, projectId: string) {
try {
if (!contributorId || !userId || !projectId) {
throw new Error("Missing required parameters");
}

const project = await db.project.findUnique({
where: { id: projectId },
include: { contributors: true }
});

if (!project) throw new Error("Project not found");
const contributor = project.contributors.find(c => c.id === contributorId);
if (!contributor) throw new Error("Contributor not found in this project");
if (contributor?.profileId !== userId) throw new Error("Unauthorized: You can only leave projects you are a contributor of");

await db.contributor.delete({
where: { id: contributorId }
});

await db.application.delete({
where: {
profileId_projectId: {
profileId: contributor.profileId,
projectId: projectId,
}
}
});

return { success: true, message: "Successfully left the project" };
} catch (error) {
console.error('Error leaving project:', error);
return {
success: false,
message: 'Failed to leave project',
};
}
}
2 changes: 1 addition & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ model Profile {
image String?
section String
branch String
college String @default("Not Set")
college String?
year String
skills String[]
bio String?
Expand Down
132 changes: 132 additions & 0 deletions src/app/(user)/projects/[id]/_components/ContributorActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { UserMinus, LogOut, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { deleteContributor, leaveProject } from "../../../../../../actions/contributors";

interface ContributorActionsProps {
contributorId: string;
contributorProfileId: string;
contributorName: string;
projectId: string;
currentUserProfileId?: string;
authorProfileId: string;
}

export function ContributorActions({
contributorId,
contributorProfileId,
contributorName,
projectId,
currentUserProfileId,
authorProfileId,
}: ContributorActionsProps) {
const [loading, setLoading] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const router = useRouter();

const isAuthor = currentUserProfileId === authorProfileId;
const isCurrentUserContributor = currentUserProfileId === contributorProfileId;

if (!isAuthor && !isCurrentUserContributor) {
return null;
}

const handleRemoveContributor = async () => {
if (!currentUserProfileId) return;

setLoading(true);
try {
const result = await deleteContributor(contributorId, currentUserProfileId, projectId);

if (result.success) {
toast.success("Contributor removed successfully");
router.refresh();
} else {
toast.error(result.message || "Failed to remove contributor");
}
} catch {
toast.error("An error occurred while removing the contributor");
} finally {
setLoading(false);
setShowConfirm(false);
}
};

const handleLeaveProject = async () => {
if (!currentUserProfileId) return;

setLoading(true);
try {
const result = await leaveProject(contributorId, currentUserProfileId, projectId);

if (result.success) {
toast.success("Successfully left the project");
router.refresh();
} else {
toast.error(result.message || "Failed to leave project");
}
} catch {
toast.error("An error occurred while leaving the project");
} finally {
setLoading(false);
setShowConfirm(false);
}
};

if (showConfirm) {
return (
<div className="flex items-center gap-2 ml-auto">
<span className="text-xs text-muted-foreground">
{isAuthor ? "Remove?" : "Leave?"}
</span>
<button
onClick={isAuthor ? handleRemoveContributor : handleLeaveProject}
disabled={loading}
className="px-2 py-1 text-xs font-medium rounded-md bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20 transition-colors disabled:opacity-50"
>
{loading ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
"Yes"
)}
</button>
<button
onClick={() => setShowConfirm(false)}
disabled={loading}
className="px-2 py-1 text-xs font-medium rounded-md bg-muted text-muted-foreground hover:bg-muted/80 transition-colors disabled:opacity-50"
>
No
</button>
</div>
);
}

if (isAuthor) {
return (
<button
onClick={() => setShowConfirm(true)}
className="ml-auto p-2 rounded-lg text-muted-foreground hover:text-red-500 hover:bg-red-500/10 transition-all group"
title={`Remove ${contributorName} from project`}
>
<UserMinus className="h-4 w-4" />
</button>
);
}

if (isCurrentUserContributor) {
return (
<button
onClick={() => setShowConfirm(true)}
className="ml-auto p-2 rounded-lg text-muted-foreground hover:text-orange-500 hover:bg-orange-500/10 transition-all group"
title="Leave this project"
>
<LogOut className="h-4 w-4" />
</button>
);
}

return null;
}
75 changes: 75 additions & 0 deletions src/app/(user)/projects/[id]/_components/ContributorsSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"use client";

import Link from "next/link";
import Image from "next/image";
import { ContributorActions } from "./ContributorActions";

interface Contributor {
id: string;
contributorRecordId: string;
name: string;
avatar?: string;
}

interface ContributorsSectionProps {
contributors: Contributor[];
projectId: string;
currentUserProfileId?: string;
authorProfileId: string;
}

export function ContributorsSection({
contributors,
projectId,
currentUserProfileId,
authorProfileId,
}: ContributorsSectionProps) {
return (
<section className="bg-card/60 backdrop-blur-sm border border-border rounded-xl p-8 shadow-xl fade-in-up delay-200">
<h2 className="text-xl font-semibold text-foreground mb-6 pb-3 border-b border-border/50">
Contributors ({contributors.length})
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{contributors.map((contributor) => (
<div
key={contributor.id}
className="flex items-center gap-4 p-4 rounded-xl transition-all hover:bg-white/5 hover:shadow-md backdrop-blur-sm"
>
<Link
href={`/profile/${contributor.id}`}
className="flex items-center gap-4 flex-1 min-w-0"
>
{contributor.avatar ? (
<Image
src={contributor.avatar}
alt={contributor.name}
width={48}
height={48}
className="h-12 w-12 rounded-full bg-muted border-2 border-border shadow-sm"
/>
) : (
<div className="h-12 w-12 rounded-full bg-muted border-2 border-border flex items-center justify-center text-muted-foreground font-semibold shadow-sm">
{contributor.name.charAt(0)}
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-foreground truncate">
{contributor.name}
</p>
<p className="text-xs text-muted-foreground">Contributor</p>
</div>
</Link>
<ContributorActions
contributorId={contributor.contributorRecordId}
contributorProfileId={contributor.id}
contributorName={contributor.name}
projectId={projectId}
currentUserProfileId={currentUserProfileId}
authorProfileId={authorProfileId}
/>
</div>
))}
</div>
</section>
);
}
44 changes: 8 additions & 36 deletions src/app/(user)/projects/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { db } from "@/lib/prisma";
import { ApplyButton } from "../_components/ApplyButton";
import LikeButton from "@/components/LikeButton";
import { SettingsDropdown } from "./_components/SettingsDropdown";
import { ContributorsSection } from "./_components/ContributorsSection";
import { UserRoundCog } from "lucide-react";

interface ProjectPageProps {
Expand Down Expand Up @@ -166,6 +167,7 @@ export default async function ProjectPage({ params }: ProjectPageProps) {
endDate: apiProject.endDate,
contributors: apiProject.contributors.map((c) => ({
id: c.user.id,
contributorRecordId: c.id, // The Contributor table record ID
name: c.user.name,
avatar: c.user.image || undefined,
})),
Expand Down Expand Up @@ -301,42 +303,12 @@ export default async function ProjectPage({ params }: ProjectPageProps) {
</section>

{project.contributors.length > 0 && (
<section className="bg-card/60 backdrop-blur-sm border border-border rounded-xl p-8 shadow-xl fade-in-up delay-200">
<h2 className="text-xl font-semibold text-foreground mb-6 pb-3 border-b border-border/50">
Contributors ({project.contributors.length})
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{project.contributors.map((contributor) => (
<Link
key={contributor.id}
href={`/profile/${contributor.id}`}
className="flex items-center gap-4 p-4 rounded-xl transition-all hover:bg-white/5 hover:shadow-md backdrop-blur-sm"
>
{contributor.avatar ? (
<Image
src={contributor.avatar}
alt={contributor.name}
width={48}
height={48}
className="h-12 w-12 rounded-full bg-muted border-2 border-border shadow-sm"
/>
) : (
<div className="h-12 w-12 rounded-full bg-muted border-2 border-border flex items-center justify-center text-muted-foreground font-semibold shadow-sm">
{contributor.name.charAt(0)}
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-foreground truncate">
{contributor.name}
</p>
<p className="text-xs text-muted-foreground">
Contributor
</p>
</div>
</Link>
))}
</div>
</section>
<ContributorsSection
contributors={project.contributors}
projectId={project.id}
currentUserProfileId={currentUserProfile?.id}
authorProfileId={apiProject.authorId}
/>
)}
</div>

Expand Down
3 changes: 2 additions & 1 deletion src/app/api/projects/[projectId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,5 @@ export async function DELETE(
{ status: 500 }
);
}
}
}