diff --git a/next.config.mjs b/next.config.mjs index 2a3914ee..f5509539 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -14,6 +14,14 @@ const nextConfig = { hostname: 'images.unsplash.com', protocol: 'https', }, + { + hostname: 'storage.aceternity.com', + protocol: 'https', + }, + { + hostname: 'sltoipimgzzeegsdygra.supabase.co', + protocol: 'https', + }, ], }, }; diff --git a/package.json b/package.json index d3447cef..afec4dc6 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-tooltip": "^1.1.6", "@react-email/render": "^1.1.2", + "@supabase/ssr": "^0.6.1", "@tabler/icons-react": "^3.12.0", "@tanstack/react-query": "^5.53.1", "@tsparticles/engine": "^3.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2ab5937..0e943dbe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: '@react-email/render': specifier: ^1.1.2 version: 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@supabase/ssr': + specifier: ^0.6.1 + version: 0.6.1(@supabase/supabase-js@2.49.9) '@tabler/icons-react': specifier: ^3.12.0 version: 3.12.0(react@18.3.1) @@ -1374,6 +1377,33 @@ packages: '@selderee/plugin-htmlparser2@0.11.0': resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} + '@supabase/auth-js@2.69.1': + resolution: {integrity: sha512-FILtt5WjCNzmReeRLq5wRs3iShwmnWgBvxHfqapC/VoljJl+W8hDAyFmf1NVw3zH+ZjZ05AKxiKxVeb0HNWRMQ==} + + '@supabase/functions-js@2.4.4': + resolution: {integrity: sha512-WL2p6r4AXNGwop7iwvul2BvOtuJ1YQy8EbOd0dhG1oN1q8el/BIRSFCFnWAMM/vJJlHWLi4ad22sKbKr9mvjoA==} + + '@supabase/node-fetch@2.6.15': + resolution: {integrity: sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==} + engines: {node: 4.x || >=6.0.0} + + '@supabase/postgrest-js@1.19.4': + resolution: {integrity: sha512-O4soKqKtZIW3olqmbXXbKugUtByD2jPa8kL2m2c1oozAO11uCcGrRhkZL0kVxjBLrXHE0mdSkFsMj7jDSfyNpw==} + + '@supabase/realtime-js@2.11.9': + resolution: {integrity: sha512-fLseWq8tEPCO85x3TrV9Hqvk7H4SGOqnFQ223NPJSsxjSYn0EmzU1lvYO6wbA0fc8DE94beCAiiWvGvo4g33lQ==} + + '@supabase/ssr@0.6.1': + resolution: {integrity: sha512-QtQgEMvaDzr77Mk3vZ3jWg2/y+D8tExYF7vcJT+wQ8ysuvOeGGjYbZlvj5bHYsj/SpC0bihcisnwPrM4Gp5G4g==} + peerDependencies: + '@supabase/supabase-js': ^2.43.4 + + '@supabase/storage-js@2.7.1': + resolution: {integrity: sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA==} + + '@supabase/supabase-js@2.49.9': + resolution: {integrity: sha512-lB2A2X8k1aWAqvlpO4uZOdfvSuZ2s0fCMwJ1Vq6tjWsi3F+au5lMbVVn92G0pG8gfmis33d64Plkm6eSDs6jRA==} + '@swc/counter@0.1.3': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} @@ -1538,6 +1568,9 @@ packages: '@types/nodemailer@6.4.17': resolution: {integrity: sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==} + '@types/phoenix@1.6.6': + resolution: {integrity: sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==} + '@types/prop-types@15.7.12': resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} @@ -1550,6 +1583,9 @@ packages: '@types/shuffle-array@1.0.5': resolution: {integrity: sha512-mwqFRdqxNpraOhjjW/50Ejs7Z025pB7tnf/yM2tNZ6DRuZLGOd1UWX0u9a+aK+xBQFtLs7YStVpumGoNm2Ta/g==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/parser@7.2.0': resolution: {integrity: sha512-5FKsVcHTk6TafQKQbuIVkXq58Fnbkd2wDL4LB7AURN7RUOu1utVP+G8+6u3ZhEroW3DF6hyo3ZEXxgKgp4KeCg==} engines: {node: ^16.0.0 || >=18.0.0} @@ -1776,6 +1812,10 @@ packages: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} + cookie@1.0.2: + resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} + engines: {node: '>=18'} + create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} @@ -3159,6 +3199,9 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + ts-api-utils@1.3.0: resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==} engines: {node: '>=16'} @@ -3283,6 +3326,12 @@ packages: v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-boxed-primitive@1.0.2: resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} @@ -3318,6 +3367,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.18.2: + resolution: {integrity: sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + yaml@2.5.0: resolution: {integrity: sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==} engines: {node: '>= 14'} @@ -4367,6 +4428,53 @@ snapshots: domhandler: 5.0.3 selderee: 0.11.0 + '@supabase/auth-js@2.69.1': + dependencies: + '@supabase/node-fetch': 2.6.15 + + '@supabase/functions-js@2.4.4': + dependencies: + '@supabase/node-fetch': 2.6.15 + + '@supabase/node-fetch@2.6.15': + dependencies: + whatwg-url: 5.0.0 + + '@supabase/postgrest-js@1.19.4': + dependencies: + '@supabase/node-fetch': 2.6.15 + + '@supabase/realtime-js@2.11.9': + dependencies: + '@supabase/node-fetch': 2.6.15 + '@types/phoenix': 1.6.6 + '@types/ws': 8.18.1 + ws: 8.18.2 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@supabase/ssr@0.6.1(@supabase/supabase-js@2.49.9)': + dependencies: + '@supabase/supabase-js': 2.49.9 + cookie: 1.0.2 + + '@supabase/storage-js@2.7.1': + dependencies: + '@supabase/node-fetch': 2.6.15 + + '@supabase/supabase-js@2.49.9': + dependencies: + '@supabase/auth-js': 2.69.1 + '@supabase/functions-js': 2.4.4 + '@supabase/node-fetch': 2.6.15 + '@supabase/postgrest-js': 1.19.4 + '@supabase/realtime-js': 2.11.9 + '@supabase/storage-js': 2.7.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@swc/counter@0.1.3': {} '@swc/helpers@0.5.12': @@ -4587,6 +4695,8 @@ snapshots: dependencies: '@types/node': 20.14.15 + '@types/phoenix@1.6.6': {} + '@types/prop-types@15.7.12': {} '@types/react-dom@18.3.0': @@ -4600,6 +4710,10 @@ snapshots: '@types/shuffle-array@1.0.5': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 20.14.15 + '@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.4)': dependencies: '@typescript-eslint/scope-manager': 7.2.0 @@ -4851,6 +4965,8 @@ snapshots: cookie@0.6.0: {} + cookie@1.0.2: {} + create-require@1.1.1: {} cross-spawn@7.0.3: @@ -5138,7 +5254,7 @@ snapshots: eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) eslint-plugin-jsx-a11y: 6.9.0(eslint@8.57.0) eslint-plugin-react: 7.35.0(eslint@8.57.0) eslint-plugin-react-hooks: 4.6.2(eslint@8.57.0) @@ -5166,7 +5282,7 @@ snapshots: enhanced-resolve: 5.17.1 eslint: 8.57.0 eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) fast-glob: 3.3.2 get-tsconfig: 4.7.6 is-core-module: 2.15.0 @@ -5188,7 +5304,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0): + eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0): dependencies: array-includes: 3.1.8 array.prototype.findlastindex: 1.2.5 @@ -6379,6 +6495,8 @@ snapshots: dependencies: is-number: 7.0.0 + tr46@0.0.3: {} + ts-api-utils@1.3.0(typescript@5.5.4): dependencies: typescript: 5.5.4 @@ -6513,6 +6631,13 @@ snapshots: v8-compile-cache-lib@3.0.1: {} + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which-boxed-primitive@1.0.2: dependencies: is-bigint: 1.0.4 @@ -6571,6 +6696,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.18.2: {} + yaml@2.5.0: {} yn@3.1.1: {} diff --git a/prisma/migrations/20250609200043_add_setup_model/migration.sql b/prisma/migrations/20250609200043_add_setup_model/migration.sql new file mode 100644 index 00000000..7f4e6532 --- /dev/null +++ b/prisma/migrations/20250609200043_add_setup_model/migration.sql @@ -0,0 +1,31 @@ +/* + Warnings: + + - A unique constraint covering the columns `[userId,setupId]` on the table `Like` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "Like" ADD COLUMN "setupId" TEXT, +ALTER COLUMN "adviseId" DROP NOT NULL; + +-- CreateTable +CREATE TABLE "Setup" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "imageUrl" TEXT NOT NULL, + "authorId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Setup_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Like_userId_setupId_key" ON "Like"("userId", "setupId"); + +-- AddForeignKey +ALTER TABLE "Setup" ADD CONSTRAINT "Setup_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Like" ADD CONSTRAINT "Like_setupId_fkey" FOREIGN KEY ("setupId") REFERENCES "Setup"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a341b5d3..fe94768f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,6 +19,7 @@ model User { linkedinUrl String? sessions Session[] advises Advise[] + setups Setup[] languages UserLanguage[] comments Comment[] likes Like[] @@ -51,6 +52,21 @@ model Advise { updatedAt DateTime @updatedAt } +model Setup { + id String @id @default(cuid()) + title String + content String + imageUrl String + + author User @relation(fields: [authorId], references: [id]) + authorId String + + likes Like[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + model Comment { id String @id @default(cuid()) content String @@ -111,13 +127,17 @@ model Image { model Like { id String @id @default(cuid()) userId String - adviseId String + adviseId String? + setupId String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) - advise Advise @relation(fields: [adviseId], references: [id], onDelete: Cascade) + advise Advise? @relation(fields: [adviseId], references: [id], onDelete: Cascade) + setup Setup? @relation(fields: [setupId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@unique([userId, adviseId]) + @@unique([userId, setupId]) } + diff --git a/public/elementor-placeholder-image.webp b/public/elementor-placeholder-image.webp new file mode 100644 index 00000000..e4738346 Binary files /dev/null and b/public/elementor-placeholder-image.webp differ diff --git a/src/actions/setup/create-setup.ts b/src/actions/setup/create-setup.ts new file mode 100644 index 00000000..4852b670 --- /dev/null +++ b/src/actions/setup/create-setup.ts @@ -0,0 +1,42 @@ +'use server'; + +import prisma from '@/lib/prisma'; +import { setupSchema } from '@/schemas/setup-schema'; +import { revalidatePath } from 'next/cache'; +import { cookies } from 'next/headers'; + +export const createSetup = async (formData: FormData, imageUrl: string) => { + const validatedData = setupSchema.parse({ + title: formData.get('title'), + description: formData.get('description'), + imageUrl: imageUrl, + }); + + const sessionId = cookies().get('sessionId'); + + if (!sessionId) throw new Error('Not authenticated'); + + const session = await prisma.session.findUnique({ + where: { id: sessionId.value }, + }); + + if (!session) throw new Error('Session not found'); + + const user = await prisma.user.findUnique({ + where: { id: session.userId }, + }); + + if (!user) throw new Error('User not found'); + + const setup = await prisma.setup.create({ + data: { + title: validatedData.title, + content: validatedData.description!, + imageUrl: validatedData.imageUrl, + author: { connect: { id: user.id } }, + }, + }); + revalidatePath('/setups'); + revalidatePath('/'); + return setup.id; +}; diff --git a/src/actions/setup/delete-setup.ts b/src/actions/setup/delete-setup.ts new file mode 100644 index 00000000..2837c8e0 --- /dev/null +++ b/src/actions/setup/delete-setup.ts @@ -0,0 +1,24 @@ +'use server'; + +import prisma from '@/lib/prisma'; +import { revalidatePath } from 'next/cache'; +import { cookies } from 'next/headers'; + +export const deleteSetup = async (id: string) => { + const sessionId = await cookies().get('sessionId'); + + if (!sessionId) throw new Error('User not authenticated'); + + const session = await prisma.session.findUnique({ + where: { id: sessionId.value }, + }); + + if (!session) throw new Error('Session not found'); + + await prisma.setup.delete({ + where: { id, authorId: session.userId }, + }); + + revalidatePath('/setups'); + revalidatePath('/'); +}; diff --git a/src/actions/setup/edit-setup.ts b/src/actions/setup/edit-setup.ts new file mode 100644 index 00000000..066a8605 --- /dev/null +++ b/src/actions/setup/edit-setup.ts @@ -0,0 +1,43 @@ +'use server'; + +import prisma from '@/lib/prisma'; +import { setupSchema } from '@/schemas/setup-schema'; +import { revalidatePath } from 'next/cache'; +import { cookies } from 'next/headers'; + +export const editSetup = async ({ + id, + formData, + imageUrl, +}: { + id: string; + formData: FormData; + imageUrl: string; +}) => { + const validatedData = setupSchema.parse({ + title: formData.get('title'), + description: formData.get('description'), + imageUrl + }); + + const sessionId = await cookies().get('sessionId'); + + if (!sessionId) throw new Error('User not authenticated'); + + const session = await prisma.session.findUnique({ + where: { id: sessionId.value }, + }); + + if (!session) throw new Error('Session not found'); + + await prisma.setup.update({ + where: { id, authorId: session.userId }, + data: { + title: validatedData.title, + content: validatedData.description!, + imageUrl: validatedData.imageUrl, + }, + }); + + revalidatePath('/setups'); +}; diff --git a/src/actions/setup/fetch-setups.ts b/src/actions/setup/fetch-setups.ts new file mode 100644 index 00000000..b6dda7fa --- /dev/null +++ b/src/actions/setup/fetch-setups.ts @@ -0,0 +1,32 @@ +'use server'; + +import { ADVISES_PER_PAGE } from '@/lib/constants'; +import prisma from '@/lib/prisma'; +import { Setup, User } from '@prisma/client'; + +export const fetchSetups = async (page: number): Promise<(Setup & { + author: Pick; + likes: { userId: string }[]; +})[]> => + prisma.setup.findMany({ + include: { + author: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + likes: { + select: { + userId: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + take: ADVISES_PER_PAGE, + skip: (page - 1) * ADVISES_PER_PAGE, + }); diff --git a/src/actions/setup/like-setup.ts b/src/actions/setup/like-setup.ts new file mode 100644 index 00000000..42b38b8c --- /dev/null +++ b/src/actions/setup/like-setup.ts @@ -0,0 +1,50 @@ +'use server'; + +import prisma from '@/lib/prisma'; +import { revalidatePath } from 'next/cache'; +import { cookies } from 'next/headers'; + +export const toggleLikeSetup = async (setupId: string) => { + try { + const sessionId = await cookies().get('sessionId'); + + if (!sessionId) throw new Error('User not authenticated'); + + const session = await prisma.session.findUnique({ + where: { id: sessionId.value }, + }); + + if (!session) throw new Error('Session not found'); + + const existingLike = await prisma.like.findUnique({ + where: { + userId_setupId: { + userId: session.userId, + setupId, + }, + }, + }); + + if (existingLike) { + await prisma.like.delete({ + where: { + id: existingLike.id, + }, + }); + } else { + await prisma.like.create({ + data: { + userId: session.userId, + setupId, + }, + }); + } + + // Revalidar todas las rutas relevantes + revalidatePath('/setups'); + revalidatePath('/'); + } catch (error) { + console.error('Error in toggleLike:', error); + throw error; + } +}; diff --git a/src/actions/setup/update-setup-image.ts b/src/actions/setup/update-setup-image.ts new file mode 100644 index 00000000..0084cd6c --- /dev/null +++ b/src/actions/setup/update-setup-image.ts @@ -0,0 +1,14 @@ +'use server'; + +import prisma from '@/lib/prisma'; +import { revalidatePath } from 'next/cache'; + +export const updateSetupImage = async (setupId: string, imageUrl: string) => { + await prisma.setup.update({ + where: { id: setupId }, + data: { imageUrl }, + }); + + revalidatePath('/setups'); + revalidatePath('/'); +}; diff --git a/src/app/(platform)/setups/page.tsx b/src/app/(platform)/setups/page.tsx new file mode 100644 index 00000000..cf090a4c --- /dev/null +++ b/src/app/(platform)/setups/page.tsx @@ -0,0 +1,31 @@ +import { fetchSetups } from '@/actions/setup/fetch-setups'; +import { SetupsList } from '@/components/setup/setups-list'; +import prisma from '@/lib/prisma'; +import { Session, User } from '@prisma/client'; +import { cookies } from 'next/headers'; + +const SetupsPage = async () => { + const sessionId = cookies().get('sessionId')?.value; + let session: (Session & { user: User }) | null = null; + + if (sessionId) { + session = await prisma.session.findUnique({ + where: { + id: sessionId, + }, + include: { + user: true, + }, + }); + } + + const setups = await fetchSetups(1); + + return ( +
+ +
+ ); +}; + +export default SetupsPage; diff --git a/src/components/setup/setup-card.tsx b/src/components/setup/setup-card.tsx new file mode 100644 index 00000000..9f6e6fcf --- /dev/null +++ b/src/components/setup/setup-card.tsx @@ -0,0 +1,174 @@ +'use client'; + +import { toggleLikeSetup } from '@/actions/setup/like-setup'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Session, Setup, User } from '@prisma/client'; +import { Edit, Heart, MoreVertical, Trash } from 'lucide-react'; +import { useOptimistic, useState, useTransition } from 'react'; + +interface SetupCardProps { + setup: Setup & { + author: Pick; + likes: { userId: string }[]; + }; + session: (Session & { user: User }) | null; + onDelete: () => void; + onEdit: () => void; + onRequireAuth?: () => void; +} + +export function SetupCard({ setup, session, onDelete, onEdit, onRequireAuth }: SetupCardProps) { + const [imgError, setImgError] = useState(false); + const [isPending, startTransition] = useTransition(); + const [localLikes, setLocalLikes] = useState(setup.likes); + + const [optimisticLikes, addOptimisticLike] = useOptimistic( + localLikes, + (state, userId: string) => { + const isLiked = state.some((like) => like.userId === userId); + return isLiked ? state.filter((like) => like.userId !== userId) : [...state, { userId }]; + } + ); + + const isAuthor = + (session?.user?.id && session.user.id === setup.author.id) || + (session?.user?.email && session.user.email === setup.author.email); + + const isLiked = session?.user?.id + ? optimisticLikes.some((like) => like.userId === session.user.id) + : false; + + const handleLike = () => { + if (!session?.user?.id) { + onRequireAuth?.(); + return; + } + + // Actualizar el estado local primero + const newLikes = isLiked + ? localLikes.filter((like) => like.userId !== session.user.id) + : [...localLikes, { userId: session.user.id }]; + setLocalLikes(newLikes); + + // Luego actualizar optimistamente + addOptimisticLike(session.user.id); + + startTransition(async () => { + try { + await toggleLikeSetup(setup.id); + } catch (error) { + console.error('Error toggling like:', error); + // Revertir el estado local si hay error + setLocalLikes(setup.likes); + } + }); + }; + + const Options = ( + + + + + + + + + Editar + + + + + Eliminar + + + + ); + + return ( +
+ + +
+
+ + + + {setup.author.name.charAt(0)} + + + +
+

+ {setup.author.name} +

+
+
+ +
{isAuthor && Options}
+
+
+ + +

+ {setup.title} +

+

+ {setup.content} +

+ +
+ {setup.title} setImgError(true)} + /> +
+
+ + + +
+
+ +
+ + + {setup.createdAt.toLocaleDateString()} + +
+
+ +
+ ); +} diff --git a/src/components/setup/setups-list.tsx b/src/components/setup/setups-list.tsx new file mode 100644 index 00000000..da14747c --- /dev/null +++ b/src/components/setup/setups-list.tsx @@ -0,0 +1,255 @@ +'use client'; +import { deleteSetup } from '@/actions/setup/delete-setup'; +import { fetchSetups } from '@/actions/setup/fetch-setups'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Session, Setup, User } from '@prisma/client'; +import { Heart, LogIn, Plus, UserPlus, X } from 'lucide-react'; +import Link from 'next/link'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { SetupCard } from './setup-card'; +import UploadSetupModal from './upload-setup-modal'; + +export function SetupsList({ + session, + initialSetups +}: { + session: (Session & { user: User }) | null; + initialSetups: (Setup & { + author: Pick; + likes: { userId: string }[]; + })[]; +}) { + const [showAuthModal, setShowAuthModal] = useState(false); + const [showUploadModal, setShowUploadModal] = useState(false); + const [modalAction, setModalAction] = useState<'delete' | null>(null); + const [selectedSetup, setSelectedSetup] = useState(null); + const [setupToEdit, setSetupToEdit] = useState(null); + const [setups, setSetups] = useState(initialSetups); + + const fetchSetupsData = async () => { + try { + const data = await fetchSetups(1); + setSetups(data); + } catch (error) { + toast.error('Error al cargar los setups'); + } + }; + + const handlePublishSetup = () => { + if (!session) { + setShowAuthModal(true); + return; + } + setSetupToEdit(null); + setShowUploadModal(true); + }; + + const handleDeleteSetup = (setup: any) => { + if (!session) { + setModalAction('delete'); + setShowAuthModal(true); + return; + } + setSelectedSetup(setup); + setModalAction('delete'); + setShowAuthModal(true); + }; + + const handleEditSetup = (setup: any) => { + if (!session) { + setShowAuthModal(true); + return; + } + setSetupToEdit({ + id: setup.id, + title: setup.title, + description: setup.content, + imageUrl: setup.imageUrl, + }); + setShowUploadModal(true); + }; + + const handleConfirmDelete = async () => { + if (!selectedSetup) return; + + try { + setShowAuthModal(false); + await deleteSetup(selectedSetup.id); + toast.success('Setup eliminado exitosamente'); + fetchSetupsData(); + } catch (error) { + toast.error('Error al eliminar el setup'); + } finally { + setShowAuthModal(false); + setSelectedSetup(null); + setModalAction(null); + } + }; + + return ( +
+
+
+
+

Setups

+

+ {'Comparte tu espacio de trabajo y descubre configuraciones increíbles 🖥️'} +

+
+ +
+ + {/* Setups Grid */} +
+ {setups?.length === 0 ? ( +
+
+ + + +
+

No hay setups aún

+

+ Sé el primero en compartir tu setup con la comunidad +

+
+ ) : ( + setups?.map((setup) => ( + handleDeleteSetup(setup)} + onEdit={() => handleEditSetup(setup)} + onRequireAuth={() => setShowAuthModal(true)} + /> + )) + )} +
+ + ) : ( + + ) + } + onConfirm={modalAction === 'delete' ? handleConfirmDelete : undefined} + /> + + {/* Upload Setup Modal */} + { + setShowUploadModal(open); + if (!open) setSetupToEdit(null); + }} + onSubmit={() => { + setShowUploadModal(false); + setSetupToEdit(null); + fetchSetupsData(); + }} + isAuthenticated={!!session?.user.id} + onAuthRequired={() => setShowAuthModal(true)} + refetch={fetchSetupsData} + setupToEdit={setupToEdit} + /> +
+
+ ); +} + +interface ModalSetupProps { + showAuthModal: boolean; + setShowAuthModal: (show: boolean) => void; + title?: string; + description?: string; + icon?: React.ReactNode; + onConfirm?: () => void; +} + +export const ModalSetup = ({ + showAuthModal, + setShowAuthModal, + title, + description, + onConfirm, +}: ModalSetupProps) => { + return ( + + + + + {title} + +

{description}

+
+ +
+ {onConfirm ? ( + + ) : ( + <> + + + + + + + + + )} +
+
+
+ ); +}; diff --git a/src/components/setup/upload-setup-modal.tsx b/src/components/setup/upload-setup-modal.tsx new file mode 100644 index 00000000..5e906dfd --- /dev/null +++ b/src/components/setup/upload-setup-modal.tsx @@ -0,0 +1,358 @@ +'use client'; + +import type React from 'react'; + +import { createSetup } from '@/actions/setup/create-setup'; +import { editSetup } from '@/actions/setup/edit-setup'; +import { updateSetupImage } from '@/actions/setup/update-setup-image'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { createBrowserClient } from '@supabase/ssr'; +import { AlertCircle, Check, Upload, X } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState, useTransition } from 'react'; +import { toast } from 'sonner'; +import { Input } from '../ui/input'; +import { Textarea } from '../ui/textarea'; + +interface UploadSetupModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (setupData: any) => void; + refetch: () => void; + isAuthenticated: boolean; + onAuthRequired: () => void; + userId: string; + setupToEdit?: { + id: string; + title: string; + description: string; + imageUrl: string; + }; +} + +const ACCEPTED_FORMATS = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']; +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB + +const createClient = () => + createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + ); + +export default function UploadSetupModal({ + open, + onOpenChange, + userId, + refetch, + setupToEdit, +}: UploadSetupModalProps) { + const [isPending, startTransition] = useTransition(); + const [dragActive, setDragActive] = useState(false); + const [preview, setPreview] = useState(setupToEdit?.imageUrl || null); + const [error, setError] = useState(null); + const [isUploading, setIsUploading] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const [titleLength, setTitleLength] = useState(0); + const [descriptionLength, setDescriptionLength] = useState(0); + + const fileInputRef = useRef(null); + const formRef = useRef(null); + + // Efecto para actualizar los estados cuando setupToEdit cambia + useEffect(() => { + if (setupToEdit) { + setTitleLength(setupToEdit.title.length); + setDescriptionLength(setupToEdit.description.length); + setPreview(setupToEdit.imageUrl); + } else { + setTitleLength(0); + setDescriptionLength(0); + setPreview(null); + } + }, [setupToEdit]); + + const validateFile = (file: File): string | null => { + if (!ACCEPTED_FORMATS.includes(file.type)) { + return 'Formato no válido. Solo se aceptan JPG, PNG y WebP.'; + } + if (file.size > MAX_FILE_SIZE) { + return 'El archivo es muy grande. Máximo 5MB.'; + } + return null; + }; + + const handleFile = async (file: File) => { + if (!userId) { + setError('Debes iniciar sesión para subir imágenes'); + return; + } + + const validationError = validateFile(file); + if (validationError) { + setError(validationError); + return; + } + + setError(null); + setIsUploading(true); + + try { + setSelectedFile(file); + const reader = new FileReader(); + reader.onload = (e) => { + setPreview(e.target?.result as string); + }; + reader.readAsDataURL(file); + } catch (error) { + setError('Error al procesar la imagen'); + } finally { + setIsUploading(false); + } + }; + + const handleDrag = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.type === 'dragenter' || e.type === 'dragover') { + setDragActive(true); + } else if (e.type === 'dragleave') { + setDragActive(false); + } + }, []); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + + if (e.dataTransfer.files && e.dataTransfer.files[0]) { + handleFile(e.dataTransfer.files[0]); + } + }, []); + + const handleFileInput = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + handleFile(e.target.files[0]); + } + }; + + const handleSubmit = async (formData: FormData) => { + setIsUploading(true); + try { + startTransition(async () => { + if (setupToEdit) { + let imageUrl = setupToEdit.imageUrl; + + if (selectedFile) { + const supabase = createClient(); + const { data, error } = await supabase.storage + .from('setups') + .upload(`${setupToEdit.id}`, selectedFile, { + upsert: true, + contentType: selectedFile.type, + }); + + // Añadimos el timestamp a la URL + const timestamp = new Date().getTime(); + imageUrl = `${setupToEdit.imageUrl}?t=${timestamp}`; + } + await editSetup({ id: setupToEdit.id, formData, imageUrl }); + } else { + // Primero creamos el setup con una URL temporal + const setupId = await createSetup(formData, 'https://placehold.co/600x400/png'); + + const supabase = createClient(); + // Subimos la imagen a supabase storage usando el ID del setup + const { data, error } = await supabase.storage + .from('setups') + .upload(`${setupId}`, selectedFile!, { + upsert: true, + contentType: selectedFile!.type, + }); + if (error) { + console.log({ error }); + return; + } + + // Obtenemos la URL pública de la imagen + const { data: publicUrlData } = supabase.storage.from('setups').getPublicUrl(data.path); + + // Actualizamos el setup con la URL real de la imagen + await updateSetupImage(setupId, publicUrlData.publicUrl); + } + + resetForm(); + onOpenChange(false); + refetch(); + toast.success( + setupToEdit ? 'Setup actualizado exitosamente! 🎉' : 'Setup subido exitosamente! 🎉', + ); + }); + } catch (error) { + setError('Error al subir la imagen'); + } finally { + setIsUploading(false); + } + }; + + const resetForm = () => { + setPreview(null); + setSelectedFile(null); + setError(null); + setIsUploading(false); + setTitleLength(0); + setDescriptionLength(0); + formRef.current?.reset(); + }; + + return ( + { + if (!open) resetForm(); + onOpenChange(open); + }} + > + + + + {setupToEdit ? 'Editar setup' : 'Comparte tu setup'} + + + +
+
+
+ setTitleLength(e.target.value.length)} + /> +

{titleLength}/45

+
+ +
+