From 790f066404a9495d6d0718ca50fb00b0853ebc42 Mon Sep 17 00:00:00 2001
From: Melvin Jones Repol
Date: Wed, 25 Mar 2026 15:14:02 +0800
Subject: [PATCH] feat: Add user flex feature and pages
- Add public /flex page to list community flexes and show CTA for guests
- Add dashboard flex page that mounts the Flex component for
authenticated
users
- Implement Flex component: project selection modal, submit flow,
user flex list, and UI elements (modal, search, links)
- Add user_flexes DB types and migrations: create table, RLS policies,
flex_project function, and expires_at column
- Update sitemap and Footer to include /flex and leaderboard
- Minor tweaks: Chat refactor and Navbar icon addition
---
app/(public)/flex/page.tsx | 104 ++++++
app/(user)/dashboard/flex/page.tsx | 14 +
app/components/Chat.tsx | 20 +-
app/components/Flex.tsx | 337 ++++++++++++++++++
app/components/dashboard/Navbar.tsx | 60 ++--
app/components/layout/Footer.tsx | 6 +
app/sitemaps/static.ts | 2 +
app/supabase-types.ts | 47 ++-
.../20260325044133_add_user_flexes.sql | 43 +++
...60325054413_add_expires_at_user_flexes.sql | 2 +
10 files changed, 598 insertions(+), 37 deletions(-)
create mode 100644 app/(public)/flex/page.tsx
create mode 100644 app/(user)/dashboard/flex/page.tsx
create mode 100644 app/components/Flex.tsx
create mode 100644 supabase/migrations/20260325044133_add_user_flexes.sql
create mode 100644 supabase/migrations/20260325054413_add_expires_at_user_flexes.sql
diff --git a/app/(public)/flex/page.tsx b/app/(public)/flex/page.tsx
new file mode 100644
index 0000000..8937260
--- /dev/null
+++ b/app/(public)/flex/page.tsx
@@ -0,0 +1,104 @@
+import { createClient } from "../../lib/supabase/server";
+import Footer from "@/app/components/layout/Footer";
+import CTA from "@/app/components/layout/CTA";
+import BackButton from "@/app/components/leaderboard/BackButton";
+import Image from "next/image";
+import { timeAgo } from "@/app/utils/time";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faExternalLink } from "@fortawesome/free-solid-svg-icons";
+
+export default async function Flexs() {
+ const supabase = await createClient();
+
+ const [userFlexes, userResult] = await Promise.all([
+ supabase
+ .from("user_flexes")
+ .select("*")
+ .order("created_at", { ascending: false }),
+ supabase.auth.getUser(),
+ ]);
+
+ const { data, error } = userFlexes;
+ const { data: user } = userResult;
+
+ return (
+
+
+
+
+
+
+
DevPulse Flexes
+
+
+ {data?.length === 0 && (
+
+
No Flexes Yet
+
+ Please come back later to see the latest flexes from our
+ community.
+
+
+ )}
+
+ {data && data.length > 0 && (
+
+ {data.map((flex) => (
+
+
+
+ {flex.project_name}
+
+ {timeAgo(flex.created_at)}
+
+
+ {flex.project_time}
+
+
+ Description:
+
+
{flex.project_description}
+ {flex.is_open_source && (
+ <>
+
+ Open Source:
+
+
+ {flex.open_source_url}
+
+ >
+ )}
+
+
+ Posted by {flex.user_email.split("@")[0]}
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ {!user &&
}
+
+
+ );
+}
diff --git a/app/(user)/dashboard/flex/page.tsx b/app/(user)/dashboard/flex/page.tsx
new file mode 100644
index 0000000..21fcc6b
--- /dev/null
+++ b/app/(user)/dashboard/flex/page.tsx
@@ -0,0 +1,14 @@
+import Flex from "@/app/components/Flex";
+import { createClient } from "@/app/lib/supabase/server";
+
+export default async function FlexPage() {
+ const supabase = await createClient();
+
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+
+ if (!user) return null;
+
+ return ;
+}
diff --git a/app/components/Chat.tsx b/app/components/Chat.tsx
index f02723b..601cdc0 100644
--- a/app/components/Chat.tsx
+++ b/app/components/Chat.tsx
@@ -66,7 +66,9 @@ export default function Chat({ user }: { user: User }) {
const [badgesByUserId, setBadgesByUserId] = useState<
Record
>({});
- const badgeCacheRef = useRef>({});
+ const badgeCacheRef = useRef<
+ Record
+ >({});
const channelRef = useRef(null);
const textareaRef = useRef(null);
const bottomRef = useRef(null);
@@ -662,7 +664,7 @@ export default function Chat({ user }: { user: User }) {
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search user..."
- className="w-full mb-3 px-3 py-2 rounded bg-neutral-800 outline-none"
+ 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"
/>
{allUsers
@@ -684,12 +686,14 @@ export default function Chat({ user }: { user: User }) {
))}
-
+
+
+
)}
diff --git a/app/components/Flex.tsx b/app/components/Flex.tsx
new file mode 100644
index 0000000..595f098
--- /dev/null
+++ b/app/components/Flex.tsx
@@ -0,0 +1,337 @@
+"use client";
+
+import { User } from "@supabase/supabase-js";
+import { createClient } from "../lib/supabase/client";
+import { Database } from "../supabase-types";
+import { useEffect, useState } from "react";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import {
+ faAdd,
+ faClock,
+ faCode,
+ faExternalLink,
+ faPlus,
+} from "@fortawesome/free-solid-svg-icons";
+import { formatHours, timeAgo } from "../utils/time";
+
+const supabase = createClient();
+
+export interface Projects {
+ name: string;
+ text: string;
+ project_description: string;
+ project_url: string;
+ project_time: string;
+ is_open_source: boolean;
+ open_source_url?: string;
+}
+
+export default function Flex({ user }: { user: User }) {
+ const [loading, setLoading] = useState(false);
+ const [flexes, setFlexes] = useState([]);
+ const [flex, setFlex] = useState(null);
+ const [userFlexes, setUserFlexes] = useState<
+ Database["public"]["Tables"]["user_flexes"]["Row"][]
+ >([]);
+ const [showModal, setShowModal] = useState(false);
+ const [search, setSearch] = useState("");
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!flex) return;
+
+ const { data, error } = await supabase
+ .from("user_flexes")
+ .insert({
+ user_id: user.id,
+ user_email: user.email!,
+ project_name: flex.name,
+ project_description: flex.project_description,
+ project_url: flex.project_url,
+ project_time: flex.text,
+ is_open_source: flex.is_open_source,
+ open_source_url: flex.open_source_url,
+ })
+ .select()
+ .single();
+
+ if (error) {
+ console.error("Error submitting flex:", error);
+ } else {
+ setUserFlexes((prev) => [data, ...prev]);
+ setFlex(null);
+ }
+ };
+
+ const expireAt = (expireAt: string) => {
+ const expiresAt = new Date(expireAt);
+ const now = new Date();
+
+ const diffMs = expiresAt.getTime() - now.getTime();
+ return Math.max(Math.floor(diffMs / (1000 * 60 * 60)), 0) + "hr";
+ };
+
+ useEffect(() => {
+ async function fetchFlexes() {
+ setLoading(true);
+ const { data, error } = await supabase
+ .from("user_flexes")
+ .select("*")
+ .eq("user_id", user.id);
+
+ if (error) {
+ console.error("Error fetching flexes:", error);
+ } else {
+ setUserFlexes(data);
+ }
+ setLoading(false);
+ }
+
+ fetchFlexes();
+ }, [user.id]);
+
+ useEffect(() => {
+ if (!showModal) return;
+
+ async function fetchFlexes() {
+ const { data, error } = await supabase
+ .from("user_projects")
+ .select("projects")
+ .eq("user_id", user.id);
+
+ if (error) {
+ console.error("Error fetching flexes:", error);
+ } else {
+ const projects: Projects[] = data[0].projects as unknown as Projects[];
+ const newProjects = projects.filter(
+ (p) => !userFlexes.some((f) => f.project_name === p.name),
+ );
+ setFlexes(newProjects);
+ }
+ }
+
+ fetchFlexes();
+ }, [showModal, userFlexes, user.id]);
+
+ return (
+
+
+
+
+ Flex
+
+
+
+
+ Share your flexes with the community
+
+
+
+
+
+
+
+
+
+ {loading && (
+
+
Loading your flexes...
+
+ )}
+
+ {flex && (
+
+ )}
+
+ {userFlexes.length === 0 && !loading && (
+
+
+ You have no flexes yet. Start by sharing your first project!
+
+
+ )}
+
+ {userFlexes.length > 0 && (
+
+ {userFlexes.map((f) => (
+
+
+
{f.project_name}
+
+
+ {f.project_time}
+
+
+
{f.project_description}
+
+
+ {f.project_url}
+
+ {f.is_open_source && (
+
+
+ {f.open_source_url}
+
+ )}
+
+ Expires in {expireAt(f.expires_at || "")} • Posted{" "}
+ {timeAgo(f.created_at)}
+
+
+ ))}
+
+ )}
+
+ {showModal && (
+
+
+
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"
+ />
+
+ {flexes.length === 0 && !loading && (
+
+ You have no projects to flex yet.
+
+ )}
+
+ {flexes
+ .filter((u) =>
+ u.name.toLowerCase().includes(search.toLowerCase()),
+ )
+ .map((u, idx) => (
+
{
+ setFlex(u);
+ setShowModal(false);
+ }}
+ className="flex items-center gap-3 p-2 rounded hover:bg-neutral-800 cursor-pointer"
+ >
+
+ {u.name[0].toUpperCase()}
+
+
+ {u.name}
+ {u.text}
+
+
+ ))}
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/app/components/dashboard/Navbar.tsx b/app/components/dashboard/Navbar.tsx
index 02a5c1f..8141bf1 100644
--- a/app/components/dashboard/Navbar.tsx
+++ b/app/components/dashboard/Navbar.tsx
@@ -11,6 +11,7 @@ import {
faChevronLeft,
faChevronRight,
faMessage,
+ faCrown,
} from "@fortawesome/free-solid-svg-icons";
import type { IconDefinition } from "@fortawesome/free-solid-svg-icons";
@@ -29,6 +30,7 @@ function Sidebar() {
const navItems: { href: string; label: string; icon: IconDefinition }[] = [
{ href: "/dashboard", label: "Dashboard", icon: faChartLine },
{ href: "/dashboard/chat", label: "Chat", icon: faMessage },
+ { href: "/dashboard/flex", label: "Flex", icon: faCrown },
{ href: "/dashboard/leaderboards", label: "Leaderboards", icon: faTrophy },
];
@@ -64,33 +66,33 @@ function Sidebar() {
{!isMobile || !mobileHidden ? (
+ )}
+ {navItems.map((item) => (
+
- {!collapsed && item.label}
-
- ))}
+ title={collapsed ? item.label : undefined}
+ >
+
+ {!collapsed && item.label}
+
+ ))}
) : null}
@@ -134,7 +136,6 @@ export default function DashboardLayout({
height: 0,
});
-
useEffect(() => {
const handleResize = () => {
const nextIsMobile = window.innerWidth < 768;
@@ -142,6 +143,7 @@ export default function DashboardLayout({
if (nextIsMobile) {
setCollapsed(true);
+ setMobileHidden(true);
} else {
setCollapsed(false);
setMobileHidden(false);
@@ -264,7 +266,9 @@ export default function DashboardLayout({
}}
className={[
"md:hidden fixed z-50 transition-opacity duration-300",
- mobileHidden ? "opacity-100 pointer-events-auto" : "opacity-100 pointer-events-none",
+ mobileHidden
+ ? "opacity-100 pointer-events-auto"
+ : "opacity-100 pointer-events-none",
"rounded-full bg-[#0f0f28] border border-white/10 hover:border-indigo-500/30",
"shadow-lg flex items-center justify-center",
].join(" ")}
diff --git a/app/components/layout/Footer.tsx b/app/components/layout/Footer.tsx
index 9b875d3..f97a9ce 100644
--- a/app/components/layout/Footer.tsx
+++ b/app/components/layout/Footer.tsx
@@ -22,6 +22,12 @@ export default function Footer() {
>
Leaderboard
+
+ Flex
+
now() - interval '24 hours'
+ ) then
+ return 'You can only flex once every 24 hours';
+ else
+ insert into public.user_flexes(user_id, project)
+ values (p_user_id, p_project);
+ return 'Project flexed successfully!';
+ end if;
+end;
+$$ language plpgsql;
diff --git a/supabase/migrations/20260325054413_add_expires_at_user_flexes.sql b/supabase/migrations/20260325054413_add_expires_at_user_flexes.sql
new file mode 100644
index 0000000..736fc9c
--- /dev/null
+++ b/supabase/migrations/20260325054413_add_expires_at_user_flexes.sql
@@ -0,0 +1,2 @@
+alter table public.user_flexes
+add column expires_at timestamp with time zone default (now() + interval '24 hours');