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 Logo +

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 && ( +
+
+
+

{flex.name}

+

{flex.text}

+ + + + + + + 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" + /> + +
+ + setFlex({ ...flex, is_open_source: e.target.checked }) + } + className="rounded bg-neutral-800" + /> + +
+ + {flex.is_open_source && ( + + 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" + /> + )} + +
+ + +
+
+
+
+ )} + + {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 ? ( ) : 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');