@@ -220,7 +205,7 @@ export default function Flex({ user }: { user: User }) {
Flex
-
+
Share your flexes with the community
@@ -244,7 +229,7 @@ export default function Flex({ user }: { user: User }) {
{loading && (
-
Loading your flexes...
+
Loading your flexes...
)}
@@ -259,7 +244,7 @@ export default function Flex({ user }: { user: User }) {
type="text"
value={flex.name || ""}
onChange={(e) => setFlex({ ...flex, name: e.target.value })}
- className="w-full mt-4 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-4 px-3 py-2 bg-transparent text-gray-700 placeholder:text-gray-500 border border-neutral-800 rounded-xl outline-none"
/>
@@ -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 && (
-
+
Edit
void handleDeleteFlex(f.id)}
- className="w-full text-left px-3 py-1.5 text-xs text-red-300 hover:bg-red-500/10 flex items-center gap-2"
+ className="w-full text-left px-3 py-1.5 text-xs text-red-300 hover:bg-red-50 flex items-center gap-2"
>
Delete
@@ -391,40 +382,40 @@ export default function Flex({ user }: { user: User }) {
)}
-
- {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 (
-
+
User
Email
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 (
handleOAuth("google")}
+ disabled
+ className="disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 w-full py-3 rounded-lg border border-gray-200 bg-gray-50 text-gray-700 hover:bg-gray-100 hover:border-gray-300 transition-colors shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/60"
>
-
+
Google
handleOAuth("microsoft-entra-id")}
+ disabled
+ className="disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 w-full py-3 rounded-lg border border-gray-200 bg-gray-50 text-gray-700 hover:bg-gray-100 hover:border-gray-300 transition-colors shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/60"
>
-
+
Microsoft
handleOAuth("github")}
+ disabled
+ className="disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 w-full py-3 rounded-lg border border-gray-200 bg-gray-50 text-gray-700 hover:bg-gray-100 hover:border-gray-300 transition-colors shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/60"
>
-
+
GitHub
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.
+
+
+
+
+
+
+ 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 && (
+
+ {resending ? "Sending..." : "Resend verification 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 && (
+
+ {resending ? "Sending..." : "Resend verification 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() {
-
- {showCaptcha && (
-
-
-
- Verify you are human
-
-
-
-
- setShowCaptcha(false)}
- className="mt-4 text-sm text-gray-500 hover:text-gray-300 transition"
- >
- Cancel
-
-
-
- )}
>
);
}
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
-
-
-
-
- setShowCaptcha(false)}
- className="mt-4 text-sm text-gray-500 hover:text-gray-300 transition"
- >
- Cancel
-
-
-
- )}
>
);
}
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() {
-
- {showCaptcha && (
-
-
-
- Verify you are human
-
-
-
-
- setShowCaptcha(false)}
- className="mt-4 text-sm text-gray-500 hover:text-gray-300 transition"
- >
- Cancel
-
-
-
- )}
>
);
}
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() {
- Sign Up
+ Comming Soon
-
+
Or continue with
-
+
-
+
-
- {showCaptcha && (
-
-
-
- Verify you are human
-
-
-
-
- setShowCaptcha(false)}
- className="mt-4 text-sm text-gray-500 hover:text-gray-300 transition"
- >
- Cancel
-
-
-
- )}
>
);
}
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() {
setShowCaptcha(false)}
- className="mt-4 text-sm text-gray-500 hover:text-gray-300 transition"
+ className="mt-4 text-sm text-gray-500 hover:text-gray-600 transition"
>
Cancel
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({
navigateMedia(e, -1)}
- className="absolute left-2 sm:left-6 top-1/2 -translate-y-1/2 z-[10000] w-12 h-12 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/80 transition shadow-lg backdrop-blur-md border border-white/10"
+ className="absolute left-2 sm:left-6 top-1/2 -translate-y-1/2 z-[10000] w-12 h-12 flex items-center justify-center rounded-full bg-black/50 text-gray-900 hover:bg-black/80 transition shadow-lg backdrop-blur-md border border-gray-200"
>
@@ -84,14 +86,14 @@ export default function MediaViewerModal({
navigateMedia(e, 1)}
- className="absolute right-2 sm:right-6 top-1/2 -translate-y-1/2 z-[10000] w-12 h-12 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/80 transition shadow-lg backdrop-blur-md border border-white/10"
+ className="absolute right-2 sm:right-6 top-1/2 -translate-y-1/2 z-[10000] w-12 h-12 flex items-center justify-center rounded-full bg-black/50 text-gray-900 hover:bg-black/80 transition shadow-lg backdrop-blur-md border border-gray-200"
>
)}
e.stopPropagation()}
>
void downloadRemoteMedia(viewer.url, viewer.filename || "media")}
- className="w-8 h-8 rounded-md text-white/90 hover:text-white transition"
+ onClick={() =>
+ void downloadRemoteMedia(
+ viewer.url,
+ viewer.filename || "media",
+ )
+ }
+ className="w-8 h-8 rounded-md text-gray-900/90 hover:text-gray-900 transition"
aria-label="Download media"
>
@@ -121,7 +128,7 @@ export default function MediaViewerModal({
onChange(null)}
- 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"
>
@@ -134,7 +141,9 @@ export default function MediaViewerModal({
autoPlay={true}
immersive={true}
className="w-full h-full"
- onDownload={() => void downloadRemoteMedia(viewer.url, viewer.filename || "media")}
+ onDownload={() =>
+ void downloadRemoteMedia(viewer.url, viewer.filename || "media")
+ }
onClose={() => onChange(null)}
/>
)}
@@ -143,4 +152,4 @@ export default function MediaViewerModal({
,
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 && (
bottomRef.current?.scrollIntoView({ behavior: "smooth" })}
+ onClick={() =>
+ bottomRef.current?.scrollIntoView({ behavior: "smooth" })
+ }
className="fixed right-4 bottom-24 z-50 w-9 h-9 flex items-center justify-center
- bg-neutral-900/80 hover:bg-neutral-800 backdrop-blur border border-white/10
- text-gray-300 rounded-full shadow-lg transition"
+ bg-neutral-900/80 hover:bg-neutral-800 backdrop-blur border border-gray-200
+ text-gray-600 rounded-full shadow-lg transition"
>
↓
@@ -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 ? (
-
onUserProfileClick?.(msg.sender_id, senderRow.email as string)}
- className="text-[12px] font-semibold leading-none text-gray-200 hover:text-indigo-300 transition"
- title="Start private chat"
- >
- {senderName}
-
- ) : (
+ {canOpenPrivateChat && senderRow?.email ? (
+
+ onUserProfileClick?.(
+ msg.sender_id,
+ senderRow.email as string,
+ )
+ }
+ className="text-[12px] font-semibold leading-none text-gray-700 hover:text-indigo-600 transition"
+ title="Start private chat"
+ >
+ {senderName}
+
+ ) : (
+
+ {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({
}`}
>
-
+
{
@@ -582,7 +581,7 @@ export default function Player({
onTouchStart={(e) =>
showHint(playing ? "Pause" : "Play", e.currentTarget)
}
- 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={playing ? "Pause" : "Play"}
>
-
+
{fmt(currentTime)} / {fmt(duration)}
@@ -637,7 +636,7 @@ export default function Player({
showHint(getVolumeHintLabel(), e.currentTarget);
}
}}
- 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={muted ? "Unmute" : "Mute"}
>
{showUi && showMobileVolume && (
-
-
+
+
setMuted((v) => !v)}
- className="w-7 h-7 rounded-md text-white/90 hover:text-white transition"
+ className="w-7 h-7 rounded-md text-gray-900/90 hover:text-gray-900 transition"
aria-label={muted ? "Unmute" : "Mute"}
>
{
if (!showSettings) showHint("Settings", e.currentTarget);
}}
- 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="Player settings"
>
@@ -723,12 +722,12 @@ export default function Player({
onTouchStart={(e) =>
showHint("Picture in Picture", e.currentTarget)
}
- 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="Picture in picture"
>
setAmbientBackground((v) => !v)}
- className="w-full flex items-center justify-between px-2 py-1.5 rounded hover:bg-white/10 transition"
+ className="w-full flex items-center justify-between px-2 py-1.5 rounded hover:bg-gray-100 transition"
>
Ambient background
-
+
{ambientBackground ? "On" : "Off"}
-
-
+
+
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}
+
{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 && (
-
+
{owned.map((board) => (
-