From 737ffca4bc897fbb3a1b337e2bb275d8f886cdb5 Mon Sep 17 00:00:00 2001
From: Izan Gil <66965250+SrIzan10@users.noreply.github.com>
Date: Sun, 22 Dec 2024 23:43:27 +0100
Subject: [PATCH] feat: initial collaboration implementation
---
package.json | 1 +
.../migration.sql | 24 +++++++
.../migration.sql | 40 +++++++++++
.../migration.sql | 5 ++
prisma/migrations/migration_lock.toml | 2 +-
prisma/schema.prisma | 36 +++++++---
src/app/(protected)/dashboard/page.tsx | 19 +++--
src/app/(protected)/join/action.ts | 36 ++++++++++
src/app/(protected)/join/page.tsx | 61 ++++++++++++++++
src/app/(protected)/project/[id]/page.tsx | 8 +--
.../project/[id]/settings/page.tsx | 8 ++-
.../app/InviteCodeViewer/InviteCodeViewer.tsx | 24 +++++++
src/components/app/NavBar/NavBar.tsx | 1 +
.../app/ProjectSettings/ProjectSettings.tsx | 47 +++++++++++-
.../app/ProjectTeamUsers/ProjectTeamUsers.tsx | 71 +++++++++++++++++++
src/components/app/ProjectTeamUsers/delete.ts | 24 +++++++
src/components/ui/input-otp.tsx | 71 +++++++++++++++++++
src/lib/auth/index.ts | 2 +-
src/lib/forms/actions.ts | 29 ++++++--
tailwind.config.ts | 5 ++
yarn.lock | 5 ++
21 files changed, 489 insertions(+), 30 deletions(-)
create mode 100644 prisma/migrations/20241222102322_add_multiple_users/migration.sql
create mode 100644 prisma/migrations/20241222201815_add_project_invite_code/migration.sql
create mode 100644 prisma/migrations/20241222223410_user_is_owner/migration.sql
create mode 100644 src/app/(protected)/join/action.ts
create mode 100644 src/app/(protected)/join/page.tsx
create mode 100644 src/components/app/InviteCodeViewer/InviteCodeViewer.tsx
create mode 100644 src/components/app/ProjectTeamUsers/ProjectTeamUsers.tsx
create mode 100644 src/components/app/ProjectTeamUsers/delete.ts
create mode 100644 src/components/ui/input-otp.tsx
diff --git a/package.json b/package.json
index 2335834..2eb9952 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.0",
"cmdk": "1.0.0",
+ "input-otp": "^1.4.1",
"ioredis": "^5.4.1",
"lucia": "^3.1.1",
"lucide-react": "^0.368.0",
diff --git a/prisma/migrations/20241222102322_add_multiple_users/migration.sql b/prisma/migrations/20241222102322_add_multiple_users/migration.sql
new file mode 100644
index 0000000..c145698
--- /dev/null
+++ b/prisma/migrations/20241222102322_add_multiple_users/migration.sql
@@ -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";
\ No newline at end of file
diff --git a/prisma/migrations/20241222201815_add_project_invite_code/migration.sql b/prisma/migrations/20241222201815_add_project_invite_code/migration.sql
new file mode 100644
index 0000000..8e188c2
--- /dev/null
+++ b/prisma/migrations/20241222201815_add_project_invite_code/migration.sql
@@ -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;
diff --git a/prisma/migrations/20241222223410_user_is_owner/migration.sql b/prisma/migrations/20241222223410_user_is_owner/migration.sql
new file mode 100644
index 0000000..be282fd
--- /dev/null
+++ b/prisma/migrations/20241222223410_user_is_owner/migration.sql
@@ -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;
diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml
index fbffa92..648c57f 100644
--- a/prisma/migrations/migration_lock.toml
+++ b/prisma/migrations/migration_lock.toml
@@ -1,3 +1,3 @@
# 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"
\ No newline at end of file
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 786a5d0..2aa17ac 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -14,12 +14,13 @@ datasource db {
}
model User {
- id String @id @default(cuid())
- githubId String @unique
+ id String @id @default(cuid())
+ githubId String @unique
username String
installations String[]
- projects Project[]
+ projects Project[] @relation("UserProjects")
sessions Session[]
+ UserProject UserProject[]
}
model Session {
@@ -27,6 +28,8 @@ model Session {
userId String
expiresAt DateTime
user User @relation(references: [id], fields: [userId], onDelete: Cascade)
+
+ @@index([userId])
}
model Project {
@@ -37,17 +40,32 @@ model Project {
customData String[]
rateLimitReq Int @default(5)
rateLimitTime Int @default(60)
+ // 8 digit random number
+ inviteCode String @unique @default(dbgenerated("floor(random() * 90000000 + 10000000)::text"))
- userId String
- user User @relation(fields: [userId], references: [id])
- feedback Feedback[]
+ users User[] @relation("UserProjects")
+ 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 {
- id Int @id @default(autoincrement())
+ id Int @id @default(autoincrement())
message String
customData String
+ projectId String
+ project Project @relation(fields: [projectId], references: [id])
- projectId String
- project Project @relation(fields: [projectId], references: [id])
+ @@index([projectId])
}
diff --git a/src/app/(protected)/dashboard/page.tsx b/src/app/(protected)/dashboard/page.tsx
index 36c2ab0..9bbeead 100644
--- a/src/app/(protected)/dashboard/page.tsx
+++ b/src/app/(protected)/dashboard/page.tsx
@@ -8,7 +8,11 @@ export default async function Page() {
const { user } = await validateRequest();
const db = await prisma.project.findMany({
where: {
- userId: user!.id,
+ UserProject: {
+ some: {
+ userId: user!.id,
+ },
+ },
},
});
if (db.length === 0) {
@@ -16,9 +20,16 @@ export default async function Page() {
No projects found
Create a project to get started
-
-
-
+
+
+
+
+
+
+
+
);
}
diff --git a/src/app/(protected)/join/action.ts b/src/app/(protected)/join/action.ts
new file mode 100644
index 0000000..f4ec184
--- /dev/null
+++ b/src/app/(protected)/join/action.ts
@@ -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 };
+}
\ No newline at end of file
diff --git a/src/app/(protected)/join/page.tsx b/src/app/(protected)/join/page.tsx
new file mode 100644
index 0000000..69a7142
--- /dev/null
+++ b/src/app/(protected)/join/page.tsx
@@ -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 (
+
+
Join another user's project!
+ setValue(val)}
+ value={value}
+ disabled={loading}
+ >
+
+ {/* THIS DOESNT WORK:
+ {Array.from({ length: 8 }).map((_, i) => (
+
+ ))}
+ SO HERE'S BAD CODE YOU ARE WELCOME
+ */}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(protected)/project/[id]/page.tsx b/src/app/(protected)/project/[id]/page.tsx
index e4979cc..661626f 100644
--- a/src/app/(protected)/project/[id]/page.tsx
+++ b/src/app/(protected)/project/[id]/page.tsx
@@ -27,7 +27,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
const { id } = await params;
const { user } = await validateRequest();
const project = await prisma.project.findFirst({
- where: { id, userId: user!.id },
+ where: { id, UserProject: { some: { userId: user!.id } } },
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]) => (
{value as string}
))}
-
+
- {project.github && (
-
- )}
+ {project.github && }
))}
diff --git a/src/app/(protected)/project/[id]/settings/page.tsx b/src/app/(protected)/project/[id]/settings/page.tsx
index 537af81..fa6d7c6 100644
--- a/src/app/(protected)/project/[id]/settings/page.tsx
+++ b/src/app/(protected)/project/[id]/settings/page.tsx
@@ -6,7 +6,13 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
const { id } = await params;
const { user } = await validateRequest();
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) {
return Project not found
;
diff --git a/src/components/app/InviteCodeViewer/InviteCodeViewer.tsx b/src/components/app/InviteCodeViewer/InviteCodeViewer.tsx
new file mode 100644
index 0000000..c898382
--- /dev/null
+++ b/src/components/app/InviteCodeViewer/InviteCodeViewer.tsx
@@ -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 (
+
+
+ {}}
+ onMouseOver={() => setHover(true)}
+ onMouseOut={() => setHover(false)}
+ value={code}
+ />
+
+ );
+}
diff --git a/src/components/app/NavBar/NavBar.tsx b/src/components/app/NavBar/NavBar.tsx
index 26c1f88..34a896d 100644
--- a/src/components/app/NavBar/NavBar.tsx
+++ b/src/components/app/NavBar/NavBar.tsx
@@ -18,6 +18,7 @@ import { ThemeSwitcher } from "../ThemeSwitcher/ThemeSwitcher"
export const links = [
{ href: '/dashboard', name: 'Dashboard' },
{ href: '/create', name: 'Create' },
+ { href: '/join', name: 'Join' },
]
function NavbarLinks() {
diff --git a/src/components/app/ProjectSettings/ProjectSettings.tsx b/src/components/app/ProjectSettings/ProjectSettings.tsx
index ecf7960..7519129 100644
--- a/src/components/app/ProjectSettings/ProjectSettings.tsx
+++ b/src/components/app/ProjectSettings/ProjectSettings.tsx
@@ -2,7 +2,7 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
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 {
customData,
@@ -23,8 +23,10 @@ import {
import GithubRepoChooser from '../GithubRepoChooser/GithubRepoChooser';
import React from 'react';
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 [hasSubmitted, setHasSubmitted] = React.useState(false);
const apiUrl = `https://${window.location.hostname}/api/feedback/${project.id}`;
@@ -121,6 +123,41 @@ export default function ProjectSettings(project: Project) {
/>
+
+
+ Your team
+ Invite people to join the project!
+
+
+
+
+
+
+ {/*
+
+ Project Deletion
+ Permanently delete your project
+
+
+
+ This action is irreversible. All feedback and settings will be lost.
+
+
+
+ */}
@@ -297,3 +334,9 @@ function stripIndents(strings: TemplateStringsArray, ...values: any[]) {
.join('\n')
.trim();
}
+
+interface ProjectWithUsers extends Project {
+ UserProject: (UserProject & {
+ user: User;
+ })[];
+}
\ No newline at end of file
diff --git a/src/components/app/ProjectTeamUsers/ProjectTeamUsers.tsx b/src/components/app/ProjectTeamUsers/ProjectTeamUsers.tsx
new file mode 100644
index 0000000..8c02a00
--- /dev/null
+++ b/src/components/app/ProjectTeamUsers/ProjectTeamUsers.tsx
@@ -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([]);
+ 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 (
+
+ {users.map((user) => (
+ -
+
+
+
+ {user.user.username.toUpperCase()}
+
+
{user.user.username}
+
+
+
+ ))}
+
+ );
+}
+
+interface ProjectTeamUsersProps {
+ UserProject: (UserProject & {
+ user: User;
+ })[];
+}
diff --git a/src/components/app/ProjectTeamUsers/delete.ts b/src/components/app/ProjectTeamUsers/delete.ts
new file mode 100644
index 0000000..e448c14
--- /dev/null
+++ b/src/components/app/ProjectTeamUsers/delete.ts
@@ -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.` };
+}
\ No newline at end of file
diff --git a/src/components/ui/input-otp.tsx b/src/components/ui/input-otp.tsx
new file mode 100644
index 0000000..f66fcfa
--- /dev/null
+++ b/src/components/ui/input-otp.tsx
@@ -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,
+ React.ComponentPropsWithoutRef
+>(({ className, containerClassName, ...props }, ref) => (
+
+))
+InputOTP.displayName = "InputOTP"
+
+const InputOTPGroup = React.forwardRef<
+ React.ElementRef<"div">,
+ React.ComponentPropsWithoutRef<"div">
+>(({ className, ...props }, ref) => (
+
+))
+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 (
+
+ {char}
+ {hasFakeCaret && (
+
+ )}
+
+ )
+})
+InputOTPSlot.displayName = "InputOTPSlot"
+
+const InputOTPSeparator = React.forwardRef<
+ React.ElementRef<"div">,
+ React.ComponentPropsWithoutRef<"div">
+>(({ ...props }, ref) => (
+
+
+
+))
+InputOTPSeparator.displayName = "InputOTPSeparator"
+
+export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts
index 55f01ec..86864fe 100644
--- a/src/lib/auth/index.ts
+++ b/src/lib/auth/index.ts
@@ -1,5 +1,5 @@
import { PrismaAdapter } from '@lucia-auth/adapter-prisma';
-import { Lucia, Session, User } from 'lucia';
+import { Lucia } from 'lucia';
import prisma from '../db';
import { cache } from 'react';
import { cookies } from 'next/headers';
diff --git a/src/lib/forms/actions.ts b/src/lib/forms/actions.ts
index 5564043..0b669da 100644
--- a/src/lib/forms/actions.ts
+++ b/src/lib/forms/actions.ts
@@ -27,9 +27,10 @@ export async function create(prev: any, formData: FormData) {
const dbCreate = await prisma.project.create({
data: {
...zod.data,
- user: {
- connect: {
- id: user.id,
+ UserProject: {
+ create: {
+ userId: user.id,
+ isOwner: true,
},
},
},
@@ -141,7 +142,14 @@ export async function githubTestIssue(prev: any, formData: FormData) {
id: zod.data.id,
},
include: {
- user: true,
+ UserProject: {
+ where: {
+ userId: user.id,
+ },
+ include: {
+ user: true,
+ },
+ }
},
});
if (!project) {
@@ -152,7 +160,7 @@ export async function githubTestIssue(prev: any, formData: FormData) {
const [owner, repo] = project.github!.split('/').slice(-2);
let issueCreated = false;
- for (const installationId of project.user.installations) {
+ for (const installationId of project.UserProject[0].user.installations) {
if (issueCreated) break;
const installation = await octokitApp.getInstallationOctokit(Number(installationId));
@@ -199,7 +207,14 @@ export async function githubCreateIssue(prev: any, formData: FormData) {
id: zod.data.project,
},
include: {
- user: true,
+ UserProject: {
+ where: {
+ userId: user.id,
+ },
+ include: {
+ user: true,
+ }
+ },
},
});
if (!project) {
@@ -209,7 +224,7 @@ export async function githubCreateIssue(prev: any, formData: FormData) {
try {
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 getRepo = await installation
.request('GET /repos/{owner}/{repo}', {
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 84287e8..73bd5d9 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -67,10 +67,15 @@ const config = {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
+ "caret-blink": {
+ "0%,70%,100%": { opacity: "1" },
+ "20%,50%": { opacity: "0" },
+ },
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
+ "caret-blink": "caret-blink 1.25s ease-out infinite",
},
},
},
diff --git a/yarn.lock b/yarn.lock
index 455daa9..8cb32fe 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"
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:
version "1.0.7"
resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.7.tgz#c06dcca3ed874249881007b0a5523b172a190802"