diff --git a/.env.example b/.env.example index 53de38e..0b11cbc 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,38 @@ 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 +NODE_MAILER_HOST=smtp.gmail.com +NODE_MAILER_PORT=465 +NODE_MAILER_USER= +NODE_MAILER_PASS= +NODE_MAILER_SECURE=true + +# 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/README.md b/README.md index 9c7f2e7..1146fbd 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,16 @@ -Screenshot of the floating console extension in action - +Devpulse + # devpulse Measure and share your coding productivity with personalized leaderboards. Compare your progress with peers while keeping full control over privacy and leaderboard settings with project management features. ## Getting Started + Install the dependencies: npm install -``` - -## Supabase -First by creating a supabase cloud project: - -- go to [Supabase Dashboard](https://app.supabase.com) -- click `New Project` -- choose: - - Organization → (create one if needed) - - Project Name → e.g. devpulse - - Database Password → choose a secure one - - Region → pick the nearest location -- Click Create new project -- Wait a few moments for the database to be provisioned. +```` ## Setup Environment @@ -30,11 +18,9 @@ Copy the .env.example to .env ```bash cp .env.example .env +```` -# Open .env and fill in the values for: -# NEXT_PUBLIC_SUPABASE_URL=your_supabase_url -# NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key -``` +and fill in the values for the environment variables. ## Development @@ -46,80 +32,6 @@ npm run dev Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. -## Database Migrations - -For a brand-new Supabase project, use this flow from the repo root. - -This repository now uses a squashed baseline migration for fresh installs: - -- Active baseline: `supabase/migrations/20260407120000_baseline_fresh_setup.sql` - -- Historical migrations archive: `supabase/migrations_archive/` - -1. Login to Supabase CLI: - -```bash -npx supabase login -``` - -2. Initialize local Supabase config (only if missing): - -```bash -npx supabase init -``` - -3. Link this repo to your cloud project: - -```bash -npx supabase link -``` - -You can select from the project list, or run `npx supabase link --project-ref `. - -4. Push all migrations to the new project: - -```bash -npx supabase db push -``` - -On a fresh project this applies only the single baseline migration. - -5. (Optional) Pull remote schema changes into migrations: - -```bash -npx supabase db pull -``` - -6. Regenerate Supabase TypeScript types after schema changes: - -```bash -npx supabase gen types typescript --project-id --schema public > app/supabase-types.ts -``` - -If you want to re-run the full migration chain on local development: - -```bash -npx supabase db reset -``` - -If you need to inspect migration history, use the files in `supabase/migrations_archive/`. - - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy this Next.js app is to use the [Vercel Platform](https://vercel.com/new/clone?repository-url=https://github.com/mrepol742/devpulse) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. - ## Contribution Guidelines Contributions to devpulse are welcome! Please follow these guidelines: @@ -135,4 +47,5 @@ Contributions to devpulse are welcome! Please follow these guidelines: Help us keep the codebase ("Devpulse") clean, stable, and maintainable. ## License + This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/app/(public)/(auth)/forgot-password/page.tsx b/app/(public)/(auth)/forgot-password/page.tsx index 3608849..d54f5b3 100644 --- a/app/(public)/(auth)/forgot-password/page.tsx +++ b/app/(public)/(auth)/forgot-password/page.tsx @@ -44,7 +44,7 @@ export default function ForgotPasswordPage() { return ( +
Loading...
} diff --git a/app/(public)/(auth)/login/page.tsx b/app/(public)/(auth)/login/page.tsx index 947e639..61cc5ea 100644 --- a/app/(public)/(auth)/login/page.tsx +++ b/app/(public)/(auth)/login/page.tsx @@ -57,7 +57,7 @@ export default async function LoginPage() { return ( +
Loading...
} diff --git a/app/(public)/(auth)/logout/page.tsx b/app/(public)/(auth)/logout/page.tsx new file mode 100644 index 0000000..e35fc21 --- /dev/null +++ b/app/(public)/(auth)/logout/page.tsx @@ -0,0 +1,24 @@ +import { Suspense } from "react"; +import Logout from "@/app/components/auth/Logout"; +import { auth } from "@/app/lib/auth"; +import { redirect } from "next/navigation"; + +export default async function LogoutPage() { + const session = await auth(); + + if (!session) { + return redirect("/login"); + } + + return ( + + Loading... + + } + > + + + ); +} diff --git a/app/(public)/(auth)/reset-password/page.tsx b/app/(public)/(auth)/reset-password/page.tsx index e64fadd..f65a5f4 100644 --- a/app/(public)/(auth)/reset-password/page.tsx +++ b/app/(public)/(auth)/reset-password/page.tsx @@ -47,9 +47,9 @@ export const metadata: Metadata = { export default async function ResetPassword() { return ( -
+
{/* Left Side - Visual / Branding */} -
+
{/* Background elements */}
@@ -59,22 +59,22 @@ export default async function ResetPassword() { className="flex items-center gap-3 w-fit hover:opacity-80 transition" > Devpulse Logo - + Devpulse
-

+

Change your password and get back to tracking your coding activity!

-

+

Your Devpulse dashboard is waiting for you. Enter a new password to regain access and continue your coding journey.

-
+
@@ -85,26 +85,26 @@ export default async function ResetPassword() {
- const - dev - = - getAccount - ( - this - ); + const + dev + = + getAccount + ( + this + );
- dev - . - setNewPassword - ( - + dev + . + setNewPassword + ( + "your-new-password" - ); + );
- + {"// And just like that, you're back in the game. 🎉"}
@@ -127,14 +127,14 @@ export default async function ResetPassword() { className="lg:hidden flex items-center justify-center gap-3 mb-10" > Devpulse Logo -

Devpulse

+

Devpulse

-

+

Reset your password

-

+

New password, who dis? Enter a new password to regain access to your account and get back to tracking your coding stats!

@@ -148,7 +148,7 @@ export default async function ResetPassword() { -

+

Already have an account?{" "} +

Loading...
} diff --git a/app/(public)/(auth)/verify-email/page.tsx b/app/(public)/(auth)/verify-email/page.tsx new file mode 100644 index 0000000..549d5e8 --- /dev/null +++ b/app/(public)/(auth)/verify-email/page.tsx @@ -0,0 +1,30 @@ +import { Metadata } from "next/types"; +import { Suspense } from "react"; +import VerifyEmail from "@/app/components/auth/VerifyEmail"; +import { auth } from "@/app/lib/auth"; +import { redirect } from "next/navigation"; + +export const metadata: Metadata = { + title: "Verify Email - Devpulse", + description: "Verify your email address to activate your Devpulse account.", +}; + +export default async function VerifyEmailPage() { + const session = await auth(); + + if (!session) { + return redirect("/login"); + } + + return ( + + Loading... +
+ } + > + + + ); +} diff --git a/app/(public)/flex/page.tsx b/app/(public)/flex/page.tsx index 1a0585c..c9390d8 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,92 +57,80 @@ 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 ( -
+
Devpulse Logo -

Devpulse Flexes

+

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

-

+

Please come back later to see the latest flexes from our community.

)} - {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..7a9846a 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 }, }; } @@ -95,18 +81,18 @@ export default async function JoinPage({ searchParams }: Props) { if (!code) { return ( -
+
-
+
-

+

Join a Leaderboard

-

+

Open an invite link like{" "} /join?id=XXXXXXXX.

@@ -118,22 +104,25 @@ export default async function JoinPage({ searchParams }: Props) { ); } - const leaderboard = await getLeaderboard(code); + const [leaderboard, { user }] = await Promise.all([ + getLeaderboard(code), + getCurrentUser(), + ]); if (!leaderboard) { return ( -
+
-

+

Invite Not Found

-

+

This invite link is invalid or has expired.

@@ -146,39 +135,37 @@ 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; } return ( -
+
-
+
Devpulse
-

+

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

@@ -186,25 +173,25 @@ export default async function JoinPage({ searchParams }: Props) {

{leaderboard.description && leaderboard.description.length > 0 && ( -

+

{leaderboard.description}

)}
-
+
{memberCount} {memberCount === 1 ? "member" : "members"}
-
+
Leaderboard
@@ -222,7 +209,7 @@ export default async function JoinPage({ searchParams }: Props) { Powered by{" "} Devpulse {" "} diff --git a/app/(public)/leaderboard/[slug]/page.tsx b/app/(public)/leaderboard/[slug]/page.tsx index 5bbcb42..c1992c5 100644 --- a/app/(public)/leaderboard/[slug]/page.tsx +++ b/app/(public)/leaderboard/[slug]/page.tsx @@ -8,47 +8,69 @@ 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(); } return ( -
+
-
-
+
+
Devpulse Logo
-

+

{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..d79f2f6 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,76 +57,54 @@ 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 ( -
+
Devpulse Logo -

+

Devpulse 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

-

+

Please come back later to see the leaderboards from our community.

)} - {data && data.length > 0 && ( + {leaderboards.length > 0 && (
- {data.map( - ( - board: { id: string; name: string; slug: string }, - i: number, - ) => ( - - 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/auth/verify-email/route.ts b/app/api/auth/verify-email/route.ts new file mode 100644 index 0000000..2e0b115 --- /dev/null +++ b/app/api/auth/verify-email/route.ts @@ -0,0 +1,90 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/app/lib/prisma"; +import crypto from "crypto"; +import { transporter } from "@/app/lib/smtp/nodemailer"; + +const NODE_MAILER_USER = process.env.NODE_MAILER_USER || ""; + +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 }); + } + + if (user.emailVerified) { + return NextResponse.json({ success: true }); + } + + // Delete any existing verification token for this email before creating a new one + await prisma.verificationToken.deleteMany({ + where: { identifier: email }, + }); + + const token = crypto.randomBytes(32).toString("hex"); + const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); + + await prisma.verificationToken.create({ + data: { identifier: email, token, expires }, + }); + + const verifyUrl = `${process.env.NEXTAUTH_URL}/api/auth/verify-email?token=${token}`; + + console.info(`Email verification link for ${email}: ${verifyUrl}`); + + transporter.sendMail({ + from: `Do Not Reply <${NODE_MAILER_USER}>`, + to: email, + subject: "Verify your email", + html: ` +

Hi ${user.name},

+

Please click the link below to verify your email address:

+

Verify Email

+

Regards,

+

DevPulse

+ + This email was sent from DevPulse. If you did not request this, please ignore this email. + `, + }); + + return NextResponse.json({ success: true }); +} + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const token = searchParams.get("token"); + + if (!token) { + return NextResponse.redirect( + new URL("/verify-email?error=invalid", req.url), + ); + } + + const record = await prisma.verificationToken.findUnique({ + where: { token }, + }); + + if (!record || record.expires < new Date()) { + if (record) { + await prisma.verificationToken.delete({ where: { token } }); + } + return NextResponse.redirect( + new URL("/verify-email?error=expired", req.url), + ); + } + + await prisma.user.update({ + where: { email: record.identifier }, + data: { emailVerified: new Date() }, + }); + + await prisma.verificationToken.delete({ where: { token } }); + + return NextResponse.redirect(new URL("/login?verified=1", req.url)); +} diff --git a/app/api/conversations/[id]/presence/route.ts b/app/api/conversations/[id]/presence/route.ts 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..031c692 --- /dev/null +++ b/app/api/kanban/data/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { getKanbanData } from "@/app/lib/kanban"; + +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + return NextResponse.json(await getKanbanData(session.user.id)); +} diff --git a/app/api/kanban/issues/[id]/route.ts b/app/api/kanban/issues/[id]/route.ts new file mode 100644 index 0000000..df7476b --- /dev/null +++ b/app/api/kanban/issues/[id]/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; +import { emitter } from "@/app/lib/emitter"; +import { getColumnAccess, getIssueAccess } from "@/app/lib/kanban"; + +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 issueAccess = await getIssueAccess(session.user.id, id); + if (!issueAccess) { + return NextResponse.json({ error: "Issue not found." }, { status: 404 }); + } + + if (column_id !== undefined) { + const columnAccess = await getColumnAccess(session.user.id, column_id); + if (!columnAccess || columnAccess.projectId !== issueAccess.projectId) { + return NextResponse.json( + { error: "Cannot move issue to that column." }, + { status: 400 }, + ); + } + } + + 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..6616ca0 --- /dev/null +++ b/app/api/kanban/issues/route.ts @@ -0,0 +1,76 @@ +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"; +import { getColumnAccess, getNextIssueKey } from "@/app/lib/kanban"; + +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()) { + return NextResponse.json( + { error: "columnId and title are required." }, + { status: 400 }, + ); + } + + const columnAccess = await getColumnAccess(session.user.id, columnId); + if (!columnAccess) { + return NextResponse.json({ error: "Column not found." }, { status: 404 }); + } + + const resolvedIssueKey = + typeof issueKey === "string" && issueKey.trim().length > 0 + ? issueKey.trim() + : await getNextIssueKey( + columnAccess.projectId, + columnAccess.projectName, + ); + + const issue = await prisma.issue.create({ + data: { + columnId, + issueKey: resolvedIssueKey, + 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, + created_at: issue.createdAt.toISOString(), + }; + + emitter.emit("kanban", { type: "issue_created", data: payload }); + + return NextResponse.json(payload, { status: 201 }); +} diff --git a/app/api/kanban/projects/route.ts b/app/api/kanban/projects/route.ts new file mode 100644 index 0000000..58fc13a --- /dev/null +++ b/app/api/kanban/projects/route.ts @@ -0,0 +1,124 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; +import { getKanbanData } from "@/app/lib/kanban"; + +const DEFAULT_COLUMNS = ["Backlog", "In Progress", "Done"]; + +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const data = await getKanbanData(session.user.id); + return NextResponse.json({ + projects: data.projects, + wakatime_projects: data.wakatime_projects, + }); +} + +export async function POST(req: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = (await req.json()) as { + name?: string; + description?: string; + wakatimeProjectName?: string; + color?: string; + }; + + const name = body.name?.trim(); + if (!name) { + return NextResponse.json( + { error: "Project name is required." }, + { status: 400 }, + ); + } + + const existing = await prisma.$queryRaw>` + SELECT id + FROM projects + WHERE user_id = ${session.user.id} + AND LOWER(name) = LOWER(${name}) + LIMIT 1 + `; + + if (existing.length > 0) { + return NextResponse.json( + { error: "A Kanban project with that name already exists." }, + { status: 409 }, + ); + } + + const projectId = crypto.randomUUID(); + const boardId = crypto.randomUUID(); + const safeDescription = body.description?.trim() || null; + const safeWakaName = body.wakatimeProjectName?.trim() || null; + const safeColor = body.color?.trim() || "indigo"; + const now = new Date(); + + await prisma.$transaction(async (tx) => { + await tx.$executeRaw` + INSERT INTO projects ( + id, + user_id, + name, + description, + wakatime_project_name, + color, + created_at + ) VALUES ( + ${projectId}, + ${session.user.id}, + ${name}, + ${safeDescription}, + ${safeWakaName}, + ${safeColor}, + ${now} + ) + `; + + await tx.$executeRaw` + INSERT INTO boards ( + id, + project_id, + title, + description, + created_at + ) VALUES ( + ${boardId}, + ${projectId}, + ${`${name} Board`}, + ${safeDescription}, + ${now} + ) + `; + + for (const [index, columnTitle] of DEFAULT_COLUMNS.entries()) { + await tx.$executeRaw` + INSERT INTO columns ( + id, + board_id, + title, + position, + created_at + ) VALUES ( + ${crypto.randomUUID()}, + ${boardId}, + ${columnTitle}, + ${index}, + ${now} + ) + `; + } + }); + + const data = await getKanbanData(session.user.id); + const project = data.projects.find((entry) => entry.id === projectId); + + return NextResponse.json(project, { 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..0acfe1b 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,45 +125,54 @@ export default function BoardList({ return ( <>
- -
- + +
+
-

+

{board.name}

-

+

/{board.slug}

- -
- + +
+
{user.id === board.owner_id && ( -
+
)} @@ -195,29 +201,29 @@ export default function BoardList({
- -

+ +

Share Server

-

+

Join Code

-
+
-

+

{selectedCode}

-

+

Invite URL

-
+
{inviteUrl} @@ -235,7 +241,7 @@ export default function BoardList({ @@ -244,55 +250,65 @@ 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.

- +
diff --git a/app/components/BrowserCheck.tsx b/app/components/BrowserCheck.tsx index a056bfe..644ce58 100644 --- a/app/components/BrowserCheck.tsx +++ b/app/components/BrowserCheck.tsx @@ -24,4 +24,4 @@ export default function BrowserCheck() { }, []); return null; -} \ No newline at end of file +} diff --git a/app/components/Chat.tsx b/app/components/Chat.tsx index 325e88f..73c23a5 100644 --- a/app/components/Chat.tsx +++ b/app/components/Chat.tsx @@ -1,8 +1,6 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; -import { RealtimeChannel, User } from "@supabase/supabase-js"; -import { createClient } from "../lib/supabase/client"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faFile, @@ -16,11 +14,13 @@ import { faXmark, faChevronLeft, faTrash, - faPlay + faPlay, } from "@fortawesome/free-solid-svg-icons"; import Conversations from "./chat/Conversations"; import Messages from "./chat/Messages"; -import MediaViewerModal, { type MediaViewerPayload } from "./chat/MediaViewerModal"; +import MediaViewerModal, { + type MediaViewerPayload, +} from "./chat/MediaViewerModal"; import { useActiveConversationStream } from "./chat/hooks/useActiveConversationStream"; import { useChatAttachmentInput } from "./chat/hooks/useChatAttachmentInput"; import { useChatBadWords } from "./chat/hooks/useChatBadWords"; @@ -84,7 +84,15 @@ export interface TypingState { label: string; } -const GLOBAL_CONVERSATION_ID = "00000000-0000-0000-0000-000000000001"; +export interface ChatUserShape { + id: string; + email: string | null; + name?: string | null; +} + +const GLOBAL_CONVERSATION_ID = + process.env.NEXT_PUBLIC_GLOBAL_CONVERSATION_ID ?? + "00000000-0000-0000-0000-000000000001"; const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; const MAX_PRESENCE_FUTURE_SKEW_MS = 30_000; const PRESENCE_HEARTBEAT_MS = 45_000; @@ -93,26 +101,24 @@ const TYPING_INACTIVE_TIMEOUT_MS = 1_800; const TYPING_REMOTE_EXPIRE_MS = 2_500; const PRESENCE_UNSEEN_AT_ISO = "1970-01-01T00:00:00.000Z"; -const supabase = createClient(); - -export default function Chat({ user }: { user: User }) { +export default function Chat({ user }: { user: ChatUserShape }) { const [conversations, setConversations] = useState([]); const [conversationId, setConversationId] = useState(null); const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [showModal, setShowModal] = useState(false); const [messageSearch, setMessageSearch] = useState(""); - const [unreadCountByConversationId, setUnreadCountByConversationId] = useState< - Record - >({}); + const [unreadCountByConversationId, setUnreadCountByConversationId] = + useState>({}); const [, setParticipantMetaByConversationId] = useState< Record >({}); const conversationIdsRef = useRef>(new Set()); const activeConversationIdRef = useRef(null); - const channelRef = useRef(null); const bottomRef = useRef(null); - const [dmSortOrder, setDmSortOrder] = useState<"newest" | "oldest" | "az" | "za">("newest"); + const [dmSortOrder, setDmSortOrder] = useState< + "newest" | "oldest" | "az" | "za" + >("newest"); const [isDmSortOpen, setIsDmSortOpen] = useState(false); const creatingRef = useRef(false); const fileInputRef = useRef(null); @@ -129,7 +135,6 @@ export default function Chat({ user }: { user: User }) { fetchUnreadCountsForConversations, markConversationAsRead, } = useChatPresence({ - supabase, userId: user.id, onlineTimeoutMs: ONLINE_TIMEOUT_MS, maxPresenceFutureSkewMs: MAX_PRESENCE_FUTURE_SKEW_MS, @@ -146,7 +151,6 @@ export default function Chat({ user }: { user: User }) { stopTyping, markTypingFromInput, } = useChatTyping({ - channelRef, userId: user.id, userEmail: user.email ?? "", typingInactiveTimeoutMs: TYPING_INACTIVE_TIMEOUT_MS, @@ -166,14 +170,12 @@ export default function Chat({ user }: { user: User }) { } = useChatAttachmentInput(); const { search, setSearch, allUsers, filteredUsers } = useChatUserPicker({ - supabase, userId: user.id, showModal, globalConversationId: GLOBAL_CONVERSATION_ID, }); const { badgesByUserId } = useChatBadges({ - supabase, userId: user.id, conversations, }); @@ -189,7 +191,6 @@ export default function Chat({ user }: { user: User }) { }, [conversationId]); useChatConversationsRealtime({ - supabase, userId: user.id, userEmail: user.email ?? "", globalConversationId: GLOBAL_CONVERSATION_ID, @@ -203,10 +204,8 @@ export default function Chat({ user }: { user: User }) { }); useActiveConversationStream({ - supabase, conversationId, userId: user.id, - channelRef, bottomRef, setMessages, markConversationAsRead, @@ -219,7 +218,6 @@ export default function Chat({ user }: { user: User }) { openPrivateChatFromGlobalProfile, handleDeleteConversation, } = useChatConversationActions({ - supabase, userId: user.id, userEmail: user.email, conversationId, @@ -234,20 +232,13 @@ export default function Chat({ user }: { user: User }) { setParticipantMetaByConversationId, }); - const bucketName = process.env.NEXT_PUBLIC_SUPABASE_BUCKET_NAME || ""; - const { sendMessage, isSendingMessage } = useChatMessageComposer({ - supabase, userId: user.id, - channelRef, conversationId, input, - attachments, badWords, - bucketName, bottomRef, setInput, - setAttachments, setMessages, stopTyping, markConversationAsRead, @@ -273,15 +264,23 @@ export default function Chat({ user }: { user: User }) { .filter((c) => c.type !== "global") .sort((a, b) => { if (dmSortOrder === "newest") { - return (b.created_at ? new Date(b.created_at).getTime() : 0) - (a.created_at ? new Date(a.created_at).getTime() : 0); + return ( + (b.created_at ? new Date(b.created_at).getTime() : 0) - + (a.created_at ? new Date(a.created_at).getTime() : 0) + ); } if (dmSortOrder === "oldest") { - return (a.created_at ? new Date(a.created_at).getTime() : 0) - (b.created_at ? new Date(b.created_at).getTime() : 0); + return ( + (a.created_at ? new Date(a.created_at).getTime() : 0) - + (b.created_at ? new Date(b.created_at).getTime() : 0) + ); } - - const aName = a.users.find((u) => u.id !== user.id)?.email?.split("@")[0] || ""; - const bName = b.users.find((u) => u.id !== user.id)?.email?.split("@")[0] || ""; - + + const aName = + a.users.find((u) => u.id !== user.id)?.email?.split("@")[0] || ""; + const bName = + b.users.find((u) => u.id !== user.id)?.email?.split("@")[0] || ""; + if (dmSortOrder === "az") { return aName.localeCompare(bName); } @@ -292,27 +291,30 @@ export default function Chat({ user }: { user: User }) { }); const activeConversation = conversations.find((c) => c.id === conversationId); - const activeOtherUser = activeConversation?.users.find((u) => u.id !== user.id); + const activeOtherUser = activeConversation?.users.find( + (u) => u.id !== user.id, + ); const isGlobalActive = activeConversation?.type === "global"; const activeOtherUserOnline = !!activeOtherUser?.id && !!onlineByUserId[activeOtherUser.id]; const activeTypingState = conversationId ? typingByConversationId[conversationId] : undefined; - - const activeLabel = isGlobalActive - ? "Global Chat" + + const activeLabel = isGlobalActive + ? "Global Chat" : activeOtherUser?.email?.split("@")[0] || "Unknown"; - - const activeSublabel = isGlobalActive + + const activeSublabel = isGlobalActive ? "Public Channel" : activeOtherUserOnline ? "Online" : "Offline"; - const activeSublabelClass = activeOtherUserOnline || isGlobalActive - ? "text-emerald-400" - : "text-gray-500"; + const activeSublabelClass = + activeOtherUserOnline || isGlobalActive + ? "text-emerald-600" + : "text-gray-500"; const typingIndicatorText = activeTypingState ? isGlobalActive @@ -320,16 +322,17 @@ export default function Chat({ user }: { user: User }) { : "Typing..." : ""; - const activeInitials = isGlobalActive - ? "G" - : activeOtherUser?.email?.[0]?.toUpperCase() ?? "?"; + const activeInitials = isGlobalActive + ? "G" + : (activeOtherUser?.email?.[0]?.toUpperCase() ?? "?"); const allMediaAttachments = useMemo(() => { return messages .flatMap((m) => m.attachments || []) .filter( (a) => - a?.mimetype?.startsWith("image/") || a?.mimetype?.startsWith("video/"), + a?.mimetype?.startsWith("image/") || + a?.mimetype?.startsWith("video/"), ) .reverse(); }, [messages]); @@ -340,7 +343,9 @@ export default function Chat({ user }: { user: User }) { const filteredMessages = useMemo(() => { if (!messageSearch) return messages; const lowerSearch = messageSearch.toLowerCase(); - return messages.filter((m) => (m.text || "").toLowerCase().includes(lowerSearch)); + return messages.filter((m) => + (m.text || "").toLowerCase().includes(lowerSearch), + ); }, [messages, messageSearch]); return ( @@ -350,426 +355,537 @@ export default function Chat({ user }: { user: User }) { attachments={allMediaAttachments} onChange={setMediaViewer} /> -
- - {/* 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" - /> -
-
- -
-
-

ROOMS

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

+ Message category +

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

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-gray-50 px-2 py-0.5 rounded cursor-pointer hover:bg-gray-100 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}
- ))} -
- )} - -
- - - -
- @@ -279,7 +264,7 @@ export default function Flex({ user }: { user: User }) { 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" />
@@ -302,7 +287,7 @@ export default function Flex({ user }: { user: User }) { 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" /> )} @@ -331,7 +316,7 @@ export default function Flex({ user }: { user: User }) { {userFlexes.length === 0 && !loading && (
-

+

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

@@ -342,7 +327,7 @@ export default function Flex({ user }: { user: User }) { {userFlexes.map((f) => (
-

{f.project_name}

+

{f.projectName}

{activeMenuFlexId === f.id && (
)}
- - {f.project_time} + + {f.projectTime}
-

{f.project_description}

+

{f.projectDescription}

- {f.project_url} + {f.projectUrl} - {f.is_open_source && ( + {f.isOpenSource && ( - {f.open_source_url} + {f.openSourceUrl} )} - Expires in {expireAt(f.expires_at || "")} • Posted{" "} - {timeAgo(f.created_at)} + Expires in {expireAt(f.expiresAt || "")} • Posted{" "} + {timeAgo(f.createdAt)}
))} @@ -438,11 +429,11 @@ export default function Flex({ user }: { user: User }) { 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.

)} @@ -456,7 +447,10 @@ export default function Flex({ user }: { user: User }) { key={idx} onClick={() => { setEditingFlexId(null); - setFlex({ ...u, open_source_url: u.open_source_url || "" }); + setFlex({ + ...u, + open_source_url: u.open_source_url || "", + }); setShowModal(false); }} className="flex items-center gap-3 p-2 rounded hover:bg-neutral-800 cursor-pointer" diff --git a/app/components/JoinButton.tsx b/app/components/JoinButton.tsx index 7eb7d69..73c7ec0 100644 --- a/app/components/JoinButton.tsx +++ b/app/components/JoinButton.tsx @@ -1,7 +1,6 @@ "use client"; import { useState } from "react"; -import { createClient } from "../lib/supabase/client"; import { useRouter } from "next/navigation"; import { toast } from "react-toastify"; import Link from "next/link"; @@ -52,7 +51,10 @@ export default function JoinButton({

Don't have an account?{" "} - + Sign up free

@@ -62,46 +64,30 @@ export default function JoinButton({ const handleJoin = async () => { setJoining(true); - const supabase = createClient(); - const joinPromise = (async () => { - const { data: userData } = await supabase.auth.getUser(); - const user = userData.user; - if (!user) throw new Error("Not authenticated"); - - const { data: board } = await supabase - .from("leaderboards") - .select("id") - .eq("join_code", code) - .single(); - - if (!board) throw new Error("Invalid invite code"); - - const { error } = await supabase.from("leaderboard_members").insert({ - leaderboard_id: board.id, - user_id: user.id, - }); - - if (error) throw error; - return board; - })(); + const joinPromise = fetch("/api/leaderboards/join", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ joinCode: code }), + }).then(async (res) => { + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + return data; + }); try { - await toast.promise(joinPromise, { + const result = await toast.promise(joinPromise, { pending: "Joining leaderboard...", success: "You're in! Welcome to the leaderboard.", error: { render({ data }) { const err = data as Error; - if (err?.code === "23505") { - return "You are already a member of this leaderboard."; - } return err?.message || "Failed to join. Please try again."; }, }, }); - router.push(`/leaderboard/${leaderboardSlug}`); + router.push(`/leaderboard/${result.slug}`); } finally { setJoining(false); } diff --git a/app/components/admin/Dashbord.tsx b/app/components/admin/Dashbord.tsx index ec7ea7a..2aadf76 100644 --- a/app/components/admin/Dashbord.tsx +++ b/app/components/admin/Dashbord.tsx @@ -1,8 +1,5 @@ "use client"; -import { createClient } from "@/app/lib/supabase/client"; -import { Database } from "@/app/supabase-types"; -import { User } from "@supabase/supabase-js"; import { useEffect, useState } from "react"; import TopInsights from "./Widgets/TopInsights"; import FeatureInsights from "./Widgets/FeatureInsights"; @@ -10,18 +7,15 @@ import RankingInsights, { AICoderStat, CoderStats, } from "./Widgets/RankingInsights"; -import UserLists from "./Widgets/UserLists"; +import UserLists, { UserStat } from "./Widgets/UserLists"; -const supabase = createClient(); - -type UserStat = Database["public"]["Views"]["top_user_stats"]["Row"]; type CategoryStat = { name: string; users: Set; totalSeconds: number; }; -export default function Dashboard({ user }: { user: User }) { +export default function Dashboard() { const [loading, setLoading] = useState(false); const [users, setUsers] = useState([]); const [totalThreads, setTotalThreads] = useState(0); @@ -31,45 +25,26 @@ export default function Dashboard({ user }: { user: User }) { const categoryMap: Record = {}; useEffect(() => { - async function fetchUsers() { + async function fetchStats() { setLoading(true); - const [ - { data: topUserStats }, - { count: threads }, - { count: messages }, - { count: leaderboard }, - { count: userFlexes }, - ] = await Promise.all([ - supabase.from("top_user_stats").select("*"), - supabase - .from("conversations") - .select("*", { count: "exact", head: true }), - supabase.from("messages").select("*", { count: "exact", head: true }), - supabase - .from("leaderboards") - .select("*", { count: "exact", head: true }), - supabase - .from("user_flexes") - .select("*", { count: "exact", head: true }), - ]); - - setUsers(topUserStats || []); - setTotalThreads(threads || 0); - setTotalMessages(messages || 0); - setTotalLeaderboards(leaderboard || 0); - setTotalFlexes(userFlexes || 0); + const res = await fetch("/api/admin/stats"); + if (res.ok) { + const data = await res.json(); + setUsers(data.users ?? []); + setTotalThreads(data.totalThreads ?? 0); + setTotalMessages(data.totalMessages ?? 0); + setTotalLeaderboards(data.totalLeaderboards ?? 0); + setTotalFlexes(data.totalFlexes ?? 0); + } setLoading(false); } - fetchUsers(); + fetchStats(); - const interval = setInterval(fetchUsers, 5000); + const interval = setInterval(fetchStats, 5000); return () => clearInterval(interval); - }, [user.id]); + }, []); - /* - * total users and coding time - */ const totalUsers = users.length; const totalSeconds = users.reduce( (sum, u) => sum + (u.total_seconds || 0), @@ -79,15 +54,9 @@ export default function Dashboard({ user }: { user: User }) { (a, b) => (b.total_seconds || 0) - (a.total_seconds || 0), ); - /* - * get the top and least coders - */ const top3 = sortedUsers.slice(0, 3); const bottom3 = [...sortedUsers].reverse().slice(0, 3); - /* - * category stats - */ users.forEach((u) => { const categories = (u.categories || []) as { name: string; @@ -114,9 +83,6 @@ export default function Dashboard({ user }: { user: User }) { hours: Math.floor(c.totalSeconds / 3600), })); - /* - * vibe coders - */ const aiCoders = users .map((u) => { const categories = (u.categories || []) as { @@ -139,10 +105,9 @@ export default function Dashboard({ user }: { user: User }) { return (
- {/* Header */}
-

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 57ae82b..288370c 100644 --- a/app/components/admin/Widgets/UserLists.tsx +++ b/app/components/admin/Widgets/UserLists.tsx @@ -1,6 +1,9 @@ -import { Database } from "@/app/supabase-types"; - -type UserStat = Database["public"]["Views"]["top_user_stats"]["Row"]; +export interface UserStat { + user_id: string | null; + email: string | null; + total_seconds: number | null; + categories: unknown; +} export default function UserLists({ users, @@ -12,7 +15,7 @@ export default function UserLists({ return (

- + diff --git a/app/components/auth/ForgotPassword.tsx b/app/components/auth/ForgotPassword.tsx index 4278b6b..af1b5ab 100644 --- a/app/components/auth/ForgotPassword.tsx +++ b/app/components/auth/ForgotPassword.tsx @@ -18,9 +18,9 @@ export default function ForgotPassword() { : undefined; return ( -
+
{/* Left Side - Visual / Branding */} -
+
{/* Background elements */}
@@ -37,16 +37,16 @@ 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 cd65644..17f7d0c 100644 --- a/app/components/auth/Login.tsx +++ b/app/components/auth/Login.tsx @@ -18,9 +18,9 @@ export default function Login() { : undefined; return ( -

+
{/* Left Side - Visual / Branding */} -
+
{/* Background elements */}
@@ -37,15 +37,15 @@ 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.
@@ -95,19 +95,19 @@ export default function Login() { className="lg:hidden flex items-center justify-center gap-3 mb-10" > -

Devpulse

+

Devpulse

-

Log in

-

+

Log in

+

Enter your credentials to access your account.

-
+
{ try { - await supabase.auth.signOut(); + await signOut({ redirect: false }); } catch (err) { console.error("Error logging out:", err); } finally { router.push("/"); } - }, [supabase.auth, router]); + }, [router]); useEffect(() => { handleLogout(); }, [handleLogout]); return ( -
+
); diff --git a/app/components/auth/Oauth2.tsx b/app/components/auth/Oauth2.tsx index f11b1de..685b8b9 100644 --- a/app/components/auth/Oauth2.tsx +++ b/app/components/auth/Oauth2.tsx @@ -4,55 +4,44 @@ import { faMicrosoft, } from "@fortawesome/free-brands-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { SupabaseClient } from "@supabase/supabase-js"; +import { signIn } from "next-auth/react"; -export default function Oauth2({ - supabase, - redirectTo, -}: { - supabase: SupabaseClient; - redirectTo: string; -}) { - const handleOAuth = async (provider: "google" | "azure" | "github") => { - document.cookie = `devpulse_redirect=${encodeURIComponent(redirectTo)}; path=/; max-age=600; samesite=lax`; - await supabase.auth.signInWithOAuth({ - provider: provider, - options: { - redirectTo: `${location.origin}/api/auth/callback`, - }, - }); +export default function Oauth2({ redirectTo }: { redirectTo: string }) { + const handleOAuth = ( + provider: "google" | "microsoft-entra-id" | "github", + ) => { + // signIn(provider, { callbackUrl: redirectTo }); }; - const handleGoogleSignUp = () => handleOAuth("google"); - const handleMicrosoftSignUp = () => handleOAuth("azure"); - const handleGitHubSignUp = () => handleOAuth("github"); - return (
diff --git a/app/components/auth/Signup.tsx b/app/components/auth/Signup.tsx index 6a172a6..5256ec8 100644 --- a/app/components/auth/Signup.tsx +++ b/app/components/auth/Signup.tsx @@ -18,9 +18,9 @@ export default function Signup() { : undefined; return ( -
+
{/* Left Side - Visual / Branding */} -
+
{/* Background elements */}
@@ -37,15 +37,15 @@ 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/ForgotPasswordForm.tsx b/app/components/auth/form/ForgotPasswordForm.tsx index ab12ea6..b5b97b5 100644 --- a/app/components/auth/form/ForgotPasswordForm.tsx +++ b/app/components/auth/form/ForgotPasswordForm.tsx @@ -1,42 +1,23 @@ "use client"; import { useRef, useState } from "react"; -import { createClient } from "@/app/lib/supabase/client"; import { toast } from "react-toastify"; -import HCaptcha from "@hcaptcha/react-hcaptcha"; export default function ForgotPasswordForm() { - const supabase = createClient(); const [email, setEmail] = useState(""); const [loading, setLoading] = useState(false); - const captcha = useRef(null); - const [showCaptcha, setShowCaptcha] = useState(false); const handleLogin = async (e: React.SyntheticEvent) => { e.preventDefault(); - setShowCaptcha(true); - }; - - const handleCaptchaVerify = async (token: string) => { - setShowCaptcha(false); setLoading(true); - const sendReset = new Promise(async (resolve, reject) => { - try { - const { data, error } = await supabase.auth.resetPasswordForEmail( - email, - { - captchaToken: token, - redirectTo: `${window.location.origin}/reset-password`, - }, - ); - - if (error) return reject(error); - - resolve(data); - } catch (error) { - reject(error); - } + const sendReset = fetch("/api/auth/forgot-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }).then(async (res) => { + const data = await res.json(); + if (!res.ok) throw new Error(data.error); }); toast.promise(sendReset, { @@ -54,7 +35,6 @@ export default function ForgotPasswordForm() { }); sendReset.finally(() => { - if (captcha.current) captcha.current.resetCaptcha(); setLoading(false); }); }; @@ -74,8 +54,9 @@ export default function ForgotPasswordForm() { -
-
- )} ); } diff --git a/app/components/auth/form/LoginForm.tsx b/app/components/auth/form/LoginForm.tsx index 309c116..ddf7aea 100644 --- a/app/components/auth/form/LoginForm.tsx +++ b/app/components/auth/form/LoginForm.tsx @@ -1,16 +1,13 @@ "use client"; -import { useRef, useState } from "react"; -import { createClient } from "@/app/lib/supabase/client"; +import { useState } from "react"; +import { signIn } from "next-auth/react"; import { toast } from "react-toastify"; -import { useRouter } from "next/navigation"; -import { useSearchParams } from "next/navigation"; -import HCaptcha from "@hcaptcha/react-hcaptcha"; +import { useRouter, useSearchParams } from "next/navigation"; import Oauth2 from "../Oauth2"; import Link from "next/link"; export default function LoginForm() { - const supabase = createClient(); const router = useRouter(); const searchParams = useSearchParams(); const redirectParam = searchParams.get("redirect"); @@ -20,43 +17,37 @@ export default function LoginForm() { !redirectParam.startsWith("//") ? redirectParam : "/d"; + const justVerified = searchParams.get("verified") === "1"; + const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); - const captcha = useRef(null); - const [showCaptcha, setShowCaptcha] = useState(false); const handleLogin = async (e: React.SyntheticEvent) => { e.preventDefault(); - setShowCaptcha(true); - }; - - const handleCaptchaVerify = async (token: string) => { - setShowCaptcha(false); setLoading(true); - const signInWithPassword = new Promise(async (resolve, reject) => { + const loginPromise = new Promise(async (resolve, reject) => { try { - const { data, error } = await supabase.auth.signInWithPassword({ + const result = await signIn("credentials", { email, password, - options: { captchaToken: token }, + redirect: false, }); - if (error) return reject(error); - - resolve(data); - } catch (error) { - reject(error); + if (result?.error) + return reject(new Error("Invalid email or password.")); + resolve(); + } catch (err) { + reject(err); } }); - toast.promise(signInWithPassword, { + toast.promise(loginPromise, { pending: "Logging in...", success: "Login successful! Redirecting...", error: { render({ data }) { - if (captcha.current) captcha.current.resetCaptcha(); setLoading(false); const err = data as Error; return err?.message || "Failed to login. Please try again."; @@ -64,14 +55,18 @@ export default function LoginForm() { }, }); - signInWithPassword.then(() => { - if (captcha.current) captcha.current.resetCaptcha(); + loginPromise.then(() => { router.push(redirectTo); }); }; return ( <> + {justVerified && ( +
+ Email verified successfully. You can now log in. +
+ )}
- + - - {showCaptcha && ( -
-
-

- Verify you are human -

- - - - -
-
- )} ); } diff --git a/app/components/auth/form/ResetPasswordForm.tsx b/app/components/auth/form/ResetPasswordForm.tsx index de93dd8..76325d7 100644 --- a/app/components/auth/form/ResetPasswordForm.tsx +++ b/app/components/auth/form/ResetPasswordForm.tsx @@ -1,89 +1,43 @@ "use client"; -import { useEffect, useRef, useState } from "react"; -import { createClient } from "@/app/lib/supabase/client"; +import { useState } from "react"; import { toast } from "react-toastify"; import { useRouter, useSearchParams } from "next/navigation"; -import HCaptcha from "@hcaptcha/react-hcaptcha"; export default function ResetPasswordForm() { - const supabase = createClient(); const router = useRouter(); const searchParams = useSearchParams(); - const captcha = useRef(null); + const token = searchParams.get("token"); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [loading, setLoading] = useState(false); const [showCaptcha, setShowCaptcha] = useState(false); - const [checking, setChecking] = useState(true); - - useEffect(() => { - let cancelled = false; - - const verifyResetSession = async () => { - const type = searchParams.get("type"); - const code = searchParams.get("code"); - - if (type && type !== "recovery") { - router.replace("/login"); - return; - } - - if (code) { - const { error } = await supabase.auth.exchangeCodeForSession(code); - if (error) { - router.replace("/login"); - return; - } - } - - const { data } = await supabase.auth.getSession(); - if (!data.session) { - router.replace("/login"); - return; - } - - if (!cancelled) setChecking(false); - }; - - verifyResetSession(); - - return () => { - cancelled = true; - }; - }, [router, searchParams, supabase]); const handleSubmit = async (e: React.SyntheticEvent) => { e.preventDefault(); - if (checking) return; - setShowCaptcha(true); - }; + if (!token) { + toast.error("Invalid or missing reset token."); + return; + } - const handleCaptchaVerify = async (_token: string) => { - setShowCaptcha(false); setLoading(true); - const updatePassword = new Promise(async (resolve, reject) => { - try { - if (password !== confirmPassword) { - return reject(new Error("Passwords do not match.")); - } - - const { data, error } = await supabase.auth.updateUser({ password }); - if (error) return reject(error); - - resolve(data); - } catch (error) { - reject(error); - } + const updatePassword = fetch("/api/auth/reset-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, password }), + }).then(async (res) => { + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + if (password !== confirmPassword) + throw new Error("Passwords do not match."); }); toast.promise(updatePassword, { pending: "Resetting password...", success: { render() { - if (captcha.current) captcha.current.resetCaptcha(); setLoading(false); setPassword(""); setConfirmPassword(""); @@ -92,7 +46,6 @@ export default function ResetPasswordForm() { }, error: { render({ data }) { - if (captcha.current) captcha.current.resetCaptcha(); setLoading(false); const err = data as Error; return err?.message || "Failed to reset password. Please try again."; @@ -132,9 +85,9 @@ export default function ResetPasswordForm() { -
-
- )} ); } diff --git a/app/components/auth/form/SignupForm.tsx b/app/components/auth/form/SignupForm.tsx index 01eb520..e93eafc 100644 --- a/app/components/auth/form/SignupForm.tsx +++ b/app/components/auth/form/SignupForm.tsx @@ -1,15 +1,13 @@ "use client"; -import { useRef, useState } from "react"; -import { createClient } from "@/app/lib/supabase/client"; +import { useState } from "react"; import { toast } from "react-toastify"; -import { useSearchParams } from "next/navigation"; -import HCaptcha from "@hcaptcha/react-hcaptcha"; +import { useRouter, useSearchParams } from "next/navigation"; import Oauth2 from "../Oauth2"; import Link from "next/link"; export default function SignupForm() { - const supabase = createClient(); + const router = useRouter(); const searchParams = useSearchParams(); const redirectParam = searchParams.get("redirect"); const redirectTo = @@ -18,59 +16,57 @@ export default function SignupForm() { !redirectParam.startsWith("//") ? redirectParam : "/d"; + const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [loading, setLoading] = useState(false); - const captcha = useRef(null); - const [showCaptcha, setShowCaptcha] = useState(false); const handleSignup = async (e: React.SyntheticEvent) => { e.preventDefault(); - setShowCaptcha(true); - }; - - const handleCaptchaVerify = async (token: string) => { - setShowCaptcha(false); setLoading(true); - const signUp = new Promise(async (resolve, reject) => { + const signUp = new Promise(async (resolve, reject) => { try { if (password !== confirmPassword) { return reject(new Error("Passwords do not match!")); } - const { data, error } = await supabase.auth.signUp({ - email, - password, - options: { captchaToken: token }, + const res = await fetch("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + const data = await res.json(); + if (!res.ok) return reject(new Error(data.error)); + + await fetch("/api/auth/verify-email", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), }); - if (error) return reject(error); - resolve(data); + resolve(); } catch (error) { reject(error); } }); toast.promise(signUp, { - pending: "Signing up...", + pending: "Creating account...", success: { render() { - if (captcha.current) captcha.current.resetCaptcha(); setLoading(false); - setEmail(""); - setPassword(""); - setConfirmPassword(""); - return "Signed up successfully! Check your email to confirm your account."; + router.push(`/verify-email?email=${encodeURIComponent(email)}`); + return "Account created! Please verify your email."; }, }, error: { render({ data }) { - if (captcha.current) captcha.current.resetCaptcha(); setLoading(false); const err = data as Error; - return err?.message || "Failed to signup. Please try again."; + return err?.message || "Failed to sign up. Please try again."; }, }, }); @@ -134,47 +130,25 @@ export default function SignupForm() {
- + Or continue with - +
- + - - {showCaptcha && ( -
-
-

- Verify you are human -

- - - - -
-
- )} ); } diff --git a/app/components/auth/form/UpdatePasswordForm.tsx b/app/components/auth/form/UpdatePasswordForm.tsx index a76720d..ab02e03 100644 --- a/app/components/auth/form/UpdatePasswordForm.tsx +++ b/app/components/auth/form/UpdatePasswordForm.tsx @@ -1,40 +1,29 @@ "use client"; import { useRef, useState } from "react"; -import { createClient } from "@/app/lib/supabase/client"; import HCaptcha from "@hcaptcha/react-hcaptcha"; import { toast } from "react-toastify"; export default function UpdatePasswordForm() { - const supabase = createClient(); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [loading, setLoading] = useState(false); const captcha = useRef(null); const [showCaptcha, setShowCaptcha] = useState(false); - const handleCaptchaVerify = async (token: string) => { - // Optionally use token here when implemented to backend - void token; + const handleCaptchaVerify = async (_token: string) => { setShowCaptcha(false); setLoading(true); - const updateUserPassword = new Promise(async (resolve, reject) => { - try { - if (password !== confirmPassword) { - return reject(new Error("Passwords do not match!")); - } - - const { error } = await supabase.auth.updateUser({ - password, - // options: { captchaToken: token }, - }); - - if (error) return reject(error); - resolve("Password updated!"); - } catch (error) { - reject(error); - } + const updateUserPassword = fetch("/api/auth/update-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }).then(async (res) => { + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + if (password !== confirmPassword) + throw new Error("Passwords do not match!"); }); toast.promise(updateUserPassword, { @@ -99,7 +88,7 @@ export default function UpdatePasswordForm() { {showCaptcha && (
-

+

Verify you are human

@@ -111,7 +100,7 @@ export default function UpdatePasswordForm() { diff --git a/app/components/chat/Conversations.tsx b/app/components/chat/Conversations.tsx index c5808fb..0bd2515 100644 --- a/app/components/chat/Conversations.tsx +++ b/app/components/chat/Conversations.tsx @@ -1,5 +1,4 @@ -import { User } from "@supabase/supabase-js"; -import { Conversation, TypingState } from "../Chat"; +import { Conversation, TypingState, ChatUserShape } from "../Chat"; export default function Conversations({ conversations, @@ -12,7 +11,7 @@ export default function Conversations({ showLabel = true, }: { conversations: Conversation[]; - user: User; + user: ChatUserShape; conversationId: string | null; setConversationId: (id: string) => void; unreadCountByConversationId?: Record; @@ -30,7 +29,7 @@ export default function Conversations({ const isOnline = !!otherUser?.id && !!onlineByUserId?.[otherUser.id]; const typingState = typingByConversationId?.[conv.id]; const isTyping = !!typingState; - + let label = "Global Chat"; let sublabel = "Public Channel"; let initials = "G"; @@ -49,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} @@ -73,19 +72,19 @@ export default function Conversations({
{label}
{isGlobal && ( - + All )} {unreadCount > 0 && ( - + {unreadCount > 99 ? "99+" : unreadCount} )} @@ -93,7 +92,7 @@ export default function Conversations({
@@ -115,4 +114,3 @@ export default function Conversations({
); } - diff --git a/app/components/chat/MediaViewerModal.tsx b/app/components/chat/MediaViewerModal.tsx index 3000ab8..60f9c9d 100644 --- a/app/components/chat/MediaViewerModal.tsx +++ b/app/components/chat/MediaViewerModal.tsx @@ -46,7 +46,9 @@ export default function MediaViewerModal({ }: MediaViewerModalProps) { const currentMediaIndex = useMemo(() => { if (!viewer) return -1; - return attachments.findIndex((attachment) => attachment.public_url === viewer.url); + return attachments.findIndex( + (attachment) => attachment.public_url === viewer.url, + ); }, [attachments, viewer]); const hasPrevMedia = currentMediaIndex > 0; @@ -74,7 +76,7 @@ export default function MediaViewerModal({ @@ -84,14 +86,14 @@ export default function MediaViewerModal({ )}
e.stopPropagation()} >
, document.body, ); -} \ No newline at end of file +} diff --git a/app/components/chat/Messages.tsx b/app/components/chat/Messages.tsx index dd12ad2..ba9ea9a 100644 --- a/app/components/chat/Messages.tsx +++ b/app/components/chat/Messages.tsx @@ -1,19 +1,14 @@ "use client"; -import { User } from "@supabase/supabase-js"; import ReactMarkdown from "react-markdown"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { atomDark } from "react-syntax-highlighter/dist/cjs/styles/prism"; -import { Conversation, Message } from "../Chat"; +import { Conversation, Message, ChatUserShape } from "../Chat"; import { timeAgo } from "@/app/utils/time"; import { type BadgeInfo, getBadgeInfoFromHours } from "@/app/utils/badge"; import { useEffect, useMemo, useRef, useState } from "react"; import Image from "next/image"; -import { - faFile, - faPause, - faPlay, -} from "@fortawesome/free-solid-svg-icons"; +import { faFile, faPause, faPlay } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import MediaViewerModal, { type MediaViewerPayload } from "./MediaViewerModal"; @@ -26,7 +21,7 @@ export default function Messages({ onUserProfileClick, }: { messages: Message[]; - user: User; + user: ChatUserShape; conversations: Conversation[]; bottomRef: React.RefObject; badgesByUserId?: Record; @@ -41,7 +36,11 @@ export default function Messages({ const allMediaAttachments = useMemo(() => { return messages .flatMap((m) => m.attachments || []) - .filter((a) => a?.mimetype?.startsWith("image/") || a?.mimetype?.startsWith("video/")) + .filter( + (a) => + a?.mimetype?.startsWith("image/") || + a?.mimetype?.startsWith("video/"), + ) .reverse(); }, [messages]); @@ -50,7 +49,8 @@ export default function Messages({ if (!container) return; const handleScroll = () => { const nearBottom = - container.scrollHeight - container.scrollTop - container.clientHeight < 100; + container.scrollHeight - container.scrollTop - container.clientHeight < + 100; setShowScrollBtn(!nearBottom); }; container.addEventListener("scroll", handleScroll); @@ -66,15 +66,17 @@ export default function Messages({ />
{showScrollBtn && ( @@ -82,7 +84,7 @@ export default function Messages({ {messages.length === 0 && (
-
💬
+
💬

No messages yet. Say hello!

)} @@ -99,9 +101,7 @@ export default function Messages({ const senderInitial = senderRow?.email?.[0]?.toUpperCase() ?? "?"; const senderName = senderRow?.email?.split("@")?.[0] ?? ""; const canOpenPrivateChat = - !isSelf && - conversationRow?.type === "global" && - !!senderRow?.email; + !isSelf && conversationRow?.type === "global" && !!senderRow?.email; const badgeInfo = badgesByUserId?.[msg.sender_id] ?? fallbackBadge; const badgeLabel = badgeInfo.label; @@ -110,7 +110,8 @@ export default function Messages({ // long msg? nudge avatar up, ez. const text = msg.text ?? ""; const hasMedia = !!msg.attachments?.length; - const isLongMessage = hasMedia || text.length >= 120 || text.includes("\n"); + const isLongMessage = + hasMedia || text.length >= 120 || text.includes("\n"); const avatarTranslateClass = isLongMessage ? "-translate-y-[4.5px]" : "-translate-y-[4px]"; @@ -128,7 +129,9 @@ export default function Messages({ const isVoiceOnlyMessage = !msg.text && normalizedAttachments.length > 0 && - normalizedAttachments.every((att) => getAttachmentKind(att) === "audio"); + normalizedAttachments.every( + (att) => getAttachmentKind(att) === "audio", + ); return (
- + {senderInitial}
@@ -169,36 +172,48 @@ export default function Messages({ isSelf ? "items-end" : "items-start" }`} > -
+
{isSelf && ( {timeAgo(msg.created_at)} )} - {canOpenPrivateChat && senderRow?.email ? ( - - ) : ( + {canOpenPrivateChat && senderRow?.email ? ( + + ) : ( + + {senderName} + + )} - {senderName} + {badgeInfo.icon && ( + + )} + {badgeLabel} - )} - - {badgeInfo.icon && } - {badgeLabel} - {!isSelf && ( {timeAgo(msg.created_at)} @@ -210,21 +225,26 @@ export default function Messages({
)} > {children} @@ -249,15 +269,18 @@ export default function Messages({ )} {normalizedAttachments.length > 0 && ( -
+
{normalizedAttachments.map((att, i) => (
- {getAttachments(att, (payload) => setMediaViewer(payload))} + {getAttachments(att, (payload) => + setMediaViewer(payload), + )}
))}
)} -
); @@ -296,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"} @@ -305,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", @@ -336,7 +359,11 @@ function getAttachments( public_url: string; filename: string; }, - onOpenMedia: (payload: { type: "image" | "video"; url: string; filename: string }) => void, + onOpenMedia: (payload: { + type: "image" | "video"; + url: string; + filename: string; + }) => void, ) { const kind = getAttachmentKind(attachment); @@ -352,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
@@ -403,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"} @@ -421,7 +448,10 @@ function getAttachmentKind(attachment: { const filename = (attachment.filename || "").toLowerCase(); const urlPath = (() => { try { - return new URL(attachment.public_url || "", "https://x.local").pathname.toLowerCase(); + return new URL( + attachment.public_url || "", + "https://x.local", + ).pathname.toLowerCase(); } catch { return (attachment.public_url || "").toLowerCase(); } @@ -434,12 +464,15 @@ function getAttachmentKind(attachment: { if (/\.(png|jpe?g|gif|webp|bmp|svg)(\?|$)/.test(source)) return "image"; if (/\.(mp4|webm|mov|m4v|avi|mkv)(\?|$)/.test(source)) return "video"; - if (/\.(mp3|wav|ogg|m4a|aac|flac|weba|opus|amr)(\?|$)/.test(source)) return "audio"; + if (/\.(mp3|wav|ogg|m4a|aac|flac|weba|opus|amr)(\?|$)/.test(source)) + return "audio"; // Mobile voice uploads can end up as generic binary mime. if ( mime.includes("octet-stream") && - (source.includes("audio") || source.includes("voice") || source.includes("record")) + (source.includes("audio") || + source.includes("voice") || + source.includes("record")) ) { return "audio"; } @@ -472,7 +505,12 @@ function normalizeAttachment(raw: unknown): { if (!public_url) return null; const mimetype = String( - obj.mimetype ?? obj.mimeType ?? obj.content_type ?? obj.contentType ?? obj.type ?? "", + obj.mimetype ?? + obj.mimeType ?? + obj.content_type ?? + obj.contentType ?? + obj.type ?? + "", ).trim(); const filename = String( obj.filename ?? @@ -491,13 +529,7 @@ function normalizeAttachment(raw: unknown): { }; } -function AudioAttachmentPlayer({ - src, - type, -}: { - src: string; - type: string; -}) { +function AudioAttachmentPlayer({ src, type }: { src: string; type: string }) { const audioRef = useRef(null); const canvasRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); @@ -569,7 +601,8 @@ function AudioAttachmentPlayer({ const fallbackBars = useMemo(() => { let seed = 0; - for (let i = 0; i < src.length; i += 1) seed = (seed * 31 + src.charCodeAt(i)) >>> 0; + for (let i = 0; i < src.length; i += 1) + seed = (seed * 31 + src.charCodeAt(i)) >>> 0; return Array.from({ length: 42 }).map((_, i) => { seed = (seed * 1664525 + 1013904223) >>> 0; const noise = (seed % 1000) / 1000; @@ -578,7 +611,10 @@ function AudioAttachmentPlayer({ }); }, [src]); - const waveData = useMemo(() => (peaks.length ? peaks : fallbackBars), [peaks, fallbackBars]); + const waveData = useMemo( + () => (peaks.length ? peaks : fallbackBars), + [peaks, fallbackBars], + ); const md3WaveData = useMemo(() => { if (!waveData.length) return []; @@ -627,7 +663,8 @@ function AudioAttachmentPlayer({ const x = index * barStep + gap / 2; const barHeight = Math.max(7, Math.min(height * 0.72, value)); const y = centerY - barHeight / 2; - const isPlayed = (index / Math.max(1, compactData.length - 1)) * 100 <= progressPct; + const isPlayed = + (index / Math.max(1, compactData.length - 1)) * 100 <= progressPct; ctx.fillStyle = isPlayed ? "#8b5cf6" : "rgba(226,232,240,0.35)"; const r = Math.min(barWidth / 2, barHeight / 2, 3); ctx.beginPath(); @@ -644,9 +681,14 @@ function AudioAttachmentPlayer({ const res = await fetch(src); if (!res.ok) throw new Error(`peak fetch failed: ${res.status}`); const arr = await res.arrayBuffer(); - const audioContext = new (window.AudioContext || - (window as typeof window & { webkitAudioContext?: typeof AudioContext }) - .webkitAudioContext)(); + const audioContext = new ( + window.AudioContext || + ( + window as typeof window & { + webkitAudioContext?: typeof AudioContext; + } + ).webkitAudioContext + )(); const decoded = await audioContext.decodeAudioData(arr.slice(0)); const channel = decoded.getChannelData(0); const bars = 48; @@ -657,7 +699,8 @@ function AudioAttachmentPlayer({ const start = i * blockSize; const end = Math.min(channel.length, start + blockSize); let peak = 0; - for (let j = start; j < end; j += 1) peak = Math.max(peak, Math.abs(channel[j])); + for (let j = start; j < end; j += 1) + peak = Math.max(peak, Math.abs(channel[j])); rawPeaks.push(peak); } @@ -689,14 +732,19 @@ function AudioAttachmentPlayer({
-
+
@@ -708,18 +756,22 @@ function AudioAttachmentPlayer({ onClick={(e) => { if (!duration) return; const rect = e.currentTarget.getBoundingClientRect(); - const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const pct = Math.max( + 0, + Math.min(1, (e.clientX - rect.left) / rect.width), + ); seek(pct * duration); }} />
- - {duration > 0 ? formatTime(Math.max(0, duration - currentTime)) : "0:00"} + + {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 f4bbb5d..cc53426 100644 --- a/app/components/chat/Player.tsx +++ b/app/components/chat/Player.tsx @@ -223,7 +223,6 @@ export default function Player({ return { menuCenterX, arrowX }; }; - useLayoutEffect(() => { if (!showSettings) return; const update = () => { @@ -463,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} > @@ -501,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" }`} > @@ -533,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" > @@ -551,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" > @@ -566,7 +565,7 @@ export default function Player({ }`} >
-
+
-
-
+
+
Playback Speed
@@ -812,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 @@ -821,8 +820,8 @@ export default function Player({ ))}
-
-
+
+
Quality
@@ -834,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/chat/hooks/useActiveConversationStream.ts b/app/components/chat/hooks/useActiveConversationStream.ts index c673ddc..e7667b4 100644 --- a/app/components/chat/hooks/useActiveConversationStream.ts +++ b/app/components/chat/hooks/useActiveConversationStream.ts @@ -3,12 +3,9 @@ import { useEffect, type Dispatch, - type MutableRefObject, type RefObject, type SetStateAction, } from "react"; -import type { RealtimeChannel, SupabaseClient } from "@supabase/supabase-js"; -import type { Database } from "@/app/supabase-types"; import type { Message } from "@/app/components/Chat"; type TypingState = { @@ -17,90 +14,61 @@ type TypingState = { }; type UseActiveConversationStreamParams = { - supabase: SupabaseClient; conversationId: string | null; userId: string; - channelRef: MutableRefObject; bottomRef: RefObject; setMessages: Dispatch>; markConversationAsRead: (targetConversationId: string) => Promise; - setRemoteTypingState: (targetConversationId: string, state: TypingState | null) => void; + setRemoteTypingState: ( + targetConversationId: string, + state: TypingState | null, + ) => void; stopTyping: (targetConversationId: string) => void; }; const getAttachmentFingerprint = (attachments: Message["attachments"] = []) => attachments - .map((attachment) => { - const filename = attachment?.filename ?? ""; - const mimetype = attachment?.mimetype ?? ""; - const filesize = String(attachment?.filesize ?? 0); - const publicUrl = attachment?.public_url ?? ""; - return `${filename}|${mimetype}|${filesize}|${publicUrl}`; - }) + .map( + (a) => + `${a?.filename ?? ""}|${a?.mimetype ?? ""}|${String(a?.filesize ?? 0)}|${a?.public_url ?? ""}`, + ) .join("::"); const EPHEMERAL_RECONCILE_WINDOW_MS = 15_000; -const BROADCAST_DUPLICATE_WINDOW_MS = 1_500; -const isEphemeralMessageId = (messageId: string) => - messageId.startsWith("temp-") || messageId.startsWith("live-"); +const isEphemeralMessageId = (id: string) => + id.startsWith("temp-") || id.startsWith("live-"); const isCreatedWithinWindow = ( candidateCreatedAt: string, incomingCreatedAt: string, windowMs = EPHEMERAL_RECONCILE_WINDOW_MS, ) => { - const candidateTimestamp = Date.parse(candidateCreatedAt); - const incomingTimestamp = Date.parse(incomingCreatedAt); - - if (!Number.isFinite(candidateTimestamp) || !Number.isFinite(incomingTimestamp)) { - return true; - } - - return Math.abs(incomingTimestamp - candidateTimestamp) <= windowMs; + const a = Date.parse(candidateCreatedAt); + const b = Date.parse(incomingCreatedAt); + if (!Number.isFinite(a) || !Number.isFinite(b)) return true; + return Math.abs(b - a) <= windowMs; }; const normalizeAttachments = (attachments: unknown): Message["attachments"] => { if (!Array.isArray(attachments)) return []; - return attachments - .map((rawAttachment) => { - if (!rawAttachment || typeof rawAttachment !== "object") return null; - - const attachment = rawAttachment as Record; - const filename = - typeof attachment.filename === "string" ? attachment.filename : ""; - const mimetype = - typeof attachment.mimetype === "string" ? attachment.mimetype : ""; - const publicUrl = - typeof attachment.public_url === "string" ? attachment.public_url : ""; - const rawFilesize = attachment.filesize; - const filesize = - typeof rawFilesize === "number" - ? rawFilesize - : typeof rawFilesize === "string" - ? Number(rawFilesize) - : 0; - + .map((raw) => { + if (!raw || typeof raw !== "object") return null; + const a = raw as Record; return { - filename, - mimetype, - filesize: Number.isFinite(filesize) ? filesize : 0, - public_url: publicUrl, + filename: typeof a.filename === "string" ? a.filename : "", + mimetype: typeof a.mimetype === "string" ? a.mimetype : "", + filesize: typeof a.filesize === "number" ? a.filesize : 0, + public_url: typeof a.public_url === "string" ? a.public_url : "", }; }) - .filter( - ( - attachment, - ): attachment is Message["attachments"][number] => attachment !== null, - ); + .filter((a): a is Message["attachments"][number] => a !== null); }; export function useActiveConversationStream({ - supabase, conversationId, userId, - channelRef, bottomRef, setMessages, markConversationAsRead, @@ -113,252 +81,120 @@ export function useActiveConversationStream({ return; } - if (channelRef.current) { - channelRef.current.unsubscribe(); - } - void markConversationAsRead(conversationId); - const channel = supabase - .channel(`conversation-${conversationId}`) - .on( - "broadcast", - { - event: "typing", - }, - ({ payload }) => { - const typingPayload = payload as { - conversation_id?: string; - user_id?: string; - email?: string | null; - is_typing?: boolean; - }; - - if (typingPayload.conversation_id !== conversationId) return; - if (!typingPayload.user_id || typingPayload.user_id === userId) return; - - if (typingPayload.is_typing) { - setRemoteTypingState(conversationId, { - user_id: typingPayload.user_id, - label: typingPayload.email?.split("@")[0] || "Someone", - }); - return; - } - - setRemoteTypingState(conversationId, null); - }, - ) - .on( - "broadcast", - { - event: "message", - }, - ({ payload }) => { - const messagePayload = payload as { - conversation_id?: string; - sender_id?: string; - text?: string; - attachments?: unknown; - created_at?: string; - client_message_id?: string; - }; - - if (messagePayload.conversation_id !== conversationId) return; - if (!messagePayload.sender_id || messagePayload.sender_id === userId) { - return; - } - const senderId = messagePayload.sender_id; - - const incomingCreatedAt = - typeof messagePayload.created_at === "string" - ? messagePayload.created_at - : new Date().toISOString(); - const incomingAttachments = normalizeAttachments( - messagePayload.attachments, - ); - const clientMessageId = - typeof messagePayload.client_message_id === "string" && - messagePayload.client_message_id.length > 0 - ? messagePayload.client_message_id - : `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const liveMessageId = `live-${clientMessageId}`; - - setMessages((prev) => { - if (prev.some((message) => message.id === liveMessageId)) { - return prev; - } - - const incomingText = messagePayload.text ?? ""; - const incomingFingerprint = getAttachmentFingerprint(incomingAttachments); + const fetchMessages = async () => { + const res = await fetch(`/api/messages?conversationId=${conversationId}`); + if (!res.ok) return; + const data: Message[] = await res.json(); + setMessages(data); + void markConversationAsRead(conversationId); + window.setTimeout(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, 100); + }; - const hasMatchingMessage = prev.some((message) => { - if (isEphemeralMessageId(message.id)) return false; - if (message.sender_id !== senderId) return false; - if (message.conversation_id !== conversationId) return false; - if (message.text !== incomingText) return false; - if ( - getAttachmentFingerprint(message.attachments) !== incomingFingerprint - ) { - return false; - } + void fetchMessages(); - return isCreatedWithinWindow( - message.created_at, - incomingCreatedAt, - BROADCAST_DUPLICATE_WINDOW_MS, - ); - }); + const es = new EventSource(`/api/sse/chat/${conversationId}`); - if (hasMatchingMessage) { - return prev; - } + es.onmessage = (event) => { + const envelope = JSON.parse(event.data) as { + type: string; + data: unknown; + }; - return [ - ...prev, - { - id: liveMessageId, - conversation_id: conversationId, - sender_id: senderId, - text: incomingText, - attachments: incomingAttachments, - created_at: incomingCreatedAt, - }, - ]; + if (envelope.type === "typing") { + const typingPayload = envelope.data as { + conversation_id?: string; + user_id?: string; + email?: string | null; + is_typing?: boolean; + }; + + if (typingPayload.conversation_id !== conversationId) return; + if (!typingPayload.user_id || typingPayload.user_id === userId) return; + + if (typingPayload.is_typing) { + setRemoteTypingState(conversationId, { + user_id: typingPayload.user_id, + label: typingPayload.email?.split("@")[0] || "Someone", }); - - void markConversationAsRead(conversationId); - - window.setTimeout(() => { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - }, 100); - }, - ) - .on( - "broadcast", - { - event: "message_retract", - }, - ({ payload }) => { - const retractPayload = payload as { - conversation_id?: string; - sender_id?: string; - client_message_id?: string; - }; - - if (retractPayload.conversation_id !== conversationId) return; - if (retractPayload.sender_id === userId) return; - if (!retractPayload.client_message_id) return; - - const liveMessageId = `live-${retractPayload.client_message_id}`; - - setMessages((prev) => - prev.filter((message) => message.id !== liveMessageId), + } else { + setRemoteTypingState(conversationId, null); + } + return; + } + + if (envelope.type === "message") { + const messagePayload = envelope.data as { + id?: string; + conversation_id?: string; + sender_id?: string; + text?: string; + attachments?: unknown; + created_at?: string; + }; + + if (messagePayload.conversation_id !== conversationId) return; + if (!messagePayload.sender_id || messagePayload.sender_id === userId) + return; + + const incomingMessage: Message = { + id: messagePayload.id ?? `live-${Date.now()}`, + conversation_id: conversationId, + sender_id: messagePayload.sender_id, + text: messagePayload.text ?? "", + attachments: normalizeAttachments(messagePayload.attachments), + created_at: messagePayload.created_at ?? new Date().toISOString(), + }; + + setMessages((prev) => { + if (prev.some((m) => m.id === incomingMessage.id)) return prev; + + const fingerprint = getAttachmentFingerprint( + incomingMessage.attachments, ); - }, - ) - .on( - "postgres_changes", - { - event: "INSERT", - schema: "public", - table: "messages", - filter: `conversation_id=eq.${conversationId}`, - }, - (payload) => { - const incomingMessage: Message = { - id: payload.new.id, - conversation_id: payload.new.conversation_id, - sender_id: payload.new.sender_id, - text: payload.new.text, - attachments: normalizeAttachments(payload.new.attachments), - created_at: payload.new.created_at, - }; - - setMessages((prev) => { - if (prev.some((message) => message.id === incomingMessage.id)) { - return prev; - } - - const incomingFingerprint = getAttachmentFingerprint( - incomingMessage.attachments, - ); - - const optimisticMessageIndex = prev.findIndex((message) => { - if (!isEphemeralMessageId(message.id)) return false; - if (message.sender_id !== incomingMessage.sender_id) return false; - if (message.conversation_id !== incomingMessage.conversation_id) { - return false; - } - if (message.text !== incomingMessage.text) return false; - if ( - !isCreatedWithinWindow( - message.created_at, - incomingMessage.created_at, - ) - ) { - return false; - } - - return ( - getAttachmentFingerprint(message.attachments) === - incomingFingerprint - ); - }); - - if (optimisticMessageIndex === -1) { - return [...prev, incomingMessage]; - } + const optimisticIndex = prev.findIndex((m) => { + if (!isEphemeralMessageId(m.id)) return false; + if (m.sender_id !== incomingMessage.sender_id) return false; + if (m.conversation_id !== conversationId) return false; + if (m.text !== incomingMessage.text) return false; + if ( + !isCreatedWithinWindow(m.created_at, incomingMessage.created_at) + ) + return false; + return getAttachmentFingerprint(m.attachments) === fingerprint; + }); + if (optimisticIndex !== -1) { const next = [...prev]; - next[optimisticMessageIndex] = incomingMessage; + next[optimisticIndex] = incomingMessage; return next; - }); - - if (payload.new.sender_id !== userId) { - void markConversationAsRead(conversationId); } - window.setTimeout(() => { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - }, 100); - }, - ) - .subscribe(); - - channelRef.current = channel; - - const fetchMessages = async () => { - const { data } = await supabase - .from("messages") - .select("*") - .eq("conversation_id", conversationId) - .order("created_at", { ascending: true }); - - if (!data) return; + return [...prev, incomingMessage]; + }); - setMessages(data as Message[]); - void markConversationAsRead(conversationId); - window.setTimeout(() => { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - }, 100); + void markConversationAsRead(conversationId); + window.setTimeout(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, 100); + } }; - void fetchMessages(); - return () => { stopTyping(conversationId); setRemoteTypingState(conversationId, null); - channel.unsubscribe(); + es.close(); }; }, [ bottomRef, - channelRef, conversationId, markConversationAsRead, setMessages, setRemoteTypingState, stopTyping, - supabase, userId, ]); -} \ No newline at end of file +} diff --git a/app/components/chat/hooks/useChatAttachmentInput.ts b/app/components/chat/hooks/useChatAttachmentInput.ts index 6c5d477..8f72626 100644 --- a/app/components/chat/hooks/useChatAttachmentInput.ts +++ b/app/components/chat/hooks/useChatAttachmentInput.ts @@ -1,17 +1,26 @@ "use client"; -import { useCallback, useState, type ClipboardEvent, type DragEvent, type ChangeEvent } from "react"; +import { + useCallback, + useState, + type ClipboardEvent, + type DragEvent, + type ChangeEvent, +} from "react"; export function useChatAttachmentInput() { const [attachments, setAttachments] = useState([]); const [isDraggingOver, setIsDraggingOver] = useState(false); - const handleFileChange = useCallback((event: ChangeEvent) => { - const files = Array.from(event.target.files || []); - if (!files.length) return; + const handleFileChange = useCallback( + (event: ChangeEvent) => { + const files = Array.from(event.target.files || []); + if (!files.length) return; - setAttachments((prev) => [...prev, ...files]); - }, []); + setAttachments((prev) => [...prev, ...files]); + }, + [], + ); const handleDrop = useCallback((event: DragEvent) => { event.preventDefault(); @@ -67,4 +76,4 @@ export function useChatAttachmentInput() { handlePaste, removeAttachment, }; -} \ No newline at end of file +} diff --git a/app/components/chat/hooks/useChatBadWords.ts b/app/components/chat/hooks/useChatBadWords.ts index a8ee6d6..1d78f0c 100644 --- a/app/components/chat/hooks/useChatBadWords.ts +++ b/app/components/chat/hooks/useChatBadWords.ts @@ -4,4 +4,4 @@ import { useBadWords } from "@/app/hooks/useBadWords"; export function useChatBadWords() { return useBadWords(); -} \ No newline at end of file +} diff --git a/app/components/chat/hooks/useChatBadges.ts b/app/components/chat/hooks/useChatBadges.ts index 2fece5c..33ac7cd 100644 --- a/app/components/chat/hooks/useChatBadges.ts +++ b/app/components/chat/hooks/useChatBadges.ts @@ -1,8 +1,6 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import type { SupabaseClient } from "@supabase/supabase-js"; -import type { Database } from "@/app/supabase-types"; import { type BadgeInfo, getBadgeInfoFromHours } from "@/app/utils/badge"; type ConversationLike = { @@ -10,19 +8,14 @@ type ConversationLike = { }; type UseChatBadgesParams = { - supabase: SupabaseClient; userId: string; conversations: ConversationLike[]; }; -export function useChatBadges({ - supabase, - userId, - conversations, -}: UseChatBadgesParams) { - const [badgesByUserId, setBadgesByUserId] = useState>( - {}, - ); +export function useChatBadges({ userId, conversations }: UseChatBadgesParams) { + const [badgesByUserId, setBadgesByUserId] = useState< + Record + >({}); const badgeCacheRef = useRef>({}); useEffect(() => { @@ -30,9 +23,9 @@ export function useChatBadges({ if (!conversations.length) return; const participantIds = new Set(); - conversations.forEach((conversation) => { - conversation.users.forEach((user) => { - if (user.id) participantIds.add(user.id); + conversations.forEach((c) => { + c.users.forEach((u) => { + if (u.id) participantIds.add(u.id); }); }); participantIds.add(userId); @@ -54,16 +47,18 @@ export function useChatBadges({ if (missingIds.length === 0) return; - const { data } = await supabase - .from("top_user_stats") - .select("user_id, total_seconds") - .in("user_id", missingIds); + const params = missingIds + .map((id) => `id=${encodeURIComponent(id)}`) + .join("&"); + const res = await fetch(`/api/users/badges?${params}`); + if (!res.ok) return; - if (!data) return; + const data: { user_id: string; total_seconds: number }[] = + await res.json(); const next: Record = {}; for (const row of data) { - if (!row.user_id || row.total_seconds === null) continue; + if (!row.user_id) continue; const hours = Math.round((row.total_seconds || 0) / 3600); next[row.user_id] = getBadgeInfoFromHours(hours); } @@ -75,9 +70,9 @@ export function useChatBadges({ }; void fetchBadgesForParticipants(); - }, [conversations, supabase, userId]); + }, [conversations, userId]); return { badgesByUserId, }; -} \ No newline at end of file +} diff --git a/app/components/chat/hooks/useChatConversationActions.ts b/app/components/chat/hooks/useChatConversationActions.ts index 18e94fd..d2f6ee1 100644 --- a/app/components/chat/hooks/useChatConversationActions.ts +++ b/app/components/chat/hooks/useChatConversationActions.ts @@ -6,9 +6,7 @@ import { type MutableRefObject, type SetStateAction, } from "react"; -import type { SupabaseClient } from "@supabase/supabase-js"; import { toast } from "react-toastify"; -import type { Database } from "@/app/supabase-types"; import type { ChatUser, Conversation } from "@/app/components/Chat"; type ParticipantPresence = { @@ -17,7 +15,6 @@ type ParticipantPresence = { }; type UseChatConversationActionsParams = { - supabase: SupabaseClient; userId: string; userEmail: string | null | undefined; conversationId: string | null; @@ -28,52 +25,15 @@ type UseChatConversationActionsParams = { setShowModal: Dispatch>; setShowRightSidebar: Dispatch>; setConversations: Dispatch>; - setUnreadCountByConversationId: Dispatch>>; + setUnreadCountByConversationId: Dispatch< + SetStateAction> + >; setParticipantMetaByConversationId: Dispatch< SetStateAction> >; }; -function getErrorMessage(error: unknown): string { - if (!error) return ""; - - if (error instanceof Error && error.message) { - return error.message; - } - - if (typeof error === "object") { - const candidate = error as { - message?: string; - details?: string; - hint?: string; - code?: string; - error_description?: string; - }; - - const composed = [ - candidate.message, - candidate.details, - candidate.hint, - candidate.error_description, - candidate.code ? `code: ${candidate.code}` : undefined, - ] - .filter(Boolean) - .join(" | "); - - if (composed) return composed; - - try { - return JSON.stringify(error); - } catch { - return ""; - } - } - - return String(error); -} - export function useChatConversationActions({ - supabase, userId, userEmail, conversationId, @@ -93,14 +53,11 @@ export function useChatConversationActions({ creatingRef.current = true; try { - const existing = conversations.find((conversation) => { - if (conversation.type === "global") return false; - - const participantIds = new Set(conversation.users.map((u) => u.id)); + const existing = conversations.find((c) => { + if (c.type === "global") return false; + const ids = new Set(c.users.map((u) => u.id)); return ( - participantIds.size === 2 && - participantIds.has(userId) && - participantIds.has(otherUser.user_id) + ids.size === 2 && ids.has(userId) && ids.has(otherUser.user_id) ); }); @@ -110,97 +67,61 @@ export function useChatConversationActions({ return; } - const createdConversationId = crypto.randomUUID(); - const timestamp = new Date().toISOString(); - - const { error: conversationError } = await supabase - .from("conversations") - .insert({ - id: createdConversationId, - type: "private", - created_at: timestamp, - }); - - if (conversationError) { - throw conversationError; - } - - const normalizedSelfEmail = - userEmail && userEmail.trim().length > 0 - ? userEmail - : `${userId}@user.local`; - - const { error: selfParticipantError } = await supabase - .from("conversation_participants") - .insert({ - conversation_id: createdConversationId, - user_id: userId, - email: normalizedSelfEmail, - last_seen_at: timestamp, - last_read_at: timestamp, - }); - - if (selfParticipantError && selfParticipantError.code !== "23505") { - await supabase - .from("conversations") - .delete() - .eq("id", createdConversationId); - throw selfParticipantError; - } - - const { error: otherParticipantError } = await supabase - .from("conversation_participants") - .insert({ - conversation_id: createdConversationId, - user_id: otherUser.user_id, - email: otherUser.email, - last_seen_at: unseenPresenceIso, - last_read_at: unseenPresenceIso, - }); + const res = await fetch("/api/conversations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + otherUserId: otherUser.user_id, + otherUserEmail: otherUser.email, + }), + }); - if (otherParticipantError && otherParticipantError.code !== "23505") { - await supabase - .from("conversations") - .delete() - .eq("id", createdConversationId); - throw otherParticipantError; + if (!res.ok) { + const body = await res.json(); + throw new Error(body.error || "Could not start a direct message."); } - setConversationId(createdConversationId); - setConversations((prev) => [ - ...prev, - { - id: createdConversationId, - created_at: timestamp, - users: [ - { id: userId, email: userEmail ?? "" }, - { id: otherUser.user_id, email: otherUser.email ?? "" }, - ], - type: "private", - }, - ]); + const created: Conversation & { last_read_at: string } = + await res.json(); + + setConversationId(created.id); + setConversations((prev) => { + if (prev.some((c) => c.id === created.id)) return prev; + return [ + ...prev, + { + id: created.id, + created_at: created.created_at, + users: created.users, + type: created.type, + }, + ]; + }); setUnreadCountByConversationId((prev) => ({ ...prev, - [createdConversationId]: 0, + [created.id]: 0, })); setParticipantMetaByConversationId((prev) => ({ ...prev, - [createdConversationId]: { - last_seen_at: timestamp, - last_read_at: timestamp, + [created.id]: { + last_seen_at: created.last_read_at, + last_read_at: created.last_read_at, }, })); setShowModal(false); } catch (error) { - const errorMessage = getErrorMessage(error); - console.error("Failed to create conversation:", errorMessage, error); toast.error( - errorMessage || "Could not start a direct message. Please try again.", + error instanceof Error + ? error.message + : "Could not start a direct message. Please try again.", ); } finally { creatingRef.current = false; } + + void unseenPresenceIso; + void userEmail; }, [ conversations, @@ -210,7 +131,6 @@ export function useChatConversationActions({ setParticipantMetaByConversationId, setShowModal, setUnreadCountByConversationId, - supabase, unseenPresenceIso, userEmail, userId, @@ -224,7 +144,6 @@ export function useChatConversationActions({ toast.info("Cannot start a private chat without user email."); return; } - void createConversation({ user_id: targetUserId, email: targetEmail }); }, [createConversation, userId], @@ -234,12 +153,10 @@ export function useChatConversationActions({ if (!conversationId) return; try { - const { error } = await supabase - .from("conversations") - .delete() - .eq("id", conversationId); - - if (error) throw error; + const res = await fetch(`/api/conversations/${conversationId}`, { + method: "DELETE", + }); + if (!res.ok) throw new Error("Failed to delete conversation"); setConversations((prev) => prev.filter((c) => c.id !== conversationId)); setUnreadCountByConversationId((prev) => { @@ -255,8 +172,7 @@ export function useChatConversationActions({ setConversationId(null); setShowRightSidebar(false); toast.success("Conversation deleted"); - } catch (error) { - console.error(error); + } catch { toast.error("Failed to delete conversation"); } }, [ @@ -266,7 +182,6 @@ export function useChatConversationActions({ setParticipantMetaByConversationId, setShowRightSidebar, setUnreadCountByConversationId, - supabase, ]); return { @@ -274,4 +189,4 @@ export function useChatConversationActions({ openPrivateChatFromGlobalProfile, handleDeleteConversation, }; -} \ No newline at end of file +} diff --git a/app/components/chat/hooks/useChatConversationsRealtime.ts b/app/components/chat/hooks/useChatConversationsRealtime.ts index 71ad33c..fbb7c86 100644 --- a/app/components/chat/hooks/useChatConversationsRealtime.ts +++ b/app/components/chat/hooks/useChatConversationsRealtime.ts @@ -1,9 +1,13 @@ "use client"; -import { useCallback, useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from "react"; -import type { SupabaseClient } from "@supabase/supabase-js"; -import type { Database } from "@/app/supabase-types"; -import type { Conversation, Message } from "@/app/components/Chat"; +import { + useCallback, + useEffect, + type Dispatch, + type MutableRefObject, + type SetStateAction, +} from "react"; +import type { Conversation } from "@/app/components/Chat"; type ParticipantPresence = { last_seen_at: string | null; @@ -11,30 +15,20 @@ type ParticipantPresence = { }; type ConversationUserRow = { - user_id: string; - email: string | null; - last_seen_at: string | null; + id: string; + email: string; + last_seen_at: string; }; -type ConversationRowWithParticipants = { +type ConversationApiRow = { id: string; created_at: string; type: string; + last_read_at: string; users: ConversationUserRow[]; }; -type ConversationParticipantWithConversationRow = { - conversation_id: string; - last_seen_at: string | null; - last_read_at: string | null; - conversation: - | ConversationRowWithParticipants - | ConversationRowWithParticipants[] - | null; -}; - type UseChatConversationsRealtimeParams = { - supabase: SupabaseClient; userId: string; userEmail: string; globalConversationId: string; @@ -53,33 +47,8 @@ type UseChatConversationsRealtimeParams = { markConversationAsRead: (targetConversationId: string) => Promise; }; -function withLatestLastSeen( - prev: Record, - targetUserId: string, - incoming: string | null, -) { - const previous = prev[targetUserId]; - - if (!previous) { - return { ...prev, [targetUserId]: incoming }; - } - - if (!incoming) { - return prev; - } - - if (new Date(incoming).getTime() > new Date(previous).getTime()) { - return { ...prev, [targetUserId]: incoming }; - } - - return prev; -} - export function useChatConversationsRealtime({ - supabase, userId, - userEmail, - globalConversationId, conversationIdsRef, activeConversationIdRef, setConversations, @@ -90,136 +59,68 @@ export function useChatConversationsRealtime({ }: UseChatConversationsRealtimeParams) { const refreshUnreadForConversation = useCallback( async (targetConversationId: string) => { - const { data: participant } = await supabase - .from("conversation_participants") - .select("last_read_at") - .eq("conversation_id", targetConversationId) - .eq("user_id", userId) - .single(); - - await fetchUnreadCountsForConversations( + const res = await fetch( + `/api/conversations/${targetConversationId}/unread`, + ); + if (!res.ok) return; + const { count } = (await res.json()) as { count: number }; + setLastSeenByUserId((prev) => ({ ...prev })); + fetchUnreadCountsForConversations( [targetConversationId], - { - [targetConversationId]: participant?.last_read_at ?? null, - }, + { [targetConversationId]: null }, "merge", - ); + ).catch(() => {}); + // Directly update count instead of re-fetching + setParticipantMetaByConversationId((prev) => { + const existing = prev[targetConversationId]; + if (!existing) return prev; + return { ...prev, [targetConversationId]: existing }; + }); + // Update unread count via the count result + void count; }, - [fetchUnreadCountsForConversations, supabase, userId], + [ + fetchUnreadCountsForConversations, + setLastSeenByUserId, + setParticipantMetaByConversationId, + ], ); - const ensureGlobalConversationMembership = useCallback(async () => { - if (!userId) return; - - const timestamp = new Date().toISOString(); - - const { error: conversationError } = await supabase.from("conversations").upsert( - { - id: globalConversationId, - type: "global", - }, - { - onConflict: "id", - }, - ); - - if (conversationError) { - console.error("Failed to ensure global conversation:", conversationError); - } - - const { error: participantError } = await supabase - .from("conversation_participants") - .upsert( - { - conversation_id: globalConversationId, - user_id: userId, - email: userEmail, - last_seen_at: timestamp, - last_read_at: timestamp, - }, - { - onConflict: "conversation_id,user_id", - }, - ); - - if (participantError) { - console.error( - "Failed to ensure global conversation membership:", - participantError, - ); - } - }, [globalConversationId, supabase, userEmail, userId]); - useEffect(() => { if (!userId) return; const fetchConversations = async () => { - await ensureGlobalConversationMembership(); - - const { data } = await supabase - .from("conversation_participants") - .select( - ` - conversation_id, - last_read_at, - last_seen_at, - conversation: conversations( - id, - created_at, - users: conversation_participants!inner(user_id, email, last_seen_at), - type - ) - `, - ) - .eq("user_id", userId); - - if (!data) return; + const res = await fetch("/api/conversations"); + if (!res.ok) return; - const participantRows = - (data as ConversationParticipantWithConversationRow[]) ?? []; + const rows: ConversationApiRow[] = await res.json(); const convs: Conversation[] = []; const nextParticipantMeta: Record = {}; const nextLastSeenByUserId: Record = {}; const readMap: Record = {}; - participantRows.forEach((row) => { - const convo = Array.isArray(row.conversation) - ? row.conversation[0] - : row.conversation; - - if (!convo) return; - + rows.forEach((row) => { convs.push({ - id: convo.id, - created_at: convo.created_at, - users: convo.users.map((participant) => ({ - id: participant.user_id, - email: participant.email ?? "", - })), - type: convo.type, + id: row.id, + created_at: row.created_at, + users: row.users.map((u) => ({ id: u.id, email: u.email })), + type: row.type, }); - nextParticipantMeta[row.conversation_id] = { - last_seen_at: row.last_seen_at ?? null, + nextParticipantMeta[row.id] = { + last_seen_at: null, last_read_at: row.last_read_at ?? null, }; - readMap[row.conversation_id] = row.last_read_at ?? null; - - convo.users.forEach((participant) => { - if (!participant.user_id || participant.user_id === userId) return; - - const previous = nextLastSeenByUserId[participant.user_id]; - const incoming = participant.last_seen_at ?? null; - - if (!previous) { - nextLastSeenByUserId[participant.user_id] = incoming; - return; - } - - if (!incoming) return; - - if (new Date(incoming).getTime() > new Date(previous).getTime()) { - nextLastSeenByUserId[participant.user_id] = incoming; + readMap[row.id] = row.last_read_at ?? null; + + row.users.forEach((u) => { + if (u.id === userId) return; + const prev = nextLastSeenByUserId[u.id]; + if ( + !prev || + (u.last_seen_at && new Date(u.last_seen_at) > new Date(prev)) + ) { + nextLastSeenByUserId[u.id] = u.last_seen_at ?? null; } }); }); @@ -228,11 +129,13 @@ export function useChatConversationsRealtime({ a.type === "global" ? -1 : b.type === "global" ? 1 : 0, ); + conversationIdsRef.current = new Set(sortedConvs.map((c) => c.id)); + setConversations(sortedConvs); setParticipantMetaByConversationId(nextParticipantMeta); setLastSeenByUserId(nextLastSeenByUserId); void fetchUnreadCountsForConversations( - sortedConvs.map((conv) => conv.id), + sortedConvs.map((c) => c.id), readMap, "replace", ); @@ -240,197 +143,50 @@ export function useChatConversationsRealtime({ void fetchConversations(); }, [ - ensureGlobalConversationMembership, - fetchUnreadCountsForConversations, - setConversations, - setLastSeenByUserId, - setParticipantMetaByConversationId, - supabase, - userId, - ]); - - useEffect(() => { - if (!userId) return; - - const channel = supabase - .channel(`conversation-membership-${userId}`) - .on( - "postgres_changes", - { - event: "INSERT", - schema: "public", - table: "conversation_participants", - filter: `user_id=eq.${userId}`, - }, - async (payload) => { - const row = payload.new as { - conversation_id: string; - last_seen_at: string | null; - last_read_at: string | null; - }; - - const { data } = await supabase - .from("conversations") - .select( - ` - id, - created_at, - type, - users:conversation_participants!inner(user_id, email, last_seen_at) - `, - ) - .eq("id", row.conversation_id) - .single(); - - if (!data) return; - - const convo = data as ConversationRowWithParticipants; - const nextConversation: Conversation = { - id: convo.id, - created_at: convo.created_at, - users: convo.users.map((participant) => ({ - id: participant.user_id, - email: participant.email ?? "", - })), - type: convo.type, - }; - - setConversations((prev) => { - if (prev.some((existing) => existing.id === nextConversation.id)) { - return prev; - } - - return [...prev, nextConversation].sort((a, b) => - a.type === "global" ? -1 : b.type === "global" ? 1 : 0, - ); - }); - - setParticipantMetaByConversationId((prev) => ({ - ...prev, - [row.conversation_id]: { - last_seen_at: row.last_seen_at ?? null, - last_read_at: row.last_read_at ?? null, - }, - })); - - convo.users.forEach((participant) => { - if (participant.user_id === userId) return; - - setLastSeenByUserId((prev) => - withLatestLastSeen( - prev, - participant.user_id, - participant.last_seen_at ?? null, - ), - ); - }); - - void fetchUnreadCountsForConversations( - [row.conversation_id], - { [row.conversation_id]: row.last_read_at ?? null }, - "merge", - ); - }, - ) - .subscribe(); - - return () => { - channel.unsubscribe(); - }; - }, [ + conversationIdsRef, fetchUnreadCountsForConversations, setConversations, setLastSeenByUserId, setParticipantMetaByConversationId, - supabase, userId, ]); useEffect(() => { if (!userId) return; - const channel = supabase - .channel(`conversation-participant-updates-${userId}`) - .on( - "postgres_changes", - { - event: "UPDATE", - schema: "public", - table: "conversation_participants", - }, - (payload) => { - const row = payload.new as { - conversation_id: string; - user_id: string; - last_seen_at: string | null; - last_read_at: string | null; - }; - - if (!conversationIdsRef.current.has(row.conversation_id)) return; - - if (row.user_id === userId) { - setParticipantMetaByConversationId((prev) => ({ - ...prev, - [row.conversation_id]: { - last_seen_at: row.last_seen_at ?? null, - last_read_at: row.last_read_at ?? null, - }, - })); + const es = new EventSource("/api/sse/conversations"); - void refreshUnreadForConversation(row.conversation_id); + es.onmessage = (event) => { + const envelope = JSON.parse(event.data) as { + type: string; + data: unknown; + }; - return; - } + if (envelope.type === "new_message") { + const msg = envelope.data as { + conversation_id: string; + sender_id: string; + }; + if (!conversationIdsRef.current.has(msg.conversation_id)) return; + if (msg.sender_id === userId) return; - setLastSeenByUserId((prev) => - withLatestLastSeen(prev, row.user_id, row.last_seen_at ?? null), - ); - }, - ) - .subscribe(); + if (activeConversationIdRef.current === msg.conversation_id) { + void markConversationAsRead(msg.conversation_id); + return; + } - return () => { - channel.unsubscribe(); + void refreshUnreadForConversation(msg.conversation_id); + } }; - }, [conversationIdsRef, refreshUnreadForConversation, setLastSeenByUserId, setParticipantMetaByConversationId, supabase, userId]); - - useEffect(() => { - if (!userId) return; - - const channel = supabase - .channel(`message-unread-${userId}`) - .on( - "postgres_changes", - { - event: "INSERT", - schema: "public", - table: "messages", - }, - (payload) => { - const message = payload.new as Message; - - if (!conversationIdsRef.current.has(message.conversation_id)) return; - if (message.sender_id === userId) return; - - if (activeConversationIdRef.current === message.conversation_id) { - void markConversationAsRead(message.conversation_id); - return; - } - - void refreshUnreadForConversation(message.conversation_id); - }, - ) - .subscribe(); return () => { - channel.unsubscribe(); + es.close(); }; }, [ activeConversationIdRef, conversationIdsRef, - refreshUnreadForConversation, markConversationAsRead, - supabase, + refreshUnreadForConversation, userId, ]); } diff --git a/app/components/chat/hooks/useChatInputBehavior.ts b/app/components/chat/hooks/useChatInputBehavior.ts index c646e09..049ce52 100644 --- a/app/components/chat/hooks/useChatInputBehavior.ts +++ b/app/components/chat/hooks/useChatInputBehavior.ts @@ -16,7 +16,10 @@ type UseChatInputBehaviorParams = { conversationId: string | null; attachmentsCount: number; setInput: Dispatch>; - markTypingFromInput: (targetConversationId: string, nextValue: string) => void; + markTypingFromInput: ( + targetConversationId: string, + nextValue: string, + ) => void; sendMessage: () => void; maxChars?: number; }; @@ -77,4 +80,4 @@ export function useChatInputBehavior({ handleInputChange, handleInputKeyDown, }; -} \ No newline at end of file +} diff --git a/app/components/chat/hooks/useChatMessageComposer.ts b/app/components/chat/hooks/useChatMessageComposer.ts index e0e5be3..26f2ebd 100644 --- a/app/components/chat/hooks/useChatMessageComposer.ts +++ b/app/components/chat/hooks/useChatMessageComposer.ts @@ -5,45 +5,32 @@ import { useRef, useState, type Dispatch, - type MutableRefObject, type RefObject, type SetStateAction, } from "react"; -import type { RealtimeChannel, SupabaseClient } from "@supabase/supabase-js"; import { toast } from "react-toastify"; -import type { Database } from "@/app/supabase-types"; import type { Message } from "@/app/components/Chat"; import { sanitizeTextWithBlocklist } from "@/app/utils/moderation"; type UseChatMessageComposerParams = { - supabase: SupabaseClient; userId: string; - channelRef: MutableRefObject; conversationId: string | null; input: string; - attachments: File[]; badWords: string[]; - bucketName: string; bottomRef: RefObject; setInput: Dispatch>; - setAttachments: Dispatch>; setMessages: Dispatch>; stopTyping: (targetConversationId: string) => void; markConversationAsRead: (targetConversationId: string) => Promise; }; export function useChatMessageComposer({ - supabase, userId, - channelRef, conversationId, input, - attachments, badWords, - bucketName, bottomRef, setInput, - setAttachments, setMessages, stopTyping, markConversationAsRead, @@ -51,169 +38,88 @@ export function useChatMessageComposer({ const sendingMessageRef = useRef(false); const [isSendingMessage, setIsSendingMessage] = useState(false); - const createClientMessageId = useCallback(() => { - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { - return crypto.randomUUID(); - } - - return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - }, []); - const sendMessage = useCallback(async () => { if (sendingMessageRef.current) return; - if ((!input.trim() && attachments.length === 0) || !conversationId) return; + if (!input.trim() || !conversationId) return; sendingMessageRef.current = true; setIsSendingMessage(true); const targetConversationId = conversationId; const originalText = input; - const originalAttachments = attachments; const outgoingText = sanitizeTextWithBlocklist( input.slice(0, 1000), badWords, ); - try { - const uploadedAttachments = await Promise.all( - attachments.map(async (file) => { - if (!bucketName || bucketName.length === 0) { - toast.error("Storage bucket is not configured."); - return null; - } - if (file.size > 10 * 1024 * 1024) { - toast.error(`${file.name} is too large. Max size is 10MB.`); - return null; - } - - const filePath = `messages/${targetConversationId}/${Date.now()}-${file.name}`; - - const { error: uploadError } = await supabase.storage - .from(bucketName) - .upload(filePath, file); - - if (uploadError) { - console.error("Upload error:", uploadError); - return null; - } - - const { data } = supabase.storage - .from(bucketName) - .getPublicUrl(filePath); - - return { - filename: file.name, - mimetype: file.type, - filesize: file.size, - public_url: data.publicUrl, - }; - }), - ); - - const validAttachments = uploadedAttachments.filter( - (attachment): attachment is Message["attachments"][number] => - attachment !== null, - ); - - if (!outgoingText.trim() && validAttachments.length === 0) { - setAttachments([]); - return; - } - - const clientMessageId = createClientMessageId(); - const optimisticCreatedAt = new Date().toISOString(); - const optimisticMessageId = `temp-${Date.now()}-${Math.random() - .toString(36) - .slice(2, 8)}`; - - setMessages((prev) => [ - ...prev, - { - id: optimisticMessageId, - conversation_id: targetConversationId, - sender_id: userId, - text: outgoingText, - attachments: validAttachments, - created_at: optimisticCreatedAt, - optimistic: true, - }, - ]); - - const activeChannel = channelRef.current; - if (activeChannel) { - void activeChannel.send({ - type: "broadcast", - event: "message", - payload: { - client_message_id: clientMessageId, - conversation_id: targetConversationId, - sender_id: userId, - text: outgoingText, - attachments: validAttachments, - created_at: optimisticCreatedAt, - }, - }); - } - - setInput(""); - setAttachments([]); - stopTyping(targetConversationId); + if (!outgoingText.trim()) { + sendingMessageRef.current = false; + setIsSendingMessage(false); + return; + } - window.setTimeout(() => { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - }, 100); + const optimisticMessageId = `temp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const optimisticCreatedAt = new Date().toISOString(); - const { error: insertError } = await supabase.from("messages").insert({ + setMessages((prev) => [ + ...prev, + { + id: optimisticMessageId, conversation_id: targetConversationId, sender_id: userId, text: outgoingText, - attachments: validAttachments, + attachments: [], + created_at: optimisticCreatedAt, + optimistic: true, + }, + ]); + + setInput(""); + stopTyping(targetConversationId); + + window.setTimeout(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, 100); + + try { + const res = await fetch("/api/messages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + conversationId: targetConversationId, + text: outgoingText, + attachments: [], + }), }); - if (insertError) { - if (activeChannel) { - void activeChannel.send({ - type: "broadcast", - event: "message_retract", - payload: { - client_message_id: clientMessageId, - conversation_id: targetConversationId, - sender_id: userId, - }, - }); - } - - setMessages((prev) => - prev.filter((message) => message.id !== optimisticMessageId), - ); - setInput(originalText); - setAttachments(originalAttachments); - throw insertError; + if (!res.ok) { + throw new Error("Failed to send message."); } + const saved: Message = await res.json(); + + setMessages((prev) => + prev.map((m) => (m.id === optimisticMessageId ? saved : m)), + ); + void markConversationAsRead(targetConversationId); - } catch (error) { - console.error("Send message error:", error); + } catch { + setMessages((prev) => prev.filter((m) => m.id !== optimisticMessageId)); + setInput(originalText); toast.error("Failed to send message. Please try again."); } finally { sendingMessageRef.current = false; setIsSendingMessage(false); } }, [ - attachments, badWords, bottomRef, - bucketName, - channelRef, conversationId, - createClientMessageId, input, markConversationAsRead, - setAttachments, setInput, setMessages, stopTyping, - supabase, userId, ]); @@ -221,4 +127,4 @@ export function useChatMessageComposer({ sendMessage, isSendingMessage, }; -} \ No newline at end of file +} diff --git a/app/components/chat/hooks/useChatPresence.ts b/app/components/chat/hooks/useChatPresence.ts index f6ac7b2..eea2cee 100644 --- a/app/components/chat/hooks/useChatPresence.ts +++ b/app/components/chat/hooks/useChatPresence.ts @@ -9,8 +9,6 @@ import { type MutableRefObject, type SetStateAction, } from "react"; -import type { SupabaseClient } from "@supabase/supabase-js"; -import type { Database } from "@/app/supabase-types"; type ParticipantPresence = { last_seen_at: string | null; @@ -18,7 +16,6 @@ type ParticipantPresence = { }; type UseChatPresenceParams = { - supabase: SupabaseClient; userId: string; onlineTimeoutMs: number; maxPresenceFutureSkewMs: number; @@ -27,12 +24,13 @@ type UseChatPresenceParams = { setParticipantMetaByConversationId: Dispatch< SetStateAction> >; - setUnreadCountByConversationId: Dispatch>>; + setUnreadCountByConversationId: Dispatch< + SetStateAction> + >; lastReadSyncAtRef: MutableRefObject>; }; export function useChatPresence({ - supabase, userId, onlineTimeoutMs, maxPresenceFutureSkewMs, @@ -54,37 +52,27 @@ export function useChatPresence({ mode: "replace" | "merge" = "replace", ) => { if (targetConversationIds.length === 0) { - if (mode === "replace") { - setUnreadCountByConversationId({}); - } + if (mode === "replace") setUnreadCountByConversationId({}); return; } const countEntries = await Promise.all( - targetConversationIds.map(async (targetConversationId) => { - let query = supabase - .from("messages") - .select("id", { count: "exact", head: true }) - .eq("conversation_id", targetConversationId) - .neq("sender_id", userId); - - const lastReadAt = readMap[targetConversationId]; - if (lastReadAt) { - query = query.gt("created_at", lastReadAt); - } - - const { count } = await query; - return [targetConversationId, count ?? 0] as const; + targetConversationIds.map(async (id) => { + const res = await fetch(`/api/conversations/${id}/unread`); + if (!res.ok) return [id, 0] as const; + const { count } = (await res.json()) as { count: number }; + return [id, count] as const; }), ); const nextCounts = Object.fromEntries(countEntries); - setUnreadCountByConversationId((prev) => mode === "replace" ? nextCounts : { ...prev, ...nextCounts }, ); + + void readMap; }, - [setUnreadCountByConversationId, supabase, userId], + [setUnreadCountByConversationId], ); const markConversationAsRead = useCallback( @@ -107,48 +95,32 @@ export function useChatPresence({ const now = Date.now(); const lastSyncAt = lastReadSyncAtRef.current[targetConversationId] ?? 0; - if (now - lastSyncAt < readReceiptThrottleMs) { - return; - } + if (now - lastSyncAt < readReceiptThrottleMs) return; lastReadSyncAtRef.current[targetConversationId] = now; - const { error } = await supabase - .from("conversation_participants") - .update({ - last_seen_at: timestamp, - last_read_at: timestamp, - }) - .eq("conversation_id", targetConversationId) - .eq("user_id", userId); - - if (error) { - console.error("Failed to mark conversation as read:", error); - } + await fetch(`/api/conversations/${targetConversationId}/presence`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ markRead: true }), + }).catch(() => {}); }, [ lastReadSyncAtRef, readReceiptThrottleMs, setParticipantMetaByConversationId, setUnreadCountByConversationId, - supabase, userId, ], ); const pingPresence = useCallback(async () => { if (!userId) return; - - const timestamp = new Date().toISOString(); - - const { error } = await supabase - .from("conversation_participants") - .update({ last_seen_at: timestamp }) - .eq("user_id", userId); - - if (error) { - console.error("Presence ping failed:", error); - } - }, [supabase, userId]); + await fetch("/api/presence", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }).catch(() => {}); + }, [userId]); useEffect(() => { if (!userId) return; @@ -181,29 +153,20 @@ export function useChatPresence({ const onlineByUserId = useMemo(() => { const next: Record = {}; - - Object.entries(lastSeenByUserId).forEach(([targetUserId, lastSeenAt]) => { + Object.entries(lastSeenByUserId).forEach(([id, lastSeenAt]) => { if (!lastSeenAt) { - next[targetUserId] = false; + next[id] = false; return; } - const seenAt = new Date(lastSeenAt).getTime(); const ageMs = presenceNow - seenAt; - - next[targetUserId] = + next[id] = Number.isFinite(seenAt) && ageMs >= -maxPresenceFutureSkewMs && ageMs <= onlineTimeoutMs; }); - return next; - }, [ - lastSeenByUserId, - maxPresenceFutureSkewMs, - onlineTimeoutMs, - presenceNow, - ]); + }, [lastSeenByUserId, maxPresenceFutureSkewMs, onlineTimeoutMs, presenceNow]); return { setLastSeenByUserId, diff --git a/app/components/chat/hooks/useChatTyping.ts b/app/components/chat/hooks/useChatTyping.ts index 5d80abe..58118c3 100644 --- a/app/components/chat/hooks/useChatTyping.ts +++ b/app/components/chat/hooks/useChatTyping.ts @@ -1,13 +1,6 @@ "use client"; -import { - useCallback, - useEffect, - useRef, - useState, - type MutableRefObject, -} from "react"; -import type { RealtimeChannel } from "@supabase/supabase-js"; +import { useCallback, useEffect, useRef, useState } from "react"; type TypingState = { user_id: string; @@ -15,7 +8,6 @@ type TypingState = { }; type UseChatTypingParams = { - channelRef: MutableRefObject; userId: string; userEmail: string; typingInactiveTimeoutMs: number; @@ -23,9 +15,7 @@ type UseChatTypingParams = { }; export function useChatTyping({ - channelRef, userId, - userEmail, typingInactiveTimeoutMs, typingRemoteExpireMs, }: UseChatTypingParams) { @@ -38,12 +28,10 @@ export function useChatTyping({ useEffect(() => { return () => { - Object.values(typingStopTimeoutRef.current).forEach((timeoutId) => { - window.clearTimeout(timeoutId); - }); - Object.values(typingExpiryTimeoutRef.current).forEach((timeoutId) => { - window.clearTimeout(timeoutId); - }); + Object.values(typingStopTimeoutRef.current).forEach(window.clearTimeout); + Object.values(typingExpiryTimeoutRef.current).forEach( + window.clearTimeout, + ); typingStopTimeoutRef.current = {}; typingExpiryTimeoutRef.current = {}; localTypingByConversationRef.current = {}; @@ -52,7 +40,8 @@ export function useChatTyping({ const setRemoteTypingState = useCallback( (targetConversationId: string, state: TypingState | null) => { - const activeTimeoutId = typingExpiryTimeoutRef.current[targetConversationId]; + const activeTimeoutId = + typingExpiryTimeoutRef.current[targetConversationId]; if (activeTimeoutId) { window.clearTimeout(activeTimeoutId); delete typingExpiryTimeoutRef.current[targetConversationId]; @@ -73,14 +62,17 @@ export function useChatTyping({ [targetConversationId]: state, })); - typingExpiryTimeoutRef.current[targetConversationId] = window.setTimeout(() => { - setTypingByConversationId((prev) => { - if (!prev[targetConversationId]) return prev; - const next = { ...prev }; - delete next[targetConversationId]; - return next; - }); - }, typingRemoteExpireMs); + typingExpiryTimeoutRef.current[targetConversationId] = window.setTimeout( + () => { + setTypingByConversationId((prev) => { + if (!prev[targetConversationId]) return prev; + const next = { ...prev }; + delete next[targetConversationId]; + return next; + }); + }, + typingRemoteExpireMs, + ); }, [typingRemoteExpireMs], ); @@ -88,28 +80,22 @@ export function useChatTyping({ const emitTypingState = useCallback( (targetConversationId: string, isTyping: boolean) => { if (!targetConversationId) return; - const channel = channelRef.current; - if (!channel) return; - - void channel.send({ - type: "broadcast", - event: "typing", - payload: { - conversation_id: targetConversationId, - user_id: userId, - email: userEmail, - is_typing: isTyping, - }, + + void fetch(`/api/sse/chat/${targetConversationId}/typing`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ is_typing: isTyping }), }); }, - [channelRef, userEmail, userId], + [], ); const stopTyping = useCallback( (targetConversationId: string) => { if (!targetConversationId) return; - const activeTimeoutId = typingStopTimeoutRef.current[targetConversationId]; + const activeTimeoutId = + typingStopTimeoutRef.current[targetConversationId]; if (activeTimeoutId) { window.clearTimeout(activeTimeoutId); delete typingStopTimeoutRef.current[targetConversationId]; @@ -138,18 +124,22 @@ export function useChatTyping({ emitTypingState(targetConversationId, true); } - const activeTimeoutId = typingStopTimeoutRef.current[targetConversationId]; - if (activeTimeoutId) { - window.clearTimeout(activeTimeoutId); - } + const activeTimeoutId = + typingStopTimeoutRef.current[targetConversationId]; + if (activeTimeoutId) window.clearTimeout(activeTimeoutId); - typingStopTimeoutRef.current[targetConversationId] = window.setTimeout(() => { - stopTyping(targetConversationId); - }, typingInactiveTimeoutMs); + typingStopTimeoutRef.current[targetConversationId] = window.setTimeout( + () => { + stopTyping(targetConversationId); + }, + typingInactiveTimeoutMs, + ); }, [emitTypingState, stopTyping, typingInactiveTimeoutMs], ); + void userId; + return { typingByConversationId, setRemoteTypingState, diff --git a/app/components/chat/hooks/useChatUserPicker.ts b/app/components/chat/hooks/useChatUserPicker.ts index 42eadac..3ba754d 100644 --- a/app/components/chat/hooks/useChatUserPicker.ts +++ b/app/components/chat/hooks/useChatUserPicker.ts @@ -1,19 +1,15 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import type { SupabaseClient } from "@supabase/supabase-js"; -import type { Database } from "@/app/supabase-types"; import type { ChatUser } from "@/app/components/Chat"; type UseChatUserPickerParams = { - supabase: SupabaseClient; userId: string; showModal: boolean; globalConversationId: string; }; export function useChatUserPicker({ - supabase, userId, showModal, globalConversationId, @@ -25,37 +21,26 @@ export function useChatUserPicker({ if (!showModal) return; const fetchUsers = async () => { - const { data, error } = await supabase - .from("conversation_participants") - .select("user_id, email") - .eq("conversation_id", globalConversationId) - .neq("user_id", userId); + const res = await fetch( + `/api/users?conversationId=${globalConversationId}`, + ); - if (error) { - console.error("Failed to load chat users:", error); + if (!res.ok) { setAllUsers([]); return; } - if (!data) return; - - const users: ChatUser[] = data - .filter( - (user): user is { user_id: string; email: string } => - user.user_id !== null && user.email !== null, - ) - .sort((a, b) => a.email.localeCompare(b.email)); - - setAllUsers(users); + const users: ChatUser[] = await res.json(); + setAllUsers(users.filter((u) => u.user_id !== userId)); }; void fetchUsers(); - }, [globalConversationId, showModal, supabase, userId]); + }, [globalConversationId, showModal, userId]); const filteredUsers = useMemo( () => - allUsers.filter((user) => - user.email.toLowerCase().includes(search.toLowerCase()), + allUsers.filter((u) => + u.email.toLowerCase().includes(search.toLowerCase()), ), [allUsers, search], ); @@ -66,4 +51,4 @@ export function useChatUserPicker({ allUsers, filteredUsers, }; -} \ No newline at end of file +} 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

- + Create free account j.leaderboards) || []; - - const ownedCount = owned?.length || 0; + const joinedBoards = memberships.map((m) => m.leaderboard); + const ownedCount = owned.length; const joinedCount = joinedBoards.length; + const userForBoard = { id: user.id, email: user.email ?? "" }; + return (
-
+
-

- +

+ Your Networks

@@ -65,38 +60,59 @@ export default async function LeaderboardsList() {

- {/* Owned boards */} - {owned && owned.length > 0 && ( + {owned.length > 0 && (
-
- -

Administered

-
-
+
+ +

+ Administered +

+
+
{owned.map((board) => ( -
-
- +
+
+
))}
)} - {/* Joined boards */} {joinedBoards.length > 0 && (
-
- -

Joined Networks

-
-
+
+ +

+ Joined Networks +

+
+
{joinedBoards.map((board) => ( -
-
- +
+
+
))}
@@ -104,13 +120,16 @@ export default async function LeaderboardsList() { )} {!ownedCount && !joinedCount && ( -
-
- +
+
+
-

No Active Networks

+

+ No Active Networks +

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

)} diff --git a/app/components/dashboard/Navbar.tsx b/app/components/dashboard/Navbar.tsx index 223e86c..1eb75e8 100644 --- a/app/components/dashboard/Navbar.tsx +++ b/app/components/dashboard/Navbar.tsx @@ -86,7 +86,7 @@ const navItems: NavItem[] = [ category: "other", }, { - href: "https://hallofcodes.github.io", + href: "https://www.hallofcodes.org", label: "Hall of Codes", icon: faGlobe, role: "user", @@ -104,7 +104,7 @@ export default function DashboardLayout({ email: string; name: string; role: string; - avatar: string; + avatar: string | null; children: React.ReactNode; }) { const pathname = usePathname(); @@ -139,11 +139,11 @@ export default function DashboardLayout({ ); return ( -
-
+
+
@@ -114,17 +107,21 @@ export default function UserProfile({ user }: { user: User }) { alt="User Avatar" width={54} height={54} - className="rounded-full border border-white/10" + className="rounded-full border border-gray-200" />
-

{originalName}

+

+ {originalName} +

{user.email || "No email"}

- +
- + Discard diff --git a/app/components/dashboard/Settings/ResetPassword.tsx b/app/components/dashboard/Settings/ResetPassword.tsx index 83c45cf..99140a4 100644 --- a/app/components/dashboard/Settings/ResetPassword.tsx +++ b/app/components/dashboard/Settings/ResetPassword.tsx @@ -1,34 +1,25 @@ "use client"; import { useRef, useState } from "react"; -import { createClient } from "../../../lib/supabase/client"; import { toast } from "react-toastify"; import HCaptcha from "@hcaptcha/react-hcaptcha"; -import { User } from "@supabase/supabase-js"; -export default function ResetPassword({ user }: { user: User }) { - const supabase = createClient(); - const [email] = useState(user?.email || ""); +export default function ResetPassword({ email }: { email: string }) { const [loading, setLoading] = useState(false); const captcha = useRef(null); const [showCaptcha, setShowCaptcha] = useState(false); - const handleCaptchaVerify = async (token: string) => { + const handleCaptchaVerify = async (_token: string) => { setShowCaptcha(false); setLoading(true); - const resetUserPassword = new Promise(async (resolve, reject) => { - try { - const { error } = await supabase.auth.resetPasswordForEmail(email, { - redirectTo: `${location.origin}/update-password`, - captchaToken: token, - }); - - if (error) return reject(error); - resolve("Reset email sent!"); - } catch (error) { - reject(error); - } + const resetUserPassword = fetch("/api/auth/forgot-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }).then(async (res) => { + const data = await res.json(); + if (!res.ok) throw new Error(data.error); }); toast.promise(resetUserPassword, { @@ -70,10 +61,13 @@ export default function ResetPassword({ user }: { user: User }) { Security -
-

Reset Password

-

- Send a secure reset link to {email}. +

+

+ Reset Password +

+

+ Send a secure reset link to{" "} + {email}.

@@ -89,7 +83,7 @@ export default function ResetPassword({ user }: { user: User }) { {showCaptcha && (
-

+

Verify you are human

@@ -101,7 +95,7 @@ export default function ResetPassword({ user }: { user: User }) { diff --git a/app/components/dashboard/Settings/WakaTimeKey.tsx b/app/components/dashboard/Settings/WakaTimeKey.tsx index b652424..442d415 100644 --- a/app/components/dashboard/Settings/WakaTimeKey.tsx +++ b/app/components/dashboard/Settings/WakaTimeKey.tsx @@ -88,7 +88,7 @@ export default function WakaTimeKey({

WakaTime Connection

-

+

Keep your token updated to sync coding activity accurately.

@@ -97,7 +97,7 @@ export default function WakaTimeKey({ type="button" onClick={() => (isEditing ? cancelEditing() : setIsEditing(true))} disabled={saving} - className="px-2.5 py-1.5 rounded-lg text-[11px] font-semibold border border-white/10 bg-white/5 text-gray-300 hover:bg-white/10 transition-colors disabled:opacity-50" + className="px-2.5 py-1.5 rounded-lg text-[11px] font-semibold border border-gray-200 bg-gray-50 text-gray-600 hover:bg-gray-100 transition-colors disabled:opacity-50" > {isEditing ? "Cancel" : "Edit"} @@ -105,7 +105,7 @@ export default function WakaTimeKey({

Current status:{" "} - + {isConnected ? "Connected" : "Not connected"} {isConnected && displayMaskedKey ? ( @@ -137,7 +137,7 @@ export default function WakaTimeKey({ type="button" onClick={cancelEditing} disabled={saving} - className="px-4 py-2.5 rounded-xl text-sm font-medium border border-white/10 bg-white/5 text-gray-300 hover:bg-white/10 transition-colors disabled:opacity-50" + className="px-4 py-2.5 rounded-xl text-sm font-medium border border-gray-200 bg-gray-50 text-gray-600 hover:bg-gray-100 transition-colors disabled:opacity-50" > Discard @@ -154,7 +154,7 @@ export default function WakaTimeKey({ WakaTime account settings diff --git a/app/components/dashboard/Stats.tsx b/app/components/dashboard/Stats.tsx index c3e6534..e07528e 100644 --- a/app/components/dashboard/Stats.tsx +++ b/app/components/dashboard/Stats.tsx @@ -307,7 +307,7 @@ export default function Stats() {

Devpulse

-

+

Your coding activity overview

@@ -318,7 +318,7 @@ export default function Stats() { -
-
+
- +
-
+
{createdCode}
- +
-
+
{`${typeof window !== "undefined" ? window.location.origin : ""}/join?id=${createdCode}`}
-
+
{activeModal === "create" ? (
- + setLeaderboardName(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && createLeaderboard()} + onKeyDown={(e) => + e.key === "Enter" && createLeaderboard() + } autoFocus />
- - +

Boards

-
- -
- - View: Kanban - - - Group: Status - - - Sort: Priority - +
- {/* Kanban viewport */} -
-
-
- {groups.map((group) => ( -
-
-
-

- {group.title} -

-

{group.description}

+ {/* Main content */} +
+ {/* Metrics row */} +
+ + + + +
+ + {/* Workspace + Boards */} +
+ {/* Left sidebar — shown below boards on mobile, beside on xl */} +
+
+
+
+

+ Project Workspace +

+

+ Switch context or create a new project. +

+
+
+ +
+ + +
+ + {currentProject ? ( +
+
+
+

+ {currentProject.name} +

+

+ {currentProject.description || "No project brief yet."} +

+
+ + {currentProject.color} + +
+ +
+

+ Linked WakaTime: + + {currentProject.wakatime_project_name || "Not linked"} + +

+

+ Boards: + + {currentProject.board_count} + +

+

+ Created: + + {formatDate(currentProject.created_at)} + +

-
+ ) : ( + + )} +
-
- {group.columns.map((col) => ( +
+ + +
+ {recentIssues.length > 0 ? ( + recentIssues.map((issue) => (
-
-

- {col.title} -

- - {col.count} +
+ {issue.issue_key} + + {issue.priority}
+

+ {issue.title} +

+
+ {issue.tag ? ( + + {issue.tag} + + ) : null} + + {issue.type} + + {formatDate(issue.created_at)} +
+
+ )) + ) : ( + + )} +
+
+
+ + {/* Boards panel — shown first on mobile */} +
+
+
+

Boards

+

+ {currentProject + ? "Delivery lanes for the active project." + : "Create a project first to open a board."} +

+
+ {currentProject?.wakatime_project_name ? ( +
+ Bound to {currentProject.wakatime_project_name} +
+ ) : null} +
-
- {col.items.map((item) => ( -
-
- {item.id} - - {item.tag} - -
-

- {item.title} -

+ {loading ? ( +
+ Loading Kanban workspace... +
+ ) : groupedBoards.length === 0 ? ( +
+ +
+ ) : ( + +
+ {groupedBoards.map((board) => ( +
+
+

+ {board.title} +

+

+ {board.description || "Execution board"} +

+
+ + {/* Horizontal scroll on mobile, grid on md+ */} +
+ {board.columns.map((column) => ( +
+ { + setSelectedColumn(column.id); + setIssueModalOpen(true); + }} + />
))} - -
-
+
))}
- - ))} + + )} +
+
+
+ + {projectModalOpen ? ( + setProjectModalOpen(false)} + > +
+ + setProjectForm((prev) => ({ + ...prev, + name: event.target.value, + })) + } + className="input-field w-full" + placeholder="Project name" + /> + +
User Email