mirror of
https://github.com/SrIzan10/echospace.git
synced 2026-06-06 00:56:54 +00:00
feat: initial collaboration implementation
This commit is contained in:
@@ -31,6 +31,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"cmdk": "1.0.0",
|
"cmdk": "1.0.0",
|
||||||
|
"input-otp": "^1.4.1",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
"lucia": "^3.1.1",
|
"lucia": "^3.1.1",
|
||||||
"lucide-react": "^0.368.0",
|
"lucide-react": "^0.368.0",
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- thanks ai
|
||||||
|
|
||||||
|
-- Step 1: Create the new UserProject table
|
||||||
|
CREATE TABLE "UserProject" (
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "UserProject_pkey" PRIMARY KEY ("userId","projectId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Step 2: Insert existing userId and projectId pairs into the UserProject table
|
||||||
|
INSERT INTO "UserProject" ("userId", "projectId")
|
||||||
|
SELECT "userId", "id" FROM "Project" WHERE "userId" IS NOT NULL;
|
||||||
|
|
||||||
|
-- Step 3: Drop the foreign key constraint and the userId column from the Project table
|
||||||
|
ALTER TABLE "Project" DROP CONSTRAINT "Project_userId_fkey";
|
||||||
|
ALTER TABLE "Project" DROP COLUMN "userId";
|
||||||
|
|
||||||
|
-- Step 4: Add foreign key constraints to the UserProject table
|
||||||
|
ALTER TABLE "UserProject" ADD CONSTRAINT "UserProject_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "UserProject" ADD CONSTRAINT "UserProject_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Step 5: Drop the unnecessary _UserProjects table and its constraints
|
||||||
|
DROP TABLE IF EXISTS "_UserProjects";
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[inviteCode]` on the table `Project` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Project" ADD COLUMN "inviteCode" TEXT NOT NULL DEFAULT floor(random() * 90000000 + 10000000)::text;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "_UserProjects" (
|
||||||
|
"A" TEXT NOT NULL,
|
||||||
|
"B" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "_UserProjects_AB_pkey" PRIMARY KEY ("A","B")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "_UserProjects_B_index" ON "_UserProjects"("B");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Feedback_projectId_idx" ON "Feedback"("projectId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Project_inviteCode_key" ON "Project"("inviteCode");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "UserProject_userId_idx" ON "UserProject"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "UserProject_projectId_idx" ON "UserProject"("projectId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_UserProjects" ADD CONSTRAINT "_UserProjects_A_fkey" FOREIGN KEY ("A") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_UserProjects" ADD CONSTRAINT "_UserProjects_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Project" ALTER COLUMN "inviteCode" SET DEFAULT floor(random() * 90000000 + 10000000)::text;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "UserProject" ADD COLUMN "isOwner" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
# Please do not edit this file manually
|
# Please do not edit this file manually
|
||||||
# It should be added in your version-control system (i.e. Git)
|
# It should be added in your version-control system (e.g., Git)
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
@@ -14,12 +14,13 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
githubId String @unique
|
githubId String @unique
|
||||||
username String
|
username String
|
||||||
installations String[]
|
installations String[]
|
||||||
projects Project[]
|
projects Project[] @relation("UserProjects")
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
|
UserProject UserProject[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
@@ -27,6 +28,8 @@ model Session {
|
|||||||
userId String
|
userId String
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
user User @relation(references: [id], fields: [userId], onDelete: Cascade)
|
user User @relation(references: [id], fields: [userId], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Project {
|
model Project {
|
||||||
@@ -37,17 +40,32 @@ model Project {
|
|||||||
customData String[]
|
customData String[]
|
||||||
rateLimitReq Int @default(5)
|
rateLimitReq Int @default(5)
|
||||||
rateLimitTime Int @default(60)
|
rateLimitTime Int @default(60)
|
||||||
|
// 8 digit random number
|
||||||
|
inviteCode String @unique @default(dbgenerated("floor(random() * 90000000 + 10000000)::text"))
|
||||||
|
|
||||||
userId String
|
users User[] @relation("UserProjects")
|
||||||
user User @relation(fields: [userId], references: [id])
|
feedback Feedback[]
|
||||||
feedback Feedback[]
|
UserProject UserProject[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserProject {
|
||||||
|
userId String
|
||||||
|
projectId String
|
||||||
|
isOwner Boolean @default(false)
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
project Project @relation(fields: [projectId], references: [id])
|
||||||
|
|
||||||
|
@@id([userId, projectId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([projectId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Feedback {
|
model Feedback {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
message String
|
message String
|
||||||
customData String
|
customData String
|
||||||
|
projectId String
|
||||||
|
project Project @relation(fields: [projectId], references: [id])
|
||||||
|
|
||||||
projectId String
|
@@index([projectId])
|
||||||
project Project @relation(fields: [projectId], references: [id])
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ export default async function Page() {
|
|||||||
const { user } = await validateRequest();
|
const { user } = await validateRequest();
|
||||||
const db = await prisma.project.findMany({
|
const db = await prisma.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: user!.id,
|
UserProject: {
|
||||||
|
some: {
|
||||||
|
userId: user!.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (db.length === 0) {
|
if (db.length === 0) {
|
||||||
@@ -16,9 +20,16 @@ export default async function Page() {
|
|||||||
<div className="text-center flex flex-col gap-4 pt-4">
|
<div className="text-center flex flex-col gap-4 pt-4">
|
||||||
<h2>No projects found</h2>
|
<h2>No projects found</h2>
|
||||||
<p className="text-muted-foreground">Create a project to get started</p>
|
<p className="text-muted-foreground">Create a project to get started</p>
|
||||||
<Link href="/create">
|
<div className="flex justify-center gap-5">
|
||||||
<Button size={'sm'}>Create Project</Button>
|
<Link href="/create">
|
||||||
</Link>
|
<Button size={'sm'}>Create</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/join">
|
||||||
|
<Button size={'sm'} variant={'secondary'}>
|
||||||
|
Join
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
36
src/app/(protected)/join/action.ts
Normal file
36
src/app/(protected)/join/action.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { validateRequest } from "@/lib/auth";
|
||||||
|
import prisma from "@/lib/db";
|
||||||
|
|
||||||
|
export async function join(code: string) {
|
||||||
|
const { user } = await validateRequest();
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, message: "Unauthorized" };
|
||||||
|
}
|
||||||
|
const numCode = parseInt(code);
|
||||||
|
if (isNaN(numCode) || code.length !== 8) {
|
||||||
|
return { success: false, message: "Invalid code" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await prisma.project.findFirst({
|
||||||
|
where: { inviteCode: code },
|
||||||
|
include: {
|
||||||
|
UserProject: {
|
||||||
|
where: { userId: user.id },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!project) {
|
||||||
|
return { success: false, message: "Project not found" };
|
||||||
|
}
|
||||||
|
if (project.UserProject.length > 0) {
|
||||||
|
return { success: false, message: "Already joined project" };
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.project.update({
|
||||||
|
where: { id: project.id },
|
||||||
|
data: { UserProject: { create: { userId: user.id } } },
|
||||||
|
});
|
||||||
|
return { success: true, message: "Joined project", id: project.id };
|
||||||
|
}
|
||||||
61
src/app/(protected)/join/page.tsx
Normal file
61
src/app/(protected)/join/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
'use client';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
|
||||||
|
import { REGEXP_ONLY_DIGITS } from 'input-otp';
|
||||||
|
import React from 'react';
|
||||||
|
import { join } from './action';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [value, setValue] = React.useState('');
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center pt-10 gap-5">
|
||||||
|
<h2>Join another user's project!</h2>
|
||||||
|
<InputOTP
|
||||||
|
maxLength={8}
|
||||||
|
pattern={REGEXP_ONLY_DIGITS}
|
||||||
|
onChange={(val) => setValue(val)}
|
||||||
|
value={value}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<InputOTPGroup>
|
||||||
|
{/* THIS DOESNT WORK:
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<InputOTPSlot key={i + 1} index={i + 1} />
|
||||||
|
))}
|
||||||
|
SO HERE'S BAD CODE YOU ARE WELCOME
|
||||||
|
*/}
|
||||||
|
<InputOTPSlot index={0} />
|
||||||
|
<InputOTPSlot index={1} />
|
||||||
|
<InputOTPSlot index={2} />
|
||||||
|
<InputOTPSlot index={3} />
|
||||||
|
<InputOTPSlot index={4} />
|
||||||
|
<InputOTPSlot index={5} />
|
||||||
|
<InputOTPSlot index={6} />
|
||||||
|
<InputOTPSlot index={7} />
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setLoading(true);
|
||||||
|
join(value).then((res) => {
|
||||||
|
setLoading(false);
|
||||||
|
if (res.success) {
|
||||||
|
toast.success('Joined project!');
|
||||||
|
router.push(`/project/${res.id}`);
|
||||||
|
} else {
|
||||||
|
toast.error(res.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
Let's go!
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { user } = await validateRequest();
|
const { user } = await validateRequest();
|
||||||
const project = await prisma.project.findFirst({
|
const project = await prisma.project.findFirst({
|
||||||
where: { id, userId: user!.id },
|
where: { id, UserProject: { some: { userId: user!.id } } },
|
||||||
include: { feedback: true },
|
include: { feedback: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,11 +97,9 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
|
|||||||
{Object.entries(JSON.parse(feedback.customData)).map(([key, value]) => (
|
{Object.entries(JSON.parse(feedback.customData)).map(([key, value]) => (
|
||||||
<TableCell key={key}>{value as string}</TableCell>
|
<TableCell key={key}>{value as string}</TableCell>
|
||||||
))}
|
))}
|
||||||
<TableCell className='flex gap-2'>
|
<TableCell className="flex gap-2">
|
||||||
<FeedbackView feedback={feedback} />
|
<FeedbackView feedback={feedback} />
|
||||||
{project.github && (
|
{project.github && <GithubIssueCreate project={project} feedback={feedback} />}
|
||||||
<GithubIssueCreate project={project} feedback={feedback} />
|
|
||||||
)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { user } = await validateRequest();
|
const { user } = await validateRequest();
|
||||||
const project = await prisma.project.findFirst({
|
const project = await prisma.project.findFirst({
|
||||||
where: { id, userId: user!.id },
|
where: { id, UserProject: { some: { userId: user!.id } } },
|
||||||
|
include: {
|
||||||
|
UserProject: {
|
||||||
|
where: { projectId: id },
|
||||||
|
include: { user: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return <h1>Project not found</h1>;
|
return <h1>Project not found</h1>;
|
||||||
|
|||||||
24
src/components/app/InviteCodeViewer/InviteCodeViewer.tsx
Normal file
24
src/components/app/InviteCodeViewer/InviteCodeViewer.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export default function InviteCodeViewer({ code }: { code: string }) {
|
||||||
|
const [hover, setHover] = useState(false);
|
||||||
|
return (
|
||||||
|
<div className="grid w-full max-w-sm items-center gap-1.5">
|
||||||
|
<Label htmlFor="email">Invite code</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="inviteCode"
|
||||||
|
placeholder="Invite code"
|
||||||
|
className={hover ? '' : 'blur-sm'}
|
||||||
|
onChange={() => {}}
|
||||||
|
onMouseOver={() => setHover(true)}
|
||||||
|
onMouseOut={() => setHover(false)}
|
||||||
|
value={code}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import { ThemeSwitcher } from "../ThemeSwitcher/ThemeSwitcher"
|
|||||||
export const links = [
|
export const links = [
|
||||||
{ href: '/dashboard', name: 'Dashboard' },
|
{ href: '/dashboard', name: 'Dashboard' },
|
||||||
{ href: '/create', name: 'Create' },
|
{ href: '/create', name: 'Create' },
|
||||||
|
{ href: '/join', name: 'Join' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function NavbarLinks() {
|
function NavbarLinks() {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import type { Project } from '@prisma/client';
|
import type { Project, User, UserProject } from '@prisma/client';
|
||||||
import { UniversalForm } from '../UniversalForm/UniversalForm';
|
import { UniversalForm } from '../UniversalForm/UniversalForm';
|
||||||
import {
|
import {
|
||||||
customData,
|
customData,
|
||||||
@@ -23,8 +23,10 @@ import {
|
|||||||
import GithubRepoChooser from '../GithubRepoChooser/GithubRepoChooser';
|
import GithubRepoChooser from '../GithubRepoChooser/GithubRepoChooser';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import InviteCodeViewer from '../InviteCodeViewer/InviteCodeViewer';
|
||||||
|
import ProjectTeamUsers from '../ProjectTeamUsers/ProjectTeamUsers';
|
||||||
|
|
||||||
export default function ProjectSettings(project: Project) {
|
export default function ProjectSettings(project: ProjectWithUsers) {
|
||||||
const [ghRepo, setGhRepo] = React.useState('');
|
const [ghRepo, setGhRepo] = React.useState('');
|
||||||
const [hasSubmitted, setHasSubmitted] = React.useState(false);
|
const [hasSubmitted, setHasSubmitted] = React.useState(false);
|
||||||
const apiUrl = `https://${window.location.hostname}/api/feedback/${project.id}`;
|
const apiUrl = `https://${window.location.hostname}/api/feedback/${project.id}`;
|
||||||
@@ -121,6 +123,41 @@ export default function ProjectSettings(project: Project) {
|
|||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Your team</CardTitle>
|
||||||
|
<CardDescription>Invite people to join the project!</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<InviteCodeViewer code={project.inviteCode} />
|
||||||
|
<ProjectTeamUsers UserProject={project.UserProject} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/* <Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Project Deletion</CardTitle>
|
||||||
|
<CardDescription>Permanently delete your project</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
This action is irreversible. All feedback and settings will be lost.
|
||||||
|
</p>
|
||||||
|
<UniversalForm
|
||||||
|
fields={[
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
label: 'ID',
|
||||||
|
type: 'hidden',
|
||||||
|
value: project.id,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
schemaName={'deleteProject'}
|
||||||
|
action={editProject}
|
||||||
|
submitText="Delete Project"
|
||||||
|
submitClassname="!mt-0"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card> */}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -297,3 +334,9 @@ function stripIndents(strings: TemplateStringsArray, ...values: any[]) {
|
|||||||
.join('\n')
|
.join('\n')
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProjectWithUsers extends Project {
|
||||||
|
UserProject: (UserProject & {
|
||||||
|
user: User;
|
||||||
|
})[];
|
||||||
|
}
|
||||||
71
src/components/app/ProjectTeamUsers/ProjectTeamUsers.tsx
Normal file
71
src/components/app/ProjectTeamUsers/ProjectTeamUsers.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
|
import type { User, UserProject } from '@prisma/client';
|
||||||
|
import { useSession } from '@/lib/providers/SessionProvider';
|
||||||
|
import { deleteProjectTeamUser } from './delete';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export default function ProjectTeamUsers(userProject: ProjectTeamUsersProps) {
|
||||||
|
const [users, setUsers] = useState<
|
||||||
|
(UserProject & {
|
||||||
|
user: User;
|
||||||
|
})[]
|
||||||
|
>(userProject.UserProject);
|
||||||
|
const [loading, isLoading] = useState<string[]>([]);
|
||||||
|
const { user: currentUser } = useSession();
|
||||||
|
|
||||||
|
const handleDelete = (userId: string) => {
|
||||||
|
isLoading([...loading, userId]);
|
||||||
|
deleteProjectTeamUser(userId, userProject.UserProject[0].projectId).then((res) => {
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(res.message);
|
||||||
|
setUsers(users.filter((user) => user.userId !== userId));
|
||||||
|
} else {
|
||||||
|
toast.error(res.message);
|
||||||
|
}
|
||||||
|
isLoading(loading.filter((id) => id !== userId));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="space-y-2 pt-5">
|
||||||
|
{users.map((user) => (
|
||||||
|
<li
|
||||||
|
key={user.userId}
|
||||||
|
className="flex items-center justify-between p-3 rounded-lg shadow bg-accent"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Avatar>
|
||||||
|
<AvatarImage
|
||||||
|
src={`https://github.com/${user.user.username}.png`}
|
||||||
|
alt={user.user.username}
|
||||||
|
/>
|
||||||
|
<AvatarFallback>{user.user.username.toUpperCase()}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="font-medium">{user.user.username}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDelete(user.userId)}
|
||||||
|
aria-label={`Delete ${user.user.username}`}
|
||||||
|
disabled={user.isOwner || user.userId === currentUser?.id}
|
||||||
|
loading={loading.includes(user.userId)}
|
||||||
|
>
|
||||||
|
{!loading.includes(user.userId) && <Trash2 className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectTeamUsersProps {
|
||||||
|
UserProject: (UserProject & {
|
||||||
|
user: User;
|
||||||
|
})[];
|
||||||
|
}
|
||||||
24
src/components/app/ProjectTeamUsers/delete.ts
Normal file
24
src/components/app/ProjectTeamUsers/delete.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { validateRequest } from "@/lib/auth";
|
||||||
|
import prisma from "@/lib/db";
|
||||||
|
|
||||||
|
export async function deleteProjectTeamUser(userId: string, projectId: string) {
|
||||||
|
const { user } = await validateRequest();
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, message: 'Unauthorized' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const userProject = await prisma.userProject.findFirst({
|
||||||
|
where: { userId, projectId },
|
||||||
|
include: { user: true },
|
||||||
|
});
|
||||||
|
if (!userProject) {
|
||||||
|
return { success: false, message: 'User not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.userProject.deleteMany({
|
||||||
|
where: { userId, projectId },
|
||||||
|
});
|
||||||
|
return { success: true, message: `User ${userProject.user.username} removed.` };
|
||||||
|
}
|
||||||
71
src/components/ui/input-otp.tsx
Normal file
71
src/components/ui/input-otp.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { OTPInput, OTPInputContext } from "input-otp"
|
||||||
|
import { Dot } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const InputOTP = React.forwardRef<
|
||||||
|
React.ElementRef<typeof OTPInput>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof OTPInput>
|
||||||
|
>(({ className, containerClassName, ...props }, ref) => (
|
||||||
|
<OTPInput
|
||||||
|
ref={ref}
|
||||||
|
containerClassName={cn(
|
||||||
|
"flex items-center gap-2 has-[:disabled]:opacity-50",
|
||||||
|
containerClassName
|
||||||
|
)}
|
||||||
|
className={cn("disabled:cursor-not-allowed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
InputOTP.displayName = "InputOTP"
|
||||||
|
|
||||||
|
const InputOTPGroup = React.forwardRef<
|
||||||
|
React.ElementRef<"div">,
|
||||||
|
React.ComponentPropsWithoutRef<"div">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("flex items-center", className)} {...props} />
|
||||||
|
))
|
||||||
|
InputOTPGroup.displayName = "InputOTPGroup"
|
||||||
|
|
||||||
|
const InputOTPSlot = React.forwardRef<
|
||||||
|
React.ElementRef<"div">,
|
||||||
|
React.ComponentPropsWithoutRef<"div"> & { index: number }
|
||||||
|
>(({ index, className, ...props }, ref) => {
|
||||||
|
const inputOTPContext = React.useContext(OTPInputContext)
|
||||||
|
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
|
||||||
|
isActive && "z-10 ring-2 ring-ring ring-offset-background",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
{hasFakeCaret && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
InputOTPSlot.displayName = "InputOTPSlot"
|
||||||
|
|
||||||
|
const InputOTPSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<"div">,
|
||||||
|
React.ComponentPropsWithoutRef<"div">
|
||||||
|
>(({ ...props }, ref) => (
|
||||||
|
<div ref={ref} role="separator" {...props}>
|
||||||
|
<Dot />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
InputOTPSeparator.displayName = "InputOTPSeparator"
|
||||||
|
|
||||||
|
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { PrismaAdapter } from '@lucia-auth/adapter-prisma';
|
import { PrismaAdapter } from '@lucia-auth/adapter-prisma';
|
||||||
import { Lucia, Session, User } from 'lucia';
|
import { Lucia } from 'lucia';
|
||||||
import prisma from '../db';
|
import prisma from '../db';
|
||||||
import { cache } from 'react';
|
import { cache } from 'react';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
|
|||||||
@@ -27,9 +27,10 @@ export async function create(prev: any, formData: FormData) {
|
|||||||
const dbCreate = await prisma.project.create({
|
const dbCreate = await prisma.project.create({
|
||||||
data: {
|
data: {
|
||||||
...zod.data,
|
...zod.data,
|
||||||
user: {
|
UserProject: {
|
||||||
connect: {
|
create: {
|
||||||
id: user.id,
|
userId: user.id,
|
||||||
|
isOwner: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -141,7 +142,14 @@ export async function githubTestIssue(prev: any, formData: FormData) {
|
|||||||
id: zod.data.id,
|
id: zod.data.id,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
user: true,
|
UserProject: {
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!project) {
|
if (!project) {
|
||||||
@@ -152,7 +160,7 @@ export async function githubTestIssue(prev: any, formData: FormData) {
|
|||||||
const [owner, repo] = project.github!.split('/').slice(-2);
|
const [owner, repo] = project.github!.split('/').slice(-2);
|
||||||
let issueCreated = false;
|
let issueCreated = false;
|
||||||
|
|
||||||
for (const installationId of project.user.installations) {
|
for (const installationId of project.UserProject[0].user.installations) {
|
||||||
if (issueCreated) break;
|
if (issueCreated) break;
|
||||||
|
|
||||||
const installation = await octokitApp.getInstallationOctokit(Number(installationId));
|
const installation = await octokitApp.getInstallationOctokit(Number(installationId));
|
||||||
@@ -199,7 +207,14 @@ export async function githubCreateIssue(prev: any, formData: FormData) {
|
|||||||
id: zod.data.project,
|
id: zod.data.project,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
user: true,
|
UserProject: {
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!project) {
|
if (!project) {
|
||||||
@@ -209,7 +224,7 @@ export async function githubCreateIssue(prev: any, formData: FormData) {
|
|||||||
try {
|
try {
|
||||||
const [owner, repo] = project.github!.split('/').slice(-2);
|
const [owner, repo] = project.github!.split('/').slice(-2);
|
||||||
|
|
||||||
for (const installationId of project.user.installations) {
|
for (const installationId of project.UserProject[0].user.installations) {
|
||||||
const installation = await octokitApp.getInstallationOctokit(Number(installationId));
|
const installation = await octokitApp.getInstallationOctokit(Number(installationId));
|
||||||
const getRepo = await installation
|
const getRepo = await installation
|
||||||
.request('GET /repos/{owner}/{repo}', {
|
.request('GET /repos/{owner}/{repo}', {
|
||||||
|
|||||||
@@ -67,10 +67,15 @@ const config = {
|
|||||||
from: { height: "var(--radix-accordion-content-height)" },
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
to: { height: "0" },
|
to: { height: "0" },
|
||||||
},
|
},
|
||||||
|
"caret-blink": {
|
||||||
|
"0%,70%,100%": { opacity: "1" },
|
||||||
|
"20%,50%": { opacity: "0" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
"accordion-down": "accordion-down 0.2s ease-out",
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
"accordion-up": "accordion-up 0.2s ease-out",
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
|
"caret-blink": "caret-blink 1.25s ease-out infinite",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3579,6 +3579,11 @@ inherits@2, inherits@^2.0.3, inherits@^2.0.4:
|
|||||||
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
||||||
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||||
|
|
||||||
|
input-otp@^1.4.1:
|
||||||
|
version "1.4.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/input-otp/-/input-otp-1.4.1.tgz#bc22e68b14b1667219d54adf74243e37ea79cf84"
|
||||||
|
integrity sha512-+yvpmKYKHi9jIGngxagY9oWiiblPB7+nEO75F2l2o4vs+6vpPZZmUl4tBNYuTCvQjhvEIbdNeJu70bhfYP2nbw==
|
||||||
|
|
||||||
internal-slot@^1.0.7:
|
internal-slot@^1.0.7:
|
||||||
version "1.0.7"
|
version "1.0.7"
|
||||||
resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.7.tgz#c06dcca3ed874249881007b0a5523b172a190802"
|
resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.7.tgz#c06dcca3ed874249881007b0a5523b172a190802"
|
||||||
|
|||||||
Reference in New Issue
Block a user