From b349a8693dafcc5139f23f544a122c096b0156b1 Mon Sep 17 00:00:00 2001 From: Melvin Jones Repol Date: Tue, 28 Jul 2026 12:49:06 +0800 Subject: [PATCH 1/6] feat(core): migrated our database from supabase to mysql with prisma, auth is now powered by nextauth, remove hcaptcha and build workflow --- .env.example | 34 +- .github/workflows/build.yml | 60 - .gitignore | 2 + app/(public)/flex/page.tsx | 50 +- app/(public)/join/[code]/page.tsx | 40 +- app/(public)/join/page.tsx | 83 +- app/(public)/leaderboard/[slug]/page.tsx | 75 +- app/(public)/leaderboard/page.tsx | 75 +- app/api/admin/stats/route.ts | 50 + app/api/auth/[...nextauth]/route.ts | 3 + app/api/auth/callback/route.ts | 43 - app/api/auth/forgot-password/route.ts | 29 + app/api/auth/register/route.ts | 41 + app/api/auth/reset-password/route.ts | 44 + app/api/auth/update-password/route.ts | 29 + app/api/conversations/[id]/presence/route.ts | 33 + app/api/conversations/[id]/route.ts | 29 + app/api/conversations/[id]/unread/route.ts | 37 + app/api/conversations/route.ts | 103 + app/api/cron/cleanup-messages/route.ts | 15 + app/api/flex/[id]/route.ts | 61 + app/api/flex/projects/route.ts | 17 + app/api/flex/route.ts | 59 + app/api/kanban/data/route.ts | 43 + app/api/kanban/issues/[id]/route.ts | 46 + app/api/kanban/issues/route.ts | 61 + app/api/leaderboards/[id]/join-code/route.ts | 56 + app/api/leaderboards/[id]/leave/route.ts | 21 + app/api/leaderboards/[id]/route.ts | 32 + app/api/leaderboards/join/route.ts | 47 + app/api/leaderboards/route.ts | 50 + app/api/messages/route.ts | 113 + app/api/presence/route.ts | 19 + app/api/profile/route.ts | 26 + app/api/sse/chat/[conversationId]/route.ts | 54 + .../sse/chat/[conversationId]/typing/route.ts | 40 + app/api/sse/conversations/route.ts | 40 + app/api/sse/kanban/route.ts | 34 + app/api/users/badges/route.ts | 29 + app/api/users/route.ts | 35 + app/api/wakatime/sync/route.ts | 36 +- app/components/BoardList.tsx | 206 +- app/components/BrowserCheck.tsx | 2 +- app/components/Chat.tsx | 946 ++++---- app/components/Flex.tsx | 194 +- app/components/JoinButton.tsx | 44 +- app/components/admin/Dashbord.tsx | 65 +- app/components/admin/Widgets/UserLists.tsx | 9 +- app/components/auth/Logout.tsx | 7 +- app/components/auth/Oauth2.tsx | 32 +- .../auth/form/ForgotPasswordForm.tsx | 57 +- app/components/auth/form/LoginForm.tsx | 64 +- .../auth/form/ResetPasswordForm.tsx | 104 +- app/components/auth/form/SignupForm.tsx | 60 +- .../auth/form/UpdatePasswordForm.tsx | 31 +- app/components/chat/Conversations.tsx | 8 +- app/components/chat/MediaViewerModal.tsx | 17 +- app/components/chat/Messages.tsx | 188 +- app/components/chat/Player.tsx | 1 - .../chat/hooks/useActiveConversationStream.ts | 384 +-- .../chat/hooks/useChatAttachmentInput.ts | 23 +- app/components/chat/hooks/useChatBadWords.ts | 2 +- app/components/chat/hooks/useChatBadges.ts | 39 +- .../chat/hooks/useChatConversationActions.ts | 185 +- .../hooks/useChatConversationsRealtime.ts | 404 +--- .../chat/hooks/useChatInputBehavior.ts | 7 +- .../chat/hooks/useChatMessageComposer.ts | 186 +- app/components/chat/hooks/useChatPresence.ts | 93 +- app/components/chat/hooks/useChatTyping.ts | 84 +- .../chat/hooks/useChatUserPicker.ts | 35 +- app/components/common/ui/CTA.tsx | 5 +- app/components/dashboard/LeaderbordList.tsx | 125 +- app/components/dashboard/Navbar.tsx | 4 +- app/components/dashboard/Settings/Profile.tsx | 61 +- .../dashboard/Settings/ResetPassword.tsx | 34 +- app/components/dashboard/WithKey.tsx | 200 +- app/components/dashboard/WithoutKey.tsx | 13 +- .../dashboard/widgets/Categories.tsx | 2 +- .../dashboard/widgets/CodingActivity.tsx | 2 +- .../widgets/CodingConsistencyHeatmap.tsx | 42 +- .../dashboard/widgets/Dependencies.tsx | 10 +- .../widgets/LanguageDestribution.tsx | 9 +- app/components/dashboard/widgets/Machines.tsx | 2 +- app/components/dashboard/widgets/Projects.tsx | 26 +- .../dashboard/widgets/StatsCard.tsx | 17 +- app/components/landing-page/LosserMembers.tsx | 12 +- .../landing-page/RecentLeaderboard.tsx | 13 +- app/components/landing-page/TopLeaderbord.tsx | 9 +- app/components/landing-page/VibeCoders.tsx | 5 +- app/components/layout/Nav.tsx | 15 +- app/components/leaderboard/BackButton.tsx | 6 +- app/components/leaderboard/Banner.tsx | 30 +- app/components/leaderboard/Header.tsx | 42 +- .../leaderboard/InviteFriendsButton.tsx | 8 +- .../leaderboard/LeaderboardStats.tsx | 45 +- .../leaderboard/LeaderboardTable.tsx | 20 +- app/d/admin/page.tsx | 10 +- app/d/chat/page.tsx | 4 +- app/d/flex/page.tsx | 6 +- app/d/kanban/page.tsx | 563 +++-- app/d/layout.tsx | 17 +- app/d/leaderboards/page.tsx | 12 +- app/d/page.tsx | 8 +- app/d/settings/page.tsx | 28 +- app/d/update-password/page.tsx | 1 - app/globals.css | 392 +-- app/hooks/useBadWords.ts | 2 +- app/internal-server-error.tsx | 6 +- app/lib/auth.ts | 123 + app/lib/auth/user.ts | 22 + app/lib/emitter.ts | 8 + app/lib/prisma.ts | 7 + app/lib/proxy/auth.ts | 57 +- app/lib/supabase/client.ts | 9 - app/lib/supabase/help/user.ts | 20 - app/lib/supabase/public.ts | 12 - app/lib/supabase/server.ts | 21 - app/lib/wakatime/repository.ts | 86 +- app/lib/wakatime/sync.ts | 217 +- app/page.tsx | 95 +- app/sitemaps/leaderboards.ts | 18 +- app/supabase-types.ts | 524 ---- app/utils/badge.ts | 2 +- app/utils/media.ts | 2 +- app/utils/moderation.ts | 2 +- app/utils/rate-limit.ts | 6 +- app/utils/slug.ts | 2 +- app/utils/wakatime.ts | 2 +- next-auth.d.ts | 18 + package-lock.json | 2114 +++++++++++------ package.json | 12 +- .../20260728043534_init/migration.sql | 299 +++ prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 301 +++ .../20260320234600_add_user_stats.sql | 33 - .../20260320234643_add_top_user_stats.sql | 7 - .../20260320234653_add_profiles.sql | 43 - .../20260320234714_add_leaderboards.sql | 100 - .../20260320234731_add_enforcement_checks.sql | 61 - .../20260320234742_add_user_projects.sql | 23 - .../20260320234758_add_conversations.sql | 96 - ...40532_add_categories_to_top_user_stats.sql | 8 - ...260323033205_add_type_to_conversations.sql | 1 - .../20260323033729_add_global_chat.sql | 18 - ..._add_type_to_conversation_participants.sql | 6 - .../20260323041543_add_trigger_to_type.sql | 18 - ...0323075413_add_attachments_to_messages.sql | 21 - .../20260325044133_add_user_flexes.sql | 88 - ...60325054413_add_expires_at_user_flexes.sql | 11 - .../20260325072343_add_role_to_profiles.sql | 17 - ...0327100000_cascade_conversation_delete.sql | 10 - ...329120000_add_user_dashboard_snapshots.sql | 42 - ...00_add_chat_presence_and_read_tracking.sql | 14 - ...03000_enable_chat_realtime_publication.sql | 33 - ...60330104500_fix_global_chat_membership.sql | 45 - 155 files changed, 6266 insertions(+), 5488 deletions(-) delete mode 100644 .github/workflows/build.yml create mode 100644 app/api/admin/stats/route.ts create mode 100644 app/api/auth/[...nextauth]/route.ts delete mode 100644 app/api/auth/callback/route.ts create mode 100644 app/api/auth/forgot-password/route.ts create mode 100644 app/api/auth/register/route.ts create mode 100644 app/api/auth/reset-password/route.ts create mode 100644 app/api/auth/update-password/route.ts create mode 100644 app/api/conversations/[id]/presence/route.ts create mode 100644 app/api/conversations/[id]/route.ts create mode 100644 app/api/conversations/[id]/unread/route.ts create mode 100644 app/api/conversations/route.ts create mode 100644 app/api/cron/cleanup-messages/route.ts create mode 100644 app/api/flex/[id]/route.ts create mode 100644 app/api/flex/projects/route.ts create mode 100644 app/api/flex/route.ts create mode 100644 app/api/kanban/data/route.ts create mode 100644 app/api/kanban/issues/[id]/route.ts create mode 100644 app/api/kanban/issues/route.ts create mode 100644 app/api/leaderboards/[id]/join-code/route.ts create mode 100644 app/api/leaderboards/[id]/leave/route.ts create mode 100644 app/api/leaderboards/[id]/route.ts create mode 100644 app/api/leaderboards/join/route.ts create mode 100644 app/api/leaderboards/route.ts create mode 100644 app/api/messages/route.ts create mode 100644 app/api/presence/route.ts create mode 100644 app/api/profile/route.ts create mode 100644 app/api/sse/chat/[conversationId]/route.ts create mode 100644 app/api/sse/chat/[conversationId]/typing/route.ts create mode 100644 app/api/sse/conversations/route.ts create mode 100644 app/api/sse/kanban/route.ts create mode 100644 app/api/users/badges/route.ts create mode 100644 app/api/users/route.ts create mode 100644 app/lib/auth.ts create mode 100644 app/lib/auth/user.ts create mode 100644 app/lib/emitter.ts create mode 100644 app/lib/prisma.ts delete mode 100644 app/lib/supabase/client.ts delete mode 100644 app/lib/supabase/help/user.ts delete mode 100644 app/lib/supabase/public.ts delete mode 100644 app/lib/supabase/server.ts delete mode 100644 app/supabase-types.ts create mode 100644 next-auth.d.ts create mode 100644 prisma/migrations/20260728043534_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma delete mode 100644 supabase/migrations/20260320234600_add_user_stats.sql delete mode 100644 supabase/migrations/20260320234643_add_top_user_stats.sql delete mode 100644 supabase/migrations/20260320234653_add_profiles.sql delete mode 100644 supabase/migrations/20260320234714_add_leaderboards.sql delete mode 100644 supabase/migrations/20260320234731_add_enforcement_checks.sql delete mode 100644 supabase/migrations/20260320234742_add_user_projects.sql delete mode 100644 supabase/migrations/20260320234758_add_conversations.sql delete mode 100644 supabase/migrations/20260321140532_add_categories_to_top_user_stats.sql delete mode 100644 supabase/migrations/20260323033205_add_type_to_conversations.sql delete mode 100644 supabase/migrations/20260323033729_add_global_chat.sql delete mode 100644 supabase/migrations/20260323041448_add_type_to_conversation_participants.sql delete mode 100644 supabase/migrations/20260323041543_add_trigger_to_type.sql delete mode 100644 supabase/migrations/20260323075413_add_attachments_to_messages.sql delete mode 100644 supabase/migrations/20260325044133_add_user_flexes.sql delete mode 100644 supabase/migrations/20260325054413_add_expires_at_user_flexes.sql delete mode 100644 supabase/migrations/20260325072343_add_role_to_profiles.sql delete mode 100644 supabase/migrations/20260327100000_cascade_conversation_delete.sql delete mode 100644 supabase/migrations/20260329120000_add_user_dashboard_snapshots.sql delete mode 100644 supabase/migrations/20260329133000_add_chat_presence_and_read_tracking.sql delete mode 100644 supabase/migrations/20260330103000_enable_chat_realtime_publication.sql delete mode 100644 supabase/migrations/20260330104500_fix_global_chat_membership.sql diff --git a/.env.example b/.env.example index 53de38e..abd2c54 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,35 @@ NODE_ENV=development NEXT_PUBLIC_NODE_ENV=development -NEXT_PUBLIC_SUPABASE_BUCKET_NAME= -NEXT_PUBLIC_SUPABASE_URL= -NEXT_PUBLIC_SUPABASE_ANON_KEY= +# MySQL database +DATABASE_URL=mysql://user:password@localhost:3306/devpulse +# NextAuth +NEXTAUTH_SECRET= +NEXTAUTH_URL=http://localhost:3000 + +# OAuth providers (optional) +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +AZURE_AD_CLIENT_ID= +AZURE_AD_CLIENT_SECRET= +AZURE_AD_TENANT_ID= + +# Email (for password reset) +EMAIL_SERVER=smtp://user:pass@smtp.example.com:587 +EMAIL_FROM=noreply@example.com + +# Global chat conversation ID (seed with a cuid, e.g. from `npx cuid`) +GLOBAL_CONVERSATION_ID= +NEXT_PUBLIC_GLOBAL_CONVERSATION_ID= + +# Cron job secret (protects POST /api/cron/cleanup-messages) +CRON_SECRET= + +# hCaptcha NEXT_PUBLIC_HCAPTCHA_SITE_KEY= +# Norton Safe Web NEXT_PUBLIC_NORTON_SAFEWEB_SITE_VERIFICATION= - -SUPABASE_ACCESS_TOKEN= -SUPABASE_PROJECT_ID=vswabkwgipyweqsabzwv diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 177bd56..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Build Next.js App - -on: - push: - branches: ["master"] - pull_request_target: - branches: ["master"] - workflow_dispatch: - -env: - SUPABASE_PROJECT_ID: ${{ secrets.SUPABASE_PROJECT_ID }} - SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} - NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} - NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 24 - - - name: Install dependencies - run: npm ci - - - name: Run lint - run: npm run lint - - # - name: Generate types from remote - # if: ${{ env.SUPABASE_PROJECT_ID && env.SUPABASE_ACCESS_TOKEN }} - # env: - # SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} - # run: | - # npx supabase gen types typescript \ - # --project-id ${{ secrets.SUPABASE_PROJECT_ID }} \ - # > app/supabase-types.ts - - # this will be suspended for now - # - name: Run migrations - # env: - # SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} - # run: | - # npx supabase db push --project-id ${{ secrets.SUPABASE_PROJECT_ID }} - - - name: Create .env file - if: ${{ env.NEXT_PUBLIC_SUPABASE_URL && env.NEXT_PUBLIC_SUPABASE_ANON_KEY }} - run: | - echo "NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}" >> .env - echo "NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}" >> .env - echo "NEXT_PUBLIC_HCAPTCHA_SITE_KEY=${{ secrets.NEXT_PUBLIC_HCAPTCHA_SITE_KEY }}" >> .env - echo "NEXT_PUBLIC_NORTON_SAFEWEB_SITE_VERIFICATION=${{ secrets.NEXT_PUBLIC_NORTON_SAFEWEB_SITE_VERIFICATION }}" >> .env - - - name: Build project - if: ${{ env.NEXT_PUBLIC_SUPABASE_URL && env.NEXT_PUBLIC_SUPABASE_ANON_KEY }} - run: npm run build diff --git a/.gitignore b/.gitignore index 28e5999..bfcbba6 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,5 @@ supabase/* yarn.lock pnpm-lock.yaml + +/app/generated/prisma diff --git a/app/(public)/flex/page.tsx b/app/(public)/flex/page.tsx index 1a0585c..4169a1c 100644 --- a/app/(public)/flex/page.tsx +++ b/app/(public)/flex/page.tsx @@ -6,7 +6,7 @@ import { timeAgo } from "@/app/utils/time"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faExternalLink } from "@fortawesome/free-solid-svg-icons"; import { Metadata } from "next/types"; -import { createPublicClient } from "@/app/lib/supabase/public"; +import { prisma } from "@/app/lib/prisma"; export const metadata: Metadata = { title: "Flexes - Devpulse", @@ -57,15 +57,10 @@ export const metadata: Metadata = { }; export default async function Flexs() { - const supabase = createPublicClient(); - const { data, error } = await supabase - .from("user_flexes") - .select("*") - .order("created_at", { ascending: false }); - - if (error) { - console.error("Error fetching flexes:", error); - } + const flexes = await prisma.userFlex.findMany({ + where: { expiresAt: { gt: new Date() } }, + orderBy: { createdAt: "desc" }, + }); return (
@@ -77,16 +72,7 @@ export default async function Flexs() {

Devpulse Flexes

- {error && ( -
-

Error Loading Flexes

-

- There was an error fetching the flexes. Please try again later. -

-
- )} - - {data?.length === 0 && ( + {flexes.length === 0 && (

No Flexes Yet

@@ -96,47 +82,49 @@ export default async function Flexs() {

)} - {data && data.length > 0 && ( + {flexes.length > 0 && (
- {data.map((flex) => ( + {flexes.map((flex) => (

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

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

{flex.project_description}

- {flex.is_open_source && ( +

{flex.projectDescription}

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

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

diff --git a/app/(public)/join/[code]/page.tsx b/app/(public)/join/[code]/page.tsx index c49cd21..87feb63 100644 --- a/app/(public)/join/[code]/page.tsx +++ b/app/(public)/join/[code]/page.tsx @@ -1,56 +1,38 @@ import { Metadata } from "next/types"; -import { createClient } from "../../../lib/supabase/server"; +import { prisma } from "@/app/lib/prisma"; import { redirect } from "next/navigation"; type Props = { params: Promise<{ code: string }>; }; -async function getLeaderboard(code: string) { - const supabase = await createClient(); - const { data } = await supabase - .from("leaderboards") - .select("id, name, description, slug, owner_id, created_at") - .eq("join_code", code) - .single(); - return data; -} - export async function generateMetadata({ params }: Props): Promise { const { code } = await params; - const leaderboard = await getLeaderboard(code); + + const leaderboard = await prisma.leaderboard.findUnique({ + where: { joinCode: code }, + select: { name: true, description: true }, + }); if (!leaderboard) { return { title: "Invite Not Found - Devpulse", description: "This invite link is invalid or has expired.", - alternates: { - canonical: `https://devpulse.hallofcodes.org/join`, - }, + alternates: { canonical: "https://devpulse.hallofcodes.org/join" }, }; } const title = `You're invited to join ${leaderboard.name}!`; const description = - leaderboard?.description && leaderboard.description.length > 0 + leaderboard.description && leaderboard.description.length > 0 ? leaderboard.description - : `Join the ${leaderboard.name} leaderboard on Devpulse and compete with other developers. Track your coding activity and climb the ranks!`; + : `Join the ${leaderboard.name} leaderboard on Devpulse and compete with other developers.`; return { title: `${title} - Devpulse`, description, - openGraph: { - title, - description, - type: "website", - siteName: "Devpulse", - url: `/join?id=${encodeURIComponent(code)}`, - }, - twitter: { - card: "summary_large_image", - title, - description, - }, + openGraph: { title, description, type: "website", siteName: "Devpulse" }, + twitter: { card: "summary_large_image", title, description }, }; } diff --git a/app/(public)/join/page.tsx b/app/(public)/join/page.tsx index f52f958..5d6a41f 100644 --- a/app/(public)/join/page.tsx +++ b/app/(public)/join/page.tsx @@ -1,5 +1,6 @@ import { Metadata } from "next/types"; -import { createClient } from "../../lib/supabase/server"; +import { prisma } from "@/app/lib/prisma"; +import { getCurrentUser } from "@/app/lib/auth/user"; import JoinButton from "../../components/JoinButton"; import Footer from "@/app/components/layout/Footer"; import Image from "next/image"; @@ -17,22 +18,21 @@ type Props = { }; async function getLeaderboard(code: string) { - const supabase = await createClient(); - const { data } = await supabase - .from("leaderboards") - .select("id, name, description, slug, owner_id, created_at") - .eq("join_code", code) - .single(); - return data; + return prisma.leaderboard.findUnique({ + where: { joinCode: code }, + select: { + id: true, + name: true, + description: true, + slug: true, + ownerId: true, + createdAt: true, + }, + }); } async function getMemberCount(leaderboardId: string) { - const supabase = await createClient(); - const { count } = await supabase - .from("leaderboard_members_view") - .select("*", { count: "exact", head: true }) - .eq("leaderboard_id", leaderboardId); - return count ?? 0; + return prisma.leaderboardMember.count({ where: { leaderboardId } }); } export async function generateMetadata({ @@ -45,9 +45,7 @@ export async function generateMetadata({ return { title: "Join - Devpulse", description: "Open an invite link to join a Devpulse leaderboard.", - alternates: { - canonical: "https://devpulse.hallofcodes.org/join", - }, + alternates: { canonical: "https://devpulse.hallofcodes.org/join" }, }; } @@ -56,17 +54,15 @@ export async function generateMetadata({ return { title: "Invite Not Found - Devpulse", description: "This invite link is invalid or has expired.", - alternates: { - canonical: "https://devpulse.hallofcodes.org/join", - }, + alternates: { canonical: "https://devpulse.hallofcodes.org/join" }, }; } const title = `You're invited to join ${leaderboard.name}!`; const description = - leaderboard.description && leaderboard.description?.length > 0 + leaderboard.description && leaderboard.description.length > 0 ? leaderboard.description - : `Join the ${leaderboard.name} leaderboard on Devpulse and compete with other developers. Track your coding activity and climb the ranks!`; + : `Join the ${leaderboard.name} leaderboard on Devpulse and compete with other developers.`; return { title: `${title} - Devpulse`, @@ -74,18 +70,8 @@ export async function generateMetadata({ alternates: { canonical: `https://devpulse.hallofcodes.org/join?id=${encodeURIComponent(code)}`, }, - openGraph: { - title, - description, - type: "website", - siteName: "Devpulse", - url: `https://devpulse.hallofcodes.org/join?id=${encodeURIComponent(code)}`, - }, - twitter: { - card: "summary_large_image", - title, - description, - }, + openGraph: { title, description, type: "website", siteName: "Devpulse" }, + twitter: { card: "summary_large_image", title, description }, }; } @@ -118,7 +104,10 @@ export default async function JoinPage({ searchParams }: Props) { ); } - const leaderboard = await getLeaderboard(code); + const [leaderboard, { user }] = await Promise.all([ + getLeaderboard(code), + getCurrentUser(), + ]); if (!leaderboard) { return ( @@ -146,19 +135,17 @@ export default async function JoinPage({ searchParams }: Props) { const memberCount = await getMemberCount(leaderboard.id); - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - let alreadyMember = false; if (user) { - const { data: membership } = await supabase - .from("leaderboard_members") - .select("id") - .eq("leaderboard_id", leaderboard.id) - .eq("user_id", user.id) - .single(); + const membership = await prisma.leaderboardMember.findUnique({ + where: { + leaderboardId_userId: { + leaderboardId: leaderboard.id, + userId: user.id, + }, + }, + select: { id: true }, + }); alreadyMember = !!membership; } @@ -177,8 +164,8 @@ export default async function JoinPage({ searchParams }: Props) {

{alreadyMember - ? "You\u2019re already a member of" - : "You\u2019ve been invited to"} + ? "You’re already a member of" + : "You’ve been invited to"}

diff --git a/app/(public)/leaderboard/[slug]/page.tsx b/app/(public)/leaderboard/[slug]/page.tsx index 5bbcb42..158d791 100644 --- a/app/(public)/leaderboard/[slug]/page.tsx +++ b/app/(public)/leaderboard/[slug]/page.tsx @@ -8,42 +8,64 @@ import Banner from "@/app/components/leaderboard/Banner"; import BackButton from "@/app/components/leaderboard/BackButton"; import Image from "next/image"; import InviteFriendsButton from "@/app/components/leaderboard/InviteFriendsButton"; -import { createPublicClient } from "@/app/lib/supabase/public"; import InternalServerError from "@/app/internal-server-error"; +import { prisma } from "@/app/lib/prisma"; export async function generateStaticParams() { - const supabase = createPublicClient(); + const leaderboards = await prisma.leaderboard.findMany({ + select: { slug: true }, + }); - const { data } = await supabase.from("leaderboards").select("slug"); - - return (data || []).map((item) => ({ - slug: item.slug, - })); + return leaderboards.map((item) => ({ slug: item.slug })); } export default async function LeaderboardPage(props: { params: Promise<{ slug: string }>; }) { const { slug } = await props.params; - const supabase = createPublicClient(); - const { data: leaderboard, error: leaderboardError } = await supabase - .from("leaderboards") - .select("*") - .eq("slug", slug) - .single(); + const leaderboard = await prisma.leaderboard.findUnique({ + where: { slug }, + }); + if (!leaderboard) return notFound(); - if (leaderboardError) { - console.error("Error fetching leaderboard:", leaderboardError); - return InternalServerError(); - } - const { data: members, error: membersError } = await supabase - .from("leaderboard_members_view") - .select("*") - .eq("leaderboard_id", leaderboard.id); - if (membersError) { - console.error("Error fetching members:", membersError); + let members: NonNullableMember[] = []; + try { + const rows = await prisma.leaderboardMember.findMany({ + where: { leaderboardId: leaderboard.id }, + include: { + user: { + select: { + id: true, + email: true, + role: true, + userStats: { + select: { + totalSeconds: true, + languages: true, + operatingSystems: true, + editors: true, + }, + }, + }, + }, + }, + }); + + members = rows + .filter((r) => r.user.email) + .map((r) => ({ + user_id: r.userId, + role: r.role, + email: r.user.email!, + total_seconds: Number(r.user.userStats?.totalSeconds ?? 0), + languages: (r.user.userStats?.languages as { name: string }[]) ?? [], + operating_systems: + (r.user.userStats?.operatingSystems as { name: string }[]) ?? [], + editors: (r.user.userStats?.editors as { name: string }[]) ?? [], + })); + } catch { return InternalServerError(); } @@ -81,8 +103,7 @@ export default async function LeaderboardPage(props: { {leaderboard.name}

- {leaderboard.description && - leaderboard.description?.length > 0 + {leaderboard.description && leaderboard.description.length > 0 ? leaderboard.description : `Join ${leaderboard.name} to track your coding metrics, compete with fellow developers, and showcase your engineering skills.`}

@@ -91,7 +112,7 @@ export default async function LeaderboardPage(props: {
@@ -99,7 +120,7 @@ export default async function LeaderboardPage(props: {
- +
diff --git a/app/(public)/leaderboard/page.tsx b/app/(public)/leaderboard/page.tsx index a0c0cf6..ba18d22 100644 --- a/app/(public)/leaderboard/page.tsx +++ b/app/(public)/leaderboard/page.tsx @@ -3,10 +3,9 @@ import Footer from "@/app/components/layout/Footer"; import CTA from "@/app/components/common/ui/CTA"; import Image from "next/image"; import { Metadata } from "next/types"; -import { getUserWithProfile } from "@/app/lib/supabase/help/user"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faArrowRight } from "@fortawesome/free-solid-svg-icons"; -import { createPublicClient } from "@/app/lib/supabase/public"; +import { prisma } from "@/app/lib/prisma"; export const metadata: Metadata = { title: "Leaderboards - Devpulse", @@ -58,15 +57,10 @@ export const metadata: Metadata = { }; export default async function Leaderboards() { - const supabase = createPublicClient(); - const { data, error } = await supabase - .from("leaderboards") - .select("id, name, slug") - .order("created_at", { ascending: false }); - - if (error) { - console.error("Error fetching leaderboards:", error); - } + const leaderboards = await prisma.leaderboard.findMany({ + select: { id: true, name: true, slug: true }, + orderBy: { createdAt: "desc" }, + }); return (
@@ -80,19 +74,7 @@ export default async function Leaderboards() {
- {error && ( -
-

- Error Loading Leaderboards -

-

- There was an error fetching the leaderboards. Please try again - later. -

-
- )} - - {data?.length === 0 && ( + {leaderboards.length === 0 && (

No Leaderboards Yet

@@ -101,33 +83,28 @@ export default async function Leaderboards() {

)} - {data && data.length > 0 && ( + {leaderboards.length > 0 && (
- {data.map( - ( - board: { id: string; name: string; slug: string }, - i: number, - ) => ( - -
-
- - {board.name} - -
- - View{" "} - + {leaderboards.map((board, i) => ( +
+
+ + + View{" "} + + + + ))}
)}
diff --git a/app/api/admin/stats/route.ts b/app/api/admin/stats/route.ts new file mode 100644 index 0000000..ef4be2e --- /dev/null +++ b/app/api/admin/stats/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { role: true }, + }); + + if (user?.role !== "admin") { + return NextResponse.json({ error: "Forbidden." }, { status: 403 }); + } + + const [topUserStats, threads, messages, leaderboards, flexes] = + await Promise.all([ + prisma.userStats.findMany({ + select: { + userId: true, + totalSeconds: true, + categories: true, + user: { select: { email: true } }, + }, + }), + prisma.conversation.count(), + prisma.message.count({ where: { expiresAt: { gt: new Date() } } }), + prisma.leaderboard.count(), + prisma.userFlex.count({ where: { expiresAt: { gt: new Date() } } }), + ]); + + const users = topUserStats.map((row) => ({ + user_id: row.userId, + email: row.user.email, + total_seconds: Number(row.totalSeconds), + categories: row.categories, + })); + + return NextResponse.json({ + users, + totalThreads: threads, + totalMessages: messages, + totalLeaderboards: leaderboards, + totalFlexes: flexes, + }); +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..0c27937 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/app/lib/auth"; + +export const { GET, POST } = handlers; diff --git a/app/api/auth/callback/route.ts b/app/api/auth/callback/route.ts deleted file mode 100644 index f245a1b..0000000 --- a/app/api/auth/callback/route.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { createServerClient } from "@supabase/ssr"; - -export async function GET(req: NextRequest) { - const { searchParams, origin } = new URL(req.url); - const code = searchParams.get("code"); - const cookieRedirect = req.cookies.get("devpulse_redirect")?.value; - const redirectParam = cookieRedirect ? decodeURIComponent(cookieRedirect) : null; - const redirectTo = - redirectParam && redirectParam.startsWith("/") && !redirectParam.startsWith("//") - ? redirectParam - : "/d"; - - const response = NextResponse.redirect(`${origin}${redirectTo}`); - response.cookies.set("devpulse_redirect", "", { path: "/", maxAge: 0 }); - if (!code) return response; - - const supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { - cookies: { - getAll() { - return req.cookies.getAll(); - }, - setAll(cookies) { - cookies.forEach(({ name, value, options }) => { - response.cookies.set(name, value, options); - }); - }, - }, - }, - ); - - const { error } = await supabase.auth.exchangeCodeForSession(code); - - if (error) - return NextResponse.redirect( - `${origin}/login?error=oauth_failed&redirect=${encodeURIComponent(redirectTo)}`, - ); - - return response; -} diff --git a/app/api/auth/forgot-password/route.ts b/app/api/auth/forgot-password/route.ts new file mode 100644 index 0000000..3176eae --- /dev/null +++ b/app/api/auth/forgot-password/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/app/lib/prisma"; + +export async function POST(req: Request) { + const { email } = await req.json(); + + if (!email) { + return NextResponse.json({ error: "Email is required." }, { status: 400 }); + } + + const user = await prisma.user.findUnique({ where: { email } }); + + if (!user) { + return NextResponse.json({ success: true }); + } + + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); + + const { token } = await prisma.passwordResetToken.create({ + data: { userId: user.id, expiresAt }, + select: { token: true }, + }); + + const resetUrl = `${process.env.NEXTAUTH_URL}/reset-password?token=${token}`; + + console.info(`Password reset link for ${email}: ${resetUrl}`); + + return NextResponse.json({ success: true }); +} diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts new file mode 100644 index 0000000..8612262 --- /dev/null +++ b/app/api/auth/register/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import { prisma } from "@/app/lib/prisma"; + +export async function POST(req: Request) { + const { email, password } = await req.json(); + + if (!email || !password) { + return NextResponse.json( + { error: "Email and password are required." }, + { status: 400 }, + ); + } + + if (password.length < 8) { + return NextResponse.json( + { error: "Password must be at least 8 characters." }, + { status: 400 }, + ); + } + + const existing = await prisma.user.findUnique({ where: { email } }); + if (existing) { + return NextResponse.json( + { error: "An account with this email already exists." }, + { status: 409 }, + ); + } + + const hashed = await bcrypt.hash(password, 12); + + await prisma.user.create({ + data: { + email, + password: hashed, + name: email.split("@")[0], + }, + }); + + 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 new file mode 100644 index 0000000..4a8cae7 --- /dev/null +++ b/app/api/auth/reset-password/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import { prisma } from "@/app/lib/prisma"; + +export async function POST(req: Request) { + const { token, password } = await req.json(); + + if (!token || !password) { + return NextResponse.json( + { error: "Token and password are required." }, + { status: 400 }, + ); + } + + if (password.length < 8) { + return NextResponse.json( + { error: "Password must be at least 8 characters." }, + { status: 400 }, + ); + } + + const resetToken = await prisma.passwordResetToken.findUnique({ + where: { token }, + }); + + if (!resetToken || resetToken.expiresAt < new Date()) { + return NextResponse.json( + { error: "Invalid or expired reset link." }, + { status: 400 }, + ); + } + + const hashed = await bcrypt.hash(password, 12); + + await prisma.$transaction([ + prisma.user.update({ + where: { id: resetToken.userId }, + data: { password: hashed }, + }), + prisma.passwordResetToken.delete({ where: { token } }), + ]); + + return NextResponse.json({ success: true }); +} diff --git a/app/api/auth/update-password/route.ts b/app/api/auth/update-password/route.ts new file mode 100644 index 0000000..186b44f --- /dev/null +++ b/app/api/auth/update-password/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function POST(req: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized." }, { status: 401 }); + } + + const { password } = await req.json(); + + if (!password || password.length < 8) { + return NextResponse.json( + { error: "Password must be at least 8 characters." }, + { status: 400 }, + ); + } + + const hashed = await bcrypt.hash(password, 12); + + await prisma.user.update({ + where: { id: session.user.id }, + data: { password: hashed }, + }); + + return NextResponse.json({ success: true }); +} diff --git a/app/api/conversations/[id]/presence/route.ts b/app/api/conversations/[id]/presence/route.ts new file mode 100644 index 0000000..f6cf3ea --- /dev/null +++ b/app/api/conversations/[id]/presence/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function PATCH( + req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id: conversationId } = await params; + const body = await req.json().catch(() => ({})); + const { markRead } = body as { markRead?: boolean }; + + const timestamp = new Date(); + + const data: { lastSeenAt: Date; lastReadAt?: Date } = { + lastSeenAt: timestamp, + }; + if (markRead) { + data.lastReadAt = timestamp; + } + + await prisma.conversationParticipant.updateMany({ + where: { conversationId, userId: session.user.id }, + data, + }); + + return NextResponse.json({ success: true }); +} diff --git a/app/api/conversations/[id]/route.ts b/app/api/conversations/[id]/route.ts new file mode 100644 index 0000000..6be2a41 --- /dev/null +++ b/app/api/conversations/[id]/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + const participant = await prisma.conversationParticipant.findUnique({ + where: { + conversationId_userId: { conversationId: id, userId: session.user.id }, + }, + }); + + if (!participant) { + return NextResponse.json({ error: "Not found." }, { status: 404 }); + } + + await prisma.conversation.delete({ where: { id } }); + + return NextResponse.json({ success: true }); +} diff --git a/app/api/conversations/[id]/unread/route.ts b/app/api/conversations/[id]/unread/route.ts new file mode 100644 index 0000000..d83353f --- /dev/null +++ b/app/api/conversations/[id]/unread/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function GET( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id: conversationId } = await params; + + const participant = await prisma.conversationParticipant.findUnique({ + where: { + conversationId_userId: { conversationId, userId: session.user.id }, + }, + select: { lastReadAt: true }, + }); + + if (!participant) { + return NextResponse.json({ count: 0 }); + } + + const count = await prisma.message.count({ + where: { + conversationId, + senderId: { not: session.user.id }, + createdAt: { gt: participant.lastReadAt }, + expiresAt: { gt: new Date() }, + }, + }); + + return NextResponse.json({ count }); +} diff --git a/app/api/conversations/route.ts b/app/api/conversations/route.ts new file mode 100644 index 0000000..88d929b --- /dev/null +++ b/app/api/conversations/route.ts @@ -0,0 +1,103 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const participantRows = await prisma.conversationParticipant.findMany({ + where: { userId: session.user.id }, + include: { + conversation: { + include: { + participants: { + select: { + userId: true, + email: true, + lastSeenAt: true, + lastReadAt: true, + }, + }, + }, + }, + }, + }); + + const conversations = participantRows.map((row) => ({ + id: row.conversationId, + type: row.conversation.type.toLowerCase(), + created_at: row.conversation.createdAt.toISOString(), + last_read_at: row.lastReadAt.toISOString(), + users: row.conversation.participants.map((p) => ({ + id: p.userId, + email: p.email, + last_seen_at: p.lastSeenAt.toISOString(), + })), + })); + + return NextResponse.json(conversations); +} + +export async function POST(req: Request) { + const session = await auth(); + if (!session?.user?.id || !session.user.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { otherUserId, otherUserEmail } = await req.json(); + + if (!otherUserId) { + return NextResponse.json( + { error: "otherUserId is required." }, + { status: 400 }, + ); + } + + const timestamp = new Date(); + const EPOCH = new Date("1970-01-01T00:00:00.000Z"); + + const conversation = await prisma.conversation.create({ + data: { + type: "PRIVATE", + participants: { + create: [ + { + userId: session.user.id, + email: session.user.email, + lastSeenAt: timestamp, + lastReadAt: timestamp, + }, + { + userId: otherUserId, + email: otherUserEmail ?? "", + lastSeenAt: EPOCH, + lastReadAt: EPOCH, + }, + ], + }, + }, + include: { + participants: { + select: { userId: true, email: true, lastSeenAt: true }, + }, + }, + }); + + return NextResponse.json( + { + id: conversation.id, + type: "private", + created_at: conversation.createdAt.toISOString(), + last_read_at: timestamp.toISOString(), + users: conversation.participants.map((p) => ({ + id: p.userId, + email: p.email, + last_seen_at: p.lastSeenAt.toISOString(), + })), + }, + { status: 201 }, + ); +} diff --git a/app/api/cron/cleanup-messages/route.ts b/app/api/cron/cleanup-messages/route.ts new file mode 100644 index 0000000..0399665 --- /dev/null +++ b/app/api/cron/cleanup-messages/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/app/lib/prisma"; + +export async function POST(req: Request) { + const secret = req.headers.get("x-cron-secret"); + if (!secret || secret !== process.env.CRON_SECRET) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { count } = await prisma.message.deleteMany({ + where: { expiresAt: { lt: new Date() } }, + }); + + return NextResponse.json({ deleted: count }); +} diff --git a/app/api/flex/[id]/route.ts b/app/api/flex/[id]/route.ts new file mode 100644 index 0000000..591a808 --- /dev/null +++ b/app/api/flex/[id]/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function PUT( + req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const body = await req.json(); + const { + project_name, + project_description, + project_url, + project_time, + is_open_source, + open_source_url, + } = body; + + const flex = await prisma.userFlex.updateMany({ + where: { id, userId: 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 ?? "") : "", + }, + }); + + if (flex.count === 0) { + return NextResponse.json({ error: "Not found." }, { status: 404 }); + } + + const updated = await prisma.userFlex.findUnique({ where: { id } }); + return NextResponse.json(updated); +} + +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + await prisma.userFlex.deleteMany({ + where: { id, userId: session.user.id }, + }); + + return NextResponse.json({ success: true }); +} diff --git a/app/api/flex/projects/route.ts b/app/api/flex/projects/route.ts new file mode 100644 index 0000000..92efc62 --- /dev/null +++ b/app/api/flex/projects/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const userProjects = await prisma.userProjects.findUnique({ + where: { userId: session.user.id }, + select: { projects: true }, + }); + + return NextResponse.json({ projects: userProjects?.projects ?? [] }); +} diff --git a/app/api/flex/route.ts b/app/api/flex/route.ts new file mode 100644 index 0000000..2ad4644 --- /dev/null +++ b/app/api/flex/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const flexes = await prisma.userFlex.findMany({ + where: { userId: session.user.id }, + orderBy: { createdAt: "desc" }, + }); + + return NextResponse.json(flexes); +} + +export async function POST(req: Request) { + const session = await auth(); + if (!session?.user?.id || !session.user.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await req.json(); + const { + project_name, + project_description, + project_url, + project_time, + is_open_source, + open_source_url, + } = body; + + if (!project_name?.trim()) { + return NextResponse.json( + { error: "Project name is required." }, + { status: 400 }, + ); + } + + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); + + 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, + }, + }); + + return NextResponse.json(flex, { status: 201 }); +} diff --git a/app/api/kanban/data/route.ts b/app/api/kanban/data/route.ts new file mode 100644 index 0000000..ae2bd3f --- /dev/null +++ b/app/api/kanban/data/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const [projects, boards, columns, issues] = await Promise.all([ + prisma.project.findMany({ orderBy: { createdAt: "asc" } }), + prisma.board.findMany({ orderBy: { createdAt: "asc" } }), + prisma.column.findMany({ orderBy: { position: "asc" } }), + prisma.issue.findMany({ orderBy: { position: "asc" } }), + ]); + + return NextResponse.json({ + projects: projects.map((p) => ({ id: p.id, name: p.name })), + boards: boards.map((b) => ({ + id: b.id, + project_id: b.projectId, + title: b.title, + description: b.description ?? "", + })), + columns: columns.map((c) => ({ + id: c.id, + board_id: c.boardId, + title: c.title, + position: c.position, + })), + issues: issues.map((i) => ({ + id: i.id, + column_id: i.columnId, + issue_key: i.issueKey, + title: i.title, + tag: i.tag ?? "", + type: i.type.toLowerCase(), + priority: i.priority.toLowerCase(), + position: i.position, + })), + }); +} diff --git a/app/api/kanban/issues/[id]/route.ts b/app/api/kanban/issues/[id]/route.ts new file mode 100644 index 0000000..da2503f --- /dev/null +++ b/app/api/kanban/issues/[id]/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; +import { emitter } from "@/app/lib/emitter"; + +export async function PATCH( + req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const body = await req.json(); + const { column_id, position } = body as { + column_id?: string; + position?: number; + }; + + const data: Record = {}; + if (column_id !== undefined) data.columnId = column_id; + if (position !== undefined) data.position = position; + + if (Object.keys(data).length === 0) { + return NextResponse.json({ error: "Nothing to update." }, { status: 400 }); + } + + const issue = await prisma.issue.update({ + where: { id }, + data: { ...data, updatedAt: new Date() }, + }); + + const payload = { + type: "issue_updated", + data: { id: issue.id, column_id: issue.columnId, position: issue.position }, + }; + emitter.emit("kanban", payload); + + return NextResponse.json({ + id: issue.id, + column_id: issue.columnId, + position: issue.position, + }); +} diff --git a/app/api/kanban/issues/route.ts b/app/api/kanban/issues/route.ts new file mode 100644 index 0000000..444c178 --- /dev/null +++ b/app/api/kanban/issues/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; +import { emitter } from "@/app/lib/emitter"; +import { IssueType, IssuePriority } from "@prisma/client"; + +const TYPE_MAP: Record = { + bug: "BUG", + feature: "FEATURE", + chore: "CHORE", +}; + +const PRIORITY_MAP: Record = { + p0: "P0", + p1: "P1", + p2: "P2", +}; + +export async function POST(req: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { columnId, title, tag, type, priority, issueKey, position } = + await req.json(); + + if (!columnId || !title?.trim() || !issueKey) { + return NextResponse.json( + { error: "columnId, title, and issueKey are required." }, + { status: 400 }, + ); + } + + const issue = await prisma.issue.create({ + data: { + columnId, + issueKey, + title: title.trim(), + tag: tag ?? "", + type: TYPE_MAP[type] ?? "FEATURE", + priority: PRIORITY_MAP[priority] ?? "P2", + position: position ?? 0, + }, + }); + + const payload = { + id: issue.id, + column_id: issue.columnId, + issue_key: issue.issueKey, + title: issue.title, + tag: issue.tag ?? "", + type: issue.type.toLowerCase(), + priority: issue.priority.toLowerCase(), + position: issue.position, + }; + + emitter.emit("kanban", { type: "issue_created", data: payload }); + + return NextResponse.json(payload, { status: 201 }); +} diff --git a/app/api/leaderboards/[id]/join-code/route.ts b/app/api/leaderboards/[id]/join-code/route.ts new file mode 100644 index 0000000..696cf11 --- /dev/null +++ b/app/api/leaderboards/[id]/join-code/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function GET( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + const leaderboard = await prisma.leaderboard.findUnique({ + where: { id }, + select: { joinCode: true, ownerId: true }, + }); + + if (!leaderboard || leaderboard.ownerId !== session.user.id) { + return NextResponse.json({ error: "Forbidden." }, { status: 403 }); + } + + return NextResponse.json({ joinCode: leaderboard.joinCode }); +} + +export async function PATCH( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + const leaderboard = await prisma.leaderboard.findUnique({ + where: { id }, + select: { ownerId: true }, + }); + + if (!leaderboard || leaderboard.ownerId !== session.user.id) { + return NextResponse.json({ error: "Forbidden." }, { status: 403 }); + } + + const joinCode = crypto.randomUUID().slice(0, 8); + + await prisma.leaderboard.update({ + where: { id }, + data: { joinCode }, + }); + + return NextResponse.json({ success: true, joinCode }); +} diff --git a/app/api/leaderboards/[id]/leave/route.ts b/app/api/leaderboards/[id]/leave/route.ts new file mode 100644 index 0000000..ec52d9b --- /dev/null +++ b/app/api/leaderboards/[id]/leave/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + await prisma.leaderboardMember.deleteMany({ + where: { leaderboardId: id, userId: session.user.id }, + }); + + return NextResponse.json({ success: true }); +} diff --git a/app/api/leaderboards/[id]/route.ts b/app/api/leaderboards/[id]/route.ts new file mode 100644 index 0000000..1d2134f --- /dev/null +++ b/app/api/leaderboards/[id]/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + const leaderboard = await prisma.leaderboard.findUnique({ + where: { id }, + select: { ownerId: true }, + }); + + if (!leaderboard) { + return NextResponse.json({ error: "Not found." }, { status: 404 }); + } + + if (leaderboard.ownerId !== session.user.id) { + return NextResponse.json({ error: "Forbidden." }, { status: 403 }); + } + + await prisma.leaderboard.delete({ where: { id } }); + + return NextResponse.json({ success: true }); +} diff --git a/app/api/leaderboards/join/route.ts b/app/api/leaderboards/join/route.ts new file mode 100644 index 0000000..38d2656 --- /dev/null +++ b/app/api/leaderboards/join/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function POST(req: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { joinCode } = await req.json(); + if (!joinCode) { + return NextResponse.json( + { error: "Join code is required." }, + { status: 400 }, + ); + } + + const leaderboard = await prisma.leaderboard.findUnique({ + where: { joinCode }, + select: { id: true, slug: true }, + }); + + if (!leaderboard) { + return NextResponse.json( + { error: "Invalid invite code." }, + { status: 404 }, + ); + } + + try { + await prisma.leaderboardMember.create({ + data: { + leaderboardId: leaderboard.id, + userId: session.user.id, + role: "member", + }, + }); + } catch { + return NextResponse.json( + { error: "You are already a member of this leaderboard." }, + { status: 409 }, + ); + } + + return NextResponse.json({ success: true, slug: leaderboard.slug }); +} diff --git a/app/api/leaderboards/route.ts b/app/api/leaderboards/route.ts new file mode 100644 index 0000000..73b5999 --- /dev/null +++ b/app/api/leaderboards/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; +import { toKebabSlug } from "@/app/utils/slug"; + +export async function POST(req: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { name } = await req.json(); + if (!name?.trim()) { + return NextResponse.json({ error: "Name is required." }, { status: 400 }); + } + + const joinCode = crypto.randomUUID().slice(0, 8); + const slug = toKebabSlug(name.trim(), "leaderboard"); + + try { + const leaderboard = await prisma.leaderboard.create({ + data: { + name: name.trim(), + description: "", + slug, + ownerId: session.user.id, + joinCode, + isPublic: true, + }, + }); + + await prisma.leaderboardMember.create({ + data: { + leaderboardId: leaderboard.id, + userId: session.user.id, + role: "owner", + }, + }); + + return NextResponse.json( + { joinCode: leaderboard.joinCode }, + { status: 201 }, + ); + } catch { + return NextResponse.json( + { error: "A leaderboard with that name already exists." }, + { status: 409 }, + ); + } +} diff --git a/app/api/messages/route.ts b/app/api/messages/route.ts new file mode 100644 index 0000000..e60f3d6 --- /dev/null +++ b/app/api/messages/route.ts @@ -0,0 +1,113 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; +import { emitter } from "@/app/lib/emitter"; + +export async function GET(req: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(req.url); + const conversationId = searchParams.get("conversationId"); + + if (!conversationId) { + return NextResponse.json( + { error: "conversationId is required." }, + { status: 400 }, + ); + } + + const participant = await prisma.conversationParticipant.findUnique({ + where: { + conversationId_userId: { conversationId, userId: session.user.id }, + }, + }); + + if (!participant) { + return NextResponse.json({ error: "Forbidden." }, { status: 403 }); + } + + const messages = await prisma.message.findMany({ + where: { + conversationId, + expiresAt: { gt: new Date() }, + }, + orderBy: { createdAt: "asc" }, + }); + + return NextResponse.json( + messages.map((m) => ({ + id: m.id, + conversation_id: m.conversationId, + sender_id: m.senderId, + text: m.text, + attachments: m.attachments, + created_at: m.createdAt.toISOString(), + })), + ); +} + +export async function POST(req: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { conversationId, text, attachments } = await req.json(); + + if ( + !conversationId || + (!text?.trim() && (!attachments || attachments.length === 0)) + ) { + return NextResponse.json( + { error: "conversationId and text are required." }, + { status: 400 }, + ); + } + + const participant = await prisma.conversationParticipant.findUnique({ + where: { + conversationId_userId: { conversationId, userId: session.user.id }, + }, + }); + + if (!participant) { + return NextResponse.json({ error: "Forbidden." }, { status: 403 }); + } + + const message = await prisma.message.create({ + data: { + conversationId, + senderId: session.user.id, + text: text?.trim() ?? "", + attachments: attachments ?? [], + }, + }); + + const payload = { + id: message.id, + conversation_id: message.conversationId, + sender_id: message.senderId, + text: message.text, + attachments: message.attachments, + created_at: message.createdAt.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 }, + }); + + for (const p of participants) { + emitter.emit(`user:${p.userId}`, { + type: "new_message", + data: { conversation_id: conversationId, sender_id: session.user.id }, + }); + } + + return NextResponse.json(payload, { status: 201 }); +} diff --git a/app/api/presence/route.ts b/app/api/presence/route.ts new file mode 100644 index 0000000..c64c7a2 --- /dev/null +++ b/app/api/presence/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function PATCH() { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const timestamp = new Date(); + + await prisma.conversationParticipant.updateMany({ + where: { userId: session.user.id }, + data: { lastSeenAt: timestamp }, + }); + + return NextResponse.json({ success: true }); +} diff --git a/app/api/profile/route.ts b/app/api/profile/route.ts new file mode 100644 index 0000000..7f785db --- /dev/null +++ b/app/api/profile/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function PATCH(req: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { name } = await req.json(); + + if (!name?.trim()) { + return NextResponse.json( + { error: "Display name cannot be empty." }, + { status: 400 }, + ); + } + + await prisma.user.update({ + where: { id: session.user.id }, + data: { name: name.trim() }, + }); + + return NextResponse.json({ success: true }); +} diff --git a/app/api/sse/chat/[conversationId]/route.ts b/app/api/sse/chat/[conversationId]/route.ts new file mode 100644 index 0000000..6325b20 --- /dev/null +++ b/app/api/sse/chat/[conversationId]/route.ts @@ -0,0 +1,54 @@ +import { auth } from "@/app/lib/auth"; +import { emitter } from "@/app/lib/emitter"; +import { prisma } from "@/app/lib/prisma"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET( + req: Request, + { params }: { params: Promise<{ conversationId: string }> }, +) { + const session = await auth(); + if (!session?.user?.id) { + return new Response("Unauthorized", { status: 401 }); + } + + const { conversationId } = await params; + + const participant = await prisma.conversationParticipant.findUnique({ + where: { + conversationId_userId: { conversationId, userId: session.user.id }, + }, + }); + + if (!participant) { + return new Response("Forbidden", { status: 403 }); + } + + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + const send = (event: unknown) => { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify(event)}\n\n`), + ); + }; + + emitter.on(`chat:${conversationId}`, send); + + req.signal.addEventListener("abort", () => { + emitter.off(`chat:${conversationId}`, send); + controller.close(); + }); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }); +} diff --git a/app/api/sse/chat/[conversationId]/typing/route.ts b/app/api/sse/chat/[conversationId]/typing/route.ts new file mode 100644 index 0000000..51b3de6 --- /dev/null +++ b/app/api/sse/chat/[conversationId]/typing/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { emitter } from "@/app/lib/emitter"; +import { prisma } from "@/app/lib/prisma"; + +export async function POST( + req: Request, + { params }: { params: Promise<{ conversationId: string }> }, +) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { conversationId } = await params; + + const participant = await prisma.conversationParticipant.findUnique({ + where: { + conversationId_userId: { conversationId, userId: session.user.id }, + }, + }); + + if (!participant) { + return NextResponse.json({ error: "Forbidden." }, { status: 403 }); + } + + const { is_typing } = await req.json(); + + emitter.emit(`chat:${conversationId}`, { + type: "typing", + data: { + conversation_id: conversationId, + user_id: session.user.id, + email: participant.email, + is_typing: Boolean(is_typing), + }, + }); + + return NextResponse.json({ success: true }); +} diff --git a/app/api/sse/conversations/route.ts b/app/api/sse/conversations/route.ts new file mode 100644 index 0000000..46023ab --- /dev/null +++ b/app/api/sse/conversations/route.ts @@ -0,0 +1,40 @@ +import { auth } from "@/app/lib/auth"; +import { emitter } from "@/app/lib/emitter"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(req: Request) { + const session = await auth(); + if (!session?.user?.id) { + return new Response("Unauthorized", { status: 401 }); + } + + const userId = session.user.id; + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + start(controller) { + const send = (event: unknown) => { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify(event)}\n\n`), + ); + }; + + emitter.on(`user:${userId}`, send); + + req.signal.addEventListener("abort", () => { + emitter.off(`user:${userId}`, send); + controller.close(); + }); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }); +} diff --git a/app/api/sse/kanban/route.ts b/app/api/sse/kanban/route.ts new file mode 100644 index 0000000..077bc36 --- /dev/null +++ b/app/api/sse/kanban/route.ts @@ -0,0 +1,34 @@ +import { auth } from "@/app/lib/auth"; +import { emitter } from "@/app/lib/emitter"; + +export async function GET(req: Request) { + const session = await auth(); + if (!session?.user?.id) { + return new Response("Unauthorized", { status: 401 }); + } + + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + + const send = (data: unknown) => { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)); + }; + + emitter.on("kanban", send); + + req.signal.addEventListener("abort", () => { + emitter.off("kanban", send); + controller.close(); + }); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); +} diff --git a/app/api/users/badges/route.ts b/app/api/users/badges/route.ts new file mode 100644 index 0000000..fbe884e --- /dev/null +++ b/app/api/users/badges/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function GET(req: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(req.url); + const ids = searchParams.getAll("id"); + + if (ids.length === 0) { + return NextResponse.json([]); + } + + const stats = await prisma.userStats.findMany({ + where: { userId: { in: ids } }, + select: { userId: true, totalSeconds: true }, + }); + + return NextResponse.json( + stats.map((s) => ({ + user_id: s.userId, + total_seconds: Number(s.totalSeconds), + })), + ); +} diff --git a/app/api/users/route.ts b/app/api/users/route.ts new file mode 100644 index 0000000..4b6c976 --- /dev/null +++ b/app/api/users/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function GET(req: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(req.url); + const conversationId = searchParams.get("conversationId"); + + if (!conversationId) { + return NextResponse.json( + { error: "conversationId is required." }, + { status: 400 }, + ); + } + + const participants = await prisma.conversationParticipant.findMany({ + where: { + conversationId, + userId: { not: session.user.id }, + }, + select: { userId: true, email: true }, + }); + + const users = participants + .filter((p) => p.email) + .map((p) => ({ user_id: p.userId, 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 5b2472c..6700258 100644 --- a/app/api/wakatime/sync/route.ts +++ b/app/api/wakatime/sync/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from "next/server"; -import { createClient } from "../../../lib/supabase/server"; -import { getUserWithProfile } from "@/app/lib/supabase/help/user"; +import { getCurrentUser } from "@/app/lib/auth/user"; import { saveWakatimeApiKey, syncWakatimeData, @@ -8,8 +7,7 @@ import { } from "@/app/lib/wakatime/sync"; export async function GET(request: Request) { - const supabase = await createClient(); - const { user, profile } = await getUserWithProfile(); + const { user } = await getCurrentUser(); const { searchParams } = new URL(request.url); const apiKey = searchParams.get("apiKey") || ""; const saveOnly = @@ -18,10 +16,7 @@ export async function GET(request: Request) { const validationError = validateWakatimeApiKey(apiKey); if (validationError) { - return NextResponse.json( - { error: validationError }, - { status: 400 }, - ); + return NextResponse.json({ error: validationError }, { status: 400 }); } if (!user) { @@ -29,32 +24,29 @@ export async function GET(request: Request) { } if (saveOnly) { - const result = await saveWakatimeApiKey({ - supabase, - userId: user.id, - apiKey, - }); + const result = await saveWakatimeApiKey({ userId: user.id, apiKey }); if (!result.success) { - return NextResponse.json({ error: result.error }, { status: result.status }); + return NextResponse.json( + { error: result.error }, + { status: result.status }, + ); } - return NextResponse.json({ - success: true, - data: null, - error: null, - }); + return NextResponse.json({ success: true, data: null, error: null }); } const result = await syncWakatimeData({ - supabase, userId: user.id, incomingApiKey: apiKey, - storedApiKey: profile?.wakatime_api_key, + storedApiKey: user.wakatimeApiKey, }); if (!result.success && result.status !== 200) { - return NextResponse.json({ error: result.error }, { status: result.status }); + return NextResponse.json( + { error: result.error }, + { status: result.status }, + ); } return NextResponse.json({ diff --git a/app/components/BoardList.tsx b/app/components/BoardList.tsx index e4b3fe1..18fcd86 100644 --- a/app/components/BoardList.tsx +++ b/app/components/BoardList.tsx @@ -1,26 +1,40 @@ "use client"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { createClient } from "../lib/supabase/client"; import Link from "next/link"; import { useState } from "react"; import { createPortal } from "react-dom"; -import { faKey, faRotateRight, faTrashAlt, faChevronRight, faServer, faRightFromBracket } from "@fortawesome/free-solid-svg-icons"; +import { + faKey, + faRotateRight, + faTrashAlt, + faChevronRight, + faServer, + faRightFromBracket, +} from "@fortawesome/free-solid-svg-icons"; import { toast } from "react-toastify"; -import { Leaderboard } from "./dashboard/LeaderbordList"; -import { User } from "@supabase/supabase-js"; + +interface BoardShape { + id: string; + name: string; + slug: string; + owner_id: string; +} + +interface UserShape { + id: string; + email: string; +} export default function BoardList({ user, board, allowLeave = false, }: { - user: User; - board: Leaderboard; - /** When true (joined networks only), show Leave to remove membership */ + user: UserShape; + board: BoardShape; allowLeave?: boolean; }) { - const supabase = createClient(); const [showCodeModal, setShowCodeModal] = useState(false); const [selectedCode, setSelectedCode] = useState(null); const inviteUrl = @@ -32,28 +46,27 @@ export default function BoardList({ const [leaving, setLeaving] = useState(false); const handleDelete = async () => { - const { error } = await supabase - .from("leaderboards") - .delete() - .eq("id", board.id); - - if (error) setShowDeleteModal(false); + const res = await fetch(`/api/leaderboards/${board.id}`, { + method: "DELETE", + }); + if (!res.ok) { + setShowDeleteModal(false); + return; + } window.location.reload(); }; const handleLeave = async () => { setLeaving(true); - const { error } = await supabase - .from("leaderboard_members") - .delete() - .eq("leaderboard_id", board.id) - .eq("user_id", user.id); - + const res = await fetch(`/api/leaderboards/${board.id}/leave`, { + method: "DELETE", + }); setLeaving(false); setShowLeaveModal(false); - if (error) { - toast.error(error.message || "Could not leave this leaderboard."); + if (!res.ok) { + const data = await res.json(); + toast.error(data.error || "Could not leave this leaderboard."); return; } toast.success("You left the leaderboard."); @@ -61,22 +74,12 @@ export default function BoardList({ }; const regenerateJoinCode = (boardId: string) => { - const generateJoinCode = new Promise(async (resolve, reject) => { - try { - const joinCode = crypto.randomUUID().slice(0, 8); - const { data, error } = await supabase - .from("leaderboards") - .update({ join_code: joinCode }) - .eq("id", boardId) - .select() - .single(); - - if (error) return reject(error); - - resolve(data); - } catch (error) { - reject(error); - } + const generateJoinCode = fetch(`/api/leaderboards/${boardId}/join-code`, { + method: "PATCH", + }).then(async (res) => { + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + return data; }); toast.promise(generateJoinCode, { @@ -95,20 +98,11 @@ export default function BoardList({ }; const getJoinCode = (boardId: string) => { - const joinCode: Promise<{ join_code: string }[]> = new Promise( - async (resolve, reject) => { - try { - const { data, error } = await supabase - .from("leaderboards") - .select("join_code") - .eq("id", boardId) - - if (error) return reject(error); - - resolve(data); - } catch (error) { - reject(error); - } + const joinCode = fetch(`/api/leaderboards/${boardId}/join-code`).then( + async (res) => { + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + return data.joinCode as string; }, ); @@ -117,13 +111,13 @@ export default function BoardList({ error: { render({ data }) { const err = data as Error; - return err?.message || "Failed to get join code. Please try again."; + return err?.message || "Failed to get join code. Please try again."; }, }, }); - joinCode.then((data) => { - setSelectedCode(data[0].join_code); + joinCode.then((code) => { + setSelectedCode(code); setShowCodeModal(true); }); }; @@ -131,9 +125,15 @@ export default function BoardList({ return ( <>
- +
- +
@@ -145,9 +145,12 @@ export default function BoardList({ /{board.slug}

- +
- +
@@ -185,7 +188,10 @@ export default function BoardList({ className="w-8 h-8 flex items-center justify-center rounded-md text-gray-500 hover:text-amber-400 hover:bg-amber-500/10 transition-colors" title="Leave leaderboard" > - +
)} @@ -195,7 +201,7 @@ export default function BoardList({
- +

Share Server

@@ -244,51 +250,61 @@ export default function BoardList({
)} - {showLeaveModal && typeof document !== "undefined" && createPortal( -
-
-
-

Leave leaderboard?

-

- You'll be removed from{" "} - {board.name}. - You can rejoin later with an invite link or code. -

-
- - + {showLeaveModal && + typeof document !== "undefined" && + createPortal( +
+
+
+

+ Leave leaderboard? +

+

+ You'll be removed from{" "} + + {board.name} + + . You can rejoin later with an invite link or code. +

+
+ + +
-
-
, - document.body - )} +
, + document.body, + )} {showDeleteModal && (
- +

Delete Network

- Are you sure you want to delete {board.name}? This action cannot be undone. + Are you sure you want to delete{" "} + + {board.name} + + ? This action cannot be undone.

- +
-
-
- - setMessageSearch(e.target.value)} - placeholder="Search Message..." - className="w-full bg-[rgba(10,10,30,0.6)] border border-transparent rounded-xl py-2 pl-9 pr-4 text-sm text-gray-200 placeholder:text-gray-500 outline-none focus:border-indigo-500/50 transition-colors shadow-inner" - /> -
-
- -
-
-

ROOMS

- +
+ {/* Left Sidebar */} +
+
+
+

+ Message category +

+ +
+
+ + setMessageSearch(e.target.value)} + placeholder="Search Message..." + className="w-full bg-[rgba(10,10,30,0.6)] border border-transparent rounded-xl py-2 pl-9 pr-4 text-sm text-gray-200 placeholder:text-gray-500 outline-none focus:border-indigo-500/50 transition-colors shadow-inner" + /> +
-
-
-

DIRECT MESSAGE

-
- setIsDmSortOpen(!isDmSortOpen)} - className="text-[10px] text-gray-500 bg-[rgba(10,10,30,0.6)] px-2 py-0.5 rounded cursor-pointer hover:bg-white/5 flex items-center gap-1 select-none" - > - {dmSortOrder === "newest" && "Newest"} - {dmSortOrder === "oldest" && "Oldest"} - {dmSortOrder === "az" && "A-Z"} - {dmSortOrder === "za" && "Z-A"} - - - - {isDmSortOpen && ( -
- - - - -
- )} -
+
+
+

+ ROOMS +

+
- -
-
-
- {/* Middle Chat Area */} -
- {conversationId ? ( - <> - {/* Header */} -
-
- +
+
+

+ DIRECT MESSAGE +

-
- {activeInitials} -
- {!isGlobalActive && activeOtherUserOnline && ( -
+ setIsDmSortOpen(!isDmSortOpen)} + className="text-[10px] text-gray-500 bg-[rgba(10,10,30,0.6)] px-2 py-0.5 rounded cursor-pointer hover:bg-white/5 flex items-center gap-1 select-none" + > + {dmSortOrder === "newest" && "Newest"} + {dmSortOrder === "oldest" && "Oldest"} + {dmSortOrder === "az" && "A-Z"} + {dmSortOrder === "za" && "Z-A"} + + + + {isDmSortOpen && ( +
+ + + + +
)}
-
-

{activeLabel}

-

{activeSublabel}

-
-
-
-
-
- -
-
+
+
- {activeTypingState && ( -
-
-
- - - -
- {typingIndicatorText} -
-
- )} - - {/* Input Area */} -
- {attachments.length > 0 && ( -
- {attachments.map((file, index) => ( + {/* Middle Chat Area */} +
+ {conversationId ? ( + <> + {/* Header */} +
+
+ +
- {file.type.startsWith("image/") ? ( - {file.name} - ) : ( - - )} - {file.name} - + {activeInitials}
- ))} -
- )} - -
- - - -
- @@ -264,7 +264,7 @@ export default function Flex() { setFlex({ ...flex, project_url: e.target.value }) } placeholder="Project URL" - className="w-full mt-2 px-3 py-2 bg-transparent text-gray-100 placeholder:text-gray-500 border border-neutral-800 rounded-xl outline-none" + className="w-full mt-2 px-3 py-2 bg-transparent text-gray-700 placeholder:text-gray-500 border border-neutral-800 rounded-xl outline-none" />
@@ -287,7 +287,7 @@ export default function Flex() { setFlex({ ...flex, open_source_url: e.target.value }) } placeholder="Open Source URL" - className="w-full mt-2 px-3 py-2 bg-transparent text-gray-100 placeholder:text-gray-500 border border-neutral-800 rounded-xl outline-none" + className="w-full mt-2 px-3 py-2 bg-transparent text-gray-700 placeholder:text-gray-500 border border-neutral-800 rounded-xl outline-none" /> )} @@ -316,7 +316,7 @@ export default function Flex() { {userFlexes.length === 0 && !loading && (
-

+

You have no flexes yet. Start by sharing your first project!

@@ -337,7 +337,7 @@ export default function Flex() { prev === f.id ? null : f.id, ) } - className="p-1 -m-1 text-gray-500 hover:text-gray-300 transition-colors leading-none" + className="p-1 -m-1 text-gray-500 hover:text-gray-600 transition-colors leading-none" title="Flex actions" aria-label="Open flex actions" aria-haspopup="menu" @@ -352,7 +352,7 @@ export default function Flex() { {activeMenuFlexId === f.id && (
)}
- + {f.projectTime}
-

{f.projectDescription}

+

{f.projectDescription}

{f.projectUrl} {f.isOpenSource && ( {f.openSourceUrl} @@ -429,11 +429,11 @@ export default function Flex() { value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search projects..." - className="w-full mb-3 px-3 py-2 bg-transparent text-gray-100 placeholder:text-gray-500 border border-neutral-800 rounded-xl outline-none" + className="w-full mb-3 px-3 py-2 bg-transparent text-gray-700 placeholder:text-gray-500 border border-neutral-800 rounded-xl outline-none" />
{flexes.length === 0 && !loading && ( -

+

You have no projects to flex yet.

)} diff --git a/app/components/JoinButton.tsx b/app/components/JoinButton.tsx index 690a402..73c7ec0 100644 --- a/app/components/JoinButton.tsx +++ b/app/components/JoinButton.tsx @@ -53,7 +53,7 @@ export default function JoinButton({ Don't have an account?{" "} Sign up free diff --git a/app/components/admin/Dashbord.tsx b/app/components/admin/Dashbord.tsx index 9923429..2aadf76 100644 --- a/app/components/admin/Dashbord.tsx +++ b/app/components/admin/Dashbord.tsx @@ -107,7 +107,7 @@ export default function Dashboard() {
-

Admin Panel

+

Admin Panel

diff --git a/app/components/admin/Widgets/FeatureInsights.tsx b/app/components/admin/Widgets/FeatureInsights.tsx index 7472fa2..1154e8e 100644 --- a/app/components/admin/Widgets/FeatureInsights.tsx +++ b/app/components/admin/Widgets/FeatureInsights.tsx @@ -10,7 +10,7 @@ export default function FeatureInsights({ return (
-

Leaderboard Stats

+

Leaderboard Stats

Total @@ -29,7 +29,7 @@ export default function FeatureInsights({
-

Flex Stats

+

Flex Stats

Total diff --git a/app/components/admin/Widgets/RankingInsights.tsx b/app/components/admin/Widgets/RankingInsights.tsx index faf91ca..bf77146 100644 --- a/app/components/admin/Widgets/RankingInsights.tsx +++ b/app/components/admin/Widgets/RankingInsights.tsx @@ -28,7 +28,7 @@ export default function RankingInsights({ return (
-

Top Coders

+

Top Coders

{top3.map((u, i) => (
@@ -44,7 +44,7 @@ export default function RankingInsights({
-

Least Coders

+

Least Coders

{bottom3.map((u, i) => (
@@ -60,7 +60,7 @@ export default function RankingInsights({
-

Category Stats

+

Category Stats

{categoryStats.map((c, i) => ( @@ -75,7 +75,7 @@ export default function RankingInsights({
-

Vibe Coders

+

Vibe Coders

{aiCoders.map((c, i) => ( diff --git a/app/components/admin/Widgets/TopInsights.tsx b/app/components/admin/Widgets/TopInsights.tsx index 93e1330..c8a5ed9 100644 --- a/app/components/admin/Widgets/TopInsights.tsx +++ b/app/components/admin/Widgets/TopInsights.tsx @@ -12,7 +12,7 @@ export default function TopInsights({ return (
-

Total Users

+

Total Users

{totalUsers}

(Average: {Math.floor(totalUsers / 30)} users/day) @@ -20,7 +20,7 @@ export default function TopInsights({

-

Total Coding Time

+

Total Coding Time

{Math.floor(totalSeconds / 3600)} hrs

@@ -30,7 +30,7 @@ export default function TopInsights({
-

Total Threads

+

Total Threads

{totalThreads}

(Average: {Math.floor(totalThreads / 30)} threads/day) @@ -38,7 +38,7 @@ export default function TopInsights({

-

Total Messages

+

Total Messages

{totalMessages}

(Average: {Math.floor(totalMessages / totalThreads)} msgs/thread) diff --git a/app/components/admin/Widgets/UserLists.tsx b/app/components/admin/Widgets/UserLists.tsx index c0469ea..288370c 100644 --- a/app/components/admin/Widgets/UserLists.tsx +++ b/app/components/admin/Widgets/UserLists.tsx @@ -15,7 +15,7 @@ export default function UserLists({ return (

- + diff --git a/app/components/auth/ForgotPassword.tsx b/app/components/auth/ForgotPassword.tsx index 8022b91..af1b5ab 100644 --- a/app/components/auth/ForgotPassword.tsx +++ b/app/components/auth/ForgotPassword.tsx @@ -20,7 +20,7 @@ export default function ForgotPassword() { return (
{/* Left Side - Visual / Branding */} -
+
{/* Background elements */}
@@ -40,13 +40,13 @@ export default function ForgotPassword() {

Loss of access? No problem!

-

+

We got you covered. All you need to do is enter your email address and we will send you a password reset link to get you back on track with monitoring your coding activity and competing on leaderboards.

-
+
@@ -57,23 +57,23 @@ export default function ForgotPassword() {
- const - auth - = - new + const + auth + = + new SupabaseAuth - (); + ();
- auth - . + auth + . sendPasswordResetEmail - ( - email - ); + ( + email + );
- + {"// Check your inbox. "}
@@ -81,7 +81,7 @@ export default function ForgotPassword() {
-
+
© {new Date().getFullYear()} Devpulse. All rights reserved.
@@ -96,14 +96,14 @@ export default function ForgotPassword() { className="lg:hidden flex items-center justify-center gap-3 mb-10" > -

Devpulse

+

Devpulse

-

+

Forgot your password?

-

+

No worries! Just enter your email address and we'll send you an email.

@@ -111,7 +111,7 @@ export default function ForgotPassword() { -

+

Already have an account?{" "} Log in diff --git a/app/components/auth/Login.tsx b/app/components/auth/Login.tsx index 0ce655f..8a71778 100644 --- a/app/components/auth/Login.tsx +++ b/app/components/auth/Login.tsx @@ -20,7 +20,7 @@ export default function Login() { return (

{/* Left Side - Visual / Branding */} -
+
{/* Background elements */}
@@ -40,12 +40,12 @@ export default function Login() {

Welcome back to your dashboard.

-

+

Access your personalized coding metrics, compare your stats, and keep your productivity streak alive.

-
+
@@ -56,23 +56,23 @@ export default function Login() {
- import - {"{ Metrics }"} - from - + import + {"{ Metrics }"} + from + '@devpulse/core' - ; + ;
- await - Metrics - . - syncToday - (); + await + Metrics + . + syncToday + ();
- + {"// Connection established. Ready to track. ⚡"}
@@ -80,7 +80,7 @@ export default function Login() {
-
+
© {new Date().getFullYear()} Devpulse. All rights reserved.
@@ -99,15 +99,15 @@ export default function Login() {
-

Log in

-

+

Log in

+

Enter your credentials to access your account.

-
+
handleOAuth("google")} - className="flex items-center justify-center gap-2 w-full py-3 rounded-lg border border-white/10 bg-white/5 text-gray-100 hover:bg-white/10 hover:border-white/20 transition-colors shadow-[0_8px_20px_rgba(0,0,0,0.25)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/60" + className="flex items-center justify-center gap-2 w-full py-3 rounded-lg border border-gray-200 bg-gray-50 text-gray-700 hover:bg-gray-100 hover:border-gray-300 transition-colors shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/60" > - + Google
diff --git a/app/components/auth/Signup.tsx b/app/components/auth/Signup.tsx index eb6fa67..5256ec8 100644 --- a/app/components/auth/Signup.tsx +++ b/app/components/auth/Signup.tsx @@ -20,7 +20,7 @@ export default function Signup() { return (
{/* Left Side - Visual / Branding */} -
+
{/* Background elements */}
@@ -40,12 +40,12 @@ export default function Signup() {

Start measuring your coding pulse.

-

+

Join thousands of developers tracking their progress, competing on leaderboards, and leveling up their skills.

-
+
@@ -56,23 +56,23 @@ export default function Signup() {
- const - dev - = - new - Developer - (); + const + dev + = + new + Developer + ();
- dev - . - connect - ( - 'wakatime' - ); + dev + . + connect + ( + 'wakatime' + );
- + {"// Your journey begins here. 🚀"}
@@ -80,7 +80,7 @@ export default function Signup() {
-
+
© {new Date().getFullYear()} Devpulse. All rights reserved.
@@ -95,21 +95,21 @@ export default function Signup() { className="lg:hidden flex items-center justify-center gap-3 mb-10" > -

Devpulse

+

Devpulse

-

+

Create an account

-

+

Start tracking your coding stats today.

-

+

Already have an account?{" "} Log in diff --git a/app/components/auth/VerifyEmail.tsx b/app/components/auth/VerifyEmail.tsx new file mode 100644 index 0000000..e1035f2 --- /dev/null +++ b/app/components/auth/VerifyEmail.tsx @@ -0,0 +1,226 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { useState } from "react"; +import { toast } from "react-toastify"; + +export default function VerifyEmail({ sessionEmail }: { sessionEmail?: string | null }) { + const searchParams = useSearchParams(); + + const emailParam = searchParams.get("email"); + const error = searchParams.get("error"); + const email = emailParam || sessionEmail || ""; + + const [resending, setResending] = useState(false); + + const handleResend = async () => { + if (!email) return; + setResending(true); + + const p = fetch("/api/auth/verify-email", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }).then((r) => { + if (!r.ok) throw new Error("Failed to resend."); + }); + + toast.promise(p, { + pending: "Sending...", + success: "Verification email sent!", + error: "Failed to resend. Please try again.", + }); + + p.finally(() => setResending(false)); + }; + + return ( +

+ {/* Left Side - Visual / Branding */} +
+
+ +
+ + + + Devpulse + + +
+ +
+

+ One step away from your dashboard. +

+

+ We sent a verification link to your inbox. Click it to activate your + account and start tracking your coding pulse. +

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

Devpulse

+ + + {error ? ( +
+
+ + + +
+

+ {error === "expired" + ? "Link expired" + : "Invalid verification link"} +

+

+ {error === "expired" + ? "This verification link has expired. Request a new one below." + : "This verification link is invalid or has already been used."} +

+ {email && ( + + )} + + Back to login + +
+ ) : ( + <> +
+
+ + + +
+

+ Check your inbox +

+

+ We sent a verification link to{" "} + {email ? ( + {email} + ) : ( + "your email address" + )} + . Click the link to activate your account. +

+
+ +
+
+

+ Didn't receive it? Check your spam folder, or resend + the email below. +

+
+ + {email && ( + + )} + + + Back to login + +
+ + )} +
+
+
+ ); +} diff --git a/app/components/auth/form/LoginForm.tsx b/app/components/auth/form/LoginForm.tsx index b6556a0..ddf7aea 100644 --- a/app/components/auth/form/LoginForm.tsx +++ b/app/components/auth/form/LoginForm.tsx @@ -17,6 +17,7 @@ export default function LoginForm() { !redirectParam.startsWith("//") ? redirectParam : "/d"; + const justVerified = searchParams.get("verified") === "1"; const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -61,6 +62,11 @@ export default function LoginForm() { return ( <> + {justVerified && ( +
+ Email verified successfully. You can now log in. +
+ )}
- + Or continue with - +
diff --git a/app/components/auth/form/UpdatePasswordForm.tsx b/app/components/auth/form/UpdatePasswordForm.tsx index 8fad953..ab02e03 100644 --- a/app/components/auth/form/UpdatePasswordForm.tsx +++ b/app/components/auth/form/UpdatePasswordForm.tsx @@ -88,7 +88,7 @@ export default function UpdatePasswordForm() { {showCaptcha && (
-

+

Verify you are human

@@ -100,7 +100,7 @@ export default function UpdatePasswordForm() { diff --git a/app/components/chat/Conversations.tsx b/app/components/chat/Conversations.tsx index ef394df..0bd2515 100644 --- a/app/components/chat/Conversations.tsx +++ b/app/components/chat/Conversations.tsx @@ -48,16 +48,16 @@ export default function Conversations({ onClick={() => setConversationId(conv.id)} className={`w-full flex items-center gap-3.5 p-3 rounded-xl transition-all text-left ${ isActive - ? "bg-white/[0.05] border border-white/[0.08] shadow-sm" - : "hover:bg-white/[0.02] border border-transparent opacity-80 hover:opacity-100" + ? "bg-gray-100 border border-gray-200 shadow-sm" + : "hover:bg-gray-100 border border-transparent opacity-80 hover:opacity-100" }`} >
{initials} @@ -72,19 +72,19 @@ export default function Conversations({
{label}
{isGlobal && ( - + All )} {unreadCount > 0 && ( - + {unreadCount > 99 ? "99+" : unreadCount} )} @@ -92,7 +92,7 @@ export default function Conversations({
diff --git a/app/components/chat/MediaViewerModal.tsx b/app/components/chat/MediaViewerModal.tsx index 4dc5c42..60f9c9d 100644 --- a/app/components/chat/MediaViewerModal.tsx +++ b/app/components/chat/MediaViewerModal.tsx @@ -76,7 +76,7 @@ export default function MediaViewerModal({ @@ -86,14 +86,14 @@ export default function MediaViewerModal({ )}
e.stopPropagation()} >
@@ -128,7 +128,7 @@ export default function MediaViewerModal({ @@ -155,13 +155,13 @@ export default function Messages({ onUserProfileClick?.(msg.sender_id, senderRow.email); }} title={canOpenPrivateChat ? "Start private chat" : undefined} - className={`flex-shrink-0 ${avatarTranslateClass} w-8 h-8 rounded-full bg-neutral-700 border border-white/10 flex items-center justify-center aspect-square overflow-hidden ${ + 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" : "" }`} > - + {senderInitial}
@@ -189,7 +189,7 @@ export default function Messages({ senderRow.email as string, ) } - className="text-[12px] font-semibold leading-none text-gray-200 hover:text-indigo-300 transition" + className="text-[12px] font-semibold leading-none text-gray-700 hover:text-indigo-600 transition" title="Start private chat" > {senderName} @@ -197,7 +197,7 @@ export default function Messages({ ) : ( {senderName} @@ -225,8 +225,8 @@ export default function Messages({
@@ -244,7 +244,7 @@ export default function Messages({ if (!match) { return ( )} > {children} @@ -319,7 +319,7 @@ function CodeBlock({ type="button" onMouseDown={(e) => e.preventDefault()} onClick={handleCopy} - className="absolute top-2 right-2 z-10 text-[10px] px-2 py-1 rounded-md border border-white/15 bg-black/45 text-gray-300 hover:text-white hover:bg-black/60 transition opacity-0 group-hover/code:opacity-100" + className="absolute top-2 right-2 z-10 text-[10px] px-2 py-1 rounded-md border border-gray-200 bg-black/45 text-gray-600 hover:text-gray-900 hover:bg-black/60 transition opacity-0 group-hover/code:opacity-100" > {copied ? "Copied" : "Copy"} @@ -328,7 +328,7 @@ function CodeBlock({ language={language} PreTag="pre" wrapLongLines={true} - className="rounded-xl text-xs border border-white/10 !bg-neutral-900/60 max-w-full" + className="rounded-xl text-xs border border-gray-200 !bg-neutral-900/60 max-w-full" codeTagProps={{ style: { whiteSpace: "pre-wrap", @@ -379,7 +379,7 @@ function getAttachments( filename: attachment.filename, }) } - className="group relative block w-full max-w-[320px] sm:max-w-[380px] overflow-hidden rounded-xl border border-white/10 bg-black/30" + className="group relative block w-full max-w-[320px] sm:max-w-[380px] overflow-hidden rounded-xl border border-gray-200 bg-black/30" >
- + Play video
@@ -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-300 hover:text-indigo-200 hover:underline text-sm" + className="inline-flex items-center gap-1.5 text-indigo-600 hover:text-indigo-200 hover:underline text-sm" > {attachment.filename || "Open attachment"} @@ -732,11 +732,11 @@ function AudioAttachmentPlayer({ src, type }: { src: string; type: string }) {
-
+
- + {duration > 0 ? formatTime(Math.max(0, duration - currentTime)) : "0:00"} diff --git a/app/components/chat/Player.tsx b/app/components/chat/Player.tsx index 454d6db..cc53426 100644 --- a/app/components/chat/Player.tsx +++ b/app/components/chat/Player.tsx @@ -462,7 +462,7 @@ export default function Player({ className={`relative group/player overflow-hidden ${ immersive ? "rounded-none border-0 bg-black shadow-none" - : "rounded-xl border border-white/10 bg-[#05050a] shadow-2xl" + : "rounded-xl border border-gray-200 bg-[#05050a] shadow-2xl" } ${className}`} style={frameStyle} > @@ -500,7 +500,7 @@ export default function Player({ setShowSettings(false); void togglePlay(); }} - className={`absolute z-30 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-11 h-11 rounded-full border border-white/20 bg-black/55 text-white transition ${ + className={`absolute z-30 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-11 h-11 rounded-full border border-gray-300 bg-black/55 text-gray-900 transition ${ showUi ? "opacity-100" : "opacity-0 pointer-events-none" }`} > @@ -532,7 +532,7 @@ export default function Player({ onTouchStart={(e) => showHint("Download", e.currentTarget, "below") } - className="w-8 h-8 rounded-md text-white/90 hover:text-white transition" + className="w-8 h-8 rounded-md text-gray-900/90 hover:text-gray-900 transition" aria-label="Download video" > @@ -550,7 +550,7 @@ export default function Player({ onMouseEnter={(e) => showHint("Close", e.currentTarget, "below")} onMouseLeave={hideHint} onTouchStart={(e) => showHint("Close", e.currentTarget, "below")} - className="w-8 h-8 rounded-md text-white/90 hover:text-white transition" + className="w-8 h-8 rounded-md text-gray-900/90 hover:text-gray-900 transition" aria-label="Close viewer" > @@ -565,7 +565,7 @@ export default function Player({ }`} >
-
+
-
-
+
+
Playback Speed
@@ -811,8 +811,8 @@ export default function Player({ onClick={() => setPlaybackRate(rate)} className={`px-2 py-1 rounded text-[10px] transition ${ playbackRate === rate - ? "bg-white/20 text-white" - : "bg-white/5 text-gray-300 hover:bg-white/10" + ? "bg-gray-200 text-gray-900" + : "bg-gray-50 text-gray-600 hover:bg-gray-100" }`} > {rate}x @@ -820,8 +820,8 @@ export default function Player({ ))}
-
-
+
+
Quality
@@ -833,8 +833,8 @@ export default function Player({ onClick={() => setQuality(q)} className={`px-2 py-1 rounded text-[10px] transition ${ quality === q - ? "bg-white/20 text-white" - : "bg-white/5 text-gray-300 hover:bg-white/10" + ? "bg-gray-200 text-gray-900" + : "bg-gray-50 text-gray-600 hover:bg-gray-100" }`} > {q} diff --git a/app/components/common/NavProfileDropdown.tsx b/app/components/common/NavProfileDropdown.tsx index 90d4629..41df3be 100644 --- a/app/components/common/NavProfileDropdown.tsx +++ b/app/components/common/NavProfileDropdown.tsx @@ -59,7 +59,7 @@ export default function NavProfileDropdown({ className="w-8 h-8 rounded-full object-cover" /> ) : ( -
+
{email.charAt(0).toUpperCase()}
)} @@ -67,13 +67,13 @@ export default function NavProfileDropdown({
-

{name}

+

{name}

{email}

setProfileOpen(false)} > @@ -82,18 +82,18 @@ export default function NavProfileDropdown({ {/* Dropdown Menu */} {profileOpen && (
e.stopPropagation()} > -
-

{name}

+
+

{name}

{email}

{showDashboardLink && ( <> setProfileOpen(false)} > @@ -101,7 +101,7 @@ export default function NavProfileDropdown({ setProfileOpen(false)} > @@ -109,7 +109,7 @@ export default function NavProfileDropdown({ setProfileOpen(false)} > @@ -117,7 +117,7 @@ export default function NavProfileDropdown({ setProfileOpen(false)} > @@ -125,7 +125,7 @@ export default function NavProfileDropdown({ setProfileOpen(false)} > @@ -135,7 +135,7 @@ export default function NavProfileDropdown({ )} setProfileOpen(false)} > @@ -144,7 +144,7 @@ export default function NavProfileDropdown({ {type === "navbar" && ( setProfileOpen(false)} >
-

+

Launch DevPulse

Ready to track your coding productivity?

-

+

Connect your data source, onboard your team, and turn raw coding time into actionable performance insights.

-
+

Setup Time

-

~5 minutes

+

~5 minutes

-
+

Sync Source

-

WakaTime

+

WakaTime

-
+

Visibility

-

Team + Public

+

Team + Public

diff --git a/app/components/dashboard/LeaderbordList.tsx b/app/components/dashboard/LeaderbordList.tsx index 8d315ff..8aeceae 100644 --- a/app/components/dashboard/LeaderbordList.tsx +++ b/app/components/dashboard/LeaderbordList.tsx @@ -44,12 +44,12 @@ export default async function LeaderboardsList() { >
-
+
-

+

Your Networks

@@ -67,7 +67,7 @@ export default async function LeaderboardsList() { icon={faCrown} className="w-3.5 h-3.5 text-yellow-500" /> -

+

Administered

@@ -76,7 +76,7 @@ export default async function LeaderboardsList() { {owned.map((board) => (
-

+

Joined Networks

@@ -105,7 +105,7 @@ export default async function LeaderboardsList() { {joinedBoards.map((board) => (
-
+
+
-

+

No Active Networks

diff --git a/app/components/dashboard/Navbar.tsx b/app/components/dashboard/Navbar.tsx index 1f14d37..1eb75e8 100644 --- a/app/components/dashboard/Navbar.tsx +++ b/app/components/dashboard/Navbar.tsx @@ -140,10 +140,10 @@ export default function DashboardLayout({ return (

-
+
@@ -107,10 +107,10 @@ export default function UserProfile({ user }: { user: UserShape }) { alt="User Avatar" width={54} height={54} - className="rounded-full border border-white/10" + className="rounded-full border border-gray-200" />
-

+

{originalName}

{user.email || "No email"}

@@ -119,7 +119,7 @@ export default function UserProfile({ user }: { user: UserShape }) {
-
User Email