diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..781bc88 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,53 @@ +name: Build CI + +on: + pull_request: + branches: ["master"] + push: + branches: ["master"] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + services: + mysql: + image: mysql:8 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: devpulse + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h localhost" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + env: + DATABASE_URL: "mysql://root:root@localhost:3306/devpulse" + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version: "26" + + - name: Install dependencies + run: npm ci + + - name: Generate Prisma client + run: npx prisma generate + + - name: Run migrations + run: npx prisma migrate deploy + + - name: Typecheck + run: npx tsc --noEmit + + - name: Build + run: npm run build diff --git a/app/(public)/(auth)/logout/page.tsx b/app/(auth)/logout/page.tsx similarity index 100% rename from app/(public)/(auth)/logout/page.tsx rename to app/(auth)/logout/page.tsx diff --git a/app/(public)/(auth)/verify-email/page.tsx b/app/(auth)/verify-email/page.tsx similarity index 89% rename from app/(public)/(auth)/verify-email/page.tsx rename to app/(auth)/verify-email/page.tsx index 549d5e8..6111665 100644 --- a/app/(public)/(auth)/verify-email/page.tsx +++ b/app/(auth)/verify-email/page.tsx @@ -6,7 +6,6 @@ import { redirect } from "next/navigation"; export const metadata: Metadata = { title: "Verify Email - Devpulse", - description: "Verify your email address to activate your Devpulse account.", }; export default async function VerifyEmailPage() { diff --git a/app/(auth)/verify-wakatime/page.tsx b/app/(auth)/verify-wakatime/page.tsx new file mode 100644 index 0000000..af0ea06 --- /dev/null +++ b/app/(auth)/verify-wakatime/page.tsx @@ -0,0 +1,33 @@ +import { Metadata } from "next/types"; +import { Suspense } from "react"; +import { auth } from "@/app/lib/auth"; +import { redirect } from "next/navigation"; +import VerifyWakatime from "@/app/components/auth/VerifyWakatime"; + +export const metadata: Metadata = { + title: "Verify Wakatime - Devpulse", +}; + +export default async function VerifyWakatimePage() { + const session = await auth(); + + if (!session) { + return redirect("/login"); + } + + if (session.user.wakatime_api_key) { + return redirect("/"); + } + + return ( + + Loading... + + } + > + + + ); +} diff --git a/app/(public)/(auth)/reset-password/page.tsx b/app/(public)/(auth)/reset-password/page.tsx index f65a5f4..cf5da8e 100644 --- a/app/(public)/(auth)/reset-password/page.tsx +++ b/app/(public)/(auth)/reset-password/page.tsx @@ -47,7 +47,7 @@ export const metadata: Metadata = { export default async function ResetPassword() { return ( -
+
{/* Left Side - Visual / Branding */}
{/* Background elements */} @@ -88,7 +88,7 @@ export default async function ResetPassword() { const dev = - getAccount + getAccount ( this ); diff --git a/app/(public)/flex/page.tsx b/app/(public)/flex/page.tsx index c9390d8..9f460ea 100644 --- a/app/(public)/flex/page.tsx +++ b/app/(public)/flex/page.tsx @@ -7,6 +7,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faExternalLink } from "@fortawesome/free-solid-svg-icons"; import { Metadata } from "next/types"; import { prisma } from "@/app/lib/prisma"; +import Link from "next/link"; export const metadata: Metadata = { title: "Flexes - Devpulse", @@ -58,8 +59,8 @@ export const metadata: Metadata = { export default async function Flexs() { const flexes = await prisma.userFlex.findMany({ - where: { expiresAt: { gt: new Date() } }, - orderBy: { createdAt: "desc" }, + where: { expires_at: { gt: new Date() } }, + orderBy: { created_at: "desc" }, }); return ( @@ -91,40 +92,40 @@ export default async function Flexs() { >

- {flex.projectName} + {flex.project_name}

- {timeAgo(flex.createdAt.toISOString())} + {timeAgo(flex.created_at.toISOString())}
- {flex.projectTime} + {flex.project_time}
Description: -

{flex.projectDescription}

- {flex.isOpenSource && ( +

{flex.project_description}

+ {flex.is_open_source && ( <> Open Source: - - {flex.openSourceUrl} - + {flex.is_open_source} + )}

- Posted by {flex.userEmail.split("@")[0]} + Posted by {flex.user_email.split("@")[0]}

diff --git a/app/(public)/join/[code]/page.tsx b/app/(public)/join/[code]/page.tsx index 87feb63..7d8abfa 100644 --- a/app/(public)/join/[code]/page.tsx +++ b/app/(public)/join/[code]/page.tsx @@ -10,7 +10,7 @@ export async function generateMetadata({ params }: Props): Promise { const { code } = await params; const leaderboard = await prisma.leaderboard.findUnique({ - where: { joinCode: code }, + where: { join_code: code }, select: { name: true, description: true }, }); diff --git a/app/(public)/join/page.tsx b/app/(public)/join/page.tsx index 7a9846a..d3fd77e 100644 --- a/app/(public)/join/page.tsx +++ b/app/(public)/join/page.tsx @@ -19,20 +19,22 @@ type Props = { async function getLeaderboard(code: string) { return prisma.leaderboard.findUnique({ - where: { joinCode: code }, + where: { join_code: code }, select: { id: true, name: true, description: true, slug: true, - ownerId: true, - createdAt: true, + owner_id: true, + created_at: true, }, }); } async function getMemberCount(leaderboardId: string) { - return prisma.leaderboardMember.count({ where: { leaderboardId } }); + return prisma.leaderboardMember.count({ + where: { leaderboard_id: leaderboardId }, + }); } export async function generateMetadata({ @@ -83,10 +85,10 @@ export default async function JoinPage({ searchParams }: Props) { return (
-
+

@@ -139,9 +141,9 @@ export default async function JoinPage({ searchParams }: Props) { if (user) { const membership = await prisma.leaderboardMember.findUnique({ where: { - leaderboardId_userId: { - leaderboardId: leaderboard.id, - userId: user.id, + leaderboard_id_user_id: { + leaderboard_id: leaderboard.id, + user_id: user.id, }, }, select: { id: true }, @@ -151,18 +153,18 @@ export default async function JoinPage({ searchParams }: Props) { return (
-
+
-
+
Devpulse
-

+

{alreadyMember ? "You’re already a member of" : "You’ve been invited to"} @@ -182,7 +184,7 @@ export default async function JoinPage({ searchParams }: Props) {

{memberCount} {memberCount === 1 ? "member" : "members"} @@ -209,7 +211,7 @@ export default async function JoinPage({ searchParams }: Props) { Powered by{" "} Devpulse {" "} diff --git a/app/(public)/leaderboard/[slug]/page.tsx b/app/(public)/leaderboard/[slug]/page.tsx index c1992c5..b9f0d38 100644 --- a/app/(public)/leaderboard/[slug]/page.tsx +++ b/app/(public)/leaderboard/[slug]/page.tsx @@ -33,18 +33,18 @@ export default async function LeaderboardPage(props: { let members: NonNullableMember[] = []; try { const rows = await prisma.leaderboardMember.findMany({ - where: { leaderboardId: leaderboard.id }, + where: { leaderboard_id: leaderboard.id }, include: { user: { select: { id: true, email: true, role: true, - userStats: { + user_stats: { select: { - totalSeconds: true, + total_seconds: true, languages: true, - operatingSystems: true, + operating_systems: true, editors: true, }, }, @@ -56,14 +56,14 @@ export default async function LeaderboardPage(props: { members = rows .filter((r) => r.user.email) .map((r) => ({ - user_id: r.userId, + user_id: r.user.id, role: r.role, email: r.user.email!, - total_seconds: Number(r.user.userStats?.totalSeconds ?? 0), - languages: (r.user.userStats?.languages as { name: string }[]) ?? [], + total_seconds: Number(r.user.user_stats?.total_seconds ?? 0), + languages: (r.user.user_stats?.languages as { name: string }[]) ?? [], operating_systems: - (r.user.userStats?.operatingSystems as { name: string }[]) ?? [], - editors: (r.user.userStats?.editors as { name: string }[]) ?? [], + (r.user.user_stats?.operating_systems as { name: string }[]) ?? [], + editors: (r.user.user_stats?.editors as { name: string }[]) ?? [], })); } catch { return InternalServerError(); @@ -112,7 +112,7 @@ export default async function LeaderboardPage(props: {
diff --git a/app/(public)/leaderboard/page.tsx b/app/(public)/leaderboard/page.tsx index d79f2f6..db9fc6d 100644 --- a/app/(public)/leaderboard/page.tsx +++ b/app/(public)/leaderboard/page.tsx @@ -59,7 +59,7 @@ export const metadata: Metadata = { export default async function Leaderboards() { const leaderboards = await prisma.leaderboard.findMany({ select: { id: true, name: true, slug: true }, - orderBy: { createdAt: "desc" }, + orderBy: { created_at: "desc" }, }); return ( @@ -89,17 +89,17 @@ export default async function Leaderboards() {
-
+
{board.name}
- + View{" "} diff --git a/app/api/admin/stats/route.ts b/app/api/admin/stats/route.ts index ef4be2e..b001598 100644 --- a/app/api/admin/stats/route.ts +++ b/app/api/admin/stats/route.ts @@ -21,22 +21,22 @@ export async function GET() { await Promise.all([ prisma.userStats.findMany({ select: { - userId: true, - totalSeconds: true, + user_id: true, + total_seconds: true, categories: true, user: { select: { email: true } }, }, }), prisma.conversation.count(), - prisma.message.count({ where: { expiresAt: { gt: new Date() } } }), + prisma.message.count({ where: { expires_at: { gt: new Date() } } }), prisma.leaderboard.count(), - prisma.userFlex.count({ where: { expiresAt: { gt: new Date() } } }), + prisma.userFlex.count({ where: { expires_at: { gt: new Date() } } }), ]); const users = topUserStats.map((row) => ({ - user_id: row.userId, + user_id: row.user_id, email: row.user.email, - total_seconds: Number(row.totalSeconds), + total_seconds: Number(row.total_seconds), categories: row.categories, })); diff --git a/app/api/auth/forgot-password/route.ts b/app/api/auth/forgot-password/route.ts index c5b967e..3e9b638 100644 --- a/app/api/auth/forgot-password/route.ts +++ b/app/api/auth/forgot-password/route.ts @@ -1,6 +1,8 @@ import { NextResponse } from "next/server"; import { prisma } from "@/app/lib/prisma"; import { recaptcha } from "@/app/lib/recaptcha"; +import { NODE_MAILER_USER, transporter } from "@/app/lib/smtp/nodemailer"; +import emailTemplate from "@/app/lib/smtp/template"; export async function POST(req: Request) { const { email, token } = await req.json(); @@ -26,16 +28,46 @@ export async function POST(req: Request) { return NextResponse.json({ success: true }); } - const expiresAt = new Date(Date.now() + 60 * 60 * 1000); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now const { token: resetToken } = await prisma.passwordResetToken.create({ - data: { userId: user.id, expiresAt }, + data: { user_id: user.id, expires_at: expiresAt }, select: { token: true }, }); const resetUrl = `${process.env.NEXTAUTH_URL}/reset-password?token=${resetToken}`; - console.info(`Password reset link for ${email}: ${resetUrl}`); + transporter.sendMail({ + from: `Do Not Reply <${NODE_MAILER_USER}>`, + to: user.email, + subject: "Reset your password", + html: emailTemplate({ + title: "Reset your password", + bodyHtml: ` +

Hi ${user.name},

+

We received a request to reset your Devpulse password. Click the button below to choose a new one.

+
+

+ Or copy and paste this link into your browser:
+
${resetUrl} +

+

+ This link will expire in 1 hour. If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged. +

+
+

+ Have a suggestion or ran into an issue? Please don't hesitate to reach out at + hallofcodes.org. + We'd love to hear from you. +

+
+ `, + }), + }); return NextResponse.json({ success: true }); } diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index 247da02..b54585f 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import bcrypt from "bcryptjs"; import { prisma } from "@/app/lib/prisma"; import { recaptcha } from "@/app/lib/recaptcha"; +import verifyEmail from "@/app/lib/auth/verify-email"; export async function POST(req: Request) { const { email, password, token } = await req.json(); @@ -48,6 +49,8 @@ export async function POST(req: Request) { name: email.split("@")[0], }, }); + + verifyEmail(email); return NextResponse.json({ success: true }, { status: 201 }); } diff --git a/app/api/auth/reset-password/route.ts b/app/api/auth/reset-password/route.ts index 4e07d17..bdd981f 100644 --- a/app/api/auth/reset-password/route.ts +++ b/app/api/auth/reset-password/route.ts @@ -35,7 +35,7 @@ export async function POST(req: Request) { where: { token: reset_token }, }); - if (!resetToken || resetToken.expiresAt < new Date()) { + if (!resetToken || resetToken.expires_at < new Date()) { return NextResponse.json( { error: "Invalid or expired reset link." }, { status: 400 }, @@ -46,7 +46,7 @@ export async function POST(req: Request) { await prisma.$transaction([ prisma.user.update({ - where: { id: resetToken.userId }, + where: { id: resetToken.user_id }, data: { password: hashed }, }), prisma.passwordResetToken.delete({ where: { token: reset_token } }), diff --git a/app/api/auth/verify-email/route.ts b/app/api/auth/verify-email/route.ts index f731f6f..10f4fe9 100644 --- a/app/api/auth/verify-email/route.ts +++ b/app/api/auth/verify-email/route.ts @@ -1,10 +1,9 @@ import { NextResponse } from "next/server"; import { prisma } from "@/app/lib/prisma"; -import crypto from "crypto"; -import { transporter } from "@/app/lib/smtp/nodemailer"; import { recaptcha } from "@/app/lib/recaptcha"; - -const NODE_MAILER_USER = process.env.NODE_MAILER_USER || ""; +import verifyEmail from "@/app/lib/auth/verify-email"; +import { NODE_MAILER_USER, transporter } from "@/app/lib/smtp/nodemailer"; +import emailTemplate from "@/app/lib/smtp/template"; export async function POST(req: Request) { const { email, token } = await req.json(); @@ -24,46 +23,7 @@ export async function POST(req: Request) { if (!(await recaptcha(token, "email_verify"))) throw new Error("reCAPTCHA verification failed. Please try again."); - const user = await prisma.user.findUnique({ where: { email } }); - - if (!user) { - return NextResponse.json({ success: true }); - } - - if (user.emailVerified) { - return NextResponse.json({ success: true }); - } - - // Delete any existing verification token for this email before creating a new one - await prisma.verificationToken.deleteMany({ - where: { identifier: email }, - }); - - const verificationToken = crypto.randomBytes(32).toString("hex"); - const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); - - await prisma.verificationToken.create({ - data: { identifier: email, token: verificationToken, expires }, - }); - - const verifyUrl = `${process.env.NEXTAUTH_URL}/api/auth/verify-email?token=${verificationToken}`; - - console.info(`Email verification link for ${email}: ${verifyUrl}`); - - transporter.sendMail({ - from: `Do Not Reply <${NODE_MAILER_USER}>`, - to: email, - subject: "Verify your email", - html: ` -

Hi ${user.name},

-

Please click the link below to verify your email address:

-

Verify Email

-

Regards,

-

DevPulse

- - This email was sent from DevPulse. If you did not request this, please ignore this email. - `, - }); + await verifyEmail(email); return NextResponse.json({ success: true }); } @@ -91,12 +51,36 @@ export async function GET(req: Request) { ); } - await prisma.user.update({ + const user = await prisma.user.update({ where: { email: record.identifier }, - data: { emailVerified: new Date() }, + data: { email_verified: new Date() }, }); await prisma.verificationToken.delete({ where: { token } }); + transporter.sendMail({ + from: `Do Not Reply <${NODE_MAILER_USER}>`, + to: user.email, + subject: "Welcome to Devpulse", + html: emailTemplate({ + title: "Welcome to Devpulse", + bodyHtml: ` +

Hi ${user.name},

+

I'm Melvin Jones Repol, founder of Hall of Codes, the team behind Devpulse. Thank you for joining us, we're excited to have you on board.

+

Devpulse is built to help you measure and understand your coding pulse, and we're just getting started. Your feedback will play a big part in shaping where we go next.

+
+

+ Have a suggestion or ran into an issue? Please don't hesitate to reach out at + hallofcodes.org. + We'd love to hear from you. +

+
+

Welcome aboard, and happy coding!

+

Melvin Jones Repol

+

Founder, Hall of Codes

+ `, + }), + }); + return NextResponse.redirect(new URL("/login?verified=1", req.url)); } diff --git a/app/api/conversations/[id]/presence/route.ts b/app/api/conversations/[id]/presence/route.ts index f6cf3ea..45cf90a 100644 --- a/app/api/conversations/[id]/presence/route.ts +++ b/app/api/conversations/[id]/presence/route.ts @@ -25,7 +25,7 @@ export async function PATCH( } await prisma.conversationParticipant.updateMany({ - where: { conversationId, userId: session.user.id }, + where: { conversation_id: conversationId, user_id: session.user.id }, data, }); diff --git a/app/api/conversations/[id]/route.ts b/app/api/conversations/[id]/route.ts index 6be2a41..e2cd72f 100644 --- a/app/api/conversations/[id]/route.ts +++ b/app/api/conversations/[id]/route.ts @@ -15,7 +15,7 @@ export async function DELETE( const participant = await prisma.conversationParticipant.findUnique({ where: { - conversationId_userId: { conversationId: id, userId: session.user.id }, + conversation_id_user_id: { conversation_id: id, user_id: session.user.id }, }, }); diff --git a/app/api/conversations/[id]/unread/route.ts b/app/api/conversations/[id]/unread/route.ts index d83353f..1c5fb06 100644 --- a/app/api/conversations/[id]/unread/route.ts +++ b/app/api/conversations/[id]/unread/route.ts @@ -15,9 +15,12 @@ export async function GET( const participant = await prisma.conversationParticipant.findUnique({ where: { - conversationId_userId: { conversationId, userId: session.user.id }, + conversation_id_user_id: { + conversation_id: conversationId, + user_id: session.user.id, + }, }, - select: { lastReadAt: true }, + select: { last_read_at: true }, }); if (!participant) { @@ -26,10 +29,10 @@ export async function GET( const count = await prisma.message.count({ where: { - conversationId, - senderId: { not: session.user.id }, - createdAt: { gt: participant.lastReadAt }, - expiresAt: { gt: new Date() }, + conversation_id: conversationId, + sender_id: { not: session.user.id }, + created_at: { gt: participant.last_read_at }, + expires_at: { gt: new Date() }, }, }); diff --git a/app/api/conversations/route.ts b/app/api/conversations/route.ts index 88d929b..c541544 100644 --- a/app/api/conversations/route.ts +++ b/app/api/conversations/route.ts @@ -9,16 +9,16 @@ export async function GET() { } const participantRows = await prisma.conversationParticipant.findMany({ - where: { userId: session.user.id }, + where: { user_id: session.user.id }, include: { conversation: { include: { participants: { select: { - userId: true, + user_id: true, email: true, - lastSeenAt: true, - lastReadAt: true, + last_seen_at: true, + last_read_at: true, }, }, }, @@ -27,14 +27,14 @@ export async function GET() { }); const conversations = participantRows.map((row) => ({ - id: row.conversationId, + id: row.conversation_id, type: row.conversation.type.toLowerCase(), - created_at: row.conversation.createdAt.toISOString(), - last_read_at: row.lastReadAt.toISOString(), + created_at: row.conversation.created_at.toISOString(), + last_read_at: row.last_read_at.toISOString(), users: row.conversation.participants.map((p) => ({ - id: p.userId, + id: p.user_id, email: p.email, - last_seen_at: p.lastSeenAt.toISOString(), + last_seen_at: p.last_seen_at.toISOString(), })), })); @@ -65,23 +65,23 @@ export async function POST(req: Request) { participants: { create: [ { - userId: session.user.id, + user_id: session.user.id, email: session.user.email, - lastSeenAt: timestamp, - lastReadAt: timestamp, + last_seen_at: timestamp, + last_read_at: timestamp, }, { - userId: otherUserId, + user_id: otherUserId, email: otherUserEmail ?? "", - lastSeenAt: EPOCH, - lastReadAt: EPOCH, + last_seen_at: EPOCH, + last_read_at: EPOCH, }, ], }, }, include: { participants: { - select: { userId: true, email: true, lastSeenAt: true }, + select: { user_id: true, email: true, last_seen_at: true }, }, }, }); @@ -90,12 +90,12 @@ export async function POST(req: Request) { { id: conversation.id, type: "private", - created_at: conversation.createdAt.toISOString(), + created_at: conversation.created_at.toISOString(), last_read_at: timestamp.toISOString(), users: conversation.participants.map((p) => ({ - id: p.userId, + id: p.user_id, email: p.email, - last_seen_at: p.lastSeenAt.toISOString(), + last_seen_at: p.last_seen_at.toISOString(), })), }, { status: 201 }, diff --git a/app/api/cron/cleanup-messages/route.ts b/app/api/cron/cleanup-messages/route.ts index 0399665..8c62adc 100644 --- a/app/api/cron/cleanup-messages/route.ts +++ b/app/api/cron/cleanup-messages/route.ts @@ -8,7 +8,7 @@ export async function POST(req: Request) { } const { count } = await prisma.message.deleteMany({ - where: { expiresAt: { lt: new Date() } }, + where: { expires_at: { lt: new Date() } }, }); return NextResponse.json({ deleted: count }); diff --git a/app/api/flex/[id]/route.ts b/app/api/flex/[id]/route.ts index 591a808..2bff92c 100644 --- a/app/api/flex/[id]/route.ts +++ b/app/api/flex/[id]/route.ts @@ -23,14 +23,14 @@ export async function PUT( } = body; const flex = await prisma.userFlex.updateMany({ - where: { id, userId: session.user.id }, + where: { id, user_id: session.user.id }, data: { - projectName: project_name?.trim(), - projectDescription: project_description ?? "", - projectUrl: project_url ?? "", - projectTime: project_time ?? "", - isOpenSource: is_open_source ?? false, - openSourceUrl: is_open_source ? (open_source_url ?? "") : "", + project_name: project_name?.trim(), + project_description: project_description ?? "", + project_url: project_url ?? "", + project_time: project_time ?? "", + is_open_source: is_open_source ?? false, + open_source_url: is_open_source ? (open_source_url ?? "") : "", }, }); @@ -54,7 +54,7 @@ export async function DELETE( const { id } = await params; await prisma.userFlex.deleteMany({ - where: { id, userId: session.user.id }, + where: { id, user_id: session.user.id }, }); return NextResponse.json({ success: true }); diff --git a/app/api/flex/projects/route.ts b/app/api/flex/projects/route.ts index 92efc62..83af68e 100644 --- a/app/api/flex/projects/route.ts +++ b/app/api/flex/projects/route.ts @@ -9,7 +9,7 @@ export async function GET() { } const userProjects = await prisma.userProjects.findUnique({ - where: { userId: session.user.id }, + where: { user_id: session.user.id }, select: { projects: true }, }); diff --git a/app/api/flex/route.ts b/app/api/flex/route.ts index 2ad4644..5ae38a4 100644 --- a/app/api/flex/route.ts +++ b/app/api/flex/route.ts @@ -9,8 +9,8 @@ export async function GET() { } const flexes = await prisma.userFlex.findMany({ - where: { userId: session.user.id }, - orderBy: { createdAt: "desc" }, + where: { user_id: session.user.id }, + orderBy: { created_at: "desc" }, }); return NextResponse.json(flexes); @@ -43,15 +43,15 @@ export async function POST(req: Request) { const flex = await prisma.userFlex.create({ data: { - userId: session.user.id, - userEmail: session.user.email, - projectName: project_name.trim(), - projectDescription: project_description ?? "", - projectUrl: project_url ?? "", - projectTime: project_time ?? "", - isOpenSource: is_open_source ?? false, - openSourceUrl: is_open_source ? (open_source_url ?? "") : "", - expiresAt, + user_id: session.user.id, + user_email: session.user.email, + project_name: project_name.trim(), + project_description: project_description ?? "", + project_url: project_url ?? "", + project_time: project_time ?? "", + is_open_source: is_open_source ?? false, + open_source_url: is_open_source ? (open_source_url ?? "") : "", + expires_at: expiresAt, }, }); diff --git a/app/api/kanban/issues/[id]/route.ts b/app/api/kanban/issues/[id]/route.ts index df7476b..2eaabbb 100644 --- a/app/api/kanban/issues/[id]/route.ts +++ b/app/api/kanban/issues/[id]/route.ts @@ -27,7 +27,7 @@ export async function PATCH( if (column_id !== undefined) { const columnAccess = await getColumnAccess(session.user.id, column_id); - if (!columnAccess || columnAccess.projectId !== issueAccess.projectId) { + if (!columnAccess || columnAccess.project_id !== issueAccess.project_id) { return NextResponse.json( { error: "Cannot move issue to that column." }, { status: 400 }, @@ -36,7 +36,7 @@ export async function PATCH( } const data: Record = {}; - if (column_id !== undefined) data.columnId = column_id; + if (column_id !== undefined) data.column_id = column_id; if (position !== undefined) data.position = position; if (Object.keys(data).length === 0) { @@ -45,18 +45,22 @@ export async function PATCH( const issue = await prisma.issue.update({ where: { id }, - data: { ...data, updatedAt: new Date() }, + data: { ...data, updated_at: new Date() }, }); const payload = { type: "issue_updated", - data: { id: issue.id, column_id: issue.columnId, position: issue.position }, + data: { + id: issue.id, + column_id: issue.column_id, + position: issue.position, + }, }; emitter.emit("kanban", payload); return NextResponse.json({ id: issue.id, - column_id: issue.columnId, + column_id: issue.column_id, position: issue.position, }); } diff --git a/app/api/kanban/issues/route.ts b/app/api/kanban/issues/route.ts index 6616ca0..67357cb 100644 --- a/app/api/kanban/issues/route.ts +++ b/app/api/kanban/issues/route.ts @@ -23,33 +23,30 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const { columnId, title, tag, type, priority, issueKey, position } = + const { column_id, title, tag, type, priority, issue_key, position } = await req.json(); - if (!columnId || !title?.trim()) { + if (!column_id || !title?.trim()) { return NextResponse.json( { error: "columnId and title are required." }, { status: 400 }, ); } - const columnAccess = await getColumnAccess(session.user.id, columnId); + const columnAccess = await getColumnAccess(session.user.id, column_id); if (!columnAccess) { return NextResponse.json({ error: "Column not found." }, { status: 404 }); } const resolvedIssueKey = - typeof issueKey === "string" && issueKey.trim().length > 0 - ? issueKey.trim() - : await getNextIssueKey( - columnAccess.projectId, - columnAccess.projectName, - ); + typeof issue_key === "string" && issue_key.trim().length > 0 + ? issue_key.trim() + : await getNextIssueKey(columnAccess.project_id, columnAccess.project_name); const issue = await prisma.issue.create({ data: { - columnId, - issueKey: resolvedIssueKey, + column_id: column_id, + issue_key: resolvedIssueKey, title: title.trim(), tag: tag ?? "", type: TYPE_MAP[type] ?? "FEATURE", @@ -60,14 +57,14 @@ export async function POST(req: Request) { const payload = { id: issue.id, - column_id: issue.columnId, - issue_key: issue.issueKey, + column_id: issue.column_id, + issue_key: issue.issue_key, title: issue.title, tag: issue.tag ?? "", type: issue.type.toLowerCase(), priority: issue.priority.toLowerCase(), position: issue.position, - created_at: issue.createdAt.toISOString(), + created_at: issue.created_at.toISOString(), }; emitter.emit("kanban", { type: "issue_created", data: payload }); diff --git a/app/api/kanban/projects/route.ts b/app/api/kanban/projects/route.ts index 58fc13a..3c14a74 100644 --- a/app/api/kanban/projects/route.ts +++ b/app/api/kanban/projects/route.ts @@ -27,8 +27,7 @@ export async function POST(req: Request) { const body = (await req.json()) as { name?: string; description?: string; - wakatimeProjectName?: string; - color?: string; + wakatime_project_name?: string; }; const name = body.name?.trim(); @@ -57,8 +56,7 @@ export async function POST(req: Request) { const projectId = crypto.randomUUID(); const boardId = crypto.randomUUID(); const safeDescription = body.description?.trim() || null; - const safeWakaName = body.wakatimeProjectName?.trim() || null; - const safeColor = body.color?.trim() || "indigo"; + const safeWakaName = body.wakatime_project_name?.trim() || null; const now = new Date(); await prisma.$transaction(async (tx) => { @@ -69,7 +67,6 @@ export async function POST(req: Request) { name, description, wakatime_project_name, - color, created_at ) VALUES ( ${projectId}, @@ -77,7 +74,6 @@ export async function POST(req: Request) { ${name}, ${safeDescription}, ${safeWakaName}, - ${safeColor}, ${now} ) `; diff --git a/app/api/leaderboards/[id]/join-code/route.ts b/app/api/leaderboards/[id]/join-code/route.ts index 696cf11..b7fda43 100644 --- a/app/api/leaderboards/[id]/join-code/route.ts +++ b/app/api/leaderboards/[id]/join-code/route.ts @@ -15,14 +15,14 @@ export async function GET( const leaderboard = await prisma.leaderboard.findUnique({ where: { id }, - select: { joinCode: true, ownerId: true }, + select: { join_code: true, owner_id: true }, }); - if (!leaderboard || leaderboard.ownerId !== session.user.id) { + if (!leaderboard || leaderboard.owner_id !== session.user.id) { return NextResponse.json({ error: "Forbidden." }, { status: 403 }); } - return NextResponse.json({ joinCode: leaderboard.joinCode }); + return NextResponse.json({ join_code: leaderboard.join_code }); } export async function PATCH( @@ -38,10 +38,10 @@ export async function PATCH( const leaderboard = await prisma.leaderboard.findUnique({ where: { id }, - select: { ownerId: true }, + select: { owner_id: true }, }); - if (!leaderboard || leaderboard.ownerId !== session.user.id) { + if (!leaderboard || leaderboard.owner_id !== session.user.id) { return NextResponse.json({ error: "Forbidden." }, { status: 403 }); } @@ -49,8 +49,8 @@ export async function PATCH( await prisma.leaderboard.update({ where: { id }, - data: { joinCode }, + data: { join_code: joinCode }, }); - return NextResponse.json({ success: true, joinCode }); + return NextResponse.json({ success: true, join_code: joinCode }); } diff --git a/app/api/leaderboards/[id]/leave/route.ts b/app/api/leaderboards/[id]/leave/route.ts index ec52d9b..d7b6161 100644 --- a/app/api/leaderboards/[id]/leave/route.ts +++ b/app/api/leaderboards/[id]/leave/route.ts @@ -14,7 +14,7 @@ export async function DELETE( const { id } = await params; await prisma.leaderboardMember.deleteMany({ - where: { leaderboardId: id, userId: session.user.id }, + where: { leaderboard_id: id, user_id: session.user.id }, }); return NextResponse.json({ success: true }); diff --git a/app/api/leaderboards/[id]/route.ts b/app/api/leaderboards/[id]/route.ts index 1d2134f..2fc048b 100644 --- a/app/api/leaderboards/[id]/route.ts +++ b/app/api/leaderboards/[id]/route.ts @@ -15,14 +15,14 @@ export async function DELETE( const leaderboard = await prisma.leaderboard.findUnique({ where: { id }, - select: { ownerId: true }, + select: { owner_id: true }, }); if (!leaderboard) { return NextResponse.json({ error: "Not found." }, { status: 404 }); } - if (leaderboard.ownerId !== session.user.id) { + if (leaderboard.owner_id !== session.user.id) { return NextResponse.json({ error: "Forbidden." }, { status: 403 }); } diff --git a/app/api/leaderboards/join/route.ts b/app/api/leaderboards/join/route.ts index 38d2656..6d1363e 100644 --- a/app/api/leaderboards/join/route.ts +++ b/app/api/leaderboards/join/route.ts @@ -8,8 +8,8 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const { joinCode } = await req.json(); - if (!joinCode) { + const { join_code } = await req.json(); + if (!join_code) { return NextResponse.json( { error: "Join code is required." }, { status: 400 }, @@ -17,7 +17,7 @@ export async function POST(req: Request) { } const leaderboard = await prisma.leaderboard.findUnique({ - where: { joinCode }, + where: { join_code }, select: { id: true, slug: true }, }); @@ -31,8 +31,8 @@ export async function POST(req: Request) { try { await prisma.leaderboardMember.create({ data: { - leaderboardId: leaderboard.id, - userId: session.user.id, + leaderboard_id: leaderboard.id, + user_id: session.user.id, role: "member", }, }); diff --git a/app/api/leaderboards/route.ts b/app/api/leaderboards/route.ts index 73b5999..eb5998d 100644 --- a/app/api/leaderboards/route.ts +++ b/app/api/leaderboards/route.ts @@ -23,22 +23,22 @@ export async function POST(req: Request) { name: name.trim(), description: "", slug, - ownerId: session.user.id, - joinCode, - isPublic: true, + owner_id: session.user.id, + join_code: joinCode, + is_public: true, }, }); await prisma.leaderboardMember.create({ data: { - leaderboardId: leaderboard.id, - userId: session.user.id, + leaderboard_id: leaderboard.id, + user_id: session.user.id, role: "owner", }, }); return NextResponse.json( - { joinCode: leaderboard.joinCode }, + { join_code: leaderboard.join_code }, { status: 201 }, ); } catch { diff --git a/app/api/messages/route.ts b/app/api/messages/route.ts index e60f3d6..360bbae 100644 --- a/app/api/messages/route.ts +++ b/app/api/messages/route.ts @@ -21,7 +21,10 @@ export async function GET(req: Request) { const participant = await prisma.conversationParticipant.findUnique({ where: { - conversationId_userId: { conversationId, userId: session.user.id }, + conversation_id_user_id: { + conversation_id: conversationId, + user_id: session.user.id, + }, }, }); @@ -31,20 +34,20 @@ export async function GET(req: Request) { const messages = await prisma.message.findMany({ where: { - conversationId, - expiresAt: { gt: new Date() }, + conversation_id: conversationId, + expires_at: { gt: new Date() }, }, - orderBy: { createdAt: "asc" }, + orderBy: { created_at: "asc" }, }); return NextResponse.json( messages.map((m) => ({ id: m.id, - conversation_id: m.conversationId, - sender_id: m.senderId, + conversation_id: m.conversation_id, + sender_id: m.sender_id, text: m.text, attachments: m.attachments, - created_at: m.createdAt.toISOString(), + created_at: m.created_at.toISOString(), })), ); } @@ -69,7 +72,10 @@ export async function POST(req: Request) { const participant = await prisma.conversationParticipant.findUnique({ where: { - conversationId_userId: { conversationId, userId: session.user.id }, + conversation_id_user_id: { + conversation_id: conversationId, + user_id: session.user.id, + }, }, }); @@ -79,8 +85,8 @@ export async function POST(req: Request) { const message = await prisma.message.create({ data: { - conversationId, - senderId: session.user.id, + conversation_id: conversationId, + sender_id: session.user.id, text: text?.trim() ?? "", attachments: attachments ?? [], }, @@ -88,22 +94,25 @@ export async function POST(req: Request) { const payload = { id: message.id, - conversation_id: message.conversationId, - sender_id: message.senderId, + conversation_id: message.conversation_id, + sender_id: message.sender_id, text: message.text, attachments: message.attachments, - created_at: message.createdAt.toISOString(), + created_at: message.created_at.toISOString(), }; emitter.emit(`chat:${conversationId}`, { type: "message", data: payload }); const participants = await prisma.conversationParticipant.findMany({ - where: { conversationId, userId: { not: session.user.id } }, - select: { userId: true }, + where: { + conversation_id: conversationId, + user_id: { not: session.user.id }, + }, + select: { user_id: true }, }); for (const p of participants) { - emitter.emit(`user:${p.userId}`, { + emitter.emit(`user:${p.user_id}`, { type: "new_message", data: { conversation_id: conversationId, sender_id: session.user.id }, }); diff --git a/app/api/presence/route.ts b/app/api/presence/route.ts index c64c7a2..1d2e51f 100644 --- a/app/api/presence/route.ts +++ b/app/api/presence/route.ts @@ -11,8 +11,8 @@ export async function PATCH() { const timestamp = new Date(); await prisma.conversationParticipant.updateMany({ - where: { userId: session.user.id }, - data: { lastSeenAt: timestamp }, + where: { user_id: session.user.id }, + data: { last_seen_at: timestamp }, }); return NextResponse.json({ success: true }); diff --git a/app/api/sse/chat/[conversationId]/route.ts b/app/api/sse/chat/[conversationId]/route.ts index 6325b20..ab7c5eb 100644 --- a/app/api/sse/chat/[conversationId]/route.ts +++ b/app/api/sse/chat/[conversationId]/route.ts @@ -18,7 +18,10 @@ export async function GET( const participant = await prisma.conversationParticipant.findUnique({ where: { - conversationId_userId: { conversationId, userId: session.user.id }, + conversation_id_user_id: { + conversation_id: conversationId, + user_id: session.user.id, + }, }, }); diff --git a/app/api/sse/chat/[conversationId]/typing/route.ts b/app/api/sse/chat/[conversationId]/typing/route.ts index 51b3de6..4e9f939 100644 --- a/app/api/sse/chat/[conversationId]/typing/route.ts +++ b/app/api/sse/chat/[conversationId]/typing/route.ts @@ -16,7 +16,10 @@ export async function POST( const participant = await prisma.conversationParticipant.findUnique({ where: { - conversationId_userId: { conversationId, userId: session.user.id }, + conversation_id_user_id: { + conversation_id: conversationId, + user_id: session.user.id, + }, }, }); diff --git a/app/api/users/badges/route.ts b/app/api/users/badges/route.ts index fbe884e..f6ef4e7 100644 --- a/app/api/users/badges/route.ts +++ b/app/api/users/badges/route.ts @@ -16,14 +16,14 @@ export async function GET(req: Request) { } const stats = await prisma.userStats.findMany({ - where: { userId: { in: ids } }, - select: { userId: true, totalSeconds: true }, + where: { user_id: { in: ids } }, + select: { user_id: true, total_seconds: true }, }); return NextResponse.json( stats.map((s) => ({ - user_id: s.userId, - total_seconds: Number(s.totalSeconds), + user_id: s.user_id, + total_seconds: Number(s.total_seconds), })), ); } diff --git a/app/api/users/route.ts b/app/api/users/route.ts index 4b6c976..f626d9c 100644 --- a/app/api/users/route.ts +++ b/app/api/users/route.ts @@ -20,15 +20,15 @@ export async function GET(req: Request) { const participants = await prisma.conversationParticipant.findMany({ where: { - conversationId, - userId: { not: session.user.id }, + conversation_id: conversationId, + user_id: { not: session.user.id }, }, - select: { userId: true, email: true }, + select: { user_id: true, email: true }, }); const users = participants .filter((p) => p.email) - .map((p) => ({ user_id: p.userId, email: p.email })) + .map((p) => ({ user_id: p.user_id, email: p.email })) .sort((a, b) => a.email.localeCompare(b.email)); return NextResponse.json(users); diff --git a/app/api/wakatime/sync/route.ts b/app/api/wakatime/sync/route.ts index 6700258..b284c44 100644 --- a/app/api/wakatime/sync/route.ts +++ b/app/api/wakatime/sync/route.ts @@ -39,7 +39,7 @@ export async function GET(request: Request) { const result = await syncWakatimeData({ userId: user.id, incomingApiKey: apiKey, - storedApiKey: user.wakatimeApiKey, + storedApiKey: user.wakatime_api_key, }); if (!result.success && result.status !== 200) { diff --git a/app/components/BoardList.tsx b/app/components/BoardList.tsx index 0acfe1b..a37968a 100644 --- a/app/components/BoardList.tsx +++ b/app/components/BoardList.tsx @@ -8,8 +8,6 @@ import { faKey, faRotateRight, faTrashAlt, - faChevronRight, - faServer, faRightFromBracket, } from "@fortawesome/free-solid-svg-icons"; import { toast } from "react-toastify"; @@ -102,7 +100,7 @@ export default function BoardList({ async (res) => { const data = await res.json(); if (!res.ok) throw new Error(data.error); - return data.joinCode as string; + return data.join_code as string; }, ); @@ -124,41 +122,28 @@ export default function BoardList({ return ( <> -
+
-
- -

{board.name}

-

+

/{board.slug}

- -
- -
{user.id === board.owner_id && (
@@ -387,7 +387,7 @@ export default function Chat({ user }: { user: ChatUserShape }) { value={messageSearch} onChange={(e) => setMessageSearch(e.target.value)} placeholder="Search Message..." - className="w-full bg-gray-50 border border-transparent rounded-xl py-2 pl-9 pr-4 text-sm text-gray-700 placeholder:text-gray-500 outline-none focus:border-indigo-500/50 transition-colors shadow-inner" + className="w-full bg-gray-50 border border-transparent rounded-xl py-2 pl-9 pr-4 text-sm text-gray-700 placeholder:text-gray-500 outline-none focus:border-blue-500/50 transition-colors shadow-inner" />
@@ -436,7 +436,7 @@ export default function Chat({ user }: { user: ChatUserShape }) { setDmSortOrder("newest"); setIsDmSortOpen(false); }} - className={`w-full text-left px-3 py-1.5 hover:bg-gray-100 transition-colors ${dmSortOrder === "newest" ? "text-indigo-600 bg-gray-50" : "text-gray-600"}`} + className={`w-full text-left px-3 py-1.5 hover:bg-gray-100 transition-colors ${dmSortOrder === "newest" ? "text-blue-600 bg-gray-50" : "text-gray-600"}`} > Newest @@ -445,7 +445,7 @@ export default function Chat({ user }: { user: ChatUserShape }) { setDmSortOrder("oldest"); setIsDmSortOpen(false); }} - className={`w-full text-left px-3 py-1.5 hover:bg-gray-100 transition-colors ${dmSortOrder === "oldest" ? "text-indigo-600 bg-gray-50" : "text-gray-600"}`} + className={`w-full text-left px-3 py-1.5 hover:bg-gray-100 transition-colors ${dmSortOrder === "oldest" ? "text-blue-600 bg-gray-50" : "text-gray-600"}`} > Oldest @@ -454,7 +454,7 @@ export default function Chat({ user }: { user: ChatUserShape }) { setDmSortOrder("az"); setIsDmSortOpen(false); }} - className={`w-full text-left px-3 py-1.5 hover:bg-gray-100 transition-colors ${dmSortOrder === "az" ? "text-indigo-600 bg-gray-50" : "text-gray-600"}`} + className={`w-full text-left px-3 py-1.5 hover:bg-gray-100 transition-colors ${dmSortOrder === "az" ? "text-blue-600 bg-gray-50" : "text-gray-600"}`} > A-Z @@ -463,7 +463,7 @@ export default function Chat({ user }: { user: ChatUserShape }) { setDmSortOrder("za"); setIsDmSortOpen(false); }} - className={`w-full text-left px-3 py-1.5 hover:bg-gray-100 transition-colors ${dmSortOrder === "za" ? "text-indigo-600 bg-gray-50" : "text-gray-600"}`} + className={`w-full text-left px-3 py-1.5 hover:bg-gray-100 transition-colors ${dmSortOrder === "za" ? "text-blue-600 bg-gray-50" : "text-gray-600"}`} > Z-A @@ -505,7 +505,7 @@ export default function Chat({ user }: { user: ChatUserShape }) {
{activeInitials}
@@ -525,7 +525,7 @@ export default function Chat({ user }: { user: ChatUserShape }) {
@@ -646,7 +646,7 @@ export default function Chat({ user }: { user: ChatUserShape }) { className={`h-10 px-5 rounded-[20px] font-semibold text-[14px] flex items-center gap-2.5 transition-all duration-300 flex-shrink-0 ${ canSendMessage - ? "bg-gradient-to-r from-indigo-500 to-violet-500 hover:from-indigo-400 hover:to-violet-400 text-gray-900 shadow-md shadow-indigo-500/25 active:scale-95" + ? "bg-gradient-to-r from-blue-500 to-violet-500 hover:from-blue-400 hover:to-violet-400 text-gray-900 shadow-md shadow-blue-500/25 active:scale-95" : "bg-gray-50 text-gray-500 cursor-not-allowed" } `} @@ -665,7 +665,7 @@ export default function Chat({ user }: { user: ChatUserShape }) { ) : (
-
+

@@ -694,7 +694,7 @@ export default function Chat({ user }: { user: ChatUserShape }) {

{activeInitials}
@@ -713,7 +713,7 @@ export default function Chat({ user }: { user: ChatUserShape }) { input.focus(); } }} - className="w-11 h-11 rounded-2xl bg-gray-50 flex items-center justify-center text-gray-500 hover:text-indigo-600 hover:bg-gray-100 transition shadow-sm" + className="w-11 h-11 rounded-2xl bg-gray-50 flex items-center justify-center text-gray-500 hover:text-blue-600 hover:bg-gray-100 transition shadow-sm" title="Search Message" > @@ -806,7 +806,7 @@ export default function Chat({ user }: { user: ChatUserShape }) { {allMediaAttachments.length > 9 && !showAllMedia && ( @@ -821,7 +821,7 @@ export default function Chat({ user }: { user: ChatUserShape }) { )} ) : ( -
+
createConversation(u)} className="flex items-center gap-3 p-2.5 rounded-xl hover:bg-gray-100 cursor-pointer transition" > -
+
{u.email[0].toUpperCase()}
diff --git a/app/components/Flex.tsx b/app/components/Flex.tsx index 32b626c..fe38f78 100644 --- a/app/components/Flex.tsx +++ b/app/components/Flex.tsx @@ -27,25 +27,25 @@ export interface Projects { interface FlexRow { id: string; - projectName: string; - projectDescription: string; - projectUrl: string; - projectTime: string; - isOpenSource: boolean; - openSourceUrl: string; - expiresAt: string; - createdAt: string; + project_name: string; + project_description: string; + project_url: string; + project_time: string; + is_open_source: boolean; + open_source_url: string; + expires_at: string; + created_at: string; } function toEditableFlex(row: FlexRow): Projects { return { - name: row.projectName ?? "", - text: row.projectTime ?? "", - project_description: row.projectDescription ?? "", - project_url: row.projectUrl ?? "", - project_time: row.projectTime ?? "", - is_open_source: row.isOpenSource ?? false, - open_source_url: row.openSourceUrl ?? "", + name: row.project_name ?? "", + text: row.project_time ?? "", + project_description: row.project_description ?? "", + project_url: row.project_url ?? "", + project_time: row.project_time ?? "", + is_open_source: row.is_open_source ?? false, + open_source_url: row.open_source_url ?? "", }; } @@ -187,7 +187,7 @@ export default function Flex() { const data: { projects: Projects[] } = await res.json(); const projects: Projects[] = data.projects ?? []; const newProjects = projects.filter( - (p) => !userFlexes.some((f) => f.projectName === p.name), + (p) => !userFlexes.some((f) => f.project_name === p.name), ); setFlexes(newProjects); } @@ -197,19 +197,13 @@ export default function Flex() { return (
-
+
-

+

Flex

-

- - - Share your flexes with the community - +

+ Share your projects with the community

@@ -327,7 +321,7 @@ export default function Flex() { {userFlexes.map((f) => (
-

{f.projectName}

+

{f.project_name}

-

{f.projectDescription}

+

{f.project_description}

- {f.projectUrl} + {f.project_url} - {f.isOpenSource && ( + {f.is_open_source && ( - {f.openSourceUrl} + {f.open_source_url} )} - Expires in {expireAt(f.expiresAt || "")} • Posted{" "} - {timeAgo(f.createdAt)} + Expires in {expireAt(f.expires_at || "")} • Posted{" "} + {timeAgo(f.created_at)}
))} diff --git a/app/components/JoinButton.tsx b/app/components/JoinButton.tsx index 73c7ec0..adb7a5c 100644 --- a/app/components/JoinButton.tsx +++ b/app/components/JoinButton.tsx @@ -30,7 +30,7 @@ export default function JoinButton({ return ( View @@ -44,7 +44,7 @@ export default function JoinButton({
Log In to Join @@ -53,7 +53,7 @@ export default function JoinButton({ Don't have an account?{" "} Sign up free @@ -68,7 +68,7 @@ export default function JoinButton({ const joinPromise = fetch("/api/leaderboards/join", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ joinCode: code }), + body: JSON.stringify({ join_code: code }), }).then(async (res) => { const data = await res.json(); if (!res.ok) throw new Error(data.error); @@ -97,7 +97,7 @@ export default function JoinButton({ )} - Back to login + Changed your mind? Log in again.
) : ( <>
-
+
- {loading ? "Sending..." : "Resend verification email"} + Resend verification email )} - Back to login + Changed your mind? Log in again.
diff --git a/app/components/auth/VerifyWakatime.tsx b/app/components/auth/VerifyWakatime.tsx new file mode 100644 index 0000000..674af20 --- /dev/null +++ b/app/components/auth/VerifyWakatime.tsx @@ -0,0 +1,215 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { toast } from "react-toastify"; + +export default function VerifyWakatime() { + const [apiKey, setApiKey] = useState(""); + const [grecaptchaLoaded, setGrecaptchaLoaded] = useState(false); + const [loading, setLoading] = useState(false); + const router = useRouter(); + + useEffect(() => { + const loadGrecaptcha = () => { + const scriptId = "recaptcha-enterprise"; + if (!document.getElementById(scriptId)) { + const script = document.createElement("script"); + script.id = scriptId; + script.src = `https://www.google.com/recaptcha/enterprise.js?render=${process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY}`; + script.async = true; + script.onload = () => setGrecaptchaLoaded(true); + document.body.appendChild(script); + } else { + setGrecaptchaLoaded(true); + } + }; + + loadGrecaptcha(); + }, []); + + const handleVerify = async (e: React.FormEvent) => { + e.preventDefault(); + if (!grecaptchaLoaded || !window.grecaptcha?.enterprise) { + toast.error( + "Recaptcha Enterprise is not loaded. Please try again later.", + ); + return; + } + + setLoading(true); + + const verifyWakatimePromise = new Promise(async (resolve, reject) => { + try { + const wakatimeSyncResponse = await fetch( + `/api/wakatime/sync?apiKey=${encodeURIComponent(apiKey)}`, + ); + if (!wakatimeSyncResponse.ok) + throw new Error("Failed to sync Wakatime."); + + resolve(); + } catch (error) { + reject(error); + } + }); + + toast.promise(verifyWakatimePromise, { + pending: "Verifying...", + success: { + render() { + router.push("/d"); + + return "Verification successful!"; + }, + }, + error: { + render({ data }) { + setLoading(false); + const err = data as Error; + return err?.message || "Failed to verify. Please try again."; + }, + }, + }); + }; + + return ( +
+ {/* Left Side - Visual / Branding */} +
+
+ +
+ + Devpulse Logo + + Devpulse + + +
+ +
+

+ Last step away from your dashboard. +

+

+ We need you to input your Wakatime API key to complete the + verification. +

+ +
+
+
+
+
+ + verify.ts + +
+
+
+ await + user + . + verifyWakatime + ( + apiKey + ); +
+
+ user + . + verifyWakatime + = + true + ; +
+
+ + {"// Welcome aboard. ✓"} + +
+
+
+
+ +
+ © {new Date().getFullYear()} Devpulse. All rights reserved. +
+
+ + {/* Right Side */} +
+
+ +
+ + Devpulse Logo +

Devpulse

+ + +
+
+ + + +
+

+ Wakatime Verification +

+

+ We need you to enter your Wakatime API Key to connect your account + to Devpulse. +

+
+ +
+
+ setApiKey(e.target.value)} + required + /> + + + + + Changed your mind? Log in again. + +
+
+
+
+
+ ); +} diff --git a/app/components/auth/form/ForgotPasswordForm.tsx b/app/components/auth/form/ForgotPasswordForm.tsx index 49e4663..9eceb76 100644 --- a/app/components/auth/form/ForgotPasswordForm.tsx +++ b/app/components/auth/form/ForgotPasswordForm.tsx @@ -94,13 +94,8 @@ export default function ForgotPasswordForm() { diff --git a/app/components/auth/form/LoginForm.tsx b/app/components/auth/form/LoginForm.tsx index 5f42978..496ce44 100644 --- a/app/components/auth/form/LoginForm.tsx +++ b/app/components/auth/form/LoginForm.tsx @@ -44,6 +44,7 @@ export default function LoginForm() { const handleLogin = async (e: React.SyntheticEvent) => { e.preventDefault(); + if (!grecaptchaLoaded || !window.grecaptcha?.enterprise) { toast.error( "Recaptcha Enterprise is not loaded. Please try again later.", @@ -96,7 +97,7 @@ export default function LoginForm() { <> {justVerified && (
- Email verified successfully. You can now log in. + Thank you for verifying your email!
)}
@@ -119,15 +120,10 @@ export default function LoginForm() { onChange={(e) => setPassword(e.target.value)} required /> - diff --git a/app/components/auth/form/ResetPasswordForm.tsx b/app/components/auth/form/ResetPasswordForm.tsx index 438e203..8bac0bb 100644 --- a/app/components/auth/form/ResetPasswordForm.tsx +++ b/app/components/auth/form/ResetPasswordForm.tsx @@ -124,11 +124,7 @@ export default function ResetPasswordForm() { diff --git a/app/components/auth/form/SignupForm.tsx b/app/components/auth/form/SignupForm.tsx index 86ab1c1..ee55275 100644 --- a/app/components/auth/form/SignupForm.tsx +++ b/app/components/auth/form/SignupForm.tsx @@ -90,7 +90,8 @@ export default function SignupForm() { render() { setLoading(false); router.push(`/verify-email?email=${encodeURIComponent(email)}`); - return "Account created! Please verify your email."; + + return "Please check your email to verify your account."; }, }, error: { @@ -162,11 +163,7 @@ export default function SignupForm() { diff --git a/app/components/auth/form/UpdatePasswordForm.tsx b/app/components/auth/form/UpdatePasswordForm.tsx index 8761d64..9e8cb93 100644 --- a/app/components/auth/form/UpdatePasswordForm.tsx +++ b/app/components/auth/form/UpdatePasswordForm.tsx @@ -107,11 +107,7 @@ export default function UpdatePasswordForm() { diff --git a/app/components/chat/Conversations.tsx b/app/components/chat/Conversations.tsx index 0bd2515..59034d7 100644 --- a/app/components/chat/Conversations.tsx +++ b/app/components/chat/Conversations.tsx @@ -56,7 +56,7 @@ export default function Conversations({
@@ -79,7 +79,7 @@ export default function Conversations({
{isGlobal && ( - + All )} @@ -92,7 +92,7 @@ export default function Conversations({
diff --git a/app/components/chat/Messages.tsx b/app/components/chat/Messages.tsx index ba9ea9a..b95a8fe 100644 --- a/app/components/chat/Messages.tsx +++ b/app/components/chat/Messages.tsx @@ -25,7 +25,7 @@ export default function Messages({ conversations: Conversation[]; bottomRef: React.RefObject; badgesByUserId?: Record; - onUserProfileClick?: (targetUserId: string, targetEmail: string) => void; + onUserProfileClick?: (targetuser_id: string, targetEmail: string) => void; }) { const [showScrollBtn, setShowScrollBtn] = useState(false); const [mediaViewer, setMediaViewer] = useState( @@ -157,7 +157,7 @@ export default function Messages({ title={canOpenPrivateChat ? "Start private chat" : undefined} className={`flex-shrink-0 ${avatarTranslateClass} w-8 h-8 rounded-full bg-neutral-700 border border-gray-200 flex items-center justify-center aspect-square overflow-hidden ${ canOpenPrivateChat - ? "cursor-pointer hover:border-indigo-400/60 hover:bg-neutral-700/80" + ? "cursor-pointer hover:border-blue-400/60 hover:bg-neutral-700/80" : "" }`} > @@ -189,7 +189,7 @@ export default function Messages({ senderRow.email as string, ) } - className="text-[12px] font-semibold leading-none text-gray-700 hover:text-indigo-600 transition" + className="text-[12px] font-semibold leading-none text-gray-700 hover:text-blue-600 transition" title="Start private chat" > {senderName} @@ -197,7 +197,7 @@ export default function Messages({ ) : ( {senderName} @@ -225,8 +225,8 @@ export default function Messages({
@@ -430,7 +430,7 @@ function getAttachments( href={attachment.public_url} target="_blank" rel="noopener noreferrer" - className="inline-flex items-center gap-1.5 text-indigo-600 hover:text-indigo-200 hover:underline text-sm" + className="inline-flex items-center gap-1.5 text-blue-600 hover:text-blue-200 hover:underline text-sm" > {attachment.filename || "Open attachment"} diff --git a/app/components/chat/Player.tsx b/app/components/chat/Player.tsx index cc53426..ca8f2e8 100644 --- a/app/components/chat/Player.tsx +++ b/app/components/chat/Player.tsx @@ -601,7 +601,7 @@ export default function Player({ onMouseUp={() => setIsSeeking(false)} onTouchStart={() => setIsSeeking(true)} onTouchEnd={() => setIsSeeking(false)} - className="flex-1 accent-indigo-400 cursor-pointer h-1" + className="flex-1 accent-blue-400 cursor-pointer h-1" aria-label="Seek video" style={{ background: `linear-gradient(90deg, rgba(129,140,248,.95) ${progress}%, rgba(255,255,255,.22) ${progress}%)`, @@ -667,7 +667,7 @@ export default function Player({ step={0.01} value={volume} onChange={(e) => setVolume(Number(e.target.value))} - className="h-24 w-1.5 accent-indigo-400 cursor-pointer" + className="h-24 w-1.5 accent-blue-400 cursor-pointer" aria-label="Mobile volume" style={{ writingMode: "vertical-lr", direction: "rtl" }} /> @@ -683,7 +683,7 @@ export default function Player({ step={0.01} value={volume} onChange={(e) => setVolume(Number(e.target.value))} - className="hidden md:block w-20 accent-indigo-400 h-1" + className="hidden md:block w-20 accent-blue-400 h-1" aria-label="Volume" /> @@ -727,7 +727,7 @@ export default function Player({ > diff --git a/app/components/dashboard/LeaderbordList.tsx b/app/components/dashboard/LeaderbordList.tsx index 8aeceae..99f038a 100644 --- a/app/components/dashboard/LeaderbordList.tsx +++ b/app/components/dashboard/LeaderbordList.tsx @@ -8,7 +8,7 @@ export interface Leaderboard { id: string; name: string; slug: string; - ownerId: string; + owner_id: string; } export default async function LeaderboardsList() { @@ -17,123 +17,63 @@ export default async function LeaderboardsList() { const [owned, memberships] = await Promise.all([ prisma.leaderboard.findMany({ - where: { ownerId: user.id }, - select: { id: true, name: true, slug: true, ownerId: true }, + where: { owner_id: user.id }, + select: { id: true, name: true, slug: true, owner_id: true }, }), prisma.leaderboardMember.findMany({ - where: { userId: user.id, role: "member" }, + where: { user_id: user.id, role: "member" }, include: { leaderboard: { - select: { id: true, name: true, slug: true, ownerId: true }, + select: { id: true, name: true, slug: true, owner_id: true }, }, }, }), ]); const joinedBoards = memberships.map((m) => m.leaderboard); - const ownedCount = owned.length; - const joinedCount = joinedBoards.length; + + const allBoards = [ + ...owned.map((board) => ({ board, isOwner: true })), + ...joinedBoards.map((board) => ({ board, isOwner: false })), + ]; const userForBoard = { id: user.id, email: user.email ?? "" }; return ( -
-
- -
-
-

- - Your Networks -

- - {ownedCount + joinedCount} Active Sessions - -
-
- -
- {owned.length > 0 && ( -
-
- -

- Administered -

-
-
-
- {owned.map((board) => ( -
-
- -
- ))} -
-
- )} - - {joinedBoards.length > 0 && ( -
-
- -

- Joined Networks -

-
-
-
- {joinedBoards.map((board) => ( -
-
- + {allBoards.length > 0 ? ( +
+
+ {allBoards.map(({ board, isOwner }) => ( +
+ {isOwner && ( + -
- ))} -
+ )} + +
+ ))}
- )} - - {!ownedCount && !joinedCount && ( -
-
- -
-

- No Active Networks -

-

- Create a new network or join an existing server to start - competing. -

+
+ ) : ( +
+
+
- )} -
+

+ You haven't joined any boards yet. +

+

+ Create a new network or join an existing server to start competing. +

+
+ )}
); } diff --git a/app/components/dashboard/Navbar.tsx b/app/components/dashboard/Navbar.tsx index 1eb75e8..78c9232 100644 --- a/app/components/dashboard/Navbar.tsx +++ b/app/components/dashboard/Navbar.tsx @@ -16,6 +16,7 @@ import { faCodeBranch, faBars, faProjectDiagram, + faGear, } from "@fortawesome/free-solid-svg-icons"; import type { IconDefinition } from "@fortawesome/free-solid-svg-icons"; import NavProfileDropdown from "../common/NavProfileDropdown"; @@ -71,6 +72,13 @@ const navItems: NavItem[] = [ role: "user", category: "dev", }, + { + href: "/d/settings", + label: "Settings", + icon: faGear, + role: "user", + category: "dev", + }, { href: "https://hallofcodes.github.io/html-editor", label: "HTML Editor", @@ -94,6 +102,10 @@ const navItems: NavItem[] = [ }, ]; +// Primary items shown directly in the bottom nav (mobile). +// Keep this to 4 so the "More" button fits as the 5th slot. +const BOTTOM_NAV_HREFS = ["/d", "/d/chat", "/d/kanban", "/d/leaderboards"]; + export default function DashboardLayout({ email, name, @@ -126,42 +138,29 @@ export default function DashboardLayout({ const canSee = (itemRole: string, userRole: string) => itemRole !== "admin" || userRole === "admin"; - const groupedNavItems = navItems - .filter((item) => canSee(item.role, role)) - .reduce( - (acc, item) => { - const last = acc[acc.length - 1]; - const showCategory = !last || last.category !== item.category; - acc.push({ ...item, showCategory }); - return acc; - }, - [] as Array, - ); + const visibleNavItems = navItems.filter((item) => canSee(item.role, role)); - return ( -
-
- + const groupedNavItems = visibleNavItems.reduce( + (acc, item) => { + const last = acc[acc.length - 1]; + const showCategory = !last || last.category !== item.category; + acc.push({ ...item, showCategory }); + return acc; + }, + [] as Array, + ); -
- Devpulse - Devpulse -
+ const bottomNavItems = visibleNavItems.filter((item) => + BOTTOM_NAV_HREFS.includes(item.href), + ); - -
+ const isSidebarItemActive = (href: string) => pathname === href; + const isAnyMoreItemActive = visibleNavItems.some( + (item) => !BOTTOM_NAV_HREFS.includes(item.href) && pathname === item.href, + ); + return ( +
{isMobile && sidebarOpen && (
-